feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
import {
MAX_OPS,
type HiveApplyResult,
type HiveCatalog,
type HiveFrame,
type HiveOp,
type HivePart,
type HiveState,
} from "./types";
import { validateHiveState } from "./validate";
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
interface Location {
part: HivePart;
list: HivePart[];
index: number;
isInsert: boolean;
}
/** Result of one op on the working copy: a reason string, or null on success. */
type OpOutcome = string | null;
function locate(state: HiveState, id: unknown): Location | null {
for (let index = 0; index < state.stack.length; index++) {
const part = state.stack[index];
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
const inserts = part.inserts ?? [];
for (let i = 0; i < inserts.length; i++) {
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
}
}
return null;
}
function missing(id: unknown): string {
return `MISSING_ID: no part with id ${String(id)}`;
}
function clamp(value: unknown, max: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return max;
return Math.min(Math.max(Math.trunc(value), 0), max);
}
/** Catalog membership and active check for a part to be added (and its inserts). */
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
for (const insert of part.inserts ?? []) {
const nested = checkAddable(insert, catalog);
if (nested) return nested;
}
return null;
}
/** Deep copy of an incoming part without empty `inserts` arrays. */
function normalisedCopy(part: HivePart): HivePart {
const copy = structuredClone(part);
if (Array.isArray(copy.inserts)) {
if (copy.inserts.length === 0) delete copy.inserts;
else copy.inserts = copy.inserts.map(normalisedCopy);
}
return copy;
}
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
return null;
}
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
const host = locate(state, op.hostId);
if (!host || host.isInsert) return missing(op.hostId);
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
return null;
}
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.list.splice(found.index, 1);
if (found.isInsert) {
const host = state.stack.find((p) => p.inserts === found.list);
if (host && found.list.length === 0) delete host.inserts;
}
return null;
}
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
const [moved] = state.stack.splice(found.index, 1);
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
return null;
}
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.part.state = { ...found.part.state, [op.key]: op.value };
return null;
}
function locateBox(state: HiveState, boxId: unknown): Location | string {
const found = locate(state, boxId);
if (!found) return missing(boxId);
if (found.isInsert || !Array.isArray(found.part.frames)) {
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
}
return found;
}
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
const frames = box.part.frames as HiveFrame[];
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
}
// name: undefined keeps the current name, null removes it.
const current = frames[op.slot - 1];
const name = op.name === undefined ? current.name : (op.name ?? undefined);
frames[op.slot - 1] =
name === undefined
? { slot: op.slot, type: op.type }
: { slot: op.slot, type: op.type, name };
return null;
}
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
box.part.frames = structuredClone(op.frames);
return null;
}
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (op.label === null) delete found.part.label;
else found.part.label = op.label;
return null;
}
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
switch (op?.op) {
case "addPart":
return applyAddPart(state, op, catalog);
case "addInsert":
return applyAddInsert(state, op, catalog);
case "removePart":
return applyRemovePart(state, op);
case "movePart":
return applyMovePart(state, op);
case "setState":
return applySetState(state, op);
case "setFrame":
return applySetFrame(state, op);
case "setFrames":
return applySetFrames(state, op);
case "setLabel":
return applySetLabel(state, op);
default:
return "UNKNOWN_OP: unsupported operation";
}
}
/**
* Applies ops in order and validates the whole state after each one.
* Never mutates its inputs: it works on a private deep copy.
*/
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
if (ops.length > MAX_OPS) {
return {
ok: false,
opIndex: MAX_OPS,
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
};
}
const working = structuredClone(state);
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
const failure =
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
if (failure) return { ok: false, opIndex, reason: failure };
}
return { ok: true, state: working };
}