Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ DS-T-01 (#1623), DS-T-02 (#1610), DS-T-03 (#1624), DS-T-04 (#1609) und DS-X-01 b
| DS-H-05 | Probe-Halle: Listenprojektion, gemeinsame Auswahl und Inspektor, jedes Werkstück einmal per Tastatur erreichbar: `hall/HallList.tsx` | Root disponiert | DS-H-04; Listenzeile aus DS-X-11 (nicht disponiert) | Server | B | Entwurfs-PR offen: #1680 (17.10.); nach Merge zeigt die Halle neben der Szene eine Werkstück-Liste (`ListRow`) und einen Inspektor mit der Aufgabe zuerst, eine gemeinsame Auswahl in Liste und Szene (Form und Text, nicht nur Farbe), jedes Werkstück einmal per Tastatur erreichbar; dazu die Übernahmen aus #1663 (Zustandstyp, Atlas-Schutz, Schlüssel, Tests); Halle weiter in keiner Ansicht (Galerie DS-H-07) | wie DS-H-01 | – |
| DS-H-06 | Probe-Halle: drei Bewegungsmodi Ruhig/Lebendig/Aus, höchstens zwölf bewegte Figuren, Pause bei verstecktem Dokument: `hall/motion.css` | Root disponiert | DS-H-05; Bewegungsmodus aus DS-X-03 (#1606) | Server | B | Entwurfs-PR offen (17.10.): Plan-ID DS-H-06; nach Merge bewegt sich in Ruhig und Lebendig nur die Arbeitsfigur (`run`), alle anderen Posen und Aus oder Betriebssystem-Bewegungsreduktion bleiben auf Bild 0, höchstens zwölf Figuren bewegen sich, ein verstecktes Dokument pausiert die Halle; die Gesten für `need`, `ok` und `bad` warten auf ein beobachtetes Wechselsignal | wie DS-H-01 | – |
| DS-H-07 | Probe-Halle: Galerie-Seite und Messaufbau (`shot.mjs --dpr --motion`), danach Abnahme durch den Nutzer: zuerst Bilder und kurzes Video vom Server (3 Bewegungsmodi × hell/dunkel), dann nach seinem Ja in der App am PC mit Speichermessung | Root disponiert; Abnahme der Nutzer | DS-H-06; Galerie aus DS-X-14 und DS-X-15 (nicht disponiert) | Server | B | Geplant: nicht disponiert (16.10.); das Urteil des Nutzers entscheidet über die übrigen Tabs | wie DS-H-01; Urteil des Nutzers | – |
| DS-H-07a | Probe-Halle, Galerie-Seite „Probe-Halle“: Umschalter Ruhig/Lebendig/Aus für diese Seite, Beispielsätze normal/leer/ungelesen, Pause außerhalb des Bildschirms, Adresse `#/designsystem?page=hall&motion=…&set=…`; Teil 1 von DS-H-07 (07b: `shot.mjs --motion/--dpr/--video`) | Root disponiert | DS-H-06, DS-X-15 | Server | B | Entwurfs-PR offen (17.10.): Plan-ID DS-H-07a; nach Merge zeigt die Galerie unter „Probe-Halle“ die Halle mit dem Schild „Beispieldaten“, der Seitenschalter überstimmt nur diese Halle (die Betriebssystem-Einstellung gewinnt), leer und ungelesen zeigen Worte und keine Arbeitsfigur, eine Halle außerhalb des Bildschirms pausiert wie ein verstecktes Dokument | wie DS-H-01 | – |
| NAV-0 | Doku zur Navigation: `docs/plan/v2.0/ia.md` §1/§3 beschreibt die sechs Bereiche mit der Tabelle „Bereich → Route-IDs“ (IDs unverändert), `docs/decisions.md` „2026-10-16 - NAV-0“ bucht E-1 B, die Sichtbarkeit (drei Bereiche) und den Reiternamen „Linie“; PLAN bucht die Entscheidungen und diese Zeilen | Root disponiert | – | Server | C | Erledigt: #1630 (Doku, (merged 2026-10-16)) | prepush Exit 0; `node scripts/plan-check.mjs --offline` Exit 0; Doc-Link-Gate Exit 0 | – |
| NAV-1a | Seitenleiste nach Bereichen: Feld `area` je Route und Liste der Bereiche, ein Link je Bereich auf seine erste sichtbare Route, `aria-current` je Bereich; neue Werte `nav.leitstand` „Fabrik“, `nav.eingangPlan` „Aufträge“, `nav.gedaechtnis` „Wissen“, neu `nav.betrieb`; zugleich der Reiter `leitstand.tab.fabrik` → „Linie“ (früher NAV-1b), damit kein Linkname doppelt vorkommt; Routen-IDs und Pfade bleiben (`src/shell/routes.ts`, `Shell.tsx` (nur `Sidebar`), `src/i18n/de.ts`, `src/screens/start/texts.ts`, `LeitstandRoute.test.tsx` und die Shell-Tests) | Root disponiert | NAV-0; #1606 (`Shell.tsx`) und #1604 gemergt oder `main` eingemergt | Server | B | Erledigt: #1636 (merged 2026-10-16); die Seitenleiste zeigt einen Link je Bereich (Schalter aus: Fabrik und Einstellungen, Betrieb ab NAV-2a), der Fluss-Reiter heißt „Linie“ | benannte Tests rot→grün (sechs Bereiche ohne Ersteinrichtung, Betrieb aktiv auf jeder Mitgliedsroute, Schalter aus zeigt Fabrik und Einstellungen, Reiter „Linie“); prepush; zwei Reviews; Zeilen ≤ 300 | – |
| NAV-2a | Bereich Betrieb bekommt die Route `betrieb` (`/betrieb`) mit dem heutigen `BetriebPanel`; kleine Alias-Tabelle in `mount/routing.ts` leitet `/leitstand/betrieb` um; `betrieb` kommt in die sichtbaren Routen von v2.0 (Entscheid „Drei“): `routes.ts`, `mount/routing.ts`, `mount/ShellMount.tsx`, `screens/betrieb/BetriebRoute.tsx`, Tests, `e2e/shell.core.spec.ts` | Root disponiert | NAV-1a; seriell mit den Routing-Dateien von KR-07a/11c/11d/12b/12c | Server | B | Erledigt: #1641 (merged 2026-10-16); der Bereich Betrieb hat die Route `/betrieb` mit dem bisherigen Panel, `/leitstand/betrieb` leitet dorthin um, Betrieb steht in der v2.0-Seitenleiste | benannte Tests rot→grün (alter Pfad löst auf `/betrieb` auf, Panel öffnet unter `/betrieb` mit aktivem Bereich Betrieb); prepush; zwei Reviews; Zeilen ≤ 300 | – |
Expand Down
35 changes: 33 additions & 2 deletions src/design/gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@ import { useEffect, useState, type ReactNode } from "react";

import { de, type MessageKey } from "../../i18n/de";
import { Button } from "../controls/Button";
import { Segmented } from "../controls/Segmented";
import { Hall } from "../hall/Hall";
import { EMPTY_HALL, SAMPLE_HALL, type HallScene } from "../hall/fixtures";
import { motionDuration } from "../motion/tokens";
import { useMotion } from "../motion/motionMode";
import { MOTION_MODES, useMotion, type MotionMode } from "../motion/motionMode";
import { COMPONENT_REGISTRY, type ComponentFamily } from "../registry";
import "./gallery.css";

// DS-X-15 (D6): read-only gallery of the design tokens. No IPC, no store: every value comes from tokens.css.
// DS-H-07a: the page "Probe-Halle" (family hall) renders Hall on Beispieldaten. Its switches override the hall only.
// The route is hash based, so the state sits in the hash query and is read once when the gallery opens:
// #/designsystem?page=hall&motion=calm|lively|off&set=normal|empty|unread
// `page` is any page id below (colour, type, space, radius, glass, motion or a family such as hall); unknown values fall back.
const PAGES = ["colour", "type", "space", "radius", "glass", "motion"] as const;
type Page = (typeof PAGES)[number] | ComponentFamily;
const FAMILIES = [...new Set(COMPONENT_REGISTRY.map((entry) => entry.family))];

/** The sample sets of the probe hall; `unread` is no scene at all. */
const HALL_SETS: Record<"normal" | "empty" | "unread", HallScene | undefined> = { normal: SAMPLE_HALL, empty: EMPTY_HALL, unread: undefined };
type HallSet = keyof typeof HALL_SETS;
const HALL_SET_IDS = Object.keys(HALL_SETS) as HallSet[];

/** The first of `ids` that equals `raw`, else the first id. */
const oneOf = <T extends string>(ids: readonly T[], raw: string | null): T => ids.find((id) => id === raw) ?? ids[0];
const query = () => new URLSearchParams(window.location.hash.split("?")[1] ?? "");

const SWATCHES = ["--g-ink", "--g-ink2", "--g-muted", "--g-accent", "--g-run", "--g-need", "--g-rev", "--g-ok", "--g-bad"];
const TYPE = ["display", "title", "heading", "subheading", "body", "label", "note", "micro", "code"];
const RADIUS = ["xs", "sm", "md", "lg", "ctl", "card", "panel", "sheet", "pill"];
Expand Down Expand Up @@ -74,6 +90,20 @@ function MotionDemo() {
);
}

function ProbeHall() {
const [motion, onMotion] = useState<MotionMode>(() => oneOf(MOTION_MODES, query().get("motion")));
const [set, onSet] = useState<HallSet>(() => oneOf(HALL_SET_IDS, query().get("set")));
return (
<>
<div className="g-gal-row">
<Segmented aria-label={de["gallery.hall.motion"]} value={motion} onChange={onMotion} options={MOTION_MODES.map((value) => ({ value, label: de[`gallery.hall.mode.${value}`] }))} />
<Segmented aria-label={de["gallery.hall.set"]} value={set} onChange={onSet} options={HALL_SET_IDS.map((value) => ({ value, label: de[`gallery.hall.set.${value}`] }))} />
</div>
<Hall scene={HALL_SETS[set]} motion={motion} />
</>
);
}

const Sample = ({ children }: { children: ReactNode }) => <><p className="g-gal-sample">{de["gallery.sample"]}</p>{children}</>;

function Body({ page }: { page: Page }) {
Expand Down Expand Up @@ -103,6 +133,7 @@ function Body({ page }: { page: Page }) {
</Sample>
);
case "motion": return <MotionDemo />;
case "hall": return <ProbeHall />;
default: return (
<>
<p className="g-gal-note">{de["gallery.storiesLater"]}</p>
Expand All @@ -116,7 +147,7 @@ const label = (page: Page): string =>
(PAGES as readonly string[]).includes(page) ? de[`gallery.${page}` as MessageKey] : de[`gallery.family.${page}` as MessageKey];

export function Gallery() {
const [page, setPage] = useState<Page>("colour");
const [page, setPage] = useState<Page>(() => oneOf<Page>(["colour", ...PAGES, ...FAMILIES], query().get("page")));
const link = (p: Page) => (
<li key={p}><Button size="sm" variant={p === page ? "tint" : "ghost"} aria-current={p === page ? "page" : undefined} onClick={() => setPage(p)}>{label(p)}</Button></li>
);
Expand Down
74 changes: 74 additions & 0 deletions src/design/gallery/hallPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { de } from "../../i18n/de";
import { Gallery } from "./Gallery";

// DS-H-07a (ASSETS.md §7-§9): the probe hall page of the gallery. The page switches override the hall only;
// a URL like `#/designsystem?page=hall&motion=lively&set=empty` opens a given mode and set.
const open = (query = "") => {
window.location.hash = `#/designsystem${query}`;
const view = render(<Gallery />);
if (!query) fireEvent.click(within(screen.getByRole("navigation", { name: de["gallery.nav"] })).getByRole("button", { name: de["gallery.family.hall"] }));
return view;
};
const scene = () => document.querySelector<HTMLElement>(".g-hall__scene")!;
const moving = () => [...document.querySelectorAll("[data-animate]")].map((e) => e.getAttribute("data-sprite"));
const pick = (group: "gallery.hall.motion" | "gallery.hall.set", name: string) => fireEvent.click(within(screen.getByRole("radiogroup", { name: de[group] })).getByRole("radio", { name }));

beforeEach(() => { localStorage.clear(); delete document.documentElement.dataset.motion; });
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.location.hash = ""; });

describe("gallery probe hall page (DS-H-07a)", () => {
it("the gallery shows the probe hall with the Beispieldaten label", () => {
open();
const hall = screen.getByRole("region", { name: de["hall.title"] });
expect(within(hall).getByText(de["hall.sample"])).toBeTruthy();
expect(screen.getByRole("heading", { name: de["gallery.family.hall"] })).toBeTruthy();
});

it("the page motion switch sets the hall to lively and off", () => {
open();
expect(scene().dataset.motion).toBe("calm");
pick("gallery.hall.motion", de["gallery.hall.mode.lively"]);
expect(scene().dataset.motion).toBe("lively");
expect(moving()).toEqual(["fig-build-run-0"]);
pick("gallery.hall.motion", de["gallery.hall.mode.off"]);
expect(scene().dataset.motion).toBe("still");
expect(moving()).toEqual([]);
expect(document.documentElement.dataset.motion).toBeUndefined();
});

it("OS reduced motion still wins over the page switch", () => {
vi.stubGlobal("matchMedia", () => ({ matches: true, addEventListener: () => {}, removeEventListener: () => {} }));
open();
pick("gallery.hall.motion", de["gallery.hall.mode.lively"]);
expect(scene().dataset.motion).toBe("still");
});

it("empty and unread sets show words and no working figure", () => {
open();
pick("gallery.hall.set", de["gallery.hall.set.empty"]);
expect(screen.getByText(de["hall.empty"])).toBeTruthy();
expect(document.querySelector('[data-sprite*="-run-"]')).toBeNull();
expect(screen.getByText(de["hall.sample"])).toBeTruthy();
pick("gallery.hall.set", de["gallery.hall.set.unread"]);
expect(screen.getByText(de["hall.unread"])).toBeTruthy();
expect(document.querySelector('[data-sprite^="fig-"]')).toBeNull();
expect(screen.getByText(de["hall.sample"])).toBeTruthy();
});

it("the query opens mode and set", () => {
open("?page=hall&motion=lively&set=empty");
expect(screen.getByRole("region", { name: de["hall.title"] })).toBeTruthy();
expect(scene().dataset.motion).toBe("lively");
expect(screen.getByText(de["hall.empty"])).toBeTruthy();
expect(screen.getByRole("radio", { name: de["gallery.hall.mode.lively"], checked: true })).toBeTruthy();
});

it("an unknown query value falls back to the defaults", () => {
open("?page=hall&motion=wild&set=nope");
expect(scene().dataset.motion).toBe("calm");
expect(screen.queryByText(de["hall.empty"])).toBeNull();
});
});
9 changes: 5 additions & 4 deletions src/design/hall/Hall.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Fragment, type CSSProperties } from "react";

import { de } from "../../i18n/de";
import type { MotionMode } from "../motion/motionMode";
import { STATE_UNKNOWN_FORM } from "../state/states";
import { poseFor, ROLE_WORDS, ROLES, type Role } from "./figures/poses";
import type { HallScene } from "./fixtures";
Expand Down Expand Up @@ -50,20 +51,20 @@ function Sprite({ sheet, id, col, row, piece, selected, onPick, animate }: Sprit
);
}

