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 @@ -271,6 +271,7 @@ Größe vor dem Start messen: `store.mjs` (570), `app.js` (504/527) und `store.t
| DS-X-12 | Bewegungstoken angewendet: ein Druck-Token (`--g-press-scale`) für Knöpfe, Filterchips, Segmente und Tabs; der Füllbalken wächst per `transform: scaleX()` statt über die Breite, die Erlaubnisliste für Layout-Animation in `motion.test.ts` ist leer; der Lauf-Ring atmet nur unter `--g-ambient-play` (Lebendig), in Ruhig steht er still und ein Zustandswechsel gibt einen einmaligen Ping (`usePulseOnChange`); eine dokumentierte Regel für reduzierte Bewegung am Kopf von `motion.css`; das doppelte `g-enter` entfällt | Implementierer · Claude; Root nimmt ab | DS-X-03 (#1606), DS-X-07 (#1683); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-12) | Server | B | Erledigt: #1693 (merged 2026-10-18) | red-first Vitest-Tests; `vitest run src/design`, typecheck, contrast-check, `plan-check --offline` und prepush Exit 0; ein unabhängiger Stufe-B-Review (Root beauftragt) | – |
| DS-X-21 | Ein Symbolsatz auf 20-px-Raster: `Icon` in `src/design/icons/` (ohne Beschriftung für Hilfstechnik verborgen, mit `label` als `role="img"`, Farbe über `currentColor`) und 24 Lucide-Pfade als Daten (`glyphs.ts`), dazu Entsprechungen für die 14 klassischen Symbole, die Seitenleisten-Symbole und den Auswahl-Pfeil; Lizenztext `LUCIDE-LIZENZ.txt` neben den Symbolen und Eintrag in `docs/THIRD_PARTY_NOTICES.md` | Implementierer · Claude; Root nimmt ab | DS-Blueprint-Schnitt 16.10. (DS-X-21), Root-Entscheidung 17.10.: Pfade kopieren, kein Paket | Server | B + Lizenzprüfung | Entwurfs-PR offen (17.10.): kein Aufrufer umgestellt, `src/components/Icon.tsx` bleibt bis KR-15; Galerie zeigt den Satz unter „Symbole“ | Tests rot→grün; `vitest run src/design`, typecheck, `contrast-check`, `plan-check --offline` und prepush Exit 0; Bilder der Galerie hell und dunkel; ein Stufe-B-Review einer Nicht-Anthropic-Familie, Root prüft den Lizenzhinweis | – |
| DS-X-32 | Datenfarben `--g-viz-1` bis `--g-viz-6` in `src/design/tokens.css` (hell und dunkel), eigene Farbtöne abseits der Zustandsfarben; `scripts/contrast-check.mjs` prüft jede Datenfarbe als Nicht-Text mit mindestens 3:1 auf Grund, Chrome und Glas (auch in allen Glas-Varianten), hält sie voneinander und von den Zustandsfarben auseinander und meldet „Token fehlt“; die Galerie-Seite Farben zeigt die sechs Farbfelder; keine Diagramm-Bausteine (das ist DS-X-33) | Implementierer · Claude; Root nimmt ab | DS-X-02; DS-Blueprint-Schnitt 16.10. (Zeile DS-X-32) | Server | B | Erledigt: #1702 (merged 2026-10-18), Branch `claude/ds-x-32`; die sechs Datenfarben stehen in `tokens.css`, `contrast-check` prüft sie und die Galerie-Seite Farben zeigt sie, Diagramm-Bausteine folgen mit DS-X-33 | red-first Gate-Tests in `scripts/lib/contrast-gate.test.mjs`; `contrast-check`, typecheck, `plan-check --offline` und prepush Exit 0; Bilder der Farben-Seite hell und dunkel; ein unabhängiger Stufe-B-Review (Root beauftragt) | – |
| DS-X-33 | Diagramm-Bausteine in `src/design/data/`: `Sparkline`, beschriftete `BarList` und `ChartFrame` mit Titel, Beschreibung, Legende und verpflichtender Tabellenansicht aus denselben Daten; ausschließlich Datenfarben, leere und unbekannte Werte als Wörter, statisch; Register und deutsche Texte ergänzt; keine bestehenden Ansichten umgestellt | Implementierer · Codex; Root nimmt ab | DS-X-32 (#1702, `4a9c8a5b`); DS-Blueprint-Schnitt 16.10. | Server | B | In Prüfung: Entwurfs-PR `codex/ds-x-33`; nach Merge stehen die drei Bausteine samt Tabellenansicht bereit | vier benannte Tests rot→grün; Registerabdeckung, typecheck, contrast-check, `plan-check --offline` und eigene prepush Exit 0; betrachtete Bilder hell/dunkel mit offener Tabelle; unabhängiger Nicht-OpenAI-Review durch Root ausstehend | – |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the PLAN row to the post-merge state

This candidate's report records completed independent reviews and review-ok, but the added row will still say that the draft is under review and the non-OpenAI review is outstanding after merge. Update the row to the delivered/reviewed state so the canonical plan is not immediately stale.

AGENTS.md reference: AGENTS.md:L121-L121

Useful? React with 👍 / 👎.

| DS-X-26 | Popover (Fabrik-Designsystem, Welle 4): `src/design/layout/Popover.tsx` setzt `aria-expanded`/`aria-controls` am Auslöser, schließt bei einem Pointerdown außerhalb genau einmal, mit Esc (Fokus zurück zum Auslöser) und beim Verlassen per Tab, misst einmal beim Öffnen, ob es nach oben klappt (keine CSS-Anker-Positionierung), und geht über den Austrittsübergang aus DS-X-07 (bei reduzierter Bewegung sofort); `ProjectPicker` und das kompakte Darstellungsmenü nutzen es mit unverändertem Verhalten und Aussehen; Eintrag im Register; kein Menü-Baustein, kein Tooltip (DS-X-27) | Implementierer · Claude; Root nimmt ab | DS-X-07 (#1683); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-26) | Server | B | Erledigt: #1705 (merged 2026-10-18); `ProjectPicker` und das kompakte Darstellungsmenü nutzen das Popover mit unverändertem Verhalten und Aussehen, kein Menü-Baustein und kein Tooltip (DS-X-27) | red-first Vitest-Tests (`popover.test.tsx`); die bestehenden Tests von `ProjectPicker` und Darstellungsmenü bleiben unverändert grün; `vitest run src/design src/shell`, typecheck, contrast-check, `plan-check --offline` und prepush Exit 0; Bildschirmfotos beider Menüs hell und dunkel; ein unabhängiger Stufe-B-Review (Root beauftragt) | – |
| DS-X-28 | Toast-Warteschlange (Fabrik-Designsystem, Welle 4): `useToast()` in `src/design/layout/useToast.tsx` liefert `push` (gibt eine Kennung zurück), `dismiss(id)` und den einzubindenden `region`; höchstens drei Hinweise sind sichtbar, weitere warten; ein optionaler Aktionsknopf ruft seine Funktion genau einmal auf; Hinweise verlassen den Bildschirm über `useExitTransition` (bei reduzierter Bewegung sofort); `ToastRegion` bleibt abwärtskompatibel, Register-Eintrag angepasst; keine Bildschirmeinbindung | Implementierer · Claude; Root nimmt ab | DS-X-07; DS-Blueprint-Schnitt 16.10. (Zeile DS-X-28) | Server | B | Erledigt: #1704 (merged 2026-10-18), Branch `claude/ds-x-28`; keine Bildschirmeinbindung | red-first Vitest-Tests (`toast.queue.test.tsx`); `vitest run src/design`, typecheck, contrast-check, `plan-check --offline` und prepush Exit 0; ein unabhängiger Stufe-B-Review (Root beauftragt) | – |
| DS-T-03 | Vertrauensfehler P1-b/e: die Kartenlampe sagt, was bewiesen ist (`lampFor.ts`, `lamps/texts.ts`, `AgentCard.tsx`, `FlowTab.tsx`) | Implementierer · Claude; Root nimmt ab | Lampenform aus DS-T-02 (#1610) | Server | B | Erledigt: #1624 (merged 2026-10-16); unbekannte Spalte oder unbekannter Worker-Status zeigt auf Karte und Fabrik-Knoten die graue Unbekannt-Lampe statt „Läuft“, die grüne Lampe heißt „Test bestanden“ mit dem Alter des Testlaufs; welcher Stand getestet wurde, bleibt ein späteres Nahtpaket | fünf Test-First-Tests rot an der Merge-Base, grün am Kopf (`red-first.sh` Exit 0); typecheck, `node scripts/contrast-check.mjs` und prepush Exit 0; zwei Stufe-B-Reviews verschiedener Nicht-Autor-Familien: Subscription + OpenRouter; Root-Abnahme | – |
Expand Down
2 changes: 1 addition & 1 deletion docs/design/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ holds the truth and, where one exists, the test that fails when it is broken. Vo
| Enter or pick a value | `inputs/`: `Input`, `Textarea`, `Select`; `forms/`: `Field` (label, hint, error by id), `Checkbox`, `RadioGroup`, `SwitchField` |
| Show a measure or identity | `inputs/`: `Lamp`, `Meter`, `Avatar`, `Kbd` |
| Show a state | `state/`: `StateMark`, `StateGlyph`; `fabrik/`: `Orb` |
| Show data, proof, "nothing here" | `data/`: `Table`, `KeyValue`, `ProofChip`, `HonestState`, `DevNote` |
| Show data, proof, "nothing here" | `data/`: `Table`, `KeyValue`, `ProofChip`, `HonestState`, `DevNote`, `Sparkline`, `BarList`, `ChartFrame` |
| Navigate, overlay, notify | `layout/`: `Tabs`, `TabPanel`, `Sheet`, `ToastRegion` |
| Lists and labels | `surface/`: `ListRow`, `Separator`, `Badge` |
| The Halle | `hall/` |
Expand Down
18 changes: 18 additions & 0 deletions src/design/data/BarList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { chartColour, chartEmpty, chartValue, known, type ChartProps } from "./charts";
import "./charts.css";

export function BarList({ label, data }: ChartProps) {
if (!data?.length) return <p>{chartEmpty(data)}</p>;
const max = Math.max(...data.map(row => known(row.value) ? Math.abs(row.value) : 0)) || 1;
return <ul className="g-bars" aria-label={label}>
{data.map((row, i) => {
const width = known(row.value) ? row.value / max * 48 : null;
return <li key={i}>
<span>{row.label}</span><span className="g-bars__value">{chartValue(row.value)}</span>
<svg viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true">
{width !== null && <rect x={50 + Math.min(0, width)} y="1" width={Math.abs(width)} height="6" fill={chartColour(i)} />}
</svg>
</li>;
})}
</ul>;
}
36 changes: 36 additions & 0 deletions src/design/data/ChartFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useId } from "react";
import { de } from "../../i18n/de";
import { BarList } from "./BarList";
import { Sparkline } from "./Sparkline";
import "./data.css";
import { chartColour, chartEmpty, chartValue, type ChartProps } from "./charts";
import "./charts.css";

export interface ChartFrameProps {
title: string;
caption: string;
kind: "sparkline" | "bars";
data: ChartProps["data"];
}

/** One dataset drives both the graphic and its mandatory table twin. */
export function ChartFrame({ title, caption, kind, data }: ChartFrameProps) {
const id = useId();
const legend = kind === "sparkline" ? [title] : (data ?? []).map(row => row.label);
return <figure className="g-chart" aria-labelledby={id}>
<figcaption><h3 id={id}>{title}</h3><p>{caption}</p></figcaption>
{!!data?.length && <ul className="g-chart__legend" aria-label={de["chart.legend"]}>
{legend.map((label, i) => <li key={i}><i aria-hidden="true" style={{ background: chartColour(i) }} />{label}</li>)}
</ul>}
{kind === "sparkline" ? <Sparkline label={title} data={data} /> : <BarList label={title} data={data} />}
<details>
<summary>{de["chart.table"]}</summary>
{data?.length ? <div className="g-tw" role="region" aria-label={title} tabIndex={0}>
<table className="g-tbl" aria-label={title}>
<thead><tr><th scope="col">{de["chart.label"]}</th><th scope="col" className="g-tbl__n">{de["chart.value"]}</th></tr></thead>
<tbody>{data.map((row, i) => <tr key={i}><th scope="row">{row.label}</th><td className="g-tbl__n">{chartValue(row.value)}</td></tr>)}</tbody>
</table>
</div> : <p>{chartEmpty(data)}</p>}
</details>
</figure>;
}
22 changes: 22 additions & 0 deletions src/design/data/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { de } from "../../i18n/de";
import { chartEmpty, known, type ChartProps } from "./charts";
import "./charts.css";

export function Sparkline({ label, data }: ChartProps) {
const values = (data ?? []).map(row => row.value).filter(known);
if (!values.length) return <p>{data?.length ? de["state.unknown"] : chartEmpty(data)}</p>;
// Normalize first so even opposite finite extremes cannot overflow the range.
const scale = Math.max(...values.map(Math.abs)) || 1;
const min = Math.min(...values) / scale, max = Math.max(...values) / scale;
const points = data!.map((row, i) => known(row.value)
? [data!.length === 1 ? 60 : 2 + i * 116 / (data!.length - 1), max === min ? 16 : 30 - (row.value / scale - min) / (max - min) * 28]
: null);
const path = points.map((p, i) => p ? `${i && points[i - 1] ? "L" : "M"}${p.join(",")}` : "").filter(Boolean).join(" ");
return <span className="g-spark">
<svg viewBox="0 0 120 32" role="img" aria-label={label}>
<path d={path} />
{points.map((p, i) => p && <circle key={i} cx={p[0]} cy={p[1]} r="1.5" />)}
</svg>
{values.length < data!.length && <span>{de["state.unknown"]}</span>}
</span>;
}
19 changes: 19 additions & 0 deletions src/design/data/charts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.g-chart { margin: 0; display: grid; align-content: start; gap: var(--g-space-3); color: var(--g-ink); }
.g-chart h3, .g-chart p { margin: 0; }
.g-chart figcaption p { color: var(--g-muted); }
.g-chart__legend, .g-bars { list-style: none; padding: 0; margin: 0; }
.g-chart__legend { display: flex; flex-wrap: wrap; gap: var(--g-space-3); }
.g-chart__legend li { display: flex; align-items: center; gap: var(--g-space-2); }
.g-chart__legend i { width: .75em; height: .75em; border-radius: var(--g-r-sm); }
.g-chart summary { cursor: pointer; width: fit-content; }
.g-chart summary:focus-visible { outline: var(--g-focus); outline-offset: var(--g-focus-offset); }
.g-spark { display: inline-flex; align-items: center; gap: var(--g-space-2); }
.g-spark svg { width: 120px; height: 32px; color: var(--g-viz-1); }
.g-spark path { fill: none; stroke: currentColor; stroke-width: 2; }
.g-spark circle { fill: currentColor; }
.g-bars { display: grid; gap: var(--g-space-3); }
.g-bars li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--g-space-1) var(--g-space-3); overflow-wrap: anywhere; }
.g-bars__value { font-variant-numeric: tabular-nums; }
.g-bars svg { grid-column: 1 / -1; width: 100%; height: 8px; }
.g-chart .g-tbl tbody th { position: static; font: inherit; font-size: 13px; color: var(--g-ink); background: none; border-bottom: 1px solid var(--g-hair); white-space: normal; }
.g-chart .g-tbl tbody tr:last-child th { border-bottom: 0; }
66 changes: 66 additions & 0 deletions src/design/data/charts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { cleanup, render, screen, within, fireEvent } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { ChartFrame } from "./ChartFrame";
import { Sparkline } from "./Sparkline";
import { BarList } from "./BarList";

afterEach(cleanup);
const data = [{ label: "Mo", value: -2 }, { label: "Di", value: 0 }, { label: "Mi", value: 4 }];

it("the table twin shows the same values as the chart", () => {
render(<ChartFrame title="Aufträge" caption="Diese Woche" kind="bars" data={data} />);
expect(screen.getByRole("heading", { name: "Aufträge" })).toBeTruthy();
expect(screen.getByText("Diese Woche")).toBeTruthy();
const table = screen.getByRole("table", { name: "Aufträge" });
expect(within(table).getAllByRole("row").map(row => row.textContent)).toEqual(["BezeichnungWert", "Mo-2", "Di0", "Mi4"]);
expect(screen.getByRole("list", { name: "Aufträge" }).textContent).toBe("Mo-2Di0Mi4");
expect(screen.getByRole("list", { name: "Legende" }).textContent).toBe("MoDiMi");
});

it("the table disclosure opens only after clicking its summary", () => {
render(<ChartFrame title="Aufträge" caption="Diese Woche" kind="bars" data={data} />);
const summary = screen.getByText("Als Tabelle");
const details = summary.closest("details")!;
expect(details.open).toBe(false);
summary.focus();
expect(document.activeElement).toBe(summary);
fireEvent.click(summary);
expect(details.open).toBe(true);
});

it("bars and table twins format fractions and thousands in German", () => {
render(<ChartFrame title="Messung" caption="Dezimalwerte" kind="bars" data={[{ label: "A", value: 1234.5 }, { label: "B", value: 0.1 + 0.2 }]} />);
const table = screen.getByRole("table", { name: "Messung" });
expect(within(table).getAllByRole("cell").map(cell => cell.textContent)).toEqual(["1.234,5", "0,3"]);
expect(screen.getByRole("list", { name: "Messung" }).textContent).toBe("A1.234,5B0,3");
});

it("each bar has a text label and value beyond colour", () => {
const { container } = render(<BarList label="Messung" data={[...data, { label: "Do", value: NaN }]} />);
expect(screen.getByRole("list", { name: "Messung" }).textContent).toBe("Mo-2Di0Mi4DoUnbekannt");
const rects = container.querySelectorAll("rect");
expect([...rects].map(r => [r.getAttribute("x"), r.getAttribute("width")])).toEqual([["26", "24"], ["50", "0"], ["50", "48"]]);
});

it("sparkline preserves gaps and renders single and constant samples", () => {
const { container, rerender } = render(<Sparkline label="Verlauf" data={[{ label: "A", value: 1 }, { label: "B", value: null }, { label: "C", value: 3 }]} />);
expect(screen.getByRole("img", { name: "Verlauf" })).toBeTruthy();
expect(container.querySelector("path")?.getAttribute("d")).toBe("M2,30 M118,2");
for (const values of [[0], [2, 2], [-Number.MAX_VALUE, Number.MAX_VALUE]]) {
rerender(<Sparkline label="Verlauf" data={values.map((value, i) => ({ label: String(i), value }))} />);
expect(container.querySelectorAll("circle")).toHaveLength(values.length);
expect(container.innerHTML).not.toMatch(/NaN|Infinity/);
}
});

it("empty and unknown data are words in graphics and table twins", () => {
for (const kind of ["sparkline", "bars"] as const) {
for (const [data, word] of [[[], "Keine Daten"], [null, "Unbekannt"], [[{ label: "Mo", value: Infinity }], "Unbekannt"]] as const) {
const { unmount } = render(<ChartFrame title="Messung" caption="Beispiel" kind={kind} data={data} />);
expect(screen.getAllByText(word).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("Als Tabelle").tagName).toBe("SUMMARY");
expect(screen.queryByRole("list", { name: "Legende" }) !== null).toBe(Boolean(data?.length));
unmount();
}
}
});
9 changes: 9 additions & 0 deletions src/design/data/charts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { de } from "../../i18n/de";

export interface ChartDatum { label: string; value: number | null }
export interface ChartProps { data: readonly ChartDatum[] | null; label: string }
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve small values in the table twin

When a dataset contains legitimate values below the fixed three-decimal precision, such as 0.0004, this formatter displays 0 (or -0) while the bar or sparkline still renders a nonzero magnitude. That makes the mandatory textual table alternative contradict the graphic; preserve sufficient precision or let callers provide a value formatter.

Useful? React with 👍 / 👎.

export const known = (value: number | null): value is number => value !== null && Number.isFinite(value);
export const chartValue = (value: number | null) => known(value) ? numberFormat.format(value) : de["state.unknown"];
export const chartEmpty = (data: ChartProps["data"]) => data === null ? de["state.unknown"] : de["chart.empty"];
export const chartColour = (index: number) => `var(--g-viz-${index % 6 + 1})`;
15 changes: 15 additions & 0 deletions src/design/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ export const COMPONENT_REGISTRY: readonly ComponentEntry[] = [
a11y: "Pages are buttons in a named nav with aria-current; swatches print their contrast ratio as text.",
text: "src/i18n/de.ts gallery.*; sample content carries the \"Beispieldaten\" label.",
},
{
name: "ChartFrame", family: "data", variants: [], states: ["empty, unknown, finite values including zero and negatives"],
a11y: "One dataset for graphic and named Table; native keyboard disclosure; labelled legend.",
text: "src/i18n/de.ts chart.*; data labels and title from the caller.",
},
{
name: "Sparkline", family: "data", variants: [], states: ["empty, unknown, finite values including zero and negatives"],
a11y: "Named SVG; gaps stay gaps; use ChartFrame for the mandatory table twin.",
text: "src/i18n/de.ts chart.*; data labels and title from the caller.",
},
{
name: "BarList", family: "data", variants: [], states: ["empty, unknown, finite values including zero and negatives"],
a11y: "Every bar has its label and value in text; use ChartFrame for the mandatory table twin.",
text: "src/i18n/de.ts chart.*; data labels and title from the caller.",
},
{
name: "DevNote", family: "data",
variants: [], states: ["hidden outside the Dev channel"],
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/** German UI copy for Glass UI v2. English locale is parked; keep Messages. */

export const de = {
"chart.empty": "Keine Daten",
"chart.table": "Als Tabelle",
"chart.legend": "Legende",
"chart.label": "Bezeichnung",
"chart.value": "Wert",
"terminal.search.label": "Suche im Terminal-Scrollback",
"terminal.search.placeholder": "Suchen…",
"terminal.search.caseSensitive": "Groß- und Kleinschreibung beachten",
Expand Down
Loading