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,140 @@
"use client";
import { useState } from "react";
import type { RefObject } from "react";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SheetFooter } from "@/components/ui/sheet";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import { FRAME_TYPES, MAX_TEXT_LENGTH } from "@/lib/hive/types";
import type { FrameType, HiveCatalog, HiveState } from "@/lib/hive/types";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import { useSheetSubmit } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const FRAME_OPTIONS = choiceOptions(FRAME_TYPE_LABELS, FRAME_TYPES);
export interface FrameSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: HiveState;
catalog: HiveCatalog;
boxId: string;
slot: number;
/** Sends the ops (source CARD); rejects on failure. */
submit: SubmitOps;
/** On close, focus returns to this frame's item in the outline. */
outlineRef: RefObject<HiveOutlineHandle | null>;
}
interface FrameFormProps {
initialType: FrameType;
initialName: string;
boxId: string;
slot: number;
submit: SubmitOps;
/** Saved, or the hive changed meanwhile (the conflict sheet takes over). */
onClose: () => void;
}
function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: FrameFormProps) {
const [type, setType] = useState<FrameType>(initialType);
const [name, setName] = useState(initialName);
const { send, pending, error } = useSheetSubmit(submit);
const trimmed = name.trim();
const changed = type !== initialType || trimmed !== initialName.trim();
async function save() {
const outcome = await send([{ op: "setFrame", boxId, slot, type, name: trimmed === "" ? null : trimmed }]);
if (outcome !== "failed") onClose();
}
return (
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
event.preventDefault();
if (changed && !pending) void save();
}}
>
<div className="flex flex-col gap-1.5">
<span id={`frame-${boxId}-${slot}-type`} className="text-sm font-medium">
Art
</span>
<ChoiceGroup
options={FRAME_OPTIONS}
value={type}
onChange={setType}
labelledBy={`frame-${boxId}-${slot}-type`}
className="grid-cols-2 sm:grid-cols-3"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`frame-${boxId}-${slot}-name`} className="text-sm font-medium">
Name
</label>
<Input
id={`frame-${boxId}-${slot}-name`}
value={name}
maxLength={MAX_TEXT_LENGTH}
autoComplete="off"
onChange={(event) => setName(event.target.value)}
aria-describedby={`frame-${boxId}-${slot}-count`}
className="h-11"
/>
<p id={`frame-${boxId}-${slot}-count`} className="text-xs text-muted-foreground">
{name.length} / {MAX_TEXT_LENGTH}
</p>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<SheetFooter className="p-0">
<Button type="submit" size="touch" disabled={!changed || pending}>
Speichern
</Button>
</SheetFooter>
</form>
);
}
/** Edit one frame of a box: its type and an optional name (spec 9.4). One `setFrame` per save. */
export function FrameSheet({ open, onOpenChange, state, catalog, boxId, slot, submit, outlineRef }: FrameSheetProps) {
const box = state.stack.find((candidate) => candidate.id === boxId);
const frame = box?.frames?.find((candidate) => candidate.slot === slot);
return (
<HiveSheetShell
open={open}
onOpenChange={onOpenChange}
title={`Rähmchen ${slot}`}
description={partDisplayName(state, catalog, boxId)}
finalFocus={() => {
outlineRef.current?.focusFrame(boxId, slot);
return false;
}}
>
{frame ? (
<FrameForm
// A different frame starts a fresh draft.
key={`${boxId}#${slot}`}
initialType={frame.type}
initialName={frame.name ?? ""}
boxId={boxId}
slot={slot}
submit={submit}
onClose={() => onOpenChange(false)}
/>
) : (
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
Dieses Rähmchen gibt es nicht mehr.
</p>
)}
</HiveSheetShell>
);
}