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,295 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { SheetFooter } from "@/components/ui/sheet";
import { partDisplayName } from "@/lib/hive/names";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import {
areDraftFieldsValid,
draftOf,
draftValue,
isFieldChanged,
StateFieldControl,
} from "./state-field-control";
import type { FieldDraft } from "./state-field-control";
import { useSheetSubmit } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const INSERT_OPTIONS = [
{ value: true, label: "drin" },
{ value: false, label: "draußen" },
];
const EVAPORATOR_OPTIONS = [
{ value: false, label: "Kein Verdunster" },
{ value: true, label: "Verdunster einsetzen" },
];
export interface PartSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: HiveState;
catalog: HiveCatalog;
partId: string;
/** Sends the ops (source CARD); rejects on failure. */
submit: SubmitOps;
/** On close, focus returns to the edited part's item in the outline. */
outlineRef: RefObject<HiveOutlineHandle | null>;
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
startWithEvaporator?: boolean;
}
interface Located {
part: HivePart;
/** The box or floor an insert sits in. */
host: HivePart | null;
}
function locate(state: HiveState, partId: string): Located | null {
for (const part of state.stack) {
if (part.id === partId) return { part, host: null };
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
if (insert) return { part: insert, host: part };
}
return null;
}
function activeTypeOf(catalog: HiveCatalog, kind: HivePartKind): CatalogPartType | undefined {
return [...catalog.partTypes.values()].find((type) => type.kind === kind && type.active);
}
function insertOfKind(part: HivePart, catalog: HiveCatalog, kind: HivePartKind): HivePart | undefined {
return (part.inserts ?? []).find((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === kind);
}
function newPart(id: string, type: CatalogPartType, draft: FieldDraft): HivePart {
const state: Record<string, number | string> = {};
for (const field of type.stateSchema) state[field.key] = draftValue(field, draft[field.key] ?? "");
return { id, partTypeId: type.id, state };
}
interface PartFormProps {
located: Located;
type: CatalogPartType;
catalog: HiveCatalog;
startWithEvaporator: boolean;
submit: SubmitOps;
/** `focusId` is the part whose item should get focus once the sheet is closed. */
onDone: (focusId: string) => void;
}
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
const { part, host } = located;
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
const evaporatorType =
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
const [evaporatorDraft, setEvaporatorDraft] = useState<FieldDraft>(() =>
evaporatorType ? draftOf(evaporatorType, {}) : {},
);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const questionRef = useRef<HTMLParagraphElement>(null);
const removeButtonRef = useRef<HTMLButtonElement>(null);
const wasConfirming = useRef(false);
// Keyboard and screen reader users follow the confirmation, and get back to the button on cancel.
useEffect(() => {
if (confirmingRemoval) questionRef.current?.focus();
else if (wasConfirming.current) removeButtonRef.current?.focus();
wasConfirming.current = confirmingRemoval;
}, [confirmingRemoval]);
const { send, pending, error } = useSheetSubmit(submit);
// Fixed per form, so a retry after an error sends the same ids.
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
? [{ op: "setState" as const, id: part.id, key: field.key, value: draftValue(field, draft[field.key]) }]
: [],
);
const ops: HiveOp[] = [...changedStateOps];
if (floorInsertType && wantsFloorInsert && !currentFloorInsert) {
ops.push({ op: "addInsert", hostId: part.id, part: newPart(newIds.floorInsert, floorInsertType, {}) });
} else if (currentFloorInsert && !wantsFloorInsert) {
ops.push({ op: "removePart", id: currentFloorInsert.id });
}
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
const valid =
areDraftFieldsValid(type, draft) &&
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
async function save() {
// After inserting an evaporator, focus goes to the new item.
const outcome = await send(ops);
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
}
async function remove() {
// An evaporator is an insert: focus goes to its box afterwards.
const outcome = await send([{ op: "removePart", id: part.id }]);
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
}
return (
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
event.preventDefault();
if (ops.length > 0 && valid && !pending) void save();
}}
>
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`part-${part.id}`}
value={draft[field.key] ?? ""}
onChange={(value) => setDraft((current) => ({ ...current, [field.key]: value }))}
/>
))}
{floorInsertType || currentFloorInsert ? (
<div className="flex flex-col gap-1.5">
<span id={`part-${part.id}-insert`} className="text-sm font-medium">
Bausperre
</span>
<ChoiceGroup
options={INSERT_OPTIONS}
value={wantsFloorInsert}
onChange={setWantsFloorInsert}
labelledBy={`part-${part.id}-insert`}
className="grid-cols-2"
/>
</div>
) : null}
{evaporatorType ? (
<div className="flex flex-col gap-3">
<span id={`part-${part.id}-evaporator`} className="text-sm font-medium">
Verdunster
</span>
<ChoiceGroup
options={EVAPORATOR_OPTIONS}
value={wantsEvaporator}
onChange={setWantsEvaporator}
labelledBy={`part-${part.id}-evaporator`}
className="grid-cols-2"
/>
{wantsEvaporator
? evaporatorType.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`part-${part.id}-new-evaporator`}
value={evaporatorDraft[field.key] ?? ""}
onChange={(value) => setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))}
/>
))
: null}
</div>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<SheetFooter className="p-0">
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
Speichern
</Button>
{type.kind === "EVAPORATOR" ? (
confirmingRemoval ? (
<div
role="group"
aria-labelledby={`part-${part.id}-confirm`}
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
>
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
Verdunster wirklich entfernen?
</p>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
Ja, entfernen
</Button>
<Button type="button" variant="outline" size="touch" onClick={() => setConfirmingRemoval(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<Button
ref={removeButtonRef}
type="button"
variant="destructive"
size="touch"
onClick={() => setConfirmingRemoval(true)}
>
Verdunster entfernen
</Button>
)
) : null}
</SheetFooter>
</form>
);
}
/**
* Edit one part: a control per state field, the floor's Bausperre, a box's
* evaporator (spec 9.4). Everything changed goes out as one batch of ops.
*/
export function PartSheet({
open,
onOpenChange,
state,
catalog,
partId,
submit,
outlineRef,
startWithEvaporator = false,
}: PartSheetProps) {
const located = locate(state, partId);
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
// Set by a save that changes what should get focus (a new evaporator, the box of a removed one).
const focusAfterSave = useRef<string | null>(null);
return (
<HiveSheetShell
open={open}
onOpenChange={onOpenChange}
title={partDisplayName(state, catalog, partId)}
finalFocus={() => {
outlineRef.current?.focusPart(focusAfterSave.current ?? partId);
focusAfterSave.current = null;
return false;
}}
>
{located && type ? (
<PartForm
key={partId}
located={located}
type={type}
catalog={catalog}
startWithEvaporator={startWithEvaporator}
submit={submit}
onDone={(id) => {
focusAfterSave.current = id;
onOpenChange(false);
}}
/>
) : (
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
Dieses Teil gibt es nicht mehr.
</p>
)}
</HiveSheetShell>
);
}