feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)

- 3D dependencies (three, react-three-fiber, drei, postprocessing,
  motion), exact versions, supply-chain checked; scene loaded only on
  demand on the colony page (283 kB gzip), SVG stays as fallback
- glass hologram per owner design 2026-10-16: true Liebig Zander
  shapes (grips, rebate, Wanderboden foot), cool blue edge glow,
  click any part -> it flies to the front while the rest stays as a
  faint ghost; focus shows content (feed levels, Bausperre, pockets);
  frames rise on hover and take their type look (colour + comb pattern)
- full-bleed card with frosted overlays and glass buttons
- Futtertasche (FRAME_FEEDER): new part kind occupying frame slots
  with a fill level, catalog row, validator rules in both ports,
  outline, sheets, follow-ups, settings

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 c608f5d405
commit 6e13236d49
100 files changed
+9432 -209

No files matched your search

@@ -34,6 +34,7 @@ const feeder: HivePartTypeDto = {
widthMm: 480,
heightMm: 200,
frameSlots: null,
slotWidth: null,
boxDepth: null,
stateSchema: [
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
@@ -22,6 +22,7 @@ const feeder: HivePartTypeDto = {
widthMm: 480,
heightMm: 200,
frameSlots: null,
slotWidth: null,
boxDepth: null,
stateSchema: [
{
@@ -62,6 +63,19 @@ async function pickBox() {
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
}
async function pickFrameFeeder() {
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
await userEvent.click(await screen.findByRole("option", { name: "Futtertasche" }));
}
const pocket: HivePartTypeDto = {
...feeder,
id: "pt2",
kind: "FRAME_FEEDER",
name: "Futtertasche",
slotWidth: 2,
};
describe("PartTypeDialog", () => {
beforeEach(() => {
vi.mocked(createHivePartType).mockReset();
@@ -247,4 +261,60 @@ describe("PartTypeDialog", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("creates a frame feeder with the number of frame slots it covers", async () => {
renderDialog();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await typeInto(/^Belegte Rähmchenplätze/, "2");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", {
kind: "FRAME_FEEDER",
name: "Futtertasche",
lengthMm: 477,
widthMm: 70,
heightMm: 220,
slotWidth: 2,
}),
);
});
it("requires the covered slots of a frame feeder and shows none for other kinds", async () => {
renderDialog();
expect(screen.queryByLabelText(/^Belegte Rähmchenplätze/)).toBeNull();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/belegten Rähmchenplätze/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("caps the covered slots at the server limit of 5", async () => {
renderDialog();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await typeInto(/^Belegte Rähmchenplätze/, "9");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ slotWidth: 5 })),
);
});
it("edit: sends only a changed slot width", async () => {
renderDialog(pocket);
expect(screen.getByLabelText(/^Belegte Rähmchenplätze/)).toHaveValue("2");
await typeInto(/^Belegte Rähmchenplätze/, "3");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { slotWidth: 3 }));
});
});
@@ -25,6 +25,7 @@ import {
import {
MAX_DIMENSION_MM,
MAX_FRAME_SLOTS,
MAX_SLOT_WIDTH,
draftProblem,
emptyDraft,
fieldFromDraft,
@@ -59,6 +60,7 @@ interface FormValues {
heightMm: string;
boxDepth: HiveBoxDepth;
frameSlots: string;
slotWidth: string;
}
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
@@ -71,6 +73,7 @@ function initialValues(partType: HivePartTypeDto | undefined): FormValues {
heightMm: partType ? String(partType.heightMm) : "",
boxDepth: partType?.boxDepth ?? "FULL",
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
slotWidth: partType?.slotWidth == null ? "" : String(partType.slotWidth),
};
}
@@ -88,6 +91,9 @@ function valuesProblem(values: FormValues): string | null {
if (values.kind === "BOX" && values.frameSlots === "") {
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
}
if (values.kind === "FRAME_FEEDER" && values.slotWidth === "") {
return "Eine Futtertasche braucht die Zahl der belegten Rähmchenplätze.";
}
return null;
}
@@ -109,6 +115,9 @@ function patchFrom(
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
}
if (partType.kind === "FRAME_FEEDER" && Number(values.slotWidth) !== partType.slotWidth) {
patch.slotWidth = Number(values.slotWidth);
}
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
return patch;
}
@@ -124,6 +133,7 @@ function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTyp
widthMm: Number(values.widthMm),
heightMm: Number(values.heightMm),
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
...(kind === "FRAME_FEEDER" ? { slotWidth: Number(values.slotWidth) } : {}),
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
};
}
@@ -269,6 +279,23 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
</Field>
</>
) : null}
{values.kind === "FRAME_FEEDER" ? (
<Field label="Belegte Rähmchenplätze" required hint="So viele Plätze nimmt die Tasche in der Zarge ein.">
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.slotWidth}
onChange={(slotWidth) => setValue("slotWidth", slotWidth)}
min={1}
max={MAX_SLOT_WIDTH}
unitLabel="Plätze"
decrementLabel="Belegte Rähmchenplätze verringern"
incrementLabel="Belegte Rähmchenplätze erhöhen"
/>
)}
</Field>
) : null}
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
<h3 id="state-fields-heading" className="text-sm font-medium">
@@ -280,7 +307,7 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
draft={newField}
onChange={setNewField}
onDiscard={() => setNewField(null)}
allowFeedType={values.kind === "FEEDER"}
allowFeedType={values.kind === "FEEDER" || values.kind === "FRAME_FEEDER"}
/>
) : (
<Button
@@ -65,7 +65,7 @@ export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }:
<TableCell className="whitespace-nowrap">
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
</TableCell>
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
<TableCell>{partType.frameSlots ?? (partType.slotWidth ? `belegt ${partType.slotWidth}` : "–")}</TableCell>
<TableCell className="min-w-48 whitespace-normal">
{describeStateFields(partType.stateSchema)}
</TableCell>
+4 -4
View File
@@ -134,8 +134,8 @@
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
@@ -194,8 +194,8 @@
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);