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

@@ -0,0 +1,110 @@
"use client";
import { useEffect } from "react";
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types";
import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
import { formatAmount } from "./hive-scene/scene-model";
// DOM overlay of focus mode (spec 9.4): glass „Zurück“ pill, the part's name
// and its state as small glass chips, „Ändern“ for parts with a sheet.
// Esc leaves focus mode unless a dialog (sheet) has the keyboard.
export interface FocusChip {
key: string;
text: string;
}
function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
/** Chips for the focused part: frame count of a box, every state field with its value. */
export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] {
const part = findPart(state, id);
const type = part ? catalog.partTypes.get(part.partTypeId) : undefined;
if (!part || !type) return [];
const chips: FocusChip[] = [];
if (type.kind === "BOX") {
const frames = (part.frames ?? []).filter((frame) => frame.type !== "NONE").length;
chips.push({ key: "frames", text: frames === 1 ? "1 Rähmchen" : `${frames} Rähmchen` });
}
for (const field of type.stateSchema) {
const raw = part.state[field.key];
if (field.kind === "level") {
const value = typeof raw === "number" ? raw : 0;
chips.push({ key: field.key, text: `${field.label} ${formatAmount(value, field.unit)} von ${formatAmount(field.max, field.unit)}` });
} else {
const index = Math.max(0, field.options.indexOf(String(raw)));
chips.push({ key: field.key, text: `${field.label}: ${field.optionLabels[index] ?? field.options[index]}` });
}
}
const inserts = (part.inserts ?? []).map((insert) => catalog.partTypes.get(insert.partTypeId)?.kind);
if (type.kind === "FLOOR") chips.push({ key: "insert", text: inserts.includes("FLOOR_INSERT") ? "Bausperre drin" : "ohne Bausperre" });
return chips;
}
export interface HiveFocusBarProps {
/** Part in focus mode; nothing is rendered without one. */
focusedId: string | null;
name: string;
chips: FocusChip[];
/** Shows „Ändern“ (the part has a sheet and the user may edit). */
canEdit: boolean;
onEdit: () => void;
onBack: () => void;
}
export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }: HiveFocusBarProps) {
useEffect(() => {
if (!focusedId) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
// A sheet handles its own Esc.
if (event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']")) return;
onBack();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [focusedId, onBack]);
if (!focusedId) return null;
// Fades in (150 ms); only at motion `full` it also slides down (motion-full variant, spec 9.8).
return (
<div
key={focusedId}
className="pointer-events-none absolute inset-x-3 top-3 flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
>
<div className="flex max-w-full items-center gap-3">
<button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}>
<ArrowLeft aria-hidden className="size-4" />
Zurück
</button>
<p className="truncate text-lg font-semibold text-holo-label-fg drop-shadow-[0_1px_8px_rgb(0_0_0/0.6)]">{name}</p>
</div>
{chips.length > 0 || canEdit ? (
<ul className="flex max-w-full flex-wrap gap-1.5" aria-label={`Zustand von ${name}`}>
{chips.map((chip) => (
<li key={chip.key} className={`rounded-full px-3 py-1 text-xs font-medium ${GLASS_SURFACE}`}>
{chip.text}
</li>
))}
{canEdit ? (
<li>
<button type="button" onClick={onEdit} className={`pointer-events-auto ${GLASS_PILL}`}>
<SlidersHorizontal aria-hidden className="size-4" />
Ändern
</button>
</li>
) : null}
</ul>
) : null}
</div>
);
}