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

@@ -0,0 +1,383 @@
"use client";
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import type { FocusEvent, KeyboardEvent, Ref } from "react";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
import { useHiveView } from "./use-hive-view";
// Accessible outline of the hive (WAI-ARIA tree, spec 9.6). It offers everything
// the 3D view does: hover, open a box, edit a frame or a part with state.
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
interface OutlineNode {
key: string;
kind: "part" | "frame" | "action";
/** Part id, or the box id for a frame. */
id: string;
slot?: number;
level: number;
parentKey: string | null;
childCount: number;
name: string;
hint: string | null;
isBox: boolean;
isFloor: boolean;
hasState: boolean;
setSize: number;
position: number;
}
function fieldText(field: StateField, value: number | string | undefined): string {
if (field.kind === "choice") {
const index = field.options.indexOf(String(value));
return index >= 0 ? field.optionLabels[index] : String(value ?? "");
}
const text = numberFormat.format(Number(value ?? 0));
return field.unit ? `${text} ${field.unit}` : text;
}
function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
return (part.inserts ?? []).filter((insert) => catalog.partTypes.get(insert.partTypeId)?.kind !== "FLOOR_INSERT");
}
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
if (!type) return null;
if (type.stateSchema.length > 0) {
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
}
if (type.kind === "BOX") {
const count = part.frames?.length ?? 0;
return count > 0 ? `${count} Rähmchen` : null;
}
const hasLock = (part.inserts ?? []).some(
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT",
);
return hasLock ? "Bausperre drin" : null;
}
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
function buildNodes(
state: HiveState,
catalog: HiveCatalog,
expanded: ReadonlySet<string>,
canEdit: boolean,
): OutlineNode[] {
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
const nodes: OutlineNode[] = [];
const topDown = [...state.stack].reverse();
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
const type = catalog.partTypes.get(part.partTypeId);
const inserts = visibleInserts(part, catalog);
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
const isBox = type?.kind === "BOX";
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
nodes.push({
key: part.id,
kind: "part",
id: part.id,
level,
parentKey,
childCount,
name: partDisplayName(state, catalog, part.id),
hint: hintOf(part, type, catalog),
isBox,
isFloor: type?.kind === "FLOOR",
hasState: (type?.stateSchema.length ?? 0) > 0,
setSize,
position,
});
if (!expanded.has(part.id)) return;
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
frames.forEach((frame, index) => {
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
nodes.push({
key: `${part.id}#${frame.slot}`,
kind: "frame",
id: part.id,
slot: frame.slot,
level: level + 1,
parentKey: part.id,
childCount: 0,
name: label,
hint: null,
isBox: false,
isFloor: false,
hasState: false,
setSize: childCount,
position: inserts.length + index + 1,
});
});
if (hasAddAction) {
nodes.push({
key: `${part.id}#add-evaporator`,
kind: "action",
id: part.id,
level: level + 1,
parentKey: part.id,
childCount: 0,
name: ADD_EVAPORATOR_LABEL,
hint: null,
isBox: false,
isFloor: false,
hasState: false,
setSize: childCount,
position: childCount,
});
}
};
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
return nodes;
}
/** Moves focus to an item, e.g. back to the edited one when a sheet closes. */
export interface HiveOutlineHandle {
focusPart(partId: string): void;
focusFrame(boxId: string, slot: number): void;
}
/** Outline key and the part that must be expanded to show it; null when the part is unknown. */
function focusTargetOf(
state: HiveState,
catalog: HiveCatalog,
partId: string,
): { key: string; expand: string | null } | null {
for (const part of state.stack) {
if (part.id === partId) return { key: partId, expand: null };
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
if (!insert) continue;
// Floor inserts have no item of their own; their floor stands for them.
const hidden = catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT";
return hidden ? { key: part.id, expand: null } : { key: partId, expand: part.id };
}
return null;
}
export interface HiveOutlineProps {
ref?: Ref<HiveOutlineHandle>;
state: HiveState;
catalog: HiveCatalog;
canEdit: boolean;
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
announcement: string;
onEditPart: (id: string) => void;
onEditFrame: (boxId: string, slot: number) => void;
}
export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEditPart, onEditFrame }: HiveOutlineProps) {
const view = useHiveView();
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
const [seenOpenBox, setSeenOpenBox] = useState<string | null>(null);
const [activeKey, setActiveKey] = useState<string | null>(null);
const itemRefs = useRef(new Map<string, HTMLDivElement>());
// A box opened elsewhere (3D, SVG) shows its frames here as well.
if (view.openBoxId !== seenOpenBox) {
setSeenOpenBox(view.openBoxId);
if (view.openBoxId && !expanded.has(view.openBoxId)) setExpanded(new Set(expanded).add(view.openBoxId));
}
const nodes = buildNodes(state, catalog, expanded, canEdit);
const keySignature = nodes.map((node) => node.key).join("|");
const previousNodes = useRef<OutlineNode[]>(nodes);
const focusWithin = useRef(false);
// When the focused item disappears, focus moves to the nearest remaining
// sibling, else its parent, else the first item.
useLayoutEffect(() => {
const before = previousNodes.current;
previousNodes.current = nodes;
if (!focusWithin.current || !activeKey) return;
if (nodes.some((node) => node.key === activeKey)) return;
const gone = before.findIndex((node) => node.key === activeKey);
if (gone < 0) return;
const present = new Set(nodes.map((node) => node.key));
const parentKey = before[gone].parentKey;
const siblings = (list: OutlineNode[]) => list.filter((n) => n.parentKey === parentKey && present.has(n.key));
const next = siblings(before.slice(gone + 1))[0] ?? siblings(before.slice(0, gone)).reverse()[0];
const target = next?.key ?? (parentKey && present.has(parentKey) ? parentKey : nodes[0]?.key);
if (target) itemRefs.current.get(target)?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keySignature]);
// Focus requested for an item that only exists after its parent expands.
const pendingFocus = useRef<string | null>(null);
useLayoutEffect(() => {
const key = pendingFocus.current;
pendingFocus.current = null;
if (key) itemRefs.current.get(key)?.focus();
});
/** Expands a collapsed parent and focuses the item once it is rendered; otherwise does nothing. */
const requestFocusAfterExpand = (itemKey: string, parentKey: string | null): void => {
if (!parentKey || expanded.has(parentKey)) return;
pendingFocus.current = itemKey;
setExpanded(new Set(expanded).add(parentKey));
};
useImperativeHandle(ref, () => ({
focusPart(partId) {
const target = focusTargetOf(state, catalog, partId);
if (!target) return;
const element = itemRefs.current.get(target.key);
if (element) {
element.focus();
return;
}
requestFocusAfterExpand(target.key, target.expand);
},
focusFrame(boxId, slot) {
const key = `${boxId}#${slot}`;
const element = itemRefs.current.get(key);
if (element) {
element.focus();
return;
}
requestFocusAfterExpand(key, boxId);
},
}));
const tabbableKey = nodes.some((node) => node.key === activeKey) ? activeKey : (nodes[0]?.key ?? null);
const toggle = (key: string, open: boolean): void => {
const next = new Set(expanded);
if (open) next.add(key);
else next.delete(key);
setExpanded(next);
};
const focusKey = (key: string | undefined): void => {
if (key) itemRefs.current.get(key)?.focus();
};
const handleFocus = (node: OutlineNode): void => {
focusWithin.current = true;
setActiveKey(node.key);
if (node.kind === "frame") {
view.setHovered(null);
view.setHoveredFrame({ boxId: node.id, slot: node.slot ?? 0 });
} else {
view.setHovered(node.id);
view.setHoveredFrame(null);
}
};
const handleBlur = (event: FocusEvent<HTMLDivElement>): void => {
// A removed item also loses focus; the microtask lets the layout effect see the flag first.
const tree = event.currentTarget.parentElement;
queueMicrotask(() => {
if (!tree?.contains(document.activeElement)) focusWithin.current = false;
});
if (event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) return;
view.setHovered(null);
view.setHoveredFrame(null);
};
const activateNode = (node: OutlineNode): void => {
if (node.kind === "action") {
if (canEdit) onEditPart(node.id);
return;
}
if (node.kind === "frame") {
if (canEdit) onEditFrame(node.id, node.slot ?? 0);
return;
}
if (node.isBox) {
view.openBox(node.id);
toggle(node.id, true);
return;
}
if (node.hasState || node.isFloor) {
if (canEdit) onEditPart(node.id);
return;
}
if (node.childCount > 0) toggle(node.key, !expanded.has(node.key));
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: OutlineNode, index: number): void => {
const isOpen = expanded.has(node.key);
switch (event.key) {
case "ArrowDown":
focusKey(nodes[index + 1]?.key);
break;
case "ArrowUp":
focusKey(nodes[index - 1]?.key);
break;
case "Home":
focusKey(nodes[0]?.key);
break;
case "End":
focusKey(nodes[nodes.length - 1]?.key);
break;
case "ArrowRight":
if (node.childCount === 0) break;
if (!isOpen) toggle(node.key, true);
else focusKey(nodes[index + 1]?.key);
break;
case "ArrowLeft":
if (node.childCount > 0 && isOpen) toggle(node.key, false);
else if (node.parentKey) focusKey(node.parentKey);
break;
case "Enter":
case " ":
activateNode(node);
break;
default:
return;
}
event.preventDefault();
};
return (
<div>
<div role="tree" aria-label="Aufbau der Beute" className="flex flex-col gap-1">
{nodes.map((node, index) => {
const isOpen = expanded.has(node.key);
const isFrame = node.kind === "frame";
const isCurrentBox = node.isBox && view.openBoxId === node.id;
return (
<div
key={node.key}
ref={(element) => {
if (element) itemRefs.current.set(node.key, element);
else itemRefs.current.delete(node.key);
}}
// aria-current marks the open box; aria-selected would suggest a selection model.
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
aria-level={node.level}
aria-setsize={node.setSize}
aria-posinset={node.position}
aria-expanded={node.childCount > 0 ? isOpen : undefined}
aria-current={isCurrentBox ? "true" : undefined}
tabIndex={node.key === tabbableKey ? 0 : -1}
onFocus={() => handleFocus(node)}
onBlur={handleBlur}
onKeyDown={(event) => handleKeyDown(event, node, index)}
onClick={() => {
focusKey(node.key);
activateNode(node);
}}
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium"
>
<span className={isFrame ? "text-muted-foreground" : undefined}>
{node.hint ? `${node.name} · ${node.hint}` : node.name}
</span>
</div>
);
})}
</div>
<div role="status" aria-live="polite" className="sr-only">
{announcement}
</div>
</div>
);
}