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 @@ -255,6 +255,7 @@ Größe vor dem Start messen: `store.mjs` (570), `app.js` (504/527) und `store.t
| DS-X-15 | Designsystem-Galerie, Hülle und Grundlagen (D6): Einstellungen › Version › „Designsystem ansehen“ öffnet `#/designsystem` (eigener `React.lazy`-Chunk, außerhalb von `ROUTES`, also nicht in Seitenleiste oder Palette); Navigation über die Register-Familien, Seiten Farben (Kontrast zur Grundfläche), Schrift, Abstand, Radius, Glas, Bewegung (ruht bei reduzierter Bewegung); keine echten Daten, Beispielinhalt trägt „Beispieldaten“ | Implementierer · Claude; Root nimmt ab | DS-Blueprint-Schnitt 16.10. (DS-X-15) | Server | B | Erledigt: #1662 (merged 2026-10-16); Geschichten je Baustein kommen mit DS-X-16/17, Werkzeugleiste und Übertrag aus #1656 (DS-X-14b) nicht enthalten | Tests rot→grün; `vitest run`, typecheck, `contrast-check`, `plan-check --offline` und prepush Exit 0; Bildschirmfotos hell/dunkel; zwei Stufe-B-Reviews fremder Nicht-Anthropic-Familien | – |
| DS-X-14b | Register-Abdeckung gehärtet und Galerie-Routentest (Review-Nachträge zu #1656/#1662): der Abdeckungstest erkennt jede exportierte Designkomponente (`export function`, `export const`, `export default function`), schlägt bei einem nicht erkennbaren Exportstil laut fehl, ohne Zählschwelle, Familie = oberster Ordner unter `src/design`, Pfade portabel verglichen; `isGallery` ignoriert `?query`; Einhänge-Test: `#/designsystem` zeigt die Galerie | Implementierer · Claude; Root nimmt ab | Root-Dispositionen #1656 (R1656-K1/G1, K2, K5/G2, G3) und #1662 (R1662-K1, K2) | Server | B | Erledigt: #1671 (merged 2026-10-17); die schärfere Suche fand keinen fehlenden Registereintrag | Tests rot→grün; `vitest run src/design src/shell`, typecheck, `plan-check --offline` und prepush Exit 0; zwei Stufe-B-Reviews fremder Nicht-Anthropic-Familien | – |
| DS-X-07 | Austrittsübergänge: `useExitTransition` (`src/design/motion/`) hält einen schließenden Knoten für `--g-dur-exit` eingehängt und setzt `data-state="closing"`, bei reduzierter Bewegung (System oder Aus) wird sofort ausgehängt; `Sheet` und `Toast` nutzen ihn, `motion.css` trägt die Austrittsregeln; die `.g-is-new`-Einblendung für neue Agentenzeilen bleibt aus (nicht in diesem Paket) | Implementierer · Claude; Root nimmt ab | DS-X-03 (#1606); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-07) | Server | B | In Arbeit: Draft-PR | 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-09 | Tabelle v2 (Fabrik-Designsystem, Welle 1): `src/design/data/Table.tsx` bekommt sortierbare Spaltenköpfe (`aria-sort`, Enter und Leertaste), optionale Zeilenauswahl mit dem Ankreuzfeld (Kopf-Feld „teils“, Anzahl wird angesagt), eine feststehende Kopfzeile sowie Platzhalter für leer und lädt über `HonestState`; bestehende Aufrufer bleiben unverändert | Implementierer · Claude; Root nimmt ab | DS-X-05 (#1678); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-09) | Server | B | In Arbeit: Entwurfs-PR `claude/ds-x-09`; keine Ansicht umgestellt (KR-07*, 10e folgen) | Tests rot→grün (`red-first.sh` Exit 0); `vitest run src/design`, typecheck, `contrast-check`, `plan-check --offline` und prepush Exit 0; Bildschirmfotos hell/dunkel; ein Stufe-B-Review einer Nicht-Anthropic-Familie, Root kommissioniert | – |
| 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 | – |

Historisch (08.10.); Quotenteil ersetzt durch AGENTS.md „Routing“, Reviewweg durch `docs/decisions.md` „Reviewer count per tier“ (beide Stand 15.10.): Routing-Freigabe Nutzer 08.10. ~21:25Z: Codex frei bis tatsächlich 98 % nutzen;
Expand Down
91 changes: 83 additions & 8 deletions src/design/data/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import type { ReactNode } from "react";
import { useState, type KeyboardEvent, type ReactNode } from "react";
import { de } from "../../i18n/de";
import { Checkbox } from "../forms/Checkbox";
import { HonestState } from "./HonestState";
import "./data.css";

export interface Column<Row> {
key: string;
header: string;
numeric?: boolean;
cell: (row: Row) => ReactNode;
/** Makes the header a sort button; the value is compared as a number or as text. */
sortValue?: (row: Row) => string | number;
}

export interface TableSelection<Row> {
selected: ReadonlySet<string>;
onChange: (next: Set<string>) => void;
/** Accessible name part of a row's checkbox; the row key when missing. */
rowLabel?: (row: Row) => string;
}

export interface TableProps<Row> {
Expand All @@ -14,28 +26,91 @@ export interface TableProps<Row> {
columns: readonly Column<Row>[];
rows: readonly Row[];
rowKey: (row: Row) => string;
/** Shown instead of the table for zero rows (use HonestState). */
/** Shown instead of the table for zero rows; plain text is drawn as HonestState. */
empty?: ReactNode;
/** Replaces the table with a HonestState while data loads. */
loading?: boolean;
/** Adds a checkbox column; the caller owns the selected keys. */
selection?: TableSelection<Row>;
}

export function Table<Row>({ label, columns, rows, rowKey, empty }: TableProps<Row>) {
type SortDir = "ascending" | "descending";

const fill = (text: string, vars: Record<string, string | number>) => text.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k]));
const compare = (a: string | number, b: string | number) =>
typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), "de", { numeric: true });

