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

@@ -5,9 +5,12 @@ 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 { freeFeederStarts } from "@/lib/hive/feeder-slots";
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 { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker";
import type { FrameFeederChoice } from "./frame-feeder-picker";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import {
@@ -25,6 +28,11 @@ const INSERT_OPTIONS = [
{ value: true, label: "drin" },
{ value: false, label: "draußen" },
];
// Inserts the sheet can remove, with the noun used in its buttons.
const REMOVABLE_NOUNS: Partial<Record<HivePartKind, string>> = {
EVAPORATOR: "Verdunster",
FRAME_FEEDER: "Futtertasche",
};
const EVAPORATOR_OPTIONS = [
{ value: false, label: "Kein Verdunster" },
{ value: true, label: "Verdunster einsetzen" },
@@ -42,6 +50,8 @@ export interface PartSheetProps {
outlineRef: RefObject<HiveOutlineHandle | null>;
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
startWithEvaporator?: boolean;
/** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */
startWithFrameFeeder?: boolean;
}
interface Located {
@@ -78,18 +88,26 @@ interface PartFormProps {
type: CatalogPartType;
catalog: HiveCatalog;
startWithEvaporator: boolean;
startWithFrameFeeder: 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) {
function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameFeeder, 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 frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined;
const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : [];
const [feederChoice, setFeederChoice] = useState<FrameFeederChoice>(() => ({
wants: startWithFrameFeeder && feederStarts.length > 0,
start: feederStarts[0] ?? 1,
draft: frameFeederType ? draftOf(frameFeederType, {}) : {},
}));
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
@@ -108,8 +126,13 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
wasConfirming.current = confirmingRemoval;
}, [confirmingRemoval]);
const { send, pending, error } = useSheetSubmit(submit);
const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null;
// Fixed per form, so a retry after an error sends the same ids.
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
const [newIds] = useState(() => ({
floorInsert: newIdempotencyKey(),
evaporator: newIdempotencyKey(),
frameFeeder: newIdempotencyKey(),
}));
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
@@ -124,19 +147,29 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
}
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
const feederToInsert =
frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start)
? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft)
: null;
if (frameFeederType && feederToInsert) {
ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert));
}
const valid =
areDraftFieldsValid(type, draft) &&
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) &&
(!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft));
async function save() {
// After inserting an evaporator, focus goes to the new item.
// After inserting an evaporator or a frame feeder, focus goes to the new item.
const outcome = await send(ops);
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
if (outcome !== "failed") {
onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id);
}
}
async function remove() {
// An evaporator is an insert: focus goes to its box afterwards.
// An evaporator or frame feeder 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);
}
@@ -197,6 +230,16 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
: null}
</div>
) : null}
{frameFeederType && feederStarts.length > 0 ? (
<FrameFeederPicker
boxId={part.id}
type={frameFeederType}
frames={part.frames ?? []}
starts={feederStarts}
choice={feederChoice}
onChange={setFeederChoice}
/>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -206,7 +249,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
Speichern
</Button>
{type.kind === "EVAPORATOR" ? (
{removalNoun ? (
confirmingRemoval ? (
<div
role="group"
@@ -214,7 +257,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
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?
{removalNoun} wirklich entfernen?
</p>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
@@ -233,7 +276,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
size="touch"
onClick={() => setConfirmingRemoval(true)}
>
Verdunster entfernen
{removalNoun} entfernen
</Button>
)
) : null}
@@ -255,6 +298,7 @@ export function PartSheet({
submit,
outlineRef,
startWithEvaporator = false,
startWithFrameFeeder = false,
}: PartSheetProps) {
const located = locate(state, partId);
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
@@ -279,6 +323,7 @@ export function PartSheet({
type={type}
catalog={catalog}
startWithEvaporator={startWithEvaporator}
startWithFrameFeeder={startWithFrameFeeder}
submit={submit}
onDone={(id) => {
focusAfterSave.current = id;