feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

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:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

@@ -0,0 +1,254 @@
"use client";
import { useId, useState } from "react";
import type { ReactNode } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type {
CatalogPartType,
HiveCatalog,
HiveFrame,
HiveOp,
HivePart,
HivePartKind,
HiveState,
} from "@/lib/hive/types";
import { frameFeederOps } from "../frame-feeder-picker";
import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control";
import type { FieldDraft } from "../state-field-control";
import type { DraftApplyResult } from "./use-hive-draft";
/** Host kind per insert kind (mirrors INSERT_KINDS of the validation). */
const INSERT_HOST_KIND: Partial<Record<HivePartKind, HivePartKind>> = {
FLOOR_INSERT: "FLOOR",
EVAPORATOR: "BOX",
FRAME_FEEDER: "BOX",
};
/** Inserts that a host can hold only once. */
const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"];
const SELECT_CLASS =
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm";
export interface AddPartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: CatalogPartType | null;
state: HiveState;
catalog: HiveCatalog;
/** Takes the ops as one draft step; a refusal is shown inline and the dialog stays open. */
onAdd: (ops: HiveOp[]) => DraftApplyResult;
}
/** „Hinzufügen …“ (spec 9.5): the keyboard and screen reader way to place a part. */
export function AddPartDialog({ open, onOpenChange, type, state, catalog, onAdd }: AddPartDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{type ? (
<AddPartForm
key={type.id}
type={type}
state={state}
catalog={catalog}
onAdd={onAdd}
onClose={() => onOpenChange(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface AddPartFormProps {
type: CatalogPartType;
state: HiveState;
catalog: HiveCatalog;
onAdd: (ops: HiveOp[]) => DraftApplyResult;
onClose: () => void;
}
function kindOf(catalog: HiveCatalog, part: HivePart): HivePartKind | undefined {
return catalog.partTypes.get(part.partTypeId)?.kind;
}
/** Stack index a new part starts at: floors at the bottom, the rest below a roof. */
function defaultPosition(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): number {
const top = state.stack.length;
if (type.kind === "FLOOR") return 0;
const last = state.stack[top - 1];
return last && type.kind !== "ROOF" && kindOf(catalog, last) === "ROOF" ? top - 1 : top;
}
/** Boxes and floors that can still take this insert. */
function hostsFor(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): HivePart[] {
const hostKind = INSERT_HOST_KIND[type.kind];
return state.stack.filter((part) => {
if (kindOf(catalog, part) !== hostKind) return false;
if (SINGLE_INSERTS.includes(type.kind)) {
return !(part.inserts ?? []).some((insert) => kindOf(catalog, insert) === type.kind);
}
const hostType = catalog.partTypes.get(part.partTypeId);
return hostType ? freeFeederStarts(part, hostType, type, catalog).length > 0 : false;
});
}
function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
</div>
);
}
function newBoxFrames(type: CatalogPartType): HiveFrame[] {
return Array.from({ length: type.frameSlots ?? 0 }, (_, index) => ({ slot: index + 1, type: "FOUNDATION" }));
}
function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) {
const formId = useId();
const [newId] = useState(newIdempotencyKey);
const isInsert = type.kind in INSERT_HOST_KIND;
const hosts = isInsert ? hostsFor(type, state, catalog) : [];
const [position, setPosition] = useState(() => String(defaultPosition(type, state, catalog)));
const [hostId, setHostId] = useState(() => hosts[0]?.id ?? "");
const [start, setStart] = useState<number | null>(null);
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, {}));
const [error, setError] = useState<string | null>(null);
const host = hosts.find((candidate) => candidate.id === hostId) ?? hosts[0];
const hostType = host ? catalog.partTypes.get(host.partTypeId) : undefined;
const starts =
type.kind === "FRAME_FEEDER" && host && hostType ? freeFeederStarts(host, hostType, type, catalog) : [];
const chosenStart = start !== null && starts.includes(start) ? start : (starts[0] ?? null);
const noPlace = isInsert && (!host || (type.kind === "FRAME_FEEDER" && chosenStart === null));
function buildOps(): HiveOp[] {
const partState: Record<string, number | string> = {};
for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? "");
const fresh: HivePart = { id: newId, partTypeId: type.id, state: partState };
if (type.kind === "BOX") fresh.frames = newBoxFrames(type);
if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }];
if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) {
return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh);
}
return [{ op: "addInsert", hostId: host?.id ?? "", part: fresh }];
}
function submit() {
const outcome = onAdd(buildOps());
if (outcome.ok) onClose();
else setError(hiveReasonText(outcome.reason));
}
const positions = Array.from({ length: state.stack.length + 1 }, (_, index) => ({
value: String(index),
label: index === 0 ? "ganz unten" : `über ${partDisplayName(state, catalog, state.stack[index - 1].id)}`,
}));
const startOptions = starts.map((from) => {
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: from }, type);
const noun = range && range.from !== range.to ? "Plätze" : "Platz";
return { value: from, label: `${noun} ${range ? rangeText(range) : from}` };
});
return (
<>
<DialogHeader>
<DialogTitle>{type.name} hinzufügen</DialogTitle>
<DialogDescription>
{PART_KIND_LABELS[type.kind]}
{isInsert ? ": Wähle, wohin das Teil kommt." : ": Wähle die Stelle im Aufbau."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isInsert ? (
host ? (
<Field label="In" htmlFor={`${formId}-host`}>
<select
id={`${formId}-host`}
className={SELECT_CLASS}
value={host.id}
onChange={(event) => setHostId(event.target.value)}
>
{hosts.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{partDisplayName(state, catalog, candidate.id)}
</option>
))}
</select>
</Field>
) : (
<p className="text-sm text-muted-foreground">Dafür gibt es gerade keinen passenden Platz.</p>
)
) : (
<Field label="Position" htmlFor={`${formId}-position`}>
<select
id={`${formId}-position`}
className={SELECT_CLASS}
value={position}
onChange={(event) => setPosition(event.target.value)}
>
{positions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
)}
{type.kind === "FRAME_FEEDER" && host && chosenStart !== null ? (
<div className="flex flex-col gap-1.5">
<span id={`${formId}-slot`} className="text-sm font-medium">
Plätze in der Zarge
</span>
<ChoiceGroup
options={startOptions}
value={chosenStart}
onChange={setStart}
labelledBy={`${formId}-slot`}
className="grid-cols-3"
/>
</div>
) : null}
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`${formId}-new`}
value={draft[field.key] ?? ""}
onChange={(value) => setDraft({ ...draft, [field.key]: value })}
/>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" size="touch" onClick={onClose}>
Abbrechen
</Button>
<Button type="button" size="touch" disabled={noPlace || !areDraftFieldsValid(type, draft)} onClick={submit}>
Hinzufügen
</Button>
</DialogFooter>
</>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
}
/** A two-button question; the safe answer (cancel) comes first and takes the initial focus order. */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="touch" onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
onOpenChange(false);
onConfirm();
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorNoticeProps {
title: string;
/** Where „Zurück zum Volk“ leads. */
backHref: string;
children?: ReactNode;
}
/** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */
export function EditorNotice({ title, backHref, children }: EditorNoticeProps) {
return (
<main className="fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
<div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}>
<h1 className="text-lg font-semibold">{title}</h1>
{children}
<Link href={backHref} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück zum Volk
</Link>
</div>
</main>
);
}
@@ -0,0 +1,97 @@
"use client";
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
import { partDisplayName } from "@/lib/hive/names";
import type { HiveCatalog, HiveOp, HivePart, HiveState } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
import type { DraftApplyResult } from "./use-hive-draft";
const ICON_BUTTON = `${GLASS_BUTTON} w-11 px-0 sm:px-0`;
export interface EditorStackListProps {
state: HiveState;
catalog: HiveCatalog;
selectedId: string | null;
onSelect: (id: string) => void;
onApply: (op: HiveOp) => DraftApplyResult;
}
interface RowProps {
part: HivePart;
name: string;
isSelected: boolean;
onSelect: () => void;
moveUp?: () => void;
moveDown?: () => void;
remove: () => void;
}
function Row({ part, name, isSelected, onSelect, moveUp, moveDown, remove }: RowProps) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={onSelect}
aria-current={isSelected ? "true" : undefined}
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-holo-edge-strong aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
data-part-id={part.id}
>
{name}
</button>
{moveUp || moveDown ? (
<>
<button type="button" aria-label={`Nach oben: ${name}`} title="Nach oben" disabled={!moveUp} onClick={moveUp} className={ICON_BUTTON}>
<ArrowUp aria-hidden />
</button>
<button type="button" aria-label={`Nach unten: ${name}`} title="Nach unten" disabled={!moveDown} onClick={moveDown} className={ICON_BUTTON}>
<ArrowDown aria-hidden />
</button>
</>
) : null}
<button type="button" aria-label={`Entfernen: ${name}`} title="Entfernen" onClick={remove} className={ICON_BUTTON}>
<Trash2 aria-hidden />
</button>
</div>
);
}
/**
* The stack as a list from top to bottom (spec 9.5/9.6): „Nach oben“, „Nach unten“
* and „Entfernen“ per part, the way to rearrange without dragging.
*/
export function EditorStackList({ state, catalog, selectedId, onSelect, onApply }: EditorStackListProps) {
const top = state.stack.length - 1;
const rows = state.stack.map((part, index) => ({ part, index })).reverse();
return (
<ul aria-label="Aufbau" className="flex flex-col gap-1">
{rows.map(({ part, index }) => (
<li key={part.id} className="flex flex-col gap-1">
<Row
part={part}
name={partDisplayName(state, catalog, part.id)}
isSelected={selectedId === part.id}
onSelect={() => onSelect(part.id)}
moveUp={index < top ? () => onApply({ op: "movePart", id: part.id, to: index + 1 }) : undefined}
moveDown={index > 0 ? () => onApply({ op: "movePart", id: part.id, to: index - 1 }) : undefined}
remove={() => onApply({ op: "removePart", id: part.id })}
/>
{(part.inserts ?? []).length > 0 ? (
<ul className="ml-4 flex flex-col gap-1 border-l border-white/15 pl-2">
{(part.inserts ?? []).map((insert) => (
<li key={insert.id}>
<Row
part={insert}
name={partDisplayName(state, catalog, insert.id)}
isSelected={selectedId === insert.id}
onSelect={() => onSelect(insert.id)}
remove={() => onApply({ op: "removePart", id: insert.id })}
/>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
);
}
@@ -0,0 +1,87 @@
"use client";
import type { ReactNode } from "react";
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorToolbarProps {
/** The „Zurück“ link (guarded while ops are open). */
back: ReactNode;
title: string;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
date: string;
/** Berlin today; later days are not accepted. */
today: string;
onDateChange: (date: string) => void;
hasOps: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
/** Only set for users with hive-systems:manage. */
onSaveTemplate?: () => void;
templateSaving?: boolean;
}
/** Top bar of the editor: back, title, undo/redo, date and the three ways to finish. */
export function EditorToolbar(props: EditorToolbarProps) {
const { date, today, hasOps, saving } = props;
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
return (
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
{props.back}
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
<button type="button" onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
<Undo2 aria-hidden />
Rückgängig
</button>
<button
type="button"
onClick={props.onRedo}
disabled={!props.canRedo}
className={GLASS_BUTTON}
title="Strg+Umschalt+Z"
>
<Redo2 aria-hidden />
Wiederholen
</button>
<label className="flex items-center gap-2 text-sm">
Datum
<input
type="date"
value={date}
max={today}
required
onChange={(event) => props.onDateChange(event.target.value)}
className="h-11 rounded-xl border border-white/20 bg-holo-label-bg/70 px-2 text-sm text-holo-label-fg [color-scheme:dark]"
/>
</label>
{props.onSaveTemplate ? (
<button
type="button"
onClick={props.onSaveTemplate}
disabled={props.templateSaving}
className={GLASS_BUTTON}
>
<LayoutTemplate aria-hidden />
Als Vorlage speichern
</button>
) : null}
<button type="button" onClick={props.onDiscard} disabled={saving} className={GLASS_BUTTON}>
<Trash2 aria-hidden />
Verwerfen
</button>
<button
type="button"
onClick={props.onSave}
disabled={!hasOps || !dateValid || saving}
className={GLASS_BUTTON}
>
<Save aria-hidden />
{saving ? "Speichert …" : "Speichern"}
</button>
</header>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { berlinCalendarDay } from "@/lib/dates";
import { hiveConflictOf, hiveKeys } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { describeHiveOps } from "@/lib/hive/describe";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { CatalogPartType, HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { HiveConflictSheet } from "../hive-conflict-sheet";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
import { HiveVisualSlot } from "../hive-visual-slot";
import { useHiveMutation } from "../use-hive-mutation";
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
import { SAVE_FAILED_TEXT } from "../use-sheet-submit";
import { AddPartDialog } from "./add-part-dialog";
import { ConfirmDialog } from "./confirm-dialog";
import { EditorStackList } from "./editor-stack-list";
import { EditorToolbar } from "./editor-toolbar";
import { templateFromState } from "./hive-template";
import { PartPalette } from "./part-palette";
import { useHiveDraft } from "./use-hive-draft";
import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft";
export interface HiveEditorProps {
colonyId: string;
setup: HiveSetupView;
catalog: HiveCatalog;
/** hive-systems:manage: may store the build as the system template. */
canManageTemplates: boolean;
}
const noop = () => {};
const STALE_HINT =
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
function hasPart(state: HiveState, id: string): boolean {
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
}
/** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */
function isTypingTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null;
}
/**
* Full-screen „Umbauen“ (spec 9.5): the same holo world as the card, with the
* parts bar and the stack list. Changes pile up in a local draft and are sent
* as one EDITOR change.
*/
export function HiveEditor(props: HiveEditorProps) {
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<HiveEditorBody {...props} />
</HiveViewProvider>
);
}
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEditorProps) {
const router = useRouter();
const queryClient = useQueryClient();
const draft = useHiveDraft(setup.state, catalog);
const mutation = useHiveMutation(colonyId, catalog);
const backHref = `/colonies/${encodeURIComponent(colonyId)}`;
const today = berlinCalendarDay(new Date().toISOString());
const [date, setDate] = useState(today);
const [addType, setAddType] = useState<CatalogPartType | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [pickedId, setPickedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState({ text: "", nonce: 0 });
const announce = useCallback(
(text: string) => setAnnouncement((current) => ({ text, nonce: current.nonce + 1 })),
[],
);
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
const hasOps = draft.ops.length > 0;
// A selection ends with its part (removed or undone).
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
// Forget a vanished selection for good, so a redo does not bring it back.
if (pickedId !== null && selectedId === null) setPickedId(null);
const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]);
const partName = useCallback((id: string) => partDisplayName(draft.state, catalog, id), [draft.state, catalog]);
/** `inline`: the caller (the add dialog) shows a refusal itself; otherwise it lands in the banner. */
const applyStep = useCallback(
(op: HiveOp | HiveOp[], inline: boolean): DraftApplyResult => {
const ops = Array.isArray(op) ? op : [op];
const before = draft.state;
const outcome = draft.apply(op);
if (outcome.ok) {
setError(null);
announce(`${describeHiveOps(ops, before, catalog)}.`);
} else if (!inline) {
setError(hiveReasonText(outcome.reason));
}
return outcome;
},
[draft, catalog, announce],
);
const apply = useCallback((op: HiveOp | HiveOp[]) => applyStep(op, false), [applyStep]);
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
const { undo: undoDraft, redo: redoDraft } = draft;
const describeStep = useCallback(
(prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`,
[catalog],
);
const undo = useCallback(() => {
const step = undoDraft();
if (step) announce(describeStep("Rückgängig", step));
}, [undoDraft, announce, describeStep]);
const redo = useCallback(() => {
const step = redoDraft();
if (step) announce(describeStep("Wiederholt", step));
}, [redoDraft, announce, describeStep]);
// Leaving the page (reload, close, outside link) with open changes asks first.
useEffect(() => {
if (!hasOps) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [hasOps]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [undo, redo]);
async function save() {
setError(null);
try {
await mutation.submit(draft.ops, "EDITOR", date);
} catch (error) {
// A conflict opens its own sheet (draft stays); anything else is reported here.
if (!hiveConflictOf(error)) setError(SAVE_FAILED_TEXT);
return;
}
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
async function resolveConflict(action: "retry" | "discard") {
const conflictVersion = mutation.conflict?.current.version ?? 0;
await mutation.resolveConflict(action);
if (action === "discard") {
draft.reset();
setError(null);
return;
}
const saved = queryClient.getQueryData<HiveSetupView>(hiveKeys.detail(colonyId));
if (action === "retry" && saved && saved.version > conflictVersion) {
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
}
const templateMutation = useMutation({
mutationFn: () => updateHiveSystem(setup.systemId, { template: templateFromState(draft.state, catalog) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
toast.success("Vorlage gespeichert");
},
onError: () => toast.error("Vorlage konnte nicht gespeichert werden."),
});
function openAdd(type: CatalogPartType) {
setAddType(type);
setAddOpen(true);
}
function onBackClick(event: MouseEvent<HTMLAnchorElement>) {
const plain = event.button === 0 && !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
if (!hasOps || !plain) return;
event.preventDefault();
setConfirm("leave");
}
return (
<div className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<EditorToolbar
back={
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück
</Link>
}
title="Umbauen"
canUndo={draft.canUndo}
canRedo={draft.canRedo}
onUndo={undo}
onRedo={redo}
date={date}
today={today}
onDateChange={setDate}
hasOps={hasOps}
saving={mutation.isPending}
onSave={() => void save()}
onDiscard={() => (hasOps ? setConfirm("discard") : draft.reset())}
onSaveTemplate={canManageTemplates ? () => setConfirm("template") : undefined}
templateSaving={templateMutation.isPending}
/>
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row">
<div className="relative min-h-[35svh] flex-1 overflow-hidden rounded-2xl">
<HiveVisualSlot
layout={layout}
state={draft.state}
catalog={catalog}
partName={partName}
onSelectPart={setPickedId}
onSelectFrame={noop}
/>
</div>
<aside
aria-label="Teile und Aufbau"
className={`flex max-h-[45svh] w-full flex-col gap-5 overflow-y-auto rounded-2xl p-3 lg:order-first lg:max-h-none lg:w-96 ${GLASS_SURFACE}`}
>
{draft.isBaseStale && !mutation.isPending ? (
<p role="note" className="rounded-lg bg-holo-edge/20 px-3 py-2 text-sm">
{STALE_HINT}
</p>
) : null}
{error ? (
<p role="alert" className="rounded-lg bg-destructive/20 px-3 py-2 text-sm">
{error}
</p>
) : null}
<section aria-labelledby="editor-parts" className="flex flex-col gap-3">
<h2 id="editor-parts" className="text-base font-semibold">
Teile
</h2>
<PartPalette catalog={catalog} onAdd={openAdd} />
</section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" className="text-base font-semibold">
Aufbau
</h2>
<EditorStackList
state={draft.state}
catalog={catalog}
selectedId={selectedId}
onSelect={setPickedId}
onApply={apply}
/>
</section>
</aside>
</div>
<div role="status" aria-live="polite" className="sr-only">
<span key={announcement.nonce}>{announcement.text}</span>
</div>
<AddPartDialog
open={addOpen}
onOpenChange={setAddOpen}
type={addType}
state={draft.state}
catalog={catalog}
onAdd={applyInDialog}
/>
<ConfirmDialog
open={confirm === "discard"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Änderungen verwerfen?"
description="Alle Änderungen an diesem Umbau gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verwerfen bestätigen"
onConfirm={() => {
draft.reset();
setError(null);
}}
/>
<ConfirmDialog
open={confirm === "leave"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Umbau verlassen?"
description="Nicht gespeicherte Änderungen gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verlassen"
onConfirm={() => router.push(backHref)}
/>
<ConfirmDialog
open={confirm === "template"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Als Vorlage speichern?"
description="Der Aufbau ersetzt die Vorlage des Beutensystems für neue Völker. Namen und Füllstände werden nicht übernommen."
cancelLabel="Abbrechen"
confirmLabel="Vorlage ersetzen"
onConfirm={() => templateMutation.mutate()}
/>
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
</div>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { templateFromState } from "./hive-template";
describe("templateFromState", () => {
it("strips ids, labels, frame names and levels but keeps the structure and choices", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b", "box", {
label: "Brut",
frames: frames(2).map((frame) => ({ ...frame, type: "BROOD" as const, name: "Königin" })),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
}),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 1 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
const template = templateFromState(state, inlineCatalog());
expect(JSON.stringify(template)).not.toMatch(/"id"|label|Königin|Brut"/);
expect(template.stack[1].frames).toEqual([
{ slot: 1, type: "BROOD" },
{ slot: 2, type: "BROOD" },
]);
expect(template.stack[1].inserts?.[0]).toMatchObject({ partTypeId: "frame-feeder", slot: 1, state: { syrupLiters: 0 } });
expect(template.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(template.stack[3].state).toEqual({ feedHole: "CLOSED" });
expect(state.stack[1].label).toBe("Brut");
});
});
@@ -0,0 +1,20 @@
import type { HiveCatalog, HivePart, HiveState, HiveTemplate, HiveTemplatePart } from "@/lib/hive/types";
function templatePart(part: HivePart, catalog: HiveCatalog): HiveTemplatePart {
const type = catalog.partTypes.get(part.partTypeId);
const levelKeys = new Set(type?.stateSchema.filter((field) => field.kind === "level").map((field) => field.key));
// Levels start empty on a new hive; choices (open/closed ...) stay as built.
const state = Object.fromEntries(
Object.entries(part.state).map(([key, value]) => [key, levelKeys.has(key) ? 0 : value]),
);
const copy: HiveTemplatePart = { partTypeId: part.partTypeId, state };
if (part.frames) copy.frames = part.frames.map((frame) => ({ slot: frame.slot, type: frame.type }));
if (part.slot !== undefined) copy.slot = part.slot;
if (part.inserts) copy.inserts = part.inserts.map((insert) => templatePart(insert, catalog));
return copy;
}
/** The editor's state as a system template: without ids, labels, frame names and fill levels. */
export function templateFromState(state: HiveState, catalog: HiveCatalog): HiveTemplate {
return { schemaVersion: 1, stack: state.stack.map((part) => templatePart(part, catalog)) };
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { inlineCatalog } from "@/lib/hive/test-utils";
import { PartPalette } from "./part-palette";
describe("PartPalette", () => {
it("groups the active part types in the four palette groups", () => {
const catalog = inlineCatalog();
catalog.partTypes.set("inner-lid", { ...catalog.partTypes.get("inner-lid")!, active: false });
render(<PartPalette catalog={catalog} onAdd={vi.fn()} />);
const groups = screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"));
expect(groups).toEqual([
"Zargen",
"Gitter und Böden",
"Fütterung und Behandlung",
"Abdeckung und Deckel",
]);
const feeding = screen.getByRole("group", { name: "Fütterung und Behandlung" });
expect(within(feeding).getAllByRole("button")).toHaveLength(3);
expect(screen.queryByRole("button", { name: /inner-lid/i })).not.toBeInTheDocument();
const cover = screen.getByRole("group", { name: "Abdeckung und Deckel" });
expect(within(cover).getAllByRole("button")).toHaveLength(3);
});
it("reports the chosen part type from „Hinzufügen …“", async () => {
const onAdd = vi.fn();
render(<PartPalette catalog={inlineCatalog()} onAdd={onAdd} />);
const zargen = screen.getByRole("group", { name: "Zargen" });
await userEvent.click(within(zargen).getByRole("button", { name: /Hinzufügen …/ }));
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" }));
});
});
@@ -0,0 +1,89 @@
"use client";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
interface PaletteGroup {
label: string;
kinds: readonly HivePartKind[];
}
const GROUPS: readonly PaletteGroup[] = [
{ label: "Zargen", kinds: ["BOX"] },
{ label: "Gitter und Böden", kinds: ["FLOOR", "DIVIDER_BOARD", "QUEEN_EXCLUDER", "PROPOLIS_SCREEN", "FLOOR_INSERT"] },
{ label: "Fütterung und Behandlung", kinds: ["FEEDER", "FRAME_FEEDER", "EVAPORATOR"] },
{ label: "Abdeckung und Deckel", kinds: ["COVER_SHEET", "INNER_LID", "INSULATION", "ROOF"] },
];
const SYMBOL_WIDTH = 32;
const SYMBOL_HEIGHT = 22;
const SYMBOL_MAX_LENGTH_MM = 600;
const SYMBOL_MAX_HEIGHT_MM = 260;
/** Small side view of a part in its real proportions (the same sizes hiveLayout uses). */
function PartSymbol({ type }: { type: CatalogPartType }) {
const width = Math.max(8, (type.lengthMm / SYMBOL_MAX_LENGTH_MM) * (SYMBOL_WIDTH - 2));
const height = Math.max(3, (type.heightMm / SYMBOL_MAX_HEIGHT_MM) * (SYMBOL_HEIGHT - 2));
return (
<svg
aria-hidden
viewBox={`0 0 ${SYMBOL_WIDTH} ${SYMBOL_HEIGHT}`}
className="h-6 w-8 shrink-0 text-holo-edge"
>
<rect
x={(SYMBOL_WIDTH - width) / 2}
y={SYMBOL_HEIGHT - 1 - height}
width={width}
height={height}
rx={1.5}
fill="currentColor"
fillOpacity={0.18}
stroke="currentColor"
strokeWidth={1.2}
/>
</svg>
);
}
export interface PartPaletteProps {
catalog: HiveCatalog;
/** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */
onAdd: (type: CatalogPartType) => void;
}
/** The parts bar of the editor (spec 9.5): active part types in four groups. */
export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
const active = [...catalog.partTypes.values()].filter((type) => type.active);
return (
<div className="flex flex-col gap-4">
{GROUPS.map((group) => {
const types = active
.filter((type) => group.kinds.includes(type.kind))
.sort((a, b) => group.kinds.indexOf(a.kind) - group.kinds.indexOf(b.kind) || a.name.localeCompare(b.name, "de"));
if (types.length === 0) return null;
return (
<div key={group.label} role="group" aria-label={group.label} className="flex flex-col gap-1.5">
<h3 aria-hidden className="text-xs font-medium uppercase tracking-wide text-holo-label-fg/70">
{group.label}
</h3>
<ul className="flex flex-col gap-1.5">
{types.map((type) => (
<li key={type.id} className="flex items-center gap-2">
<PartSymbol type={type} />
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate">{type.name}</span>
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
</span>
<button type="button" onClick={() => onAdd(type)} className={GLASS_BUTTON}>
Hinzufügen … <span className="sr-only">{type.name}</span>
</button>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { useHiveDraft } from "./use-hive-draft";
const catalog = inlineCatalog();
const base: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("r", "roof")],
};
const addBox: HiveOp = { op: "addPart", at: 2, part: part("b2", "box", { frames: frames(10) }) };
describe("useHiveDraft", () => {
it("applies an op to the state without touching the base", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply(addBox);
});
expect(outcome).toEqual({ ok: true });
expect(result.current.ops).toEqual([addBox]);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(base.stack).toHaveLength(3);
});
it("refuses an op that breaks a rule and keeps the draft", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply({ op: "addPart", at: 1, part: part("f2", "floor") });
});
expect(outcome).toMatchObject({ ok: false });
expect(outcome && !outcome.ok && outcome.reason).toMatch(/^RULE_FLOOR_BOTTOM/);
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
});
it("undoes and redoes step by step; a new op clears redo", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const label: HiveOp = { op: "setLabel", id: "b1", label: "Brut" };
act(() => void result.current.apply(addBox));
act(() => void result.current.apply(label));
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
act(() => result.current.undo());
expect(result.current.ops).toEqual([addBox]);
expect(result.current.canRedo).toBe(true);
act(() => result.current.undo());
expect(result.current.state).toEqual(base);
act(() => result.current.redo());
expect(result.current.ops).toEqual([addBox]);
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "Honig" }));
expect(result.current.canRedo).toBe(false);
});
it("treats a batch of ops as one undo step and applies it all or nothing", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const batch: HiveOp[] = [addBox, { op: "setLabel", id: "b2", label: "Neu" }];
act(() => void result.current.apply(batch));
expect(result.current.ops).toHaveLength(2);
act(() => result.current.undo());
expect(result.current.ops).toEqual([]);
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply([addBox, { op: "removePart", id: "missing" }]);
});
expect(outcome).toMatchObject({ ok: false });
expect(result.current.ops).toEqual([]);
});
it("reset drops the draft and the history", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
act(() => result.current.reset());
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
it("keeps the base from the first op on and flags a changed base as stale", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
expect(result.current.isBaseStale).toBe(false);
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.filter((p) => p.id !== "b1") };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
act(() => result.current.reset());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
});
it("drops the snapshot when undo reaches zero steps and redo starts on the current base", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.map((p) => (p.id === "b1" ? { ...p, label: "neu" } : p)) };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
act(() => void result.current.undo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
expect(result.current.canRedo).toBe(true);
act(() => void result.current.redo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(result.current.state.stack[1].label).toBe("neu");
act(() => result.current.undo());
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "x" }));
expect(result.current.canRedo).toBe(false);
});
it("reports the undone step with the state before it", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
let info: ReturnType<typeof result.current.undo> = null;
act(() => {
info = result.current.undo();
});
expect(info).toEqual({ ops: [addBox], before: base });
});
});
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
export type DraftApplyResult = { ok: true } | { ok: false; reason: string };
export interface DraftStepInfo {
ops: HiveOp[];
/** The state the step was applied to. */
before: HiveState;
}
export interface HiveDraft {
/** `applyHiveOps(snapshot, ops)`; the base itself until the first op. */
state: HiveState;
/** The server state changed after the draft started (the snapshot differs from the current base). */
isBaseStale: boolean;
/** All collected ops in order, ready to be sent as one change. */
ops: HiveOp[];
/** One undo step: a single op, or a batch that belongs together (all or nothing). */
apply(op: HiveOp | HiveOp[]): DraftApplyResult;
/** Returns the step that was taken back and the state before it (for announcements), or null. */
undo(): DraftStepInfo | null;
redo(): DraftStepInfo | null;
canUndo: boolean;
canRedo: boolean;
reset(): void;
}
interface History {
steps: HiveOp[][];
undone: HiveOp[][];
/** The base as it was when the first op was added; kept until reset. */
snapshot: HiveState | null;
}
const EMPTY: History = { steps: [], undone: [], snapshot: null };
/**
* Local draft of the editor (spec 9.5): ops pile up here and never touch the
* server until the caller sends `ops` in one change. Every step is checked
* against the rules before it is taken, so the draft is always valid.
*/
export function useHiveDraft(base: HiveState, catalog: HiveCatalog): HiveDraft {
const [history, setHistory] = useState<History>(EMPTY);
// Latest history for callers that apply twice before React re-renders.
const latest = useRef(history);
const commit = useCallback((next: History) => {
latest.current = next;
setHistory(next);
}, []);
const ops = useMemo(() => history.steps.flat(), [history.steps]);
// A refetch or an optimistic write must not change what the editor shows.
const baseline = history.snapshot ?? base;
const state = useMemo(() => {
if (ops.length === 0) return baseline;
const applied = applyHiveOps(baseline, ops, catalog);
return applied.ok ? applied.state : baseline;
}, [baseline, catalog, ops]);
const isBaseStale = history.snapshot !== null && JSON.stringify(history.snapshot) !== JSON.stringify(base);
const apply = useCallback(
(op: HiveOp | HiveOp[]): DraftApplyResult => {
const step = Array.isArray(op) ? op : [op];
const { steps, snapshot } = latest.current;
const start = snapshot ?? base;
const result = applyHiveOps(start, [...steps.flat(), ...step], catalog);
if (!result.ok) return { ok: false, reason: result.reason };
commit({ steps: [...steps, step], undone: [], snapshot: start });
return { ok: true };
},
[base, catalog, commit],
);
/** State after the given steps, applied to the snapshot. */
const stateAfter = useCallback(
(steps: HiveOp[][], snapshot: HiveState): HiveState => {
const applied = applyHiveOps(snapshot, steps.flat(), catalog);
return applied.ok ? applied.state : snapshot;
},
[catalog],
);
const undo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (steps.length === 0 || !snapshot) return null;
const step = steps[steps.length - 1];
const remaining = steps.slice(0, -1);
// Back at zero steps the draft is empty again: the next first op takes a fresh snapshot.
commit({ steps: remaining, undone: [...undone, step], snapshot: remaining.length === 0 ? null : snapshot });
return { ops: step, before: stateAfter(remaining, snapshot) };
}, [commit, stateAfter]);
const redo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (undone.length === 0) return null;
const step = undone[undone.length - 1];
// From zero steps the redo starts on the current base; it may no longer fit.
const start = snapshot ?? base;
if (!applyHiveOps(start, [...steps.flat(), ...step], catalog).ok) {
commit({ steps, undone: [], snapshot });
return null;
}
commit({ steps: [...steps, step], undone: undone.slice(0, -1), snapshot: start });
return { ops: step, before: stateAfter(steps, start) };
}, [base, catalog, commit, stateAfter]);
const reset = useCallback(() => commit(EMPTY), [commit]);
return {
state,
isBaseStale,
ops,
apply,
undo,
redo,
canUndo: history.steps.length > 0,
canRedo: history.undone.length > 0,
reset,
};
}
@@ -97,6 +97,19 @@ describe("FrameSheet", () => {
]);
});
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("sends name null when the name is emptied", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
@@ -56,7 +56,9 @@ function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: F
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault();
event.stopPropagation();
if (changed && !pending) void save();
}}
>
@@ -19,20 +19,32 @@ import { colonyFixture, historyItem, sampleState, setupView, systemDto } from ".
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
vi.mock("@/lib/hive/api", async (importOriginal) => {
const fetchHiveSystems = vi.fn();
return {
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems,
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
};
});
const pendingHive = vi.hoisted(() => ({ ops: [] as unknown[], count: 0 }));
vi.mock("@/components/hive/use-pending-hive", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/hive/use-pending-hive")>()),
usePendingHiveOps: () => pendingHive.ops,
usePendingHiveCount: () => pendingHive.count,
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
useCurrentUserId: () => null,
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
@@ -64,6 +76,8 @@ describe("HiveCard", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
pendingHive.ops = [];
pendingHive.count = 0;
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
@@ -152,6 +166,14 @@ describe("HiveCard", () => {
});
});
it("shows changes that wait for network on top of the loaded hive, with a note", async () => {
pendingHive.ops = FRAME_EDIT_OPS;
pendingHive.count = 1;
renderCard();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
@@ -239,6 +261,32 @@ describe("HiveCard", () => {
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("seeds the inspection draft from initialOps (after a remount)", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange, initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
// The outline shows the seeded draft, and seeding is not reported as a new edit.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
expect(screen.getByRole("treeitem", { name: /R5 · Brutwabe/ })).toBeInTheDocument();
expect(onOpsChange).not.toHaveBeenCalled();
});
it("ignores initialOps that do not apply to the loaded hive, and in view mode", async () => {
const broken: HiveOp[] = [{ op: "setFrame", boxId: "gone", slot: 5, type: "BROOD", name: null }];
const onOpsChange = vi.fn();
const first = renderCard({ mode: "inspection", initialOps: broken, onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
// The form learns that its stored ops were dropped, so its list matches the card.
expect(onOpsChange).toHaveBeenCalledWith([]);
first.unmount();
renderCard({ initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", {
@@ -1,22 +1,22 @@
"use client";
import { useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode, RefObject } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Boxes, History, ListTree } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth/auth-context";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
import { hiveErrorCode } from "@/lib/hive/api";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { applyHiveOps } from "@/lib/hive/ops";
import { withPendingOps } from "@/lib/hive/pending";
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet";
@@ -27,17 +27,22 @@ import { GLASS_BUTTON, GLASS_SURFACE } from "./hive-glass";
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
import { HiveInit } from "./hive-init";
import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import { HiveVisualSlot } from "./hive-visual-slot";
import { PartSheet } from "./part-sheet";
import { useHiveMutation } from "./use-hive-mutation";
import { useHiveSetup } from "./use-hive-setup";
import { usePendingHiveOps } from "./use-pending-hive";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
import { HiveDraftError } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden.";
/** The pending note inside the frosted panels (light text on the stage). */
const PENDING_NOTE_CLASS = "mt-1 flex items-center gap-1.5 text-xs text-holo-label-fg/90";
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
export interface HiveCardProps {
@@ -46,6 +51,12 @@ export interface HiveCardProps {
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
mode?: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
/**
* Inspection mode only: ops of an earlier draft to start from (the inspection
* form remounts the card after „Rückgängig“). Ignored unless they apply to the
* loaded hive; ignored in view mode.
*/
initialOps?: HiveOp[];
}
/**
@@ -75,11 +86,17 @@ function PlainCardBody({ children }: { children: ReactNode }) {
);
}
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps }: HiveCardProps) {
const { hasPermission } = useAuth();
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
// Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
const hive = useHiveSetup(colonyId);
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
const pendingKey = JSON.stringify(usePendingHiveOps(colonyId));
const setup = useMemo(
() => (hive.data && catalog ? withPendingOps(hive.data, JSON.parse(pendingKey) as HiveOp[], catalog) : undefined),
[hive.data, catalog, pendingKey],
);
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
@@ -109,7 +126,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
</PlainCardBody>
);
}
if (!hive.data || !catalog) {
if (!setup || !catalog) {
return (
<div
role="status"
@@ -121,15 +138,43 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
return (
<HiveCardBody
colonyId={colonyId}
setup={hive.data}
setup={setup}
catalog={catalog}
canEdit={canEdit}
mode={mode}
onOpsChange={onOpsChange}
initialOps={initialOps}
/>
);
}
/** Top: title panel or focus bar (placed 12 px from the top). Bottom: the button column (8 px padding). */
const TOP_OVERLAY_OFFSET_PX = 12;
const BOTTOM_OVERLAY_OFFSET_PX = 8;
/** Heights of the overlays over the stage, so the 3D camera keeps the model clear of them. */
function useOverlayInsets(top: RefObject<HTMLElement | null>, bottom: RefObject<HTMLElement | null>) {
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
useEffect(() => {
const topElement = top.current;
const bottomElement = bottom.current;
if (!topElement || !bottomElement || typeof ResizeObserver === "undefined") return;
const measure = () => {
const next = {
top: Math.round(topElement.offsetHeight + TOP_OVERLAY_OFFSET_PX),
bottom: Math.round(bottomElement.offsetHeight + BOTTOM_OVERLAY_OFFSET_PX),
};
setInsets((current) => (current.top === next.top && current.bottom === next.bottom ? current : next));
};
const observer = new ResizeObserver(measure);
observer.observe(topElement);
observer.observe(bottomElement);
measure();
return () => observer.disconnect();
}, [top, bottom]);
return insets;
}
interface FrameTarget {
boxId: string;
slot: number;
@@ -161,9 +206,16 @@ interface HiveCardBodyProps {
canEdit: boolean;
mode: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
initialOps?: HiveOp[];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) {
/** The seed of the inspection draft: the given ops when they apply to the loaded hive, else none. */
function initialDraft(mode: "view" | "inspection", setup: HiveSetupView, catalog: HiveCatalog, ops?: HiveOp[]): HiveOp[] {
if (mode !== "inspection" || !ops || ops.length === 0) return [];
return applyHiveOps(setup.state, ops, catalog).ok ? ops : [];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps }: HiveCardBodyProps) {
const view = useHiveView();
const connectivity = useConnectivity();
const mutation = useHiveMutation(colonyId, catalog);
@@ -171,8 +223,18 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
const outlineRef = useRef<HiveOutlineHandle>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null);
const outlineId = useId();
const topOverlayRef = useRef<HTMLDivElement>(null);
const bottomOverlayRef = useRef<HTMLDivElement>(null);
const insets = useOverlayInsets(topOverlayRef, bottomOverlayRef);
const [draftOps, setDraftOps] = useState<HiveOp[]>([]);
const [draftOps, setDraftOps] = useState<HiveOp[]>(() => initialDraft(mode, setup, catalog, initialOps));
// Seed ops that no longer apply were dropped: tell the form once, so its list matches the card.
const [seedDropped] = useState(() => mode === "inspection" && (initialOps?.length ?? 0) > 0 && draftOps.length === 0);
useEffect(() => {
if (seedDropped) onOpsChange?.([]);
// Once on mount: the form remounts the card when it changes the seed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedDropped]);
const [past, setPast] = useState<PastState | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -256,29 +318,31 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
partName={partName}
onSelectPart={selectPart}
onSelectFrame={editFrame}
insets={insets}
/>
</div>
{focusedId ? (
<HiveFocusBar
focusedId={focusedId}
name={partName(focusedId)}
chips={focusChips(shownState, catalog, focusedId)}
canEdit={editable && hasSheet(focusedId)}
onEdit={() => editPart(focusedId)}
onBack={leaveFocus}
/>
) : (
<div
className={`pointer-events-none absolute start-3 top-3 max-w-[min(26rem,calc(100%-1.5rem))] rounded-2xl px-4 py-3 ${GLASS_SURFACE}`}
>
<CardTitle className="text-xl font-semibold tracking-tight">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
</div>
)}
<div ref={topOverlayRef} className="pointer-events-none absolute inset-x-3 top-3">
{focusedId ? (
<HiveFocusBar
focusedId={focusedId}
name={partName(focusedId)}
chips={focusChips(shownState, catalog, focusedId)}
canEdit={editable && hasSheet(focusedId)}
onEdit={() => editPart(focusedId)}
onBack={leaveFocus}
/>
) : (
<div className={`w-fit max-w-[min(26rem,100%)] rounded-2xl px-4 py-2.5 sm:py-3 ${GLASS_SURFACE}`}>
<CardTitle className="text-lg font-semibold tracking-tight sm:text-xl">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={PENDING_NOTE_CLASS} />
</div>
)}
</div>
{/* One column for the outline panel and the button bar: the panel always ends above the
buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */}
<div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2">
<div className="order-2 flex flex-col items-center gap-2 p-1">
<div ref={bottomOverlayRef} className="order-2 flex flex-col items-center gap-2 p-1">
{past ? (
<div className="pointer-events-auto">
<HiveVersionBar
@@ -295,6 +359,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
// Phones keep the room for the part; the focus chips carry its state there.
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={`justify-center ${PENDING_NOTE_CLASS}`} />
</div>
) : null}
<div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2">
@@ -79,7 +79,7 @@ export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }
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"
className="pointer-events-none 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}`}>
@@ -0,0 +1,78 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
type DbModule = typeof import("@/lib/offline/db");
let db: DbModule;
let HivePendingNote: typeof import("./hive-pending-note").HivePendingNote;
function renderNote() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HivePendingNote colonyId="colony-1" />
</QueryClientProvider>,
);
}
const change = (colonyId: string) => ({
colonyId,
occurredOn: "2026-10-15",
source: "CARD" as const,
ops: [{ op: "setLabel" as const, id: "b", label: "x" }],
});
describe("HivePendingNote", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
HivePendingNote = (await import("./hive-pending-note")).HivePendingNote;
});
it("says nothing while no change waits", async () => {
await db.queueHiveChange(change("colony-2"), "k0", "user-1");
renderNote();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("names one waiting change", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
renderNote();
expect(await screen.findByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("counts a queued inspection with a hive change too", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
await db.queueInspection(
{ colonyId: "colony-1", date: "2026-10-15T00:00:00.000Z", hiveChange: { ops: change("x").ops } },
"k2",
"user-1",
);
renderNote();
expect(await screen.findByText("2 Änderungen warten auf Netz")).toBeInTheDocument();
});
it("points to the queue when a change was refused, and opens the queue sheet", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT", reason: "MISSING_ID: x" },
at: "2026-10-15T10:00:00.000Z",
});
renderNote();
expect(await screen.findByText(/Eine Änderung an dieser Beute wurde abgelehnt/)).toBeInTheDocument();
expect(screen.queryByText(/wartet auf Netz/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Warteschlange öffnen" }));
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CloudOff } from "lucide-react";
import { PendingQueueSheet } from "@/components/offline/pending-queue-sheet";
import { Button } from "@/components/ui/button";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { useHasRejectedHiveChange, usePendingHiveCount } from "./use-pending-hive";
export function pendingHiveText(count: number): string {
return count === 1 ? "1 Änderung wartet auf Netz" : `${count} Änderungen warten auf Netz`;
}
export const REJECTED_HIVE_TEXT =
"Eine Änderung an dieser Beute wurde abgelehnt. Neue Änderungen warten, bis du sie in der Warteschlange klärst.";
/**
* „1 Änderung wartet auf Netz“; with a refused change in the queue instead the
* hint to resolve it there, with a button that opens the queue sheet. Nothing
* while no change of the colony's hive waits.
*/
export function HivePendingNote({ colonyId, className }: { colonyId: string; className?: string }) {
const count = usePendingHiveCount(colonyId);
const rejected = useHasRejectedHiveChange(colonyId);
const userId = useCurrentUserId();
const [sheetOpen, setSheetOpen] = useState(false);
if (rejected && userId) {
return (
<div role="status" className={className ?? "flex flex-col items-start gap-1.5 text-xs"}>
<p className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{REJECTED_HIVE_TEXT}
</p>
<Button type="button" variant="outline" size="touch" onClick={() => setSheetOpen(true)}>
Warteschlange öffnen
</Button>
<PendingQueueSheet
userId={userId}
foreignCount={0}
open={sheetOpen}
onOpenChange={setSheetOpen}
trigger={null}
/>
</div>
);
}
if (count === 0) return null;
return (
<p role="status" className={className ?? "flex items-center gap-1.5 text-xs"}>
<CloudOff className="size-3.5 shrink-0" aria-hidden />
{pendingHiveText(count)}
</p>
);
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit";
describe("cameraBasis", () => {
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
@@ -35,12 +35,26 @@ describe("fitDistance", () => {
});
});
describe("overlayMargin", () => {
it("leaves more room on a short card than on a tall one, capped at 2", () => {
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9);
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9);
expect(overlayMargin(200, 150)).toBe(2);
expect(overlayMargin(0)).toBe(1);
describe("insetView", () => {
it("fits into the band between the overlays and centres the model in it", () => {
const view = insetView(460, { top: 96, bottom: 56 });
// 12 px gap next to each overlay.
expect(view.fraction).toBeCloseTo((460 - 108 - 68) / 460, 9);
// More covered at the top: the view moves the model down (negative offset).
expect(view.offsetY).toBeCloseTo(-(108 - 68) / 2, 9);
});
it("is the whole card without overlays and never less than 40 %", () => {
expect(insetView(800, { top: 0, bottom: 0 })).toEqual({ fraction: 1, offsetY: -0 });
expect(insetView(200, { top: 120, bottom: 60 }).fraction).toBeCloseTo(0.4, 9);
expect(insetView(0, { top: 10, bottom: 10 })).toEqual({ fraction: 1, offsetY: 0 });
});
});
describe("fitDistance with a vertical fraction", () => {
it("moves the camera back when only part of the height is free", () => {
const size: [number, number, number] = [5.25, 9, 4.3];
expect(fitDistance(size, 0.8, 1, undefined, undefined, 0.6)).toBeGreaterThan(fitDistance(size, 0.8, 1));
});
});
@@ -7,11 +7,11 @@ export const CAMERA_ELEVATION_DEG = 20;
export const CAMERA_AZIMUTH_DEG = 30;
export const CAMERA_FOV_DEG = 30;
/** Space around the model, as a factor on its projected size. */
export const FIT_MARGIN = 1.05;
export const FIT_MARGIN = 1.08;
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
export const FOCUS_FIT_MARGIN = 0.95;
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */
export const OVERLAY_RESERVE_PX = 150;
export const FOCUS_FIT_MARGIN = 1.0;
/** Breathing room between the model and the overlays, in pixels. */
export const OVERLAY_GAP_PX = 12;
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
@@ -45,13 +45,34 @@ export function cameraBasis(
return { back, right, up };
}
export interface OverlayInsets {
/** Pixels covered at the top (title panel or focus bar). */
top: number;
/** Pixels covered at the bottom (button bar). */
bottom: number;
}
export interface InsetView {
/** Share of the card height left free for the model (0.4 … 1). */
fraction: number;
/** Vertical view offset in pixels that centres the model in the free band (camera.setViewOffset). */
offsetY: number;
}
/**
* Extra margin so the model fits the part of the card the overlays leave free:
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2.
* The band of the card the frosted overlays leave free: the camera fits the
* model into it and shifts the view so the model sits in its middle (so the
* facts panel never covers the roof on a phone).
*/
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number {
if (!(heightPx > 0)) return 1;
return heightPx / Math.max(heightPx - reservePx, heightPx / 2);
export function insetView(heightPx: number, insets: OverlayInsets): InsetView {
if (!(heightPx > 0)) return { fraction: 1, offsetY: 0 };
const top = Math.max(0, insets.top) + (insets.top > 0 ? OVERLAY_GAP_PX : 0);
const bottom = Math.max(0, insets.bottom) + (insets.bottom > 0 ? OVERLAY_GAP_PX : 0);
const free = Math.max(heightPx - top - bottom, heightPx * 0.4);
const scale = free / Math.max(heightPx - top - bottom, 1e-6);
// When the overlays leave less than 40 %, shrink both proportionally.
const shift = scale > 1 ? (top - bottom) / (2 * scale) : (top - bottom) / 2;
return { fraction: free / heightPx, offsetY: -shift };
}
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */
@@ -66,13 +87,15 @@ export function fitDistance(
margin: number = FIT_MARGIN,
fovDeg: number = CAMERA_FOV_DEG,
basis: CameraBasis = cameraBasis(),
verticalFraction = 1,
): number {
const tanHalf = Math.tan(toRad(fovDeg) / 2);
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const halfWidth = halfExtent(size, basis.right);
const halfHeight = halfExtent(size, basis.up);
const halfDepth = halfExtent(size, basis.back);
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin;
const fraction = verticalFraction > 0 ? verticalFraction : 1;
const distance = Math.max(halfHeight / (tanHalf * fraction), halfWidth / (tanHalf * safeAspect)) * margin;
// The near face of the box sits half its depth closer to the camera.
return Math.max(distance + halfDepth, 1);
}
@@ -3,9 +3,10 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, insetView } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { useSceneKit } from "./scene-kit";
import { placeCamera } from "./scene-mutations";
import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { restBounds, stepToward } from "./use-hive-motion";
import type { FocusInfo } from "./use-hive-motion";
@@ -19,10 +20,12 @@ export interface CameraRigProps {
focus: FocusInfo | null;
/** Receives the current look-at point every frame; the model rig turns around it. */
onPivot: (center: Vec3) => void;
/** Card pixels covered by the frosted overlays. */
insets: OverlayInsets;
}
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const { motion } = useSceneKit();
export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
const { motion, signals } = useSceneKit();
const camera = useThree((state) => state.camera);
const width = useThree((state) => state.size.width);
const height = useThree((state) => state.size.height);
@@ -30,14 +33,15 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const goal = useMemo(() => {
const aspect = height > 0 ? width / height : 1;
const room = overlayMargin(height);
const band = insetView(height, insets);
const bounds = restBounds(layout);
const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction);
if (focus) {
const frame = focusFrame(focus.center, focus.size, bounds.center);
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) };
return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
}
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) };
}, [focus, layout, width, height]);
return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
}, [focus, layout, width, height, insets]);
const current = useRef<{ center: Vec3; distance: number } | null>(null);
useEffect(() => {
@@ -60,7 +64,9 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
}
const { center, distance } = current.current;
onPivot(center);
setFocusPoint(signals, focus ? focus.center : null);
placeCamera(camera, center, distance);
offsetView(camera, width, height, goal.offsetY);
const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
if (!settled) invalidate();
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three";
import type { Mesh } from "three";
import { BoxGeometry, CanvasTexture, CylinderGeometry, MeshStandardMaterial, PlaneGeometry, RepeatWrapping, SRGBColorSpace } from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import type { Color, Mesh } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createLiquidMaterial } from "./materials";
import { createCausticMaterial, createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit";
@@ -19,6 +20,8 @@ const WOBBLE = 0.02;
export type FillKind = "honey" | "acid" | "fondant";
type FillColor = Color;
export interface FillVolumeProps {
/** Centre and size of the full space (local to the part). */
center: Vec3;
@@ -35,31 +38,35 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
const mesh = useRef<Mesh>(null);
const shown = useRef(ratio);
const geometry = useMemo(
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)),
[shape],
);
const geometry = useMemo(() => {
if (kind === "fondant") return new RoundedBoxGeometry(1, 1, 1, 3, 0.1);
return shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12);
}, [shape, kind]);
const surface = useMemo(() => (kind === "fondant" ? fondantTexture() : null), [kind]);
const material = useMemo(() => {
if (kind === "fondant") {
// Feed paste: a soft sugary block with a fine grain (map and relief from one noise texture).
return new MeshStandardMaterial({
color: theme.fondant,
roughness: 0.55,
map: surface,
bumpMap: surface,
bumpScale: 2.5,
roughness: 0.62,
emissive: theme.fondant,
emissiveIntensity: 0.18,
transparent: true,
opacity: 0.94,
emissiveIntensity: 0.16,
});
}
return kind === "acid"
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
}, [kind, theme]);
}, [kind, theme, surface]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
surface?.dispose();
},
[geometry, material],
[geometry, material, surface],
);
useEffect(() => {
invalidate();
@@ -79,5 +86,72 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
if (shown.current !== ratio) invalidate();
});
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />;
return (
<>
<mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />
{kind !== "fondant" && shape === "box" && ratio > 0 ? (
<Caustics center={center} size={size} color={kind === "acid" ? theme.acid : theme.honey} />
) : null}
</>
);
}
/** Fine sugary grain for the feed paste; null without a 2D canvas (tests). */
function fondantTexture(): CanvasTexture | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) return null;
let seed = 97;
const next = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
context.fillStyle = "#d8d8d8";
context.fillRect(0, 0, 128, 128);
for (let dot = 0; dot < 900; dot += 1) {
const light = Math.round(180 + next() * 75);
context.fillStyle = `rgb(${light}, ${light}, ${light})`;
context.beginPath();
context.arc(next() * 128, next() * 128, 0.6 + next() * 1.6, 0, Math.PI * 2);
context.fill();
}
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
}
/** Soft caustics on the floor of the space a liquid fills, moving only at `full`. */
function Caustics({ center, size, color }: { center: Vec3; size: Vec3; color: FillColor }) {
const { motion } = useSceneKit();
const geometry = useMemo(() => {
const plane = new PlaneGeometry(size[0] * 0.98, size[2] * 0.98);
plane.rotateX(-Math.PI / 2);
return plane;
}, [size]);
const material = useMemo(() => createCausticMaterial(color, [size[0] * 3, size[2] * 3]), [color, size]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame((state) => {
if (motion === "full") setUniform(material, "uTime", state.clock.elapsedTime);
});
return (
<mesh
geometry={geometry}
material={material}
position={[center[0], center[1] - size[1] / 2 + 0.006, center[2]]}
raycast={noRaycast}
renderOrder={2}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MeshStandardMaterial } from "three";
import { Color, MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels";
@@ -9,7 +9,9 @@ import { createEdgeMaterial, createGlassMaterial } from "./materials";
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform } from "./scene-mutations";
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
import { useLivingEdges } from "./use-scene-effects";
import { frameRimColor } from "./frame-textures";
import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part";
import { useAnimatedNode } from "./use-animated-node";
@@ -52,7 +54,7 @@ export function FrameHints({
);
return (
<>
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh name={`frame-hints:${frames[0]?.boxId ?? ""}`} geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
</>
);
@@ -69,7 +71,7 @@ export interface FramePlateProps {
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion } = useSceneKit();
const { theme, geometries, textures, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
@@ -88,18 +90,23 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
}))
: null;
const texture = textures.get(frame.type);
const rimHex = frameRimColor(frame.type);
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
const materials = useMemo(
() => ({
bars: createGlassMaterial(theme, { frost: 0.28 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
bars: createGlassMaterial(theme, { frost: 0.28, env, dispersion: tier === "high" ? 0.03 : 0 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38, env, dispersion: 0 }),
edges: createEdgeMaterial(theme),
typed: texture
? new MeshStandardMaterial({
map: texture,
// The comb pattern as relief: cells read as depth under the studio light.
bumpMap: texture,
bumpScale: 4,
emissiveMap: texture,
emissive: "#ffffff",
emissiveIntensity: 0.95,
emissiveIntensity: 0.8,
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
metalness: 0.05,
transparent: true,
@@ -107,7 +114,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
})
: null,
}),
[theme, texture, frame.type],
[theme, texture, frame.type, env, tier],
);
useEffect(
() => () => {
@@ -128,6 +135,8 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
setUniform(materials.comb, "uGlow", glow);
setUniform(materials.edges, "uOpacity", opacity);
setUniform(materials.edges, "uIntensity", 1.3 * glow);
// Lifted, the rim takes the colour of the frame type.
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
};
useAnimatedNode(
@@ -136,13 +145,14 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
motion,
apply,
);
useLivingEdges([materials.edges]);
const restY = frame.center[1] - hostCenter[1];
const rise = Math.max(0, target.position[1] - restY);
const [fx, fy, fz] = frame.size;
return (
<group ref={group}>
<group ref={group} visible={target.look === "glass" || target.look === "type"}>
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
{comb ? (
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
@@ -154,6 +164,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
<mesh
position={[0, -rise / 2, 0]}
scale={[fx, fy + rise, fz]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -161,7 +172,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
onClick={handlers.onClick}
userData={{ hit: `${frame.boxId}#${frame.slot}` }}
>
<boxGeometry args={[fx, fy + rise, fz]} />
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial visible={false} />
</mesh>
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { FRAME_TYPES } from "@/lib/hive/types";
import type { FrameType } from "@/lib/hive/types";
import { createFrameTextureCache, framePatternSpec } from "./frame-textures";
import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
/** A canvas whose 2D context records every fill colour and shape drawn. */
function fakeCanvas() {
@@ -107,3 +107,12 @@ describe("createFrameTextureCache", () => {
expect(cache.get("BROOD")).toBeNull();
});
});
describe("frameRimColor", () => {
it("gives every frame type its own rim colour for the lifted look", () => {
const rims = FRAME_TYPES.filter((type) => type !== "NONE").map(frameRimColor);
expect(new Set(rims).size).toBe(rims.length);
expect(frameRimColor("BROOD")).toBe(framePatternSpec("BROOD")!.rim);
expect(frameRimColor("NONE")).toBeNull();
});
});
@@ -137,6 +137,14 @@ export function framePatternSpec(type: FrameType): FramePatternSpec | null {
return SPECS[type] ?? null;
}
/** Rim colours of the frames without a comb texture. */
const OPEN_RIMS: Partial<Record<FrameType, string>> = { DRONE_FRAME: "#ffc46b", DIVIDER: "#bfe4ff" };
/** Edge colour of a lifted or selected frame, matching its type; null for an empty slot. */
export function frameRimColor(type: FrameType): string | null {
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
}
/** Small deterministic PRNG (mulberry32). */
function random(seed: number): () => number {
let state = seed >>> 0;
@@ -1,15 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Canvas } from "@react-three/fiber";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { useHiveView } from "../use-hive-view";
import { useHiveViewStore } from "../use-hive-view";
import { CAMERA_FOV_DEG } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { HiveSceneContent } from "./hive-scene-content";
import { HiveStage, detectStageTier } from "./hive-stage";
import type { QualityVerdict } from "./quality";
import { SceneKitProvider } from "./scene-kit";
import { shouldIdle } from "./use-hive-motion";
@@ -24,6 +26,8 @@ export interface HiveCanvasProps {
onReady: () => void;
/** The WebGL context was lost; the browser may or may not restore it. */
onContextLost: () => void;
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
insets?: OverlayInsets;
}
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
@@ -61,12 +65,28 @@ export default function HiveCanvas({
onSelectFrame,
onReady,
onContextLost,
insets,
}: HiveCanvasProps) {
const motion = useEffectiveMotionLevel();
const view = useHiveView();
// Only acts on the view (empty click); the scene content subscribes itself.
const view = useHiveViewStore();
const root = useRef<HTMLDivElement>(null);
const detachContextLost = useRef<(() => void) | null>(null);
// Detach on unmount, so R3F's own forceContextLoss after unmount does not count as a loss.
useEffect(() => () => detachContextLost.current?.(), []);
const { visible, tabVisible } = useOnScreen(root);
const [tier] = useState(detectStageTier);
// Starts from the device class, drops to low when it renders below 45 fps for 2 s;
// depth of field only after it measured 55 fps or more (a 60 Hz screen).
const [tier, setTier] = useState(detectStageTier);
const [fast, setFast] = useState(false);
const onQuality = useCallback((verdict: QualityVerdict) => {
if (verdict === "slow") {
setTier("low");
setFast(false);
} else {
setFast(true);
}
}, []);
const idle = shouldIdle({ motion, visible, tabVisible });
return (
@@ -86,17 +106,20 @@ export default function HiveCanvas({
view.setHoveredFrame(null);
}}
onCreated={({ gl, invalidate }) => {
gl.domElement.addEventListener("webglcontextlost", (event) => {
const canvas = gl.domElement;
const onLost = (event: Event) => {
// Without preventDefault the browser never restores the context.
event.preventDefault();
onContextLost();
});
};
canvas.addEventListener("webglcontextlost", onLost);
detachContextLost.current = () => canvas.removeEventListener("webglcontextlost", onLost);
invalidate();
onReady();
}}
>
<SceneKitProvider motion={motion}>
<HiveStage tier={tier} />
<SceneKitProvider motion={motion} tier={tier}>
<HiveStage tier={tier} depthOfField={fast && motion === "full"} onQuality={onQuality} />
<HiveSceneContent
layout={layout}
state={state}
@@ -105,6 +128,8 @@ export default function HiveCanvas({
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={idle}
onScreen={visible && tabVisible}
insets={insets}
/>
</SceneKitProvider>
</Canvas>
@@ -83,6 +83,13 @@ const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactTh
const hits = (renderer: Rendered["renderer"]): string[] =>
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
/** Frame plates stay mounted (no mount work on the focus flight); only the open box shows them. */
const shownFrames = (renderer: Rendered["renderer"]): string[] =>
renderer.scene
.findAll((node) => typeof node.props.userData?.hit === "string" && node.props.userData.hit.includes("#"))
.filter((node) => node.parent?.instance.visible)
.map((node) => node.props.userData.hit);
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
const target = hit(renderer, id);
await renderer.fireEvent(target, "onPointerDown", { pointerType });
@@ -92,18 +99,29 @@ async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mo
describe("HiveSceneContent", () => {
it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
const { renderer } = await renderScene();
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(hits(renderer).filter((id) => !id.includes("#")).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(shownFrames(renderer)).toEqual([]);
});
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1"));
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#"));
const frameHits = shownFrames(renderer);
expect(frameHits).toHaveLength(8);
expect(frameHits.every((id) => id.startsWith("b1#"))).toBe(true);
expect(frameHits).not.toContain("b1#1");
expect(frameHits).not.toContain("b1#2");
});
it("keeps the merged frame-hint geometry across hover changes (no rebuild per render)", async () => {
const { renderer, store } = await renderScene();
const hint = () => renderer.scene.find((node) => node.props.name === "frame-hints:b1").instance;
const before = (hint() as unknown as { geometry: unknown }).geometry;
await act(async () => store.setHovered("b1"));
await act(async () => store.setHovered("fe"));
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
});
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThree } from "@react-three/fiber";
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { focusedIdOf, useHiveView } from "../use-hive-view";
import type { HoveredFrame } from "../use-hive-view";
import type { OverlayInsets } from "./camera-fit";
import { CameraRig } from "./camera-rig";
import { FramePlate } from "./frame-plate";
import { ModelRig } from "./model-rig";
@@ -37,9 +38,16 @@ export interface HiveSceneContentProps {
onSelectFrame: (boxId: string, slot: number) => void;
/** Idle motion may run (see shouldIdle). */
idle: boolean;
/** Card and tab visible (the quality probe runs at any motion level). */
onScreen?: boolean;
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
insets?: OverlayInsets;
}
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
const partKey = (id: string): string => `part:${id}`;
const NO_FRAMES: FrameBox[] = [];
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
@@ -72,6 +80,8 @@ export function HiveSceneContent({
onSelectPart,
onSelectFrame,
idle,
onScreen = false,
insets = NO_INSETS,
}: HiveSceneContentProps) {
const view = useHiveView();
const focusedId = focusedIdOf(view);
@@ -174,16 +184,22 @@ export function HiveSceneContent({
}, []);
const getPivot = useCallback(() => pivot.current, []);
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
// One stable array per box, so the merged hint geometry is built once per layout, not per render.
const framesByBox = useMemo(() => {
const grouped = new Map<string, FrameBox[]>();
for (const frame of layout.frames) grouped.set(frame.boxId, [...(grouped.get(frame.boxId) ?? []), frame]);
return grouped;
}, [layout]);
return (
<>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} />
<ModelRig getPivot={getPivot} idle={idle}>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
{layout.parts.map((placed) => {
const part = model.get(placed.id);
const target = targets.nodes.get(placed.id);
if (!part || !target) return null;
const frames = layout.frames.filter((frame) => frame.boxId === placed.id);
const frames = framesByBox.get(placed.id) ?? NO_FRAMES;
return (
<StackPart
key={placed.id}
@@ -209,22 +225,22 @@ export function HiveSceneContent({
/>
);
})}
{target.content
? frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})
: null}
{/* Frame plates stay mounted (hidden while their box is closed), so opening a box
only animates and does not create materials during the flight. */}
{frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})}
</StackPart>
);
})}
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { Bloom, DepthOfField, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing";
import type { DepthOfFieldEffect } from "postprocessing";
import {
AdditiveBlending,
DoubleSide,
BufferGeometry,
CanvasTexture,
Float32BufferAttribute,
@@ -15,8 +17,10 @@ import {
ShaderMaterial,
} from "three";
import type { Points } from "three";
import { createQualityMonitor } from "./quality";
import type { QualityVerdict, StageTier } from "./quality";
import { useSceneKit } from "./scene-kit";
import { setSceneBackground, setUniform } from "./scene-mutations";
import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme";
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
@@ -29,7 +33,7 @@ const OVERLAY_LAYER = 1;
const FLOOR_SIZE = 240;
const PARTICLE_COUNT = 42;
export type StageTier = "high" | "low";
export type { StageTier } from "./quality";
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
export function detectStageTier(): StageTier {
@@ -342,9 +346,43 @@ function Lights() {
);
}
function Effects({ tier }: { tier: StageTier }) {
/** Watches frame times while the scene animates and reports slow or fast (adaptive tier, depth of field). */
function QualityWatcher({ onVerdict }: { onVerdict: (verdict: QualityVerdict) => void }) {
const { signals } = useSceneKit();
const [monitor] = useState(() => createQualityMonitor());
useFrame((_, delta) => {
if (signals.timerFrame) {
// Frames paced by the idle timer say nothing about speed; start the window over.
markTimerFrame(signals, false);
monitor.push(Number.POSITIVE_INFINITY);
return;
}
const verdict = monitor.push(delta);
if (verdict) onVerdict(verdict);
});
return null;
}
/** Bokeh of the depth of field in focus mode; 0 in the rest view. */
const FOCUS_BOKEH = 2.4;
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
const { signals } = useSceneKit();
const dof = useRef<DepthOfFieldEffect>(null);
// Mounted for good once allowed (no composer rebuild and shader compile on the focus click);
// the focus point switches the blur on and off.
useFrame(() => {
if (!dof.current) return;
if (signals.focusPoint) setEffectTarget(dof.current, signals.focusPoint);
setEffectBokeh(dof.current, signals.focusPoint ? FOCUS_BOKEH : 0);
});
return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
{depthOfField ? (
<DepthOfField ref={dof} target={[0, 0, 0]} worldFocusRange={4} bokehScale={0} resolutionScale={0.5} />
) : (
<></>
)}
<Bloom
mipmapBlur
luminanceThreshold={0.7}
@@ -359,14 +397,81 @@ function Effects({ tier }: { tier: StageTier }) {
);
}
export function HiveStage({ tier }: { tier: StageTier }) {
const HAZE_VERTEX = /* glsl */ `
varying float vHeight;
varying float vFacing;
void main() {
vHeight = position.y;
vec4 view = modelViewMatrix * vec4(position, 1.0);
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
const HAZE_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uHeight;
varying float vHeight;
varying float vFacing;
void main() {
float along = clamp(vHeight / uHeight + 0.5, 0.0, 1.0);
// Brightest in the middle of the cone, fading toward the floor and the apex.
float fade = smoothstep(0.0, 0.35, along) * (1.0 - smoothstep(0.75, 1.0, along));
gl_FragColor = vec4(uColor * pow(vFacing, 2.0) * fade * 0.07, 1.0);
}
`;
const HAZE_HEIGHT = 18;
/** A faint cone of cool light falling onto the hive from above and behind. */
function Haze() {
const { theme } = useSceneKit();
const material = useMemo(
() =>
new ShaderMaterial({
uniforms: { uColor: { value: theme.rim.clone() }, uHeight: { value: HAZE_HEIGHT } },
vertexShader: HAZE_VERTEX,
fragmentShader: HAZE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
side: DoubleSide,
}),
[theme],
);
useEffect(() => () => material.dispose(), [material]);
return (
<mesh
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
rotation={[0.18, 0, 0.12]}
material={material}
renderOrder={-1}
layers={OVERLAY_LAYER}
raycast={() => {}}
>
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
</mesh>
);
}
export interface HiveStageProps {
tier: StageTier;
/** Depth of field (used in focus mode): only when the device measured fast (≥ 55 fps). */
depthOfField: boolean;
onQuality: (verdict: QualityVerdict) => void;
}
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
return (
<>
<Backdrop />
<Lights />
<Floor tier={tier} />
<Particles />
<Effects tier={tier} />
<Haze />
{tier === "high" ? <Particles /> : null}
<QualityWatcher onVerdict={onQuality} />
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
</>
);
}
@@ -1,17 +1,22 @@
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
import type { CubeTexture } from "three";
import type { HoloTheme } from "./theme";
// Hand-written shaders instead of transmission: a fake studio environment
// (white soft box above, blue rim light behind, faint cool bounce below) with
// fresnel gives the glass look at a fraction of the cost (spec 9.9).
// Hand-written shaders instead of transmission. The glass samples a small
// procedural studio cube map (scene-env.ts) for reflections, a dispersive
// refraction of it for body, blurred mips for frosted inner faces and its hot
// spots for glints; without the cube map it falls back to an analytic studio.
// One draw call per part either way (spec 9.9).
const GLASS_VERTEX = /* glsl */ `
varying vec3 vNormalW;
varying vec3 vViewDir;
varying vec3 vLocal;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz;
vLocal = position;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
@@ -20,31 +25,71 @@ const GLASS_FRAGMENT = /* glsl */ `
uniform vec3 uTint;
uniform vec3 uRim;
uniform vec3 uBounce;
uniform vec3 uSweepColor;
uniform float uOpacity;
uniform float uFrost;
uniform float uGlow;
uniform samplerCube uEnv;
uniform float uEnvStrength;
uniform float uDispersion;
uniform float uSweep;
uniform float uHeight;
varying vec3 vNormalW;
varying vec3 vViewDir;
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
if (!gl_FrontFacing) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
varying vec3 vLocal;
vec3 analyticStudio(vec3 r) {
float sky = smoothstep(-0.1, 0.9, r.y);
float ground = smoothstep(0.05, -0.7, r.y);
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel);
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45;
if (!gl_FrontFacing) {
// Faintly frosted inner faces.
return uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
}
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
bool back = !gl_FrontFacing;
if (back) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
// Reflection: sharp on the outside, blurred (frosted) on the inner faces.
vec3 cubeReflection = textureCube(uEnv, r, back ? 4.0 : 0.0).rgb;
vec3 reflection = mix(analyticStudio(r), cubeReflection, uEnvStrength);
// Body: the studio seen through the glass, slightly split per channel.
vec3 refraction = vec3(
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 - uDispersion)), 2.0).r,
textureCube(uEnv, refract(-v, n, 1.0 / 1.5), 2.0).g,
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 + uDispersion)), 2.0).b
) * uEnvStrength;
// Glints where the chamfers catch the hot spots.
float glint = smoothstep(1.6, 4.0, dot(cubeReflection, vec3(0.333))) * uEnvStrength;
vec3 color = uTint * (0.14 + uFrost * 0.3)
+ reflection * (0.22 + 0.78 * fresnel)
+ mix(refraction, vec3(dot(refraction, vec3(0.333))), 0.6) * 0.09
+ vec3(1.0) * glint * 0.9;
float alpha = 0.08 + uFrost * 0.16 + fresnel * 0.55 + glint * 0.5
+ dot(refraction, vec3(0.03));
if (back) {
alpha *= 0.6;
color = mix(color, uTint * 0.45, 0.35);
}
// Hover: a holographic shimmer sweeps across once and a thin scan line climbs the part.
if (uSweep > 0.0) {
float height = max(uHeight, 1e-3);
float y = clamp(vLocal.y / height + 0.5, 0.0, 1.0);
float diagonal = y * 0.75 + vLocal.x * 0.08;
float progress = uSweep * 2.2 - 0.6;
float band = exp(-pow((diagonal - progress) * 7.0, 2.0)) * (1.0 - smoothstep(0.8, 1.0, uSweep));
float scan = (1.0 - smoothstep(0.0, 0.012, abs(y - uSweep))) * step(uSweep, 0.999);
color += uSweepColor * (band * 0.55 + scan * 1.4);
alpha += band * 0.18 + scan * 0.45;
}
color *= 0.78 + 0.22 * uGlow;
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
}
@@ -53,6 +98,10 @@ void main() {
export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number;
/** Studio cube map; without it the analytic studio is used. */
env?: CubeTexture | null;
/** Colour split of the refraction; 0 on the low tier. */
dispersion?: number;
}
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
@@ -61,9 +110,15 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
uTint: { value: theme.tint.clone() },
uRim: { value: theme.rim.clone() },
uBounce: { value: theme.bounce.clone() },
uSweepColor: { value: theme.edgeStrong.clone() },
uOpacity: { value: 1 },
uFrost: { value: options.frost ?? 0 },
uGlow: { value: 1 },
uEnv: { value: options.env ?? null },
uEnvStrength: { value: options.env ? 1 : 0 },
uDispersion: { value: options.dispersion ?? 0.03 },
uSweep: { value: 0 },
uHeight: { value: 1 },
},
vertexShader: GLASS_VERTEX,
fragmentShader: GLASS_FRAGMENT,
@@ -77,12 +132,23 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
const EDGE_VERTEX = /* glsl */ `
attribute float aHot;
attribute float aStrength;
attribute float aAlong;
attribute float aPhase;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
vHot = aHot;
vStrength = aStrength;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vAlong = aAlong;
vPhase = aPhase;
vec4 view = modelViewMatrix * vec4(position, 1.0);
// Tube normal in view space: faces toward the camera form the white-hot core.
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
@@ -91,17 +157,26 @@ uniform vec3 uCyan;
uniform vec3 uHot;
uniform float uIntensity;
uniform float uOpacity;
uniform float uTime;
uniform float uPulse;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
float hot = vHot * vHot;
vec3 color = mix(uCyan, uHot, hot);
float energy = uIntensity * vStrength * (1.0 + hot * 1.6);
float core = pow(vFacing, 4.0);
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
// Slow light pulses travelling along the edges (motion full only).
float phase = fract(vAlong * 0.22 - uTime * 0.12 + vPhase);
float pulse = smoothstep(0.0, 0.035, phase) * (1.0 - smoothstep(0.035, 0.14, phase)) * uPulse;
float energy = uIntensity * vStrength * (0.55 + 0.45 * core + hot * 1.6 + pulse * 1.3);
gl_FragColor = vec4(color * energy * uOpacity, 1.0);
}
`;
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */
/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
@@ -109,6 +184,8 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
uHot: { value: theme.hot.clone() },
uIntensity: { value: 1.6 },
uOpacity: { value: 1 },
uTime: { value: 0 },
uPulse: { value: 0 },
},
vertexShader: EDGE_VERTEX,
fragmentShader: EDGE_FRAGMENT,
@@ -118,6 +195,49 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
});
}
const CAUSTIC_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const CAUSTIC_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uStrength;
uniform vec2 uScale;
varying vec2 vUv;
void main() {
vec2 p = vUv * uScale;
float v = sin(p.x + uTime) + sin((p.y + uTime) * 0.5) + sin((p.x + p.y + uTime) * 0.5);
vec2 q = p + vec2(sin(uTime / 3.0), cos(uTime / 2.0)) * 2.0;
v += sin(sqrt(q.x * q.x + q.y * q.y + 1.0) + uTime);
float caustic = pow(1.0 - abs(sin(v * 1.7)), 6.0);
float edge = smoothstep(0.0, 0.08, vUv.x) * smoothstep(0.0, 0.08, 1.0 - vUv.x)
* smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.08, 1.0 - vUv.y);
gl_FragColor = vec4(uColor * caustic * edge * uStrength, 1.0);
}
`;
/** Soft golden caustics on the floor under a liquid; additive, animated only at `full`. */
export function createCausticMaterial(color: Color, scale: [number, number]): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uColor: { value: color.clone() },
uTime: { value: 0 },
uStrength: { value: 0.9 },
uScale: { value: scale },
},
vertexShader: CAUSTIC_VERTEX,
fragmentShader: CAUSTIC_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
}
const GRID_VERTEX = /* glsl */ `
varying vec3 vLocal;
void main() {
@@ -5,7 +5,10 @@ import type { MutableRefObject, ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { Group } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createRigPointer } from "./rig-pointer";
import type { RigInput } from "./rig-pointer";
import { useSceneKit } from "./scene-kit";
import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
@@ -15,28 +18,11 @@ import { stepToward } from "./use-hive-motion";
// springs back. Vertical swipes scroll the page (touch-action: pan-y).
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export { MAX_TILT, MAX_TURN, SPRING_BACK_MS, clampTurn } from "./rig-pointer";
export const IDLE_FLOAT = 0.04;
export const IDLE_PERIOD_SECONDS = 6;
export const SPRING_BACK_MS = 4000;
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
export const IDLE_FRAME_MS = 33;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
const gl = useThree((state) => state.gl);
@@ -46,64 +32,31 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
useEffect(() => {
const element = gl.domElement as HTMLElement | undefined;
if (!element || typeof element.addEventListener !== "function") return;
let press: { x: number; y: number; turn: number; id: number } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
const onDown = (event: PointerEvent) => {
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId };
if (springBack) clearTimeout(springBack);
};
const onMove = (event: PointerEvent) => {
const rect = element.getBoundingClientRect();
if (press && press.id === event.pointerId) {
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.current.dragging = true;
}
if (input.current.dragging) {
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
invalidate();
}
return;
}
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.current.tiltY = nx * MAX_TILT;
input.current.tiltX = ny * MAX_TILT * 0.5;
invalidate();
}
};
const onUp = () => {
if (input.current.dragging) {
springBack = setTimeout(() => {
input.current.turn = 0;
invalidate();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
setTimeout(() => {
input.current.dragging = false;
}, 0);
};
const onLeave = () => {
input.current.tiltX = 0;
input.current.tiltY = 0;
invalidate();
};
const pointer = createRigPointer(input.current, {
tilt: enabledTilt,
rect: () => element.getBoundingClientRect(),
// Captured while dragging, so a release outside the canvas still ends the drag.
capture: (id) => capturePointer(element, id),
release: (id) => releasePointer(element, id),
changed: invalidate,
});
const onDown = (event: PointerEvent) => pointer.down(event);
const onMove = (event: PointerEvent) => pointer.move(event);
const onUp = (event: PointerEvent) => pointer.up(event);
const onLeave = () => pointer.leave();
element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp);
element.addEventListener("pointercancel", onUp);
element.addEventListener("lostpointercapture", onUp);
element.addEventListener("pointerleave", onLeave);
return () => {
if (springBack) clearTimeout(springBack);
pointer.dispose();
element.removeEventListener("pointerdown", onDown);
element.removeEventListener("pointermove", onMove);
element.removeEventListener("pointerup", onUp);
element.removeEventListener("pointercancel", onUp);
element.removeEventListener("lostpointercapture", onUp);
element.removeEventListener("pointerleave", onLeave);
};
}, [gl, invalidate, enabledTilt]);
@@ -111,21 +64,37 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
return input;
}
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */
function useIdleTicker(active: boolean): void {
/** Seconds of continuous idle frames after mount, so the quality monitor can measure the device. */
export const QUALITY_PROBE_SECONDS = 4;
/**
* Asks for frames (demand frame loop): continuously during the quality probe
* while the card is on screen (any motion level, so a weak device also drops
* from the high tier), then about every 33 ms while the idle motion runs
* (marked as timer frames).
*/
function useIdleTicker(active: boolean, probing: boolean): void {
const invalidate = useThree((state) => state.invalidate);
const { signals } = useSceneKit();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const probe = useRef(0);
useEffect(() => {
if (active) invalidate();
if (active || probing) invalidate();
return () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
}, [active, invalidate]);
useFrame(() => {
}, [active, probing, invalidate]);
useFrame((_, delta) => {
if (probing && probe.current < QUALITY_PROBE_SECONDS) {
probe.current += Math.min(delta, MAX_FRAME_SECONDS);
invalidate();
return;
}
if (!active || timer.current !== null) return;
timer.current = setTimeout(() => {
timer.current = null;
markTimerFrame(signals, true);
invalidate();
}, IDLE_FRAME_MS);
});
@@ -136,10 +105,12 @@ export interface ModelRigProps {
getPivot: () => Vec3;
/** Idle motion may run (motion `full`, card visible, tab visible). */
idle: boolean;
/** Card and tab visible: the quality probe may run (any motion level). */
onScreen?: boolean;
children: ReactNode;
}
export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
export function ModelRig({ getPivot, idle, onScreen = false, children }: ModelRigProps) {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const full = motion === "full";
@@ -147,7 +118,7 @@ export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
const outer = useRef<Group>(null);
const inner = useRef<Group>(null);
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
useIdleTicker(idle && full);
useIdleTicker(idle && full, onScreen || (idle && full));
useFrame((_, delta) => {
if (!outer.current || !inner.current) return;
@@ -169,4 +169,16 @@ describe("geometry builders", () => {
expect(min).toBe(0);
geometry.dispose();
});
it("gives the edge tubes unit normals, a distance along the edge and a phase per edge", () => {
const geometry = buildEdgeGeometry([{ a: [0, 0, 0], b: [2, 0, 0], hotA: 1, hotB: 1, strength: 1 }]);
const normal = geometry.getAttribute("normal");
const along = geometry.getAttribute("aAlong");
expect(Math.hypot(normal.getX(0), normal.getY(0), normal.getZ(0))).toBeCloseTo(1, 6);
expect(normal.getX(0)).toBeCloseTo(0, 6);
expect(along.getX(0)).toBe(0);
expect(along.getX(along.count - 1)).toBeCloseTo(2, 6);
expect(geometry.getAttribute("aPhase").getX(0)).toBeGreaterThanOrEqual(0);
geometry.dispose();
});
});
@@ -517,14 +517,21 @@ function perpendicular(direction: Vec3): [Vec3, Vec3] {
return [u, normalize(cross(u, direction))];
}
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */
/**
* Thin square tubes along every edge: `normal` (round tube normals for the
* white-hot core), `aHot` (corner glow), `aStrength`, `aAlong` (distance along
* the edge) and `aPhase` (per edge) for the travelling pulses.
*/
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const hot: number[] = [];
const strength: number[] = [];
const along: number[] = [];
const phase: number[] = [];
const indices: number[] = [];
const half = width / 2;
for (const edge of segments) {
for (const [segmentIndex, edge] of segments.entries()) {
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
const length = Math.hypot(delta[0], delta[1], delta[2]);
if (length < 1e-6) continue;
@@ -538,6 +545,8 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[1, edge.hotB],
];
const base = positions.length / 3;
// Deterministic per-edge phase, so the travelling pulses do not march in step.
const edgePhase = (Math.sin(segmentIndex * 12.9898) * 43758.5453) % 1;
for (const [t, ringHot] of rings) {
for (const [su, sw] of [
[1, 1],
@@ -545,11 +554,21 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[-1, -1],
[1, -1],
]) {
const offset: Vec3 = [
(u[0] * su + w[0] * sw) * half,
(u[1] * su + w[1] * sw) * half,
(u[2] * su + w[2] * sw) * half,
];
const offsetLength = Math.hypot(offset[0], offset[1], offset[2]) || 1;
for (let axis = 0; axis < 3; axis += 1) {
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half);
positions.push(edge.a[axis] + delta[axis] * t + offset[axis]);
// Smooth tube normal: from the edge's axis out through the corner.
normals.push(offset[axis] / offsetLength);
}
hot.push(ringHot);
strength.push(edge.strength);
along.push(t * length);
phase.push(Math.abs(edgePhase));
}
}
for (let band = 0; band < 3; band += 1) {
@@ -564,8 +583,11 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
geometry.setAttribute("aAlong", new Float32BufferAttribute(along, 1));
geometry.setAttribute("aPhase", new Float32BufferAttribute(phase, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createQualityMonitor, springStep } from "./quality";
describe("createQualityMonitor", () => {
const feed = (monitor: ReturnType<typeof createQualityMonitor>, fps: number, seconds: number) => {
const verdicts = [];
for (let time = 0; time < seconds; time += 1 / fps) verdicts.push(monitor.push(1 / fps));
return verdicts.filter((verdict) => verdict !== null);
};
it("reports slow after two seconds of rendering below 45 fps", () => {
const monitor = createQualityMonitor();
expect(feed(monitor, 30, 1.9)).toEqual([]);
expect(feed(monitor, 30, 0.3)).toEqual(["slow"]);
});
it("reports fast after two seconds at 60 fps or more", () => {
expect(feed(createQualityMonitor(), 120, 2.1)).toEqual(["fast"]);
});
it("counts a 60 Hz screen (about 59.9 fps) as fast", () => {
expect(feed(createQualityMonitor(), 59.9, 2.2)).toEqual(["fast"]);
});
it("stays silent between the two thresholds", () => {
expect(feed(createQualityMonitor(), 50, 4.5)).toEqual([]);
});
it("ignores pauses of the demand frame loop instead of counting them as slow frames", () => {
const monitor = createQualityMonitor();
feed(monitor, 120, 1);
expect(monitor.push(3)).toBeNull();
// The window restarts after the pause: one more second is not enough.
expect(feed(monitor, 120, 1)).toEqual([]);
expect(feed(monitor, 120, 1.1)).toEqual(["fast"]);
});
});
describe("springStep", () => {
it("overshoots the target slightly and settles", () => {
let state = { value: 0, velocity: 0 };
let peak = 0;
for (let frame = 0; frame < 120; frame += 1) {
state = springStep(state.value, state.velocity, 1, 1 / 60);
peak = Math.max(peak, state.value);
}
expect(peak).toBeGreaterThan(1.005);
expect(peak).toBeLessThan(1.08);
expect(state).toEqual({ value: 1, velocity: 0 });
});
it("is most of the way there after about 350 ms", () => {
let state = { value: 0, velocity: 0 };
for (let frame = 0; frame < 21; frame += 1) state = springStep(state.value, state.velocity, 1, 1 / 60);
expect(state.value).toBeGreaterThan(0.9);
});
it("is frame-rate independent enough for a long frame", () => {
const state = springStep(0, 0, 1, 1 / 15);
expect(state.value).toBeGreaterThan(0);
expect(state.value).toBeLessThan(1.1);
});
});
@@ -0,0 +1,78 @@
// Adaptive quality and the flight spring, as pure functions.
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
/** Below this the scene drops to the low tier (no reflector, MSAA, depth of field, particles). */
export const SLOW_FPS = 45;
/** At or above this depth of field may run in focus mode (55, so a 60 Hz screen measuring ~59.9 counts). */
export const FAST_FPS = 55;
/** Seconds of continuous rendering per verdict. */
export const QUALITY_WINDOW_SECONDS = 2;
/** A longer gap is a pause of the demand frame loop, not a slow frame. */
const PAUSE_SECONDS = 0.25;
export type QualityVerdict = "slow" | "fast";
export interface QualityMonitor {
/** One rendered frame with its delta; returns a verdict when a window completes. */
push(deltaSeconds: number): QualityVerdict | null;
}
/**
* Watches frame times while the scene actually renders (demand loop: flights,
* idle motion). Equivalent to drei's PerformanceMonitor, without counting the
* pauses between demand frames as slow frames.
*/
export function createQualityMonitor(
slowFps: number = SLOW_FPS,
fastFps: number = FAST_FPS,
windowSeconds: number = QUALITY_WINDOW_SECONDS,
): QualityMonitor {
let time = 0;
let frames = 0;
return {
push(delta) {
if (!(delta > 0) || delta > PAUSE_SECONDS) {
time = 0;
frames = 0;
return null;
}
time += delta;
frames += 1;
if (time < windowSeconds) return null;
const fps = frames / time;
time = 0;
frames = 0;
if (fps < slowFps) return "slow";
if (fps >= fastFps) return "fast";
return null;
},
};
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
export interface SpringState {
value: number;
velocity: number;
}
/** One frame of a damped spring toward `target` (semi-implicit Euler with small substeps). */
export function springStep(value: number, velocity: number, target: number, deltaSeconds: number): SpringState {
let x = value;
let v = velocity;
let left = Math.max(0, deltaSeconds);
while (left > 0) {
const dt = Math.min(SPRING_SUBSTEP, left);
v += (SPRING_STIFFNESS * (target - x) - SPRING_DAMPING * v) * dt;
x += v * dt;
left -= dt;
}
if (Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10) return { value: target, velocity: 0 };
return { value: x, velocity: v };
}
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer";
import type { PointerSample, RigInput } from "./rig-pointer";
function setup(tilt = true) {
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
const host = {
tilt,
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
capture: vi.fn(),
release: vi.fn(),
changed: vi.fn(),
};
const pointer = createRigPointer(input, host);
return { input, host, pointer };
}
const sample = (clientX: number, extra: Partial<PointerSample> = {}): PointerSample => ({
clientX,
clientY: 100,
pointerId: 1,
pointerType: "mouse",
buttons: 1,
...extra,
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("createRigPointer", () => {
it("turns on a horizontal drag and captures the pointer, so the release is seen outside the canvas", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(104));
expect(input.dragging).toBe(false);
pointer.move(sample(160));
expect(input.dragging).toBe(true);
expect(host.capture).toHaveBeenCalledWith(1);
expect(input.turn).toBeGreaterThan(0);
});
it("clamps the turn to ±60°", () => {
const { input, pointer } = setup();
pointer.down(sample(0));
pointer.move(sample(2000));
expect(input.turn).toBeCloseTo(MAX_TURN, 9);
});
it("ends a mouse drag whose button was released outside (buttons = 0) and springs back after 4 s", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
expect(input.dragging).toBe(true);
// The pointer comes back over the canvas with no button pressed.
pointer.move(sample(260, { buttons: 0 }));
vi.advanceTimersByTime(1);
expect(input.dragging).toBe(false);
expect(host.release).toHaveBeenCalledWith(1);
const turned = input.turn;
expect(turned).toBeGreaterThan(0);
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBe(0);
});
it("springs back 4 s after a normal release, and a new press cancels it", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
vi.advanceTimersByTime(SPRING_BACK_MS - 100);
pointer.down(sample(200));
vi.advanceTimersByTime(500);
expect(input.turn).toBeGreaterThan(0);
});
it("keeps dragging true until the click after the drag has run", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
expect(input.dragging).toBe(true);
vi.advanceTimersByTime(0);
expect(input.dragging).toBe(false);
});
it("tilts toward a hovering mouse only when tilt is enabled, and levels on leave", () => {
const tilted = setup(true);
tilted.pointer.move(sample(400, { buttons: 0 }));
expect(tilted.input.tiltY).toBeGreaterThan(0);
tilted.pointer.leave();
expect(tilted.input.tiltY).toBe(0);
const still = setup(false);
still.pointer.move(sample(400, { buttons: 0 }));
expect(still.input.tiltY).toBe(0);
});
it("clears its timers on dispose", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
pointer.dispose();
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBeGreaterThan(0);
});
});
@@ -0,0 +1,118 @@
// Pointer handling of the model rig (spec 9.4), without DOM or three.js so it
// can be tested: a horizontal drag turns the model ±60° (pointer captured, so
// a release outside the canvas still ends it), 4 s after the drag it springs
// back; a hovering mouse tilts it slightly (motion `full`).
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export const SPRING_BACK_MS = 4000;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
export interface PointerSample {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
/** Pressed buttons; 0 = none (a mouse released outside the canvas). */
buttons: number;
}
export interface RigPointerHost {
/** Pointer tilt allowed (motion `full`). */
tilt: boolean;
rect(): { left: number; top: number; width: number; height: number };
capture(pointerId: number): void;
release(pointerId: number): void;
/** Something changed: request a frame. */
changed(): void;
}
export interface RigPointer {
down(event: PointerSample): void;
move(event: PointerSample): void;
up(event?: Pick<PointerSample, "pointerId">): void;
leave(): void;
dispose(): void;
}
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
export function createRigPointer(input: RigInput, host: RigPointerHost): RigPointer {
let press: { x: number; y: number; turn: number; id: number; captured: boolean } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
let clearDrag: ReturnType<typeof setTimeout> | null = null;
const pointer: RigPointer = {
down(event) {
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
if (springBack) clearTimeout(springBack);
springBack = null;
},
move(event) {
if (press && press.id === event.pointerId) {
// The mouse button went up outside the canvas: end the drag now.
if (event.pointerType === "mouse" && event.buttons === 0) {
pointer.up(event);
return;
}
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.dragging = true;
press.captured = true;
host.capture(press.id);
}
if (input.dragging) {
input.turn = clampTurn(press.turn + (dx / Math.max(host.rect().width, 1)) * TURN_PER_WIDTH);
host.changed();
}
return;
}
const rect = host.rect();
if (host.tilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.tiltY = nx * MAX_TILT;
input.tiltX = ny * MAX_TILT * 0.5;
host.changed();
}
},
up() {
if (!press) return;
if (press.captured) host.release(press.id);
if (input.dragging) {
springBack = setTimeout(() => {
input.turn = 0;
host.changed();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
clearDrag = setTimeout(() => {
input.dragging = false;
}, 0);
},
leave() {
input.tiltX = 0;
input.tiltY = 0;
host.changed();
},
dispose() {
if (springBack) clearTimeout(springBack);
if (clearDrag) clearTimeout(clearDrag);
},
};
return pointer;
}
@@ -0,0 +1,82 @@
import {
BackSide,
BoxGeometry,
Color,
CubeCamera,
HalfFloatType,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Scene,
WebGLCubeRenderTarget,
} from "three";
import type { CubeTexture, WebGLRenderer } from "three";
// A small procedural studio, rendered once into a cube map: dark navy room,
// a white soft box above, electric-blue rim strips behind, a cyan side strip
// and a few hot spots for glints on the chamfers. Cool light only (owner
// 2026-10-16); no HDR file is downloaded.
const ENV_SIZE = 128;
interface Lightformer {
size: [number, number];
position: [number, number, number];
color: string;
intensity: number;
}
const LIGHTFORMERS: Lightformer[] = [
{ size: [9, 6], position: [-1, 9.5, -2], color: "#ffffff", intensity: 2.6 },
{ size: [1.2, 12], position: [-3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [1.2, 12], position: [3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [0.7, 10], position: [9.5, 1, 1], color: "#5fd2ff", intensity: 2.4 },
{ size: [12, 3], position: [0, 3, 9.5], color: "#1b3f7a", intensity: 0.8 },
{ size: [14, 14], position: [0, -9.5, 0], color: "#0b1d3d", intensity: 1 },
{ size: [0.8, 0.8], position: [-6, 8, -6], color: "#ffffff", intensity: 9 },
{ size: [0.6, 0.6], position: [6, 7, 4], color: "#dff4ff", intensity: 7 },
];
export interface StudioEnvironment {
texture: CubeTexture;
dispose(): void;
}
/** Renders the studio once; null when the renderer cannot (test renderer, lost context). */
export function createStudioEnvironment(gl: WebGLRenderer): StudioEnvironment | null {
const scene = new Scene();
const room = new Mesh(new BoxGeometry(20, 20, 20), new MeshBasicMaterial({ color: "#050b19", side: BackSide }));
scene.add(room);
for (const light of LIGHTFORMERS) {
const material = new MeshBasicMaterial({
color: new Color(light.color).multiplyScalar(light.intensity),
toneMapped: false,
});
const panel = new Mesh(new PlaneGeometry(light.size[0], light.size[1]), material);
panel.position.set(...light.position);
// lookAt points the plane's +z (its front face) at the centre of the room.
panel.lookAt(0, 0, 0);
scene.add(panel);
}
const target = new WebGLCubeRenderTarget(ENV_SIZE, {
type: HalfFloatType,
generateMipmaps: true,
minFilter: LinearMipmapLinearFilter,
});
const camera = new CubeCamera(0.1, 40, target);
try {
camera.update(gl, scene);
} catch {
target.dispose();
return null;
} finally {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
(object.material as MeshBasicMaterial).dispose();
}
});
}
return { texture: target.texture, dispose: () => target.dispose() };
}
@@ -1,33 +1,71 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useThree } from "@react-three/fiber";
import { Mesh } from "three";
import type { CubeTexture } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createFrameTextureCache } from "./frame-textures";
import type { FrameTextureCache } from "./frame-textures";
import { createGeometryCache } from "./part-geometry";
import type { GeometryCache } from "./part-geometry";
import type { StageTier } from "./quality";
import { createStudioEnvironment } from "./scene-env";
import { readHoloTheme } from "./theme";
import type { HoloTheme } from "./theme";
// Shared per canvas: theme (tokens read once), geometries per part kind and
// size, comb textures per frame type, the motion level. Everything is
// disposed when the canvas unmounts (spec 9.9: no leak between colonies).
// size, comb textures per frame type, the studio cube map, motion level and
// quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
// no leak between colonies).
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
export interface SceneSignals {
/** The coming frame was requested by the idle timer, not by an animation (quality monitor skips it). */
timerFrame: boolean;
/** World point the camera frames in focus mode (depth of field focuses there). */
focusPoint: Vec3 | null;
}
export interface SceneKit {
theme: HoloTheme;
geometries: GeometryCache;
textures: FrameTextureCache;
motion: MotionLevel;
tier: StageTier;
/** Studio reflections; null until rendered or where the renderer cannot. */
env: CubeTexture | null;
signals: SceneSignals;
}
const SceneKitContext = createContext<SceneKit | null>(null);
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) {
export interface SceneKitProviderProps {
motion: MotionLevel;
tier?: StageTier;
children: ReactNode;
}
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const [theme] = useState(readHoloTheme);
const [geometries] = useState(createGeometryCache);
const [textures] = useState(() => createFrameTextureCache());
const [signals] = useState<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
const [env, setEnv] = useState<CubeTexture | null>(null);
useEffect(() => {
const studio = createStudioEnvironment(gl);
// Rendered once after mount; the materials pick it up on the next render.
// eslint-disable-next-line react-hooks/set-state-in-effect -- the cube map needs the live renderer
setEnv(studio?.texture ?? null);
invalidate();
return () => studio?.dispose();
}, [gl, invalidate]);
useEffect(
() => () => {
geometries.dispose();
@@ -35,9 +73,11 @@ export function SceneKitProvider({ motion, children }: { motion: MotionLevel; ch
},
[geometries, textures],
);
return (
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider>
const kit = useMemo<SceneKit>(
() => ({ theme, geometries, textures, motion, tier, env, signals }),
[theme, geometries, textures, motion, tier, env, signals],
);
return <SceneKitContext.Provider value={kit}>{children}</SceneKitContext.Provider>;
}
export function useSceneKit(): SceneKit {
@@ -1,6 +1,7 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit";
import type { SceneSignals } from "./scene-kit";
// Imperative three.js updates used from frame callbacks and effects. They live
// outside the components on purpose: scene objects are mutable by design,
@@ -17,6 +18,12 @@ export function setUniformColor(material: ShaderMaterial, name: string, color: C
if (uniform) (uniform.value as Color).copy(color);
}
/** Sets a colour uniform to the blend of `from` and `to` at `t` (0…1). */
export function setUniformMix(material: ShaderMaterial, name: string, from: Color, to: Color, t: number): void {
const uniform = material.uniforms[name];
if (uniform) (uniform.value as Color).copy(from).lerp(to, Math.min(1, Math.max(0, t)));
}
export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity;
material.visible = opacity > 0.01;
@@ -29,6 +36,22 @@ export function placeCamera(camera: Camera, center: Vec3, distance: number): voi
camera.lookAt(center[0], center[1], center[2]);
}
/** Shifts the projection vertically by `offsetY` pixels (0 clears it). */
export function offsetView(camera: Camera, width: number, height: number, offsetY: number): void {
if (!("setViewOffset" in camera) || !(width > 0) || !(height > 0)) return;
const perspective = camera as Camera & {
setViewOffset(fw: number, fh: number, x: number, y: number, w: number, h: number): void;
clearViewOffset(): void;
view: { offsetY: number; enabled: boolean } | null;
};
if (Math.abs(offsetY) < 0.5) {
if (perspective.view?.enabled) perspective.clearViewOffset();
return;
}
if (perspective.view?.enabled && Math.abs(perspective.view.offsetY - offsetY) < 0.5) return;
perspective.setViewOffset(width, height, 0, offsetY, width, height);
}
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
scene.background = background;
}
@@ -52,3 +75,37 @@ export function placeFill(
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
return height;
}
/** Points an effect with a focus target (depth of field) at a world point. */
export function setEffectTarget(effect: { target: { set(x: number, y: number, z: number): unknown } | null }, point: Vec3): void {
effect.target?.set(point[0], point[1], point[2]);
}
export function setEffectBokeh(effect: { bokehScale: number }, scale: number): void {
if (effect.bokehScale !== scale) effect.bokehScale = scale;
}
export function markTimerFrame(signals: SceneSignals, value: boolean): void {
signals.timerFrame = value;
}
export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void {
signals.focusPoint = point;
}
/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */
export function capturePointer(element: HTMLElement, pointerId: number): void {
try {
element.setPointerCapture?.(pointerId);
} catch {
// The pointer is gone already; buttons === 0 on the next move ends the drag instead.
}
}
export function releasePointer(element: HTMLElement, pointerId: number): void {
try {
if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
} catch {
// Already released.
}
}
@@ -17,7 +17,9 @@ import { setUniform, setUniformColor } from "./scene-mutations";
import type { SceneLabel, ScenePart } from "./scene-model";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import { GLOW } from "./use-hive-motion";
import type { NodeTarget } from "./use-hive-motion";
import { useHoverSweep, useLivingEdges } from "./use-scene-effects";
// One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its
@@ -48,14 +50,17 @@ export interface StackPartProps {
}
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion } = useSceneKit();
const { theme, geometries, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length),
);
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
const glass = useMemo(
() => createGlassMaterial(theme, { frost: shape.frost, env, dispersion: tier === "high" ? 0.03 : 0 }),
[theme, shape.frost, env, tier],
);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
@@ -80,6 +85,8 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
};
useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
useLivingEdges([edges]);
useHoverSweep(glass, target.glow >= GLOW.hover, box.size[1]);
const showHints = hintFrames && hintFrames.length > 0 && !target.content;
const sy = box.size[1];
@@ -102,6 +109,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
<mesh
position={[0, -target.lift / 2, 0]}
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -109,7 +117,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
onClick={handlers.onClick}
userData={{ hit: part.id }}
>
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} />
<boxGeometry args={[1, 1, 1]} />
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
</mesh>
@@ -6,10 +6,11 @@ import { useFrame, useThree } from "@react-three/fiber";
import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { springStep } from "./quality";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` damps position, scale and
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// Moves a node toward its target (spec 9.8): `full` flies position and scale
// on a spring with a slight overshoot and eases the fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving.
/** After a pause the demand loop reports a long delta; never step more than this. */
@@ -30,8 +31,16 @@ export interface AnimatedValues {
look: number;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number {
if (motion === "full") return stepToward(current, target, dt, true);
/** Opacity fades a little slower than the flight at `full`, so the ghost hive eases in. */
const OPACITY_RATE = 7;
function easeToward(current: number, target: number, dt: number, rate: number): number {
const next = current + (target - current) * (1 - Math.exp(-rate * dt));
return Math.abs(target - next) < 1e-3 ? target : next;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel, rate?: number): number {
if (motion === "full") return rate ? easeToward(current, target, dt, rate) : stepToward(current, target, dt, true);
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
return target;
}
@@ -43,7 +52,13 @@ export function useAnimatedNode(
apply: (values: AnimatedValues) => void,
): void {
const invalidate = useThree((state) => state.invalidate);
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null);
const current = useRef<{
position: Vec3;
velocity: Vec3;
scale: number;
scaleVelocity: number;
values: AnimatedValues;
} | null>(null);
const latestApply = useRef(apply);
useEffect(() => {
latestApply.current = apply;
@@ -61,19 +76,34 @@ export function useAnimatedNode(
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
if (!current.current) {
// First frame: appear in place, no fly-in from the origin.
current.current = { position: [...target.position], scale: target.scale, values: goal };
current.current = {
position: [...target.position],
velocity: [0, 0, 0],
scale: target.scale,
scaleVelocity: 0,
values: goal,
};
} else {
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const damped = motion === "full";
const state = current.current;
state.position = [
stepToward(state.position[0], target.position[0], dt, damped),
stepToward(state.position[1], target.position[1], dt, damped),
stepToward(state.position[2], target.position[2], dt, damped),
];
state.scale = stepToward(state.scale, target.scale, dt, damped);
if (motion === "full") {
// Flights on a spring with a touch of overshoot.
for (let axis = 0; axis < 3; axis += 1) {
const next = springStep(state.position[axis], state.velocity[axis], target.position[axis], dt);
state.position[axis] = next.value;
state.velocity[axis] = next.velocity;
}
const nextScale = springStep(state.scale, state.scaleVelocity, target.scale, dt);
state.scale = nextScale.value;
state.scaleVelocity = nextScale.velocity;
} else {
state.position = [...target.position];
state.velocity = [0, 0, 0];
state.scale = target.scale;
state.scaleVelocity = 0;
}
state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion),
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion, OPACITY_RATE),
glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion),
};
@@ -0,0 +1,48 @@
"use client";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { ShaderMaterial } from "three";
import { useSceneKit } from "./scene-kit";
import { setUniform } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
// Small time-driven effects. All of them only move at motion `full`; the
// pulses ride on frames the idle motion already requests, the hover sweep
// asks for its own frames for about one second.
/** Duration of one hover sweep (shimmer and scan line), seconds. */
export const SWEEP_SECONDS = 1.1;
/** Travelling light pulses along the edges. */
export function useLivingEdges(materials: ShaderMaterial[]): void {
const { motion } = useSceneKit();
useFrame((state) => {
const pulse = motion === "full" ? 1 : 0;
for (const material of materials) {
setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPulse", pulse);
}
});
}
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const clock = useRef(0);
useFrame((_, delta) => {
if (motion !== "full" || !active) {
if (clock.current !== 0) {
clock.current = 0;
setUniform(material, "uSweep", 0);
}
return;
}
clock.current += Math.min(delta, MAX_FRAME_SECONDS);
const sweep = Math.min(clock.current / SWEEP_SECONDS, 1);
setUniform(material, "uHeight", height);
setUniform(material, "uSweep", sweep >= 1 ? 0 : sweep);
if (sweep < 1) invalidate();
});
}
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import type { OverlayInsets } from "./hive-scene/camera-fit";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference";
@@ -25,6 +26,8 @@ export interface HiveVisualSlotProps {
partName: (id: string) => string;
onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void;
/** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */
insets?: OverlayInsets;
}
function allows3D(motion: MotionLevel): boolean {
@@ -42,7 +45,15 @@ function allows3D(motion: MotionLevel): boolean {
* the pointer. Any failure to keep a context leaves the SVG, which is always
* rendered below.
*/
export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
export function HiveVisualSlot({
layout,
state,
catalog,
partName,
onSelectPart,
onSelectFrame,
insets,
}: HiveVisualSlotProps) {
const motion = useEffectiveMotionLevel();
const rootRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
@@ -91,6 +102,7 @@ export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart,
onSelectFrame={onSelectFrame}
onReady={onReady}
onContextLost={onContextLost}
insets={insets}
/>
</div>
) : null}
@@ -0,0 +1,76 @@
"use client";
import { useMemo, useState } from "react";
import { catalogFromSystem } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { withPendingOps } from "@/lib/hive/pending";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import { HiveFactsLine } from "./hive-facts-line";
import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
import { HiveSvg } from "./hive-svg";
import { usePendingHiveOps } from "./use-pending-hive";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const NOTHING = (): void => undefined;
export interface OfflineHiveProps {
colonyId: string;
/** The colony's setup from the pack; null when the pack knows it has none. */
setup: HiveSetupView | null;
/** The hive systems of the pack. */
systems: readonly HiveSystemDto[];
}
/**
* The hive of a colony on the offline page, read-only (spec 10): the SVG and
* the outline, with waiting changes applied. No 3D, no editing.
*/
export function OfflineHive({ colonyId, setup, systems }: OfflineHiveProps) {
if (!setup) {
return <p className="text-muted-foreground">Noch keine Beute angelegt</p>;
}
const system = systems.find((candidate) => candidate.id === setup.systemId);
if (!system) {
return <p className="text-muted-foreground">Die Beute ist nicht offline geladen.</p>;
}
return <OfflineHiveView colonyId={colonyId} setup={setup} system={system} />;
}
function OfflineHiveView({
colonyId,
setup,
system,
}: {
colonyId: string;
setup: HiveSetupView;
system: HiveSystemDto;
}) {
const [store] = useState(createHiveViewStore);
const catalog = useMemo(() => catalogFromSystem(system), [system]);
const pending = usePendingHiveOps(colonyId);
const shown = useMemo(() => withPendingOps(setup, pending, catalog), [setup, pending, catalog]);
const layout = useMemo(() => hiveLayout(shown.state, catalog), [shown.state, catalog]);
const partName = (id: string) => partDisplayName(shown.state, catalog, id);
return (
<HiveViewProvider store={store}>
<div className="flex flex-col gap-3">
<HiveFactsLine facts={shown.facts} />
<HivePendingNote colonyId={colonyId} />
<div className="max-h-72">
<HiveSvg layout={layout} partName={partName} />
</div>
<HiveOutline
state={shown.state}
catalog={catalog}
canEdit={false}
announcement=""
onEditPart={NOTHING}
onEditFrame={NOTHING}
/>
</div>
</HiveViewProvider>
);
}
@@ -96,6 +96,21 @@ describe("PartSheet", () => {
expect(save()).toBeDisabled();
});
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness partId="fe" submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "3");
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("saves only the changed level as setState", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
@@ -178,7 +178,9 @@ function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameF
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault();
event.stopPropagation();
if (ops.length > 0 && valid && !pending) void save();
}}
>
@@ -9,6 +9,9 @@ import { deriveHiveFacts } from "@/lib/hive/facts";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { queueHiveChange } from "@/lib/offline/db";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { syncPendingEntries } from "@/lib/offline/sync";
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
const push = vi.fn();
@@ -20,6 +23,21 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({
postHiveChange: vi.fn(),
}));
vi.mock("@/lib/hive/pending", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/pending")>()),
hasQueuedHiveChange: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const auth = vi.hoisted(() => ({ userId: null as string | null }));
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useCurrentUserId: () => auth.userId,
}));
vi.mock("@/lib/offline/db", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/db")>()),
queueHiveChange: vi.fn(),
}));
const catalog = inlineCatalog();
const COLONY = "colony-1";
@@ -69,6 +87,10 @@ beforeEach(() => {
vi.mocked(toast).mockReset();
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
push.mockReset();
auth.userId = null;
vi.mocked(hasQueuedHiveChange).mockReset().mockResolvedValue(false);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
vi.mocked(queueHiveChange).mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.useRealTimers());
@@ -129,6 +151,90 @@ describe("useHiveMutation", () => {
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
});
it("queues a card change that fails at network level, under the same key, and keeps the loaded state", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const sentKey = vi.mocked(postHiveChange).mock.calls[0][2];
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
sentKey,
"user-1",
);
// The card shows the queued ops on top of this state, so it must not hold them twice.
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues an online change behind earlier queued changes of the colony instead of overtaking them", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
expect.any(String),
"user-1",
);
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues behind earlier queued changes even when the card has no setup cached", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("does not queue server refusals or editor changes", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(400, "falsch", null));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("falsch");
});
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => {
await expect(hook.result.current.submit(refill, "EDITOR", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
it("does not queue without a signed-in user", async () => {
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
it("keeps a newer cache value instead of restoring the snapshot", async () => {
let reject: (e: unknown) => void = () => undefined;
vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r)));
@@ -5,11 +5,16 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { berlinCalendarDay } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { applyHiveOps } from "@/lib/hive/ops";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { queueHiveChange } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import type {
ClientHiveChangeSource,
HiveApplyResult,
@@ -31,7 +36,12 @@ export interface HiveConflict {
}
export interface HiveMutation {
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>;
/** Resolves with the saved setup; null when the change was queued and no setup is loaded to show it on. */
submit(
ops: HiveOp[],
source: ClientHiveChangeSource,
occurredOn?: string,
): Promise<HiveSetupView | null>;
conflict: HiveConflict | null;
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */
resolveConflict(action: "retry" | "discard"): Promise<void>;
@@ -66,10 +76,14 @@ function withState(view: HiveSetupView, state: HiveState, version: number, catal
* (optimistic); a failure refetches it. One submit at a time. An attempt keeps
* its idempotency key across manual re-presses after a network error or 5xx;
* a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only.
* A card change that fails at network level goes into the offline queue
* `pending-hive-changes` under the same key (spec 10); the card shows it from
* there, so the cache keeps the state it had.
*/
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
const queryClient = useQueryClient();
const router = useRouter();
const ownerUserId = useCurrentUserId();
const [conflict, setConflict] = useState<HiveConflict | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const lastAttempt = useRef<Attempt | null>(null);
@@ -105,7 +119,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
);
const run = useCallback(
async (attempt: Attempt): Promise<HiveSetupView> => {
async (attempt: Attempt): Promise<HiveSetupView | null> => {
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
inFlight.current = true;
const { ops, source, occurredOn } = attempt;
@@ -117,6 +131,17 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
// A running refetch must not overwrite the optimistic state.
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
// Behind an earlier queued change of this colony it must not overtake: queue it too,
// whether or not the card has a setup loaded.
if (ownerUserId && source === "CARD" && (await hasQueuedHiveChange(colonyId, ownerUserId))) {
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
// Online again, the queue goes out in order.
void syncPendingEntries(ownerUserId, { afterChange: true }).catch(() => undefined);
if (!previous) return null;
const queued = applyHiveOps(previous.state, ops, catalog);
return queued.ok ? withState(previous, queued.state, previous.version, catalog) : previous;
}
if (previous) {
const applied = applyHiveOps(previous.state, ops, catalog);
if (applied.ok) {
@@ -153,6 +178,16 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
ops,
preview: applyHiveOps(body.current.state, ops, catalog),
});
} else if (previous && ownerUserId && source === "CARD" && !(error instanceof ApiError)) {
// No network: queue it. The pending ops are shown on top of the loaded state.
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
failedKey.current = null;
lastAttempt.current = null;
if (optimistic && queryClient.getQueryData(queryKey) === optimistic) {
queryClient.setQueryData(queryKey, previous);
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return optimistic ?? previous;
} else {
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
// Restore only if nobody changed the cache since our optimistic write; then refetch.
@@ -168,7 +203,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
setPendingCount((count) => count - 1);
}
},
[catalog, colonyId, queryClient, queryKey, suggestFollowUps],
[catalog, colonyId, ownerUserId, queryClient, queryKey, suggestFollowUps],
);
const submit = useCallback(
@@ -0,0 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { fetchHive, hiveKeys } from "@/lib/hive/api";
/**
* The colony's hive setup. Without network the colony's offline pack answers
* (read from this device, so the query never waits for a network).
*/
export function useHiveSetup(colonyId: string) {
const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.detail(colonyId),
queryFn: () => fetchHive(colonyId, userId),
networkMode: "always",
});
}
@@ -2,13 +2,20 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { catalogFromSystem } from "@/lib/hive/facts";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
import { fetchHiveSystems, fetchHiveSystemsWithFallback, hiveKeys } from "@/lib/hive/api";
import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types";
/** The shared systems query (catalog of every system); the card and the init flow read the same cache. */
export function useHiveSystems() {
return useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.systems,
queryFn: () => fetchHiveSystemsWithFallback(userId, fetchHiveSystems),
// Offline the catalog of the offline packs answers from this device.
networkMode: "always",
});
}
/** Catalog of one system, stable while the systems data stays the same. */
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView, useHiveViewStore } from "./use-hive-view";
describe("createHiveViewStore", () => {
it("starts empty and keeps the same state object until something changes", () => {
@@ -88,6 +88,24 @@ describe("useHiveView", () => {
expect(result.current.focusedPartId).toBe("fe");
});
it("gives the store without subscribing (no re-render on view changes)", () => {
const store = createHiveViewStore();
let renders = 0;
const wrapper = ({ children }: { children: ReactNode }) => (
<HiveViewProvider store={store}>{children}</HiveViewProvider>
);
const { result } = renderHook(
() => {
renders += 1;
return useHiveViewStore();
},
{ wrapper },
);
expect(result.current).toBe(store);
act(() => store.setHovered("x"));
expect(renders).toBe(1);
});
it("throws outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
@@ -90,6 +90,13 @@ export function HiveViewProvider({ store, children }: { store: HiveViewStore; ch
return createElement(HiveViewContext.Provider, { value: store }, children);
}
/** The store itself, for event handlers that only act (no re-render on view changes). */
export function useHiveViewStore(): HiveViewStore {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveViewStore must be used inside a HiveViewProvider");
return store;
}
export function useHiveView(): HiveView {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
@@ -0,0 +1,44 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hasRejectedHiveChange, pendingHiveChanges } from "@/lib/hive/pending";
import type { HiveOp } from "@/lib/hive/types";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
const NO_CHANGES: never[] = [];
/** The colony's waiting hive changes (queue and queued inspections), read from this device. */
function usePendingHiveChanges(colonyId: string) {
const userId = useCurrentUserId();
const query = useQuery({
// Under the queue prefix, so a queued, sent or discarded entry refreshes it.
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId],
queryFn: () => pendingHiveChanges(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? NO_CHANGES;
}
/** All ops waiting for the colony, in sending order: apply them with `withPendingOps`. */
export function usePendingHiveOps(colonyId: string): HiveOp[] {
return usePendingHiveChanges(colonyId).flatMap((change) => change.ops);
}
/** How many changes of the colony's hive wait for network. */
export function usePendingHiveCount(colonyId: string): number {
return usePendingHiveChanges(colonyId).length;
}
/** True while a queued hive change of the colony was refused and waits for the user. */
export function useHasRejectedHiveChange(colonyId: string): boolean {
const userId = useCurrentUserId();
const query = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId, "rejected"],
queryFn: () => hasRejectedHiveChange(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? false;
}