feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)
- 3D dependencies (three, react-three-fiber, drei, postprocessing, motion), exact versions, supply-chain checked; scene loaded only on demand on the colony page (283 kB gzip), SVG stays as fallback - glass hologram per owner design 2026-10-16: true Liebig Zander shapes (grips, rebate, Wanderboden foot), cool blue edge glow, click any part -> it flies to the front while the rest stays as a faint ghost; focus shows content (feed levels, Bausperre, pockets); frames rise on hover and take their type look (colour + comb pattern) - full-bleed card with frosted overlays and glass buttons - Futtertasche (FRAME_FEEDER): new part kind occupying frame slots with a fill level, catalog row, validator rules in both ports, outline, sheets, follow-ups, settings Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c608f5d405
commit
6e13236d49
100 files changed
+9432
-209
No files matched your search
@@ -34,6 +34,7 @@ const feeder: HivePartTypeDto = {
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
slotWidth: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
|
||||
|
||||
@@ -22,6 +22,7 @@ const feeder: HivePartTypeDto = {
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
slotWidth: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{
|
||||
@@ -62,6 +63,19 @@ async function pickBox() {
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
|
||||
}
|
||||
|
||||
async function pickFrameFeeder() {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Futtertasche" }));
|
||||
}
|
||||
|
||||
const pocket: HivePartTypeDto = {
|
||||
...feeder,
|
||||
id: "pt2",
|
||||
kind: "FRAME_FEEDER",
|
||||
name: "Futtertasche",
|
||||
slotWidth: 2,
|
||||
};
|
||||
|
||||
describe("PartTypeDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createHivePartType).mockReset();
|
||||
@@ -247,4 +261,60 @@ describe("PartTypeDialog", () => {
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("creates a frame feeder with the number of frame slots it covers", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "2");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", {
|
||||
kind: "FRAME_FEEDER",
|
||||
name: "Futtertasche",
|
||||
lengthMm: 477,
|
||||
widthMm: 70,
|
||||
heightMm: 220,
|
||||
slotWidth: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the covered slots of a frame feeder and shows none for other kinds", async () => {
|
||||
renderDialog();
|
||||
expect(screen.queryByLabelText(/^Belegte Rähmchenplätze/)).toBeNull();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/belegten Rähmchenplätze/);
|
||||
expect(createHivePartType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps the covered slots at the server limit of 5", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Futtertasche");
|
||||
await pickFrameFeeder();
|
||||
await typeInto(/^Länge/, "477");
|
||||
await typeInto(/^Breite/, "70");
|
||||
await typeInto(/^Höhe/, "220");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ slotWidth: 5 })),
|
||||
);
|
||||
});
|
||||
|
||||
it("edit: sends only a changed slot width", async () => {
|
||||
renderDialog(pocket);
|
||||
expect(screen.getByLabelText(/^Belegte Rähmchenplätze/)).toHaveValue("2");
|
||||
await typeInto(/^Belegte Rähmchenplätze/, "3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { slotWidth: 3 }));
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
MAX_DIMENSION_MM,
|
||||
MAX_FRAME_SLOTS,
|
||||
MAX_SLOT_WIDTH,
|
||||
draftProblem,
|
||||
emptyDraft,
|
||||
fieldFromDraft,
|
||||
@@ -59,6 +60,7 @@ interface FormValues {
|
||||
heightMm: string;
|
||||
boxDepth: HiveBoxDepth;
|
||||
frameSlots: string;
|
||||
slotWidth: string;
|
||||
}
|
||||
|
||||
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
|
||||
@@ -71,6 +73,7 @@ function initialValues(partType: HivePartTypeDto | undefined): FormValues {
|
||||
heightMm: partType ? String(partType.heightMm) : "",
|
||||
boxDepth: partType?.boxDepth ?? "FULL",
|
||||
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
|
||||
slotWidth: partType?.slotWidth == null ? "" : String(partType.slotWidth),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +91,9 @@ function valuesProblem(values: FormValues): string | null {
|
||||
if (values.kind === "BOX" && values.frameSlots === "") {
|
||||
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
|
||||
}
|
||||
if (values.kind === "FRAME_FEEDER" && values.slotWidth === "") {
|
||||
return "Eine Futtertasche braucht die Zahl der belegten Rähmchenplätze.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,6 +115,9 @@ function patchFrom(
|
||||
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
|
||||
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
|
||||
}
|
||||
if (partType.kind === "FRAME_FEEDER" && Number(values.slotWidth) !== partType.slotWidth) {
|
||||
patch.slotWidth = Number(values.slotWidth);
|
||||
}
|
||||
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
|
||||
return patch;
|
||||
}
|
||||
@@ -124,6 +133,7 @@ function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTyp
|
||||
widthMm: Number(values.widthMm),
|
||||
heightMm: Number(values.heightMm),
|
||||
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
|
||||
...(kind === "FRAME_FEEDER" ? { slotWidth: Number(values.slotWidth) } : {}),
|
||||
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
|
||||
};
|
||||
}
|
||||
@@ -269,6 +279,23 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{values.kind === "FRAME_FEEDER" ? (
|
||||
<Field label="Belegte Rähmchenplätze" required hint="So viele Plätze nimmt die Tasche in der Zarge ein.">
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={values.slotWidth}
|
||||
onChange={(slotWidth) => setValue("slotWidth", slotWidth)}
|
||||
min={1}
|
||||
max={MAX_SLOT_WIDTH}
|
||||
unitLabel="Plätze"
|
||||
decrementLabel="Belegte Rähmchenplätze verringern"
|
||||
incrementLabel="Belegte Rähmchenplätze erhöhen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
|
||||
<h3 id="state-fields-heading" className="text-sm font-medium">
|
||||
@@ -280,7 +307,7 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
|
||||
draft={newField}
|
||||
onChange={setNewField}
|
||||
onDiscard={() => setNewField(null)}
|
||||
allowFeedType={values.kind === "FEEDER"}
|
||||
allowFeedType={values.kind === "FEEDER" || values.kind === "FRAME_FEEDER"}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -65,7 +65,7 @@ export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }:
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
|
||||
</TableCell>
|
||||
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
|
||||
<TableCell>{partType.frameSlots ?? (partType.slotWidth ? `belegt ${partType.slotWidth}` : "–")}</TableCell>
|
||||
<TableCell className="min-w-48 whitespace-normal">
|
||||
{describeStateFields(partType.stateSchema)}
|
||||
</TableCell>
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-edge: oklch(0.72 0.15 243);
|
||||
--holo-edge-strong: oklch(0.87 0.1 238);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
@@ -194,8 +194,8 @@
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-edge: oklch(0.72 0.15 243);
|
||||
--holo-edge-strong: oklch(0.87 0.1 238);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
|
||||
Reference in new issue
Block a user