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

@@ -29,10 +29,11 @@ interface HarnessProps {
initialState?: HiveState;
submit?: (ops: HiveOp[]) => Promise<unknown>;
startWithEvaporator?: boolean;
startWithFrameFeeder?: boolean;
}
/** Applies submitted ops to its own state, like the optimistic cache does. */
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) {
const [state, setState] = useState(initialState);
const [open, setOpen] = useState(true);
const outlineRef = useRef<HiveOutlineHandle>(null);
@@ -62,6 +63,7 @@ function Harness({ partId, initialState = baseState(), submit, startWithEvaporat
submit={send}
outlineRef={outlineRef}
startWithEvaporator={startWithEvaporator}
startWithFrameFeeder={startWithFrameFeeder}
/>
</HiveViewProvider>
);
@@ -329,4 +331,99 @@ describe("PartSheet", () => {
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
});
});
describe("frame feeder", () => {
const withPocket = (): HiveState => {
const s = baseState();
s.stack[1] = part("b", "box", {
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })],
});
return s;
};
it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="b" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
await user.click(screen.getByRole("radio", { name: "Plätze 3–4" }));
expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument();
const syrup = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(syrup);
await user.type(syrup, "1,5");
await user.click(save());
const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f));
expect(submit).toHaveBeenCalledWith([
{ op: "setFrames", boxId: "b", frames: expectedFrames },
{
op: "addInsert",
hostId: "b",
part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 },
},
]);
});
it("skips setFrames when the covered slots are already empty", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
const state = baseState();
state.stack[1] = part("b", "box", {
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
});
render(<Harness partId="b" initialState={state} submit={submit} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
expect(screen.queryByText(/werden zu „leer“/)).toBeNull();
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{ op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) },
]);
});
it("offers only positions where the slots are still free", async () => {
const user = userEvent.setup();
render(<Harness partId="b" initialState={withPocket()} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull();
expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument();
});
it("preselects the frame feeder when opened from the outline action", () => {
render(<Harness partId="b" startWithFrameFeeder />);
expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked();
});
it("moves focus to the new pocket item after inserting", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus());
});
it("edits the pocket level with a stepper", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument();
await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]);
});
it("asks before removing the pocket, then removes it and focuses the box", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
});
});
});