export function Table<Row>({ label, columns, rows, rowKey, empty, loading, selection }: TableProps<Row>) {
// none -> ascending -> descending -> none; one column at a time.
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
if (loading) return <div role="status"><HonestState kind="empty" title={de["table.loading"]} /></div>;
if (rows.length === 0) {
if (typeof empty === "string") return <HonestState kind="empty" title={empty} />;
if (empty) return <>{empty}</>;
return null;
}
const num = (c: Column<Row>) => (c.numeric ? "g-tbl__n" : undefined);
return (
const cycle = (key: string) => setSort((s) => (s?.key !== key ? { key, dir: "ascending" } : s.dir === "ascending" ? { key, dir: "descending" } : null));
const column = columns.find((c) => c.key === sort?.key);
const shown = !sort || !column?.sortValue ? rows : [...rows].sort((a, b) => compare(column.sortValue!(a), column.sortValue!(b)) * (sort.dir === "ascending" ? 1 : -1));
const keys = rows.map(rowKey);
const picked = selection ? keys.filter((k) => selection.selected.has(k)).length : 0;
const toggle = (key: string, on: boolean) => {
const next = new Set(selection!.selected);
if (on) next.add(key); else next.delete(key);
selection!.onChange(next);
};
const header = (c: Column<Row>) => {
if (!c.sortValue) return <th key={c.key} scope="col" className={num(c)}>{c.header}</th>;
const dir = sort?.key === c.key ? sort.dir : "none";
// Enter and Space are handled here, not left to the click a browser derives from them, so both paths cycle exactly once.
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
cycle(c.key);
};
return (
<th key={c.key} scope="col" className={num(c)} aria-sort={dir}>
<button type="button" className="g-tbl__sort" onClick={() => cycle(c.key)} onKeyDown={onKeyDown} onKeyUp={(e) => e.key === " " && e.preventDefault()}>{c.header}</button>
</th>
);
};
const table = (
<div className="g-tw" role="region" aria-label={label} tabIndex={0}>
<table className="g-tbl">
<thead>
<tr>{columns.map((c) => <th key={c.key} scope="col" className={num(c)}>{c.header}</th>)}</tr>
<tr>
{selection && (
<th scope="col" className="g-tbl__sel">
<Checkbox label={<span className="sr-only">{de["table.selectAll"]}</span>} checked={picked === 0 ? false : picked === keys.length ? true : "mixed"} onCheckedChange={(on) => selection.onChange(on ? new Set(keys) : new Set())} />
</th>
)}
{columns.map(header)}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)}>{columns.map((c) => <td key={c.key} className={num(c)}>{c.cell(row)}</td>)}</tr>
))}
{shown.map((row) => {
const key = rowKey(row);
return (
<tr key={key} aria-selected={selection ? selection.selected.has(key) : undefined}>
{selection && (
<td className="g-tbl__sel">
<Checkbox label={<span className="sr-only">{fill(de["table.selectRow"], { row: selection.rowLabel?.(row) ?? key })}</span>} checked={selection.selected.has(key)} onCheckedChange={(on) => toggle(key, on)} />
</td>
)}
{columns.map((c) => <td key={c.key} className={num(c)}>{c.cell(row)}</td>)}
</tr>
);
})}
</tbody>
</table>
</div>
);
if (!selection) return table;
return (
<>
<p className="g-tbl__count" role="status" aria-live="polite">{fill(de["table.selected"], { n: picked })}</p>
{table}
</>
);
}
15 changes: 14 additions & 1 deletion src/design/data/data.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,26 @@
background: var(--g-content-bg); border: 1px solid var(--g-hair); border-radius: var(--g-r-card); min-width: 0;
}
.g-glass .g-tw, .g-chrome .g-tw, .g-glass .g-hs, .g-chrome .g-hs { background: var(--g-tint); }
.g-tw { overflow-x: auto; }
.g-tw { overflow: auto; max-height: 100%; }
.g-tw:focus-visible { outline: 2px solid var(--g-accent); outline-offset: 2px; }
.g-tbl { width: 100%; border-collapse: collapse; }
.g-tbl th {
font: 500 12px/1.3 var(--g-font-sans); color: var(--g-muted); text-align: left;
padding: 8px 12px; border-bottom: 1px solid var(--g-line); white-space: nowrap;
position: sticky; top: 0; z-index: 1;
/* The content surface is translucent: rows would show through, so it sits on the opaque ground. */
background: linear-gradient(var(--g-content-bg), var(--g-content-bg)), var(--g-ground);
}
.g-glass .g-tbl th, .g-chrome .g-tbl th { background: linear-gradient(var(--g-tint), var(--g-tint)), var(--g-ground); }
.g-tbl__sort { all: unset; cursor: pointer; }
.g-tbl__sort:focus-visible { outline: var(--g-focus); outline-offset: var(--g-focus-offset); }
.g-tbl th[aria-sort] .g-tbl__sort::after { content: " \2195"; opacity: .5; }
.g-tbl th[aria-sort="ascending"] .g-tbl__sort::after { content: " \2191"; opacity: 1; }
.g-tbl th[aria-sort="descending"] .g-tbl__sort::after { content: " \2193"; opacity: 1; }
.g-tbl th[aria-sort="ascending"], .g-tbl th[aria-sort="descending"] { color: var(--g-ink); }
.g-tbl .g-tbl__sel { width: 1%; padding-right: 0; }
.g-tbl tbody tr[aria-selected="true"] td { background: var(--g-tint); }
.g-tbl__count { margin: 0 0 4px; font: 500 12px/1.3 var(--g-font-sans); color: var(--g-muted); }
.g-tbl td { padding: 0 12px; height: 44px; border-bottom: 1px solid var(--g-hair); font-size: 13px; color: var(--g-ink); vertical-align: middle; }
.g-tbl tbody tr:last-child td { border-bottom: 0; }
.g-tbl .g-tbl__n { text-align: right; font-variant-numeric: tabular-nums; }
Expand Down
43 changes: 43 additions & 0 deletions src/design/data/table.selection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { expect, it } from "vitest";