/** `scene` undefined: nothing read yet. A piece without a state is unknown; a role without a piece is `ready`. */
export function Hall({ scene }: { scene: HallScene | undefined }) {
/** `scene` undefined: nothing read yet. A piece without a state is unknown; a role without a piece is `ready`. `motion` overrides the app's mode for this hall only. */
export function Hall({ scene, motion: override }: { scene: HallScene | undefined; motion?: MotionMode }) {
const s = usePixelScale().css;
const pieces = scene?.pieces ?? [];
const pieceOf = (role: Role) => pieces.find((p) => p.role === role);
const { selected, toggle } = useSelection(pieces.map((p) => p.id));
const { motion, paused } = useHallMotion();
const { motion, paused, sceneRef } = useHallMotion(override);
// R1646-K1: poseFor(null) means "no run observed"; a piece with a missing state must stay unknown.
const poses = ROLES.map((role) => { const piece = pieceOf(role); return poseFor(piece ? piece.state ?? STATE_UNKNOWN_FORM : null); });
const moving = pickMoving(poses);
return (
<section className="g-hall" aria-label={de["hall.title"]} style={{ "--s": s } as CSSProperties}>
<p className="g-hall__sample">{de["hall.sample"]}</p>
<div className="g-hall__scene" aria-hidden="true" data-motion={motion} data-paused={paused ? "true" : undefined} style={{ "--cols": FLOOR[0].length, "--rows": FLOOR.length } as CSSProperties}>
<div ref={sceneRef} className="g-hall__scene" aria-hidden="true" data-motion={motion} data-paused={paused ? "true" : undefined} style={{ "--cols": FLOOR[0].length, "--rows": FLOOR.length } as CSSProperties}>
{FLOOR.flatMap((line, row) => [...line].map((c, col) => <Sprite key={`${col}-${row}`} sheet="tiles" id={TILE[c]} col={col} row={row} />))}
<Sprite sheet="tiles" id="gate-hall-0" col={GATE[0]} row={GATE[1]} />
{ROLES.map((role, i) => {
Expand Down
11 changes: 11 additions & 0 deletions src/design/hall/HallMotion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ it("a hidden document pauses the hall", () => {
expect(scene(container).dataset.paused).toBeUndefined();
});

it("an off-screen scene pauses the hall", () => {
let notify: (entries: { isIntersecting: boolean }[]) => void = () => {};
vi.stubGlobal("IntersectionObserver", class { constructor(cb: typeof notify) { notify = cb; } observe() {} disconnect() {} });
const { container } = render(<Hall scene={SCENE} />);
expect(scene(container).dataset.paused).toBeUndefined();
act(() => notify([{ isIntersecting: false }]));
expect(scene(container).dataset.paused).toBe("true");
act(() => notify([{ isIntersecting: true }]));
expect(scene(container).dataset.paused).toBeUndefined();
});

it("the second run frame sits right next to the first in every role strip", () => {
const map = figureMap as Record<string, number[]>;
expect(POSE_FRAMES.run).toBe(2);
Expand Down
33 changes: 26 additions & 7 deletions src/design/hall/useHallMotion.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from "react";

import { useMotion } from "../motion/motionMode";
import { useMotion, type MotionMode } from "../motion/motionMode";
import type { Pose } from "./figures/poses";

// DS-H-06 (ASSETS.md §7): which figures may move and when everything rests. CSS (motion.css) does the animating.
Expand All @@ -19,9 +19,28 @@ const onVisibility = (notify: () => void) => {
return () => document.removeEventListener("visibilitychange", notify);
};

/** `still` = Aus or OS reduced motion (frame 0); `paused` = the document is hidden. */
export function useHallMotion(): { motion: "calm" | "lively" | "still"; paused: boolean } {
const { mode, reduced } = useMotion();
const paused = useSyncExternalStore(onVisibility, () => document.hidden);
return { motion: reduced ? "still" : mode === "lively" ? "lively" : "calm", paused };
/** Is the element on screen? Without IntersectionObserver (old webview, jsdom) it counts as visible. */
function useOffScreen(ref: RefObject<Element | null>): boolean {
const [off, setOff] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver !== "function") return;
const watch = new IntersectionObserver((entries) => setOff(!entries[entries.length - 1].isIntersecting));
watch.observe(el);
return () => watch.disconnect();
}, [ref]);
return off;
}

/**
* `still` = Aus or OS reduced motion (frame 0); `paused` = the document is hidden or the scene is off screen.
* `override` replaces the app's stored mode for this hall only (the gallery page switch); the OS setting still wins.
*/
export function useHallMotion(override?: MotionMode): { motion: "calm" | "lively" | "still"; paused: boolean; sceneRef: RefObject<HTMLDivElement> } {
const { mode: stored, system } = useMotion();
const mode = override ?? stored;
const sceneRef = useRef<HTMLDivElement>(null);
const hidden = useSyncExternalStore(onVisibility, () => document.hidden);
const offScreen = useOffScreen(sceneRef);
return { motion: system || mode === "off" ? "still" : mode === "lively" ? "lively" : "calm", paused: hidden || offScreen, sceneRef };
}
10 changes: 9 additions & 1 deletion src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,15 @@ export const de = {
"gallery.family.fabrik": "Fabrik",
"gallery.family.forms": "Formulare",
"gallery.family.gallery": "Galerie",
"gallery.family.hall": "Halle",
"gallery.family.hall": "Probe-Halle",
"gallery.hall.motion": "Bewegung dieser Seite",
"gallery.hall.mode.calm": "Ruhig",
"gallery.hall.mode.lively": "Lebendig",
"gallery.hall.mode.off": "Aus",
"gallery.hall.set": "Beispielsatz",
"gallery.hall.set.normal": "normal",
"gallery.hall.set.empty": "leer",
"gallery.hall.set.unread": "ungelesen",
"gallery.family.inputs": "Eingaben",
"gallery.family.layout": "Layout",
"gallery.family.state": "Zustand",
Expand Down
Loading