import { Table } from "./Table";

const rows = [{ id: "a", name: "Alpha" }, { id: "b", name: "Beta" }, { id: "c", name: "Gamma" }];
type Row = (typeof rows)[number];
const columns = [{ key: "name", header: "Name", cell: (r: Row) => r.name }];

function Harness() {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
return <Table label="Agents" columns={columns} rows={rows} rowKey={(r) => r.id} selection={{ selected, onChange: setSelected, rowLabel: (r) => r.name }} />;
}

it("selection announces count", () => {
render(<Harness />);
const live = screen.getByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
expect(live).toHaveTextContent("0 ausgewählt");
fireEvent.click(screen.getByRole("checkbox", { name: "Zeile auswählen: Alpha" }));
expect(live).toHaveTextContent("1 ausgewählt");
expect(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })).toBePartiallyChecked();
fireEvent.click(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" }));
expect(live).toHaveTextContent("3 ausgewählt");
expect(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })).toBeChecked();
fireEvent.click(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" }));
expect(live).toHaveTextContent("0 ausgewählt");
expect(screen.getAllByRole("row")[1]).not.toHaveAttribute("aria-selected", "true");
});

it("marks selected rows with aria-selected", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("checkbox", { name: "Zeile auswählen: Beta" }));
expect(screen.getAllByRole("row")[2]).toHaveAttribute("aria-selected", "true");
expect(screen.getAllByRole("row")[1]).toHaveAttribute("aria-selected", "false");
});

it("renders no checkboxes or live region without a selection prop", () => {
render(<Table label="Agents" columns={columns} rows={rows} rowKey={(r) => r.id} />);
expect(screen.queryByRole("checkbox")).toBeNull();
expect(screen.queryByRole("status")).toBeNull();
});
60 changes: 60 additions & 0 deletions src/design/data/table.sort.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { expect, it } from "vitest";

import { Table } from "./Table";

const css = readFileSync(resolve(__dirname, "data.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");

const rows = [{ id: "b", name: "Beta", n: 12 }, { id: "a", name: "Alpha", n: 7 }, { id: "c", name: "Gamma", n: 30 }];
type Row = (typeof rows)[number];
const columns = [
{ key: "name", header: "Name", sortValue: (r: Row) => r.name, cell: (r: Row) => r.name },
{ key: "n", header: "Count", numeric: true, sortValue: (r: Row) => r.n, cell: (r: Row) => r.n },
{ key: "plain", header: "Plain", cell: () => "-" },
];
const names = () => screen.getAllByRole("row").slice(1).map((r) => within(r).getAllByRole("cell")[0].textContent);

it("cycles aria-sort from none to ascending to descending by keyboard", () => {
render(<Table label="Agents" columns={columns} rows={rows} rowKey={(r) => r.id} />);
const th = screen.getByRole("columnheader", { name: "Name" });
const button = within(th).getByRole("button", { name: "Name" });
expect(th).toHaveAttribute("aria-sort", "none");
expect(names()).toEqual(["Beta", "Alpha", "Gamma"]);
button.focus();
// jsdom turns no key into a click, so Enter and Space must be handled on the button itself.
fireEvent.keyDown(button, { key: "Enter" });
expect(th).toHaveAttribute("aria-sort", "ascending");
expect(names()).toEqual(["Alpha", "Beta", "Gamma"]);
fireEvent.keyDown(button, { key: " " });
expect(th).toHaveAttribute("aria-sort", "descending");
expect(names()).toEqual(["Gamma", "Beta", "Alpha"]);
fireEvent.click(button);
expect(th).toHaveAttribute("aria-sort", "none");
expect(names()).toEqual(["Beta", "Alpha", "Gamma"]);
});

it("sorts one column at a time and compares numbers numerically", () => {
render(<Table label="Agents" columns={columns} rows={rows} rowKey={(r) => r.id} />);
fireEvent.click(screen.getByRole("button", { name: "Name" }));
fireEvent.click(screen.getByRole("button", { name: "Count" }));
expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute("aria-sort", "none");
expect(screen.getByRole("columnheader", { name: "Count" })).toHaveAttribute("aria-sort", "ascending");
expect(names()).toEqual(["Alpha", "Beta", "Gamma"]);
expect(screen.getByRole("columnheader", { name: "Plain" })).not.toHaveAttribute("aria-sort");
expect(screen.queryByRole("button", { name: "Plain" })).toBeNull();
});

it("renders the empty slot text through HonestState and the loading slot instead of the table", () => {
const { rerender } = render(<Table label="Agents" columns={columns} rows={[]} rowKey={(r: Row) => r.id} empty="Keine Agenten" />);
expect(screen.getByText("Keine Agenten")).toHaveClass("g-hs__title");
expect(screen.queryByRole("table")).toBeNull();
rerender(<Table label="Agents" columns={columns} rows={rows} rowKey={(r) => r.id} loading />);
expect(screen.getByRole("status")).toHaveTextContent("Lädt …");
expect(screen.queryByRole("table")).toBeNull();
});

it("keeps the header sticky inside the scroll region", () => {
expect(css).toMatch(/\.g-tbl th\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0/);
});
6 changes: 3 additions & 3 deletions src/design/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ export const COMPONENT_REGISTRY: readonly ComponentEntry[] = [
},
{
name: "Table", family: "data",
variants: ["column numeric"], states: ["no rows shows the empty node or nothing"],
a11y: "Labelled, focusable scroll region; header cells have scope=\"col\".",
text: "Headers and cells from the caller; use HonestState for empty.",
variants: ["column numeric", "column sortValue sorts", "selection adds checkboxes"], states: ["no rows shows the empty node or nothing; empty text draws as HonestState", "loading shows HonestState instead of the table", "sorted ascending, descending or none", "selected rows, header box mixed when partial"],
a11y: "Labelled, focusable scroll region; header cells have scope=\"col\"; sortable headers hold a button and aria-sort (Enter or Space cycles); selection count in a polite status region; sticky header.",
text: "src/i18n/de.ts table.*; headers and cells from the caller; use HonestState for empty.",
},
{
name: "Orb", family: "fabrik",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export const de = {
"honest.empty": "Noch nichts da",
"honest.offline": "Nicht verbunden",
"honest.locked": "Gesperrt",
"table.loading": "Lädt …",
"table.selected": "{n} ausgewählt",
"table.selectAll": "Alle Zeilen auswählen",
"table.selectRow": "Zeile auswählen: {row}",
"proof.gates": "Gates",
"flag.d1.label": "Neue Oberfläche (Vorschau)",
"flag.d1.description": "Zeigt die neue Oberfläche als Vorschau; sie ist noch nicht fertig.",
Expand Down
Loading