From 683d50942a5fcb838f5a95aa107a42fc62dc2fac Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:03:45 +0200 Subject: [PATCH 1/8] test(ui): EYT-80 Kontrastgate und Tokenvertrag vor der Quelle (rot) --- packages/ui/test/basisdesign-tokens.test.ts | 182 ++++++++++++++++++++ packages/ui/test/helpers/kontrast.ts | 53 ++++++ 2 files changed, 235 insertions(+) create mode 100644 packages/ui/test/basisdesign-tokens.test.ts create mode 100644 packages/ui/test/helpers/kontrast.ts diff --git a/packages/ui/test/basisdesign-tokens.test.ts b/packages/ui/test/basisdesign-tokens.test.ts new file mode 100644 index 0000000..7649468 --- /dev/null +++ b/packages/ui/test/basisdesign-tokens.test.ts @@ -0,0 +1,182 @@ +/** + * Das Basisdesign v2.0 §2.1 gehoert diesem Paket (EYT-80, EYT-12). + * + * Gelesen wird GENAU die Datei, die auch die Anwendung einbindet — + * `src/basisdesign-v2.css`. Eine zweite Liste von Werten daneben waere + * wieder die Doppelpflege, die dieses Inkrement abschafft: die Erwartung + * unten ist die SPEZIFIKATION aus Confluence 8814623, die Quelle ist die + * CSS-Datei, und der Test vergleicht beide. + * + * Nicht ueber `import.meta.url`: unter vitest/jsdom traegt das kein + * file://-Schema und `fileURLToPath` wirft. `process.cwd()` ist packages/ui. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { SCHWELLE_NORMALTEXT, kontrast } from "./helpers/kontrast.js"; + +const rohCss = readFileSync(join(process.cwd(), "src/basisdesign-v2.css"), "utf8"); + +/* + * Kommentare fallen vor jeder Suche weg — sonst genuegte ein auskommentierter + * Wert, um jede Zusicherung unten zu erfuellen, waehrend die Regel fort ist. + */ +const css = rohCss.replace(/\/\*[\s\S]*?\*\//g, ""); + +/** Deklarationen (`--eyt-x: #abc`), NICHT Verwendungen (`var(--eyt-x)`). */ +function deklarationen(abschnitt: string): Map { + const gefunden = new Map(); + for (const treffer of abschnitt.matchAll(/(?:^|[;{])\s*(--eyt-[\w-]+)\s*:\s*([^;}]+)/g)) { + gefunden.set(treffer[1] ?? "", (treffer[2] ?? "").trim().toLowerCase()); + } + return gefunden; +} + +const grenze = css.indexOf("@media"); +const HELL = deklarationen(grenze >= 0 ? css.slice(0, grenze) : css); +const DUNKEL = deklarationen(grenze >= 0 ? css.slice(grenze) : ""); + +/** + * Confluence 8814623 §2.1 — die dreizehn genehmigten Rollen. + * Diese Tabelle ist die Spezifikation und darf nur geaendert werden, wenn die + * Confluence-Seite sich aendert. + */ +const GENEHMIGT: ReadonlyArray = [ + ["--eyt-bg-canvas", "#f6f4ef", "#161513"], + ["--eyt-bg-surface", "#ffffff", "#211f1c"], + ["--eyt-text-primary", "#1d1b18", "#f1efe9"], + ["--eyt-text-secondary", "#5b564e", "#b0aaa0"], + ["--eyt-border-default", "#d8d4cb", "#3a3733"], + ["--eyt-action-primary", "#1e5231", "#8fc7a2"], + ["--eyt-state-published-bg", "#e1ebe2", "#22352a"], + ["--eyt-state-draft-text", "#7a5300", "#e3b95b"], + ["--eyt-state-draft-bg", "#f4e8ce", "#3a2f17"], + ["--eyt-state-danger-text", "#9b2c1f", "#f0a08f"], + ["--eyt-state-danger-bg", "#f7e3df", "#41211c"], + ["--eyt-state-info-text", "#155e75", "#7cc1db"], + ["--eyt-state-info-bg", "#deedf2", "#1c3540"], +]; + +/** + * Zwei Tokens stehen NICHT in der Confluence-Tabelle. Sie sind ABLEITUNGEN und + * werden hier als solche geprueft — nicht als genehmigte Werte ausgegeben. + */ +const ABGELEITET: ReadonlyArray = [ + // veroeffentlichter Text traegt dieselbe Farbe wie die Hauptaktion + ["--eyt-state-published-text", "--eyt-action-primary"], + // Text AUF der Hauptaktion ist die jeweilige Gegenflaeche + ["--eyt-action-primary-contrast", ""], +]; + +/** + * Die Paare, die als NORMALER KLEINTEXT vorkommen. Bewusst aufgezaehlt statt + * kombinatorisch erzeugt: nicht jede Kombination ist eine Textkombination, und + * ein Kreuzprodukt haette Paare gemessen, die niemand rendert — gruen oder rot + * gleichermassen aussagelos. + */ +const TEXTPAARE: ReadonlyArray = [ + ["--eyt-text-primary", "--eyt-bg-canvas"], + ["--eyt-text-primary", "--eyt-bg-surface"], + ["--eyt-text-secondary", "--eyt-bg-canvas"], + ["--eyt-text-secondary", "--eyt-bg-surface"], + ["--eyt-action-primary-contrast", "--eyt-action-primary"], + ["--eyt-state-published-text", "--eyt-state-published-bg"], + ["--eyt-state-draft-text", "--eyt-state-draft-bg"], + ["--eyt-state-danger-text", "--eyt-state-danger-bg"], + ["--eyt-state-info-text", "--eyt-state-info-bg"], + ["--eyt-state-published-text", "--eyt-bg-surface"], + ["--eyt-state-danger-text", "--eyt-bg-surface"], + ["--eyt-state-info-text", "--eyt-bg-surface"], +]; + +const MODI = [ + ["hell", HELL], + ["dunkel", DUNKEL], +] as const; + +describe("Basisdesign v2.0 §2.1 — kanonische Tokenquelle (EYT-80)", () => { + it("liest ueberhaupt eine Stylesheet-Datei", () => { + // Faengt den leisen Fall: ein Pfad auf eine echte, aber leere oder halb + // geschriebene Datei. Ein FALSCHER Pfad wirft schon beim Einsammeln + // (ENOENT) und ist damit lauter, als eine Zusicherung es koennte. + expect(css.length).toBeGreaterThan(500); + }); + + it("deklariert alle dreizehn genehmigten Rollen in beiden Modi", () => { + expect([...HELL.keys()].sort()).toEqual([...DUNKEL.keys()].sort()); + for (const [token] of GENEHMIGT) { + expect(HELL.has(token), `hell: ${token} fehlt`).toBe(true); + expect(DUNKEL.has(token), `dunkel: ${token} fehlt`).toBe(true); + } + }); + + it.each(GENEHMIGT)("%s traegt die genehmigten Werte", (token, hell, dunkel) => { + expect(HELL.get(token), `hell ${token}`).toBe(hell); + expect(DUNKEL.get(token), `dunkel ${token}`).toBe(dunkel); + }); + + it("fuehrt keine Farbrolle, die die Baseline nicht kennt", () => { + const erlaubt = new Set([...GENEHMIGT.map(([t]) => t), ...ABGELEITET.map(([t]) => t)]); + expect([...HELL.keys()].filter((t) => !erlaubt.has(t))).toEqual([]); + }); + + it("haelt die beiden Ableitungen an ihre Herkunft", () => { + // published.text folgt action.primary — in BEIDEN Modi. + expect(HELL.get("--eyt-state-published-text")).toBe(HELL.get("--eyt-action-primary")); + expect(DUNKEL.get("--eyt-state-published-text")).toBe(DUNKEL.get("--eyt-action-primary")); + // Der Text AUF der Hauptaktion ist die jeweilige Gegenflaeche. + expect(HELL.get("--eyt-action-primary-contrast")).toBe("#ffffff"); + expect(DUNKEL.get("--eyt-action-primary-contrast")).toBe(DUNKEL.get("--eyt-bg-canvas")); + }); +}); + +describe("Kontrastgate fuer normalen Kleintext (EYT-80, EYT-12)", () => { + for (const [modus, tokens] of MODI) { + it.each(TEXTPAARE)(`${modus}: %s auf %s erreicht 4.5:1`, (vorne, hinten) => { + const fg = tokens.get(vorne); + const bg = tokens.get(hinten); + // Ohne diese zwei Zeilen wuerde ein GELOESCHTES Token zu + // `kontrast(undefined, …)` und damit zu einem Wurf statt zu einer + // Aussage — der Fall waere rot, aber aus dem falschen Grund. + expect(fg, `${modus}: ${vorne} nicht deklariert`).toBeTruthy(); + expect(bg, `${modus}: ${hinten} nicht deklariert`).toBeTruthy(); + const verhaeltnis = kontrast(fg as string, bg as string); + expect( + verhaeltnis, + `${modus}: ${vorne} auf ${hinten} = ${verhaeltnis.toFixed(2)}:1`, + ).toBeGreaterThanOrEqual(SCHWELLE_NORMALTEXT); + }); + } + + it("weist die verworfene Penpot-Kombination #6D786F auf #F0F4EF ab", () => { + expect(kontrast("#6D786F", "#F0F4EF")).toBeLessThan(SCHWELLE_NORMALTEXT); + }); + + it("weist die verworfene Penpot-Kombination #A86B2B auf #F6E9D8 ab", () => { + expect(kontrast("#A86B2B", "#F6E9D8")).toBeLessThan(SCHWELLE_NORMALTEXT); + }); + + it("reproduziert die in der Baseline genannten Verhaeltnisse", () => { + /* + * DIE Gegenprobe fuer die Rechnung selbst. Confluence 8814623 §2.1 + * beziffert die beiden Ablehnungen mit „ca. 4,14:1" und „ca. 3,66:1". + * Ohne diese zwei Zeilen waere eine Funktion, die konstant 1 liefert, + * fuer beide Ablehnungsfaelle oben gruen — und fuer jedes Textpaar rot, + * was wie ein Palettenfehler aussaehe und keiner waere. + */ + expect(kontrast("#6D786F", "#F0F4EF")).toBeCloseTo(4.14, 2); + expect(kontrast("#A86B2B", "#F6E9D8")).toBeCloseTo(3.66, 2); + }); + + it("weist die verworfenen Werte auch dann ab, wenn sie in den Tokens stuenden", () => { + /* + * Kein Blacklist-Test: geprueft wird, dass die GRENZE greift, nicht dass + * genau diese zwei Zeichenketten verboten sind. Ein Sekundaertext im + * Penpot-Ton auf der Penpot-Flaeche faellt durch dieselbe Schwelle, die + * oben alle echten Paare bestehen. + */ + expect(kontrast("#6D786F", "#F0F4EF")).toBeLessThan(kontrast("#5b564e", "#f6f4ef")); + }); +}); diff --git a/packages/ui/test/helpers/kontrast.ts b/packages/ui/test/helpers/kontrast.ts new file mode 100644 index 0000000..a3e17d7 --- /dev/null +++ b/packages/ui/test/helpers/kontrast.ts @@ -0,0 +1,53 @@ +/** + * WCAG-2.x-Kontrast, lokal gerechnet — bewusst OHNE Abhaengigkeit. + * + * Die Formel ist kurz und stabil (relative Leuchtdichte nach WCAG 2.1, + * Definition „relative luminance"); eine Bibliothek dafuer waere eine + * Abhaengigkeit, die genau diese zehn Zeilen mitbringt. + * + * Dass sie STIMMT, ist nicht behauptet, sondern gemessen: die beiden von + * Confluence 8814623 §2.1 ausdruecklich verworfenen Kombinationen sind dort + * mit „ca. 4,14:1" und „ca. 3,66:1" beziffert, und diese Funktion + * reproduziert beide Zahlen auf zwei Nachkommastellen (Fall „reproduziert die + * in der Baseline genannten Verhaeltnisse" in basisdesign-tokens.test.ts). + * Das ist die Gegenprobe gegen eine kaputte Implementierung — ohne sie waere + * eine Funktion, die immer 1 zurueckgibt, fuer die Ablehnungsfaelle gruen. + */ + +/** #rrggbb (auch verkuerzt #rgb) → [r, g, b] in 0..255. */ +export function kanaele(hex: string): [number, number, number] { + const roh = hex.trim().replace(/^#/, ""); + const voll = + roh.length === 3 + ? roh + .split("") + .map((z) => z + z) + .join("") + : roh; + if (!/^[0-9a-fA-F]{6}$/.test(voll)) { + throw new Error(`kein 6-stelliger Hexwert: ${hex}`); + } + const teil = (i: number): number => Number.parseInt(voll.slice(i, i + 2), 16); + return [teil(0), teil(2), teil(4)]; +} + +/** Relative Leuchtdichte nach WCAG 2.1. */ +export function leuchtdichte(hex: string): number { + const linear = (wert: number): number => { + const anteil = wert / 255; + return anteil <= 0.04045 ? anteil / 12.92 : Math.pow((anteil + 0.055) / 1.055, 2.4); + }; + const [r, g, b] = kanaele(hex); + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +/** Kontrastverhaeltnis zweier Farben, immer >= 1. */ +export function kontrast(vordergrund: string, hintergrund: string): number { + const a = leuchtdichte(vordergrund); + const b = leuchtdichte(hintergrund); + const [hell, dunkel] = a > b ? [a, b] : [b, a]; + return (hell + 0.05) / (dunkel + 0.05); +} + +/** WCAG 2.1 AA fuer normalen Kleintext. */ +export const SCHWELLE_NORMALTEXT = 4.5; From e1664bf035d9f1b045db02cceae8b09083427e49 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:15:27 +0200 Subject: [PATCH 2/8] test(ui): EYT-80 Kontrastgate nach Review geschaerft --- packages/ui/test/basisdesign-tokens.test.ts | 111 ++++++++++++++------ packages/ui/test/helpers/kontrast.ts | 19 ++-- 2 files changed, 85 insertions(+), 45 deletions(-) diff --git a/packages/ui/test/basisdesign-tokens.test.ts b/packages/ui/test/basisdesign-tokens.test.ts index 7649468..cf23aa7 100644 --- a/packages/ui/test/basisdesign-tokens.test.ts +++ b/packages/ui/test/basisdesign-tokens.test.ts @@ -60,14 +60,27 @@ const GENEHMIGT: ReadonlyArray = [ ]; /** - * Zwei Tokens stehen NICHT in der Confluence-Tabelle. Sie sind ABLEITUNGEN und - * werden hier als solche geprueft — nicht als genehmigte Werte ausgegeben. + * Zwei Rollen stehen NICHT in der Confluence-Tabelle §2.1 — aus verschiedenen + * Gruenden, und deshalb in zwei Listen. + * + * `--eyt-state-published-text` ist eine echte ABLEITUNG: es traegt in beiden + * Modi die Farbe der Hauptaktion. Die Quelle steht in der Liste und wird + * benutzt. + * + * `--eyt-action-primary-contrast` ist KEINE Ableitung. Es ist repo-eigen + * gewaehlt und nur zufaellig in Hell gleich `bg.surface` und in Dunkel gleich + * `bg.canvas` — zwei verschiedene Rollen. Diese Gleichheit als Herkunft zu + * behaupten haette eine Kopplung erfunden, die es nicht gibt: eine Aenderung + * an `bg.surface` verlangte dann eine Aenderung hier, obwohl der Wert davon + * nicht abhaengt. Zugesagt ist nicht die Herkunft, sondern der KONTRAST auf + * der Hauptaktion — und der steht in `TEXTPAARE`. */ const ABGELEITET: ReadonlyArray = [ - // veroeffentlichter Text traegt dieselbe Farbe wie die Hauptaktion ["--eyt-state-published-text", "--eyt-action-primary"], - // Text AUF der Hauptaktion ist die jeweilige Gegenflaeche - ["--eyt-action-primary-contrast", ""], +]; + +const REPO_EIGEN: ReadonlyArray = [ + ["--eyt-action-primary-contrast", "#ffffff", "#161513"], ]; /** @@ -97,11 +110,33 @@ const MODI = [ ] as const; describe("Basisdesign v2.0 §2.1 — kanonische Tokenquelle (EYT-80)", () => { - it("liest ueberhaupt eine Stylesheet-Datei", () => { - // Faengt den leisen Fall: ein Pfad auf eine echte, aber leere oder halb - // geschriebene Datei. Ein FALSCHER Pfad wirft schon beim Einsammeln - // (ENOENT) und ist damit lauter, als eine Zusicherung es koennte. - expect(css.length).toBeGreaterThan(500); + it("deklariert in beiden Modi genau so viele Rollen wie bekannt", () => { + /* + * Faengt zwei Faelle auf einmal: eine abgeschnittene oder halb + * geschriebene Datei, und eine Rolle, die still dazukommt oder + * verschwindet. Eine Byte-Zahl (frueher `css.length > 500`) tat das + * nicht — die geplante Datei misst 1116 Zeichen, eine um 55 % gekuerzte + * waere durchgekommen. + */ + const erwartet = GENEHMIGT.length + ABGELEITET.length + REPO_EIGEN.length; + expect(HELL.size, "hell").toBe(erwartet); + expect(DUNKEL.size, "dunkel").toBe(erwartet); + }); + + it("traegt genau einen Medienblock, und zwar den Dunkelmodus", () => { + /* + * `HELL` und `DUNKEL` teilen die Datei am ERSTEN `@media`. Ohne diese + * Zusicherung ist das eine Annahme statt einer Zusage. Der leise Fall ist + * NICHT ein fehlender Dunkelblock — der faellt oben beim Zaehlen auf —, + * sondern ein ZWEITER, spaeterer Medienblock, der eine `--eyt-`-Rolle neu + * setzt: `deklarationen` laesst den letzten gewinnen, und beide Gates + * messen dann einen Wert, der im Dunkelblock gar nicht steht. + * `apps/web/app/globals.css` traegt heute drei weitere Medienbloecke, die + * Form ist also nicht hypothetisch. + */ + expect([...css.matchAll(/@media[^{]*/g)].map((m) => m[0].trim())).toEqual([ + "@media (prefers-color-scheme: dark)", + ]); }); it("deklariert alle dreizehn genehmigten Rollen in beiden Modi", () => { @@ -118,17 +153,32 @@ describe("Basisdesign v2.0 §2.1 — kanonische Tokenquelle (EYT-80)", () => { }); it("fuehrt keine Farbrolle, die die Baseline nicht kennt", () => { - const erlaubt = new Set([...GENEHMIGT.map(([t]) => t), ...ABGELEITET.map(([t]) => t)]); - expect([...HELL.keys()].filter((t) => !erlaubt.has(t))).toEqual([]); + const erlaubt = new Set([ + ...GENEHMIGT.map(([t]) => t), + ...ABGELEITET.map(([t]) => t), + ...REPO_EIGEN.map(([t]) => t), + ]); + // Beide Modi einzeln: die Schluesselgleichheit oben ist eine ANDERE + // Zusicherung, und eine unbekannte Rolle nur im Dunkelblock waere sonst + // bloss transitiv gefangen. + expect( + [...HELL.keys()].filter((t) => !erlaubt.has(t)), + "hell", + ).toEqual([]); + expect( + [...DUNKEL.keys()].filter((t) => !erlaubt.has(t)), + "dunkel", + ).toEqual([]); + }); + + it.each(ABGELEITET)("%s folgt seiner Herkunft %s", (token, quelle) => { + expect(HELL.get(token), `hell ${token}`).toBe(HELL.get(quelle)); + expect(DUNKEL.get(token), `dunkel ${token}`).toBe(DUNKEL.get(quelle)); }); - it("haelt die beiden Ableitungen an ihre Herkunft", () => { - // published.text folgt action.primary — in BEIDEN Modi. - expect(HELL.get("--eyt-state-published-text")).toBe(HELL.get("--eyt-action-primary")); - expect(DUNKEL.get("--eyt-state-published-text")).toBe(DUNKEL.get("--eyt-action-primary")); - // Der Text AUF der Hauptaktion ist die jeweilige Gegenflaeche. - expect(HELL.get("--eyt-action-primary-contrast")).toBe("#ffffff"); - expect(DUNKEL.get("--eyt-action-primary-contrast")).toBe(DUNKEL.get("--eyt-bg-canvas")); + it.each(REPO_EIGEN)("%s traegt seine repo-eigenen Werte", (token, hell, dunkel) => { + expect(HELL.get(token), `hell ${token}`).toBe(hell); + expect(DUNKEL.get(token), `dunkel ${token}`).toBe(dunkel); }); }); @@ -137,12 +187,13 @@ describe("Kontrastgate fuer normalen Kleintext (EYT-80, EYT-12)", () => { it.each(TEXTPAARE)(`${modus}: %s auf %s erreicht 4.5:1`, (vorne, hinten) => { const fg = tokens.get(vorne); const bg = tokens.get(hinten); - // Ohne diese zwei Zeilen wuerde ein GELOESCHTES Token zu - // `kontrast(undefined, …)` und damit zu einem Wurf statt zu einer - // Aussage — der Fall waere rot, aber aus dem falschen Grund. - expect(fg, `${modus}: ${vorne} nicht deklariert`).toBeTruthy(); - expect(bg, `${modus}: ${hinten} nicht deklariert`).toBeTruthy(); - const verhaeltnis = kontrast(fg as string, bg as string); + // Kein `as`-Cast: der ist zur Laufzeit nichts (CLAUDE.md, „ein `as` unter + // ‚validiert' ist zur Compilezeit geloescht"). Ein geloeschtes Token soll + // mit seinem NAMEN scheitern, nicht als TypeError aus `.trim()`. + if (fg === undefined || bg === undefined) { + throw new Error(`${modus}: ${vorne} oder ${hinten} nicht deklariert`); + } + const verhaeltnis = kontrast(fg, bg); expect( verhaeltnis, `${modus}: ${vorne} auf ${hinten} = ${verhaeltnis.toFixed(2)}:1`, @@ -169,14 +220,4 @@ describe("Kontrastgate fuer normalen Kleintext (EYT-80, EYT-12)", () => { expect(kontrast("#6D786F", "#F0F4EF")).toBeCloseTo(4.14, 2); expect(kontrast("#A86B2B", "#F6E9D8")).toBeCloseTo(3.66, 2); }); - - it("weist die verworfenen Werte auch dann ab, wenn sie in den Tokens stuenden", () => { - /* - * Kein Blacklist-Test: geprueft wird, dass die GRENZE greift, nicht dass - * genau diese zwei Zeichenketten verboten sind. Ein Sekundaertext im - * Penpot-Ton auf der Penpot-Flaeche faellt durch dieselbe Schwelle, die - * oben alle echten Paare bestehen. - */ - expect(kontrast("#6D786F", "#F0F4EF")).toBeLessThan(kontrast("#5b564e", "#f6f4ef")); - }); }); diff --git a/packages/ui/test/helpers/kontrast.ts b/packages/ui/test/helpers/kontrast.ts index a3e17d7..1c82f73 100644 --- a/packages/ui/test/helpers/kontrast.ts +++ b/packages/ui/test/helpers/kontrast.ts @@ -14,20 +14,19 @@ * eine Funktion, die immer 1 zurueckgibt, fuer die Ablehnungsfaelle gruen. */ -/** #rrggbb (auch verkuerzt #rgb) → [r, g, b] in 0..255. */ +/** + * #rrggbb → [r, g, b] in 0..255. + * + * Die Kurzform `#rgb` wird bewusst NICHT akzeptiert: die kanonische Datei + * schreibt sechsstellig, und der Zweig war von keiner Aufrufstelle erreichbar + * — er versprach eine Toleranz, die der Vertrag nicht hat. + */ export function kanaele(hex: string): [number, number, number] { const roh = hex.trim().replace(/^#/, ""); - const voll = - roh.length === 3 - ? roh - .split("") - .map((z) => z + z) - .join("") - : roh; - if (!/^[0-9a-fA-F]{6}$/.test(voll)) { + if (!/^[0-9a-fA-F]{6}$/.test(roh)) { throw new Error(`kein 6-stelliger Hexwert: ${hex}`); } - const teil = (i: number): number => Number.parseInt(voll.slice(i, i + 2), 16); + const teil = (i: number): number => Number.parseInt(roh.slice(i, i + 2), 16); return [teil(0), teil(2), teil(4)]; } From 328bec64dcb6be6c4cce5dad714f1cd0ea69e024 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:16:44 +0200 Subject: [PATCH 3/8] test(ui): EYT-80 Gegenmutation auf drei Rollen erweitern; Testname ehrlich benennen --- packages/ui/test/basisdesign-tokens.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/test/basisdesign-tokens.test.ts b/packages/ui/test/basisdesign-tokens.test.ts index cf23aa7..d352453 100644 --- a/packages/ui/test/basisdesign-tokens.test.ts +++ b/packages/ui/test/basisdesign-tokens.test.ts @@ -139,7 +139,11 @@ describe("Basisdesign v2.0 §2.1 — kanonische Tokenquelle (EYT-80)", () => { ]); }); - it("deklariert alle dreizehn genehmigten Rollen in beiden Modi", () => { + it("deklariert beide Modi deckungsgleich und alle genehmigten Rollen", () => { + // Zwei Aussagen, absichtlich zusammen: die Schluesselmengen sind + // deckungsgleich (das betrifft ALLE Rollen, auch die repo-eigenen), und + // jede der dreizehn genehmigten ist einzeln da — letzteres nur, damit ein + // Fehlschlag den NAMEN der fehlenden Rolle nennt statt einer Mengendifferenz. expect([...HELL.keys()].sort()).toEqual([...DUNKEL.keys()].sort()); for (const [token] of GENEHMIGT) { expect(HELL.has(token), `hell: ${token} fehlt`).toBe(true); From 6157462153bbc196eb38b2056e85b4e799e78fbf Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:19:08 +0200 Subject: [PATCH 4/8] feat(ui): EYT-80 Basisdesign-v2-Tokens als kanonische Quelle in packages/ui --- packages/ui/package.json | 9 +++-- packages/ui/src/basisdesign-v2.css | 63 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/basisdesign-v2.css diff --git a/packages/ui/package.json b/packages/ui/package.json index a9d7e33..cdea832 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,14 +10,17 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - } + }, + "./basisdesign-v2.css": "./dist/basisdesign-v2.css" }, "files": [ "dist" ], - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && cp src/basisdesign-v2.css dist/basisdesign-v2.css", "lint": "eslint .", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run" diff --git a/packages/ui/src/basisdesign-v2.css b/packages/ui/src/basisdesign-v2.css new file mode 100644 index 0000000..59cff53 --- /dev/null +++ b/packages/ui/src/basisdesign-v2.css @@ -0,0 +1,63 @@ +/* + * Basisdesign v2.0 §2.1 — die verbindliche Tokenbasis (Confluence 8814623). + * + * DIESE Datei ist die einzige Quelle der semantischen Farbrollen. `apps/web` + * haelt keine zweite Fassung mehr, sondern bindet diese hier ein; damit gibt es + * keine Abweichung mehr zu bewachen, weil es keine zweite Liste mehr gibt. + * + * Bewacht wird sie von `test/basisdesign-tokens.test.ts`: dort steht die + * Confluence-Tabelle als ERWARTUNG, hier stehen die Werte, und jedes Paar, das + * als normaler Kleintext vorkommt, muss 4.5:1 erreichen. + * + * Zwei Rollen stehen NICHT in §2.1: `--eyt-state-published-text` ist eine + * Ableitung aus `--eyt-action-primary`, `--eyt-action-primary-contrast` ist + * repo-eigen gewaehlt. Der Test haelt beide fest; ausgegeben werden sie nie + * als „genehmigte Werte“. + * + * Kein Layout, keine Klassen, keine Schrift: das Paket besitzt die WERTE, die + * Anwendung besitzt die Gestaltung (`apps/web/app/globals.css`). + */ + +:root { + --eyt-bg-canvas: #f6f4ef; + --eyt-bg-surface: #ffffff; + --eyt-text-primary: #1d1b18; + --eyt-text-secondary: #5b564e; + --eyt-border-default: #d8d4cb; + --eyt-action-primary: #1e5231; + --eyt-action-primary-contrast: #ffffff; + --eyt-state-published-bg: #e1ebe2; + --eyt-state-published-text: #1e5231; + --eyt-state-draft-text: #7a5300; + --eyt-state-draft-bg: #f4e8ce; + --eyt-state-danger-text: #9b2c1f; + --eyt-state-danger-bg: #f7e3df; + --eyt-state-info-text: #155e75; + --eyt-state-info-bg: #deedf2; +} + +/* + * Dunkelmodus folgt dem SYSTEM (Basisdesign §7: „Dark Mode folgt System oder + * expliziter Nutzereinstellung, nicht einer ungeprueften automatischen + * Annahme“). Jede Rolle wird vollstaendig neu gesetzt — kein Teilsatz, damit + * keine Rolle unbemerkt im Hellwert stehen bleibt. + */ +@media (prefers-color-scheme: dark) { + :root { + --eyt-bg-canvas: #161513; + --eyt-bg-surface: #211f1c; + --eyt-text-primary: #f1efe9; + --eyt-text-secondary: #b0aaa0; + --eyt-border-default: #3a3733; + --eyt-action-primary: #8fc7a2; + --eyt-action-primary-contrast: #161513; + --eyt-state-published-bg: #22352a; + --eyt-state-published-text: #8fc7a2; + --eyt-state-draft-text: #e3b95b; + --eyt-state-draft-bg: #3a2f17; + --eyt-state-danger-text: #f0a08f; + --eyt-state-danger-bg: #41211c; + --eyt-state-info-text: #7cc1db; + --eyt-state-info-bg: #1c3540; + } +} From 7f46ca247d5a2ae8dce13af8d692d822cdb5538f Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:22:01 +0200 Subject: [PATCH 5/8] test(web): EYT-80 Waechter gegen eigene Palette in globals.css (rot) --- apps/web/test/basisdesign-tokens.test.ts | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/web/test/basisdesign-tokens.test.ts diff --git a/apps/web/test/basisdesign-tokens.test.ts b/apps/web/test/basisdesign-tokens.test.ts new file mode 100644 index 0000000..d30b9ce --- /dev/null +++ b/apps/web/test/basisdesign-tokens.test.ts @@ -0,0 +1,59 @@ +/** + * `apps/web` FUEHRT keine eigene Palette mehr (EYT-80 Inkrement 2). + * + * Zwei Haelften, beide noetig: + * 1. `globals.css` bindet die kanonische Datei aus `@easytree/ui` ein — und + * zwar als ERSTE Anweisung, weil `@import` in CSS nur vor jeder Regel + * gilt und ein spaeteres schweigend ignoriert wird. + * 2. `globals.css` DEKLARIERT keine `--eyt-*`-Rolle mehr. Verwendungen + * (`var(--eyt-…)`) bleiben ausdruecklich erlaubt und sind der Normalfall + * — geprueft wird die linke Seite eines Doppelpunkts, nicht das Wort. + * + * Die dritte Haelfte — dass die Werte im GEBAUTEN Browser wirklich ankommen — + * kann eine Datei-Lesung nicht leisten und steht in + * `apps/web/e2e/shell-smoke.spec.ts`. + * + * Nicht ueber `import.meta.url`: unter vitest/jsdom kein file://-Schema. + * `process.cwd()` ist apps/web. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const rohCss = readFileSync(join(process.cwd(), "app/globals.css"), "utf8"); +const css = rohCss.replace(/\/\*[\s\S]*?\*\//g, ""); + +describe("Basisdesign-Tokens kommen aus @easytree/ui (EYT-80)", () => { + it("bindet die kanonische Tokendatei als erste Anweisung ein", () => { + const ersteAnweisung = css.trim().split(";")[0]?.trim(); + expect(ersteAnweisung).toBe('@import "@easytree/ui/basisdesign-v2.css"'); + }); + + it("deklariert selbst keine einzige --eyt-Rolle mehr", () => { + const eigene = [...css.matchAll(/(?:^|[;{])\s*(--eyt-[\w-]+)\s*:/g)].map((t) => t[1]); + expect(eigene, `eigene Tokendeklarationen: ${eigene.join(", ")}`).toEqual([]); + }); + + it("benutzt die Rollen weiterhin — sonst waere die Abwesenheit oben trivial", () => { + /* + * Ohne diesen Fall waere ein `globals.css` ohne JEDE Farbe gruen: keine + * Deklaration, kein Befund. Die Zahl ist bewusst grosszuegig; sie soll den + * Totalverlust fangen, nicht jede Umbenennung. Gemessen 27.08.2026: 58 + * Verwendungen. + */ + const verwendungen = [...css.matchAll(/var\(\s*--eyt-[\w-]+/g)]; + expect(verwendungen.length).toBeGreaterThan(20); + }); + + it("fuehrt ausser den beiden Fokusfarben kein Farbliteral mehr", () => { + /* + * `--color-focus` ist bewusst KEINE §2.1-Rolle: die Baseline kennt keine + * Fokusfarbe, sie fordert nur 3:1 zur angrenzenden Flaeche (EYT-12). Sie + * bleibt darum anwendungseigen — aber sie ist die EINZIGE Ausnahme, und + * dieser Fall haelt die Liste kurz. + */ + const literale = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((t) => t[0].toLowerCase()); + expect([...new Set(literale)].sort()).toEqual(["#1d4ed8", "#7cc1db"]); + }); +}); From c7d1ab467860a0d64fa8d00f5f2bbd609c7bda72 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:25:46 +0200 Subject: [PATCH 6/8] refactor(web): EYT-80 globals.css bezieht die Farbrollen aus @easytree/ui --- apps/web/app/globals.css | 49 +++++++++------------------------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7c712ea..7bc3a89 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,31 +1,17 @@ +/* Die kanonische Tokenbasis gehoert @easytree/ui (EYT-80). */ +@import "@easytree/ui/basisdesign-v2.css"; + /* * easyTree Web-Shell — Basisdesign v2.0 „Werkbank & Feld — ruhig verdichtet" * (Confluence 8814623, verbindliche Tokenbasis). System-Font-Stack, bewusst * KEINE Google-Font-Imports: Build muss offline funktionieren. * - * Die semantischen Tokens tragen die Palette aus §2.1; die aelteren - * --color-*-Namen bleiben als Aliasse bestehen, damit Bestandsstile nicht - * doppelt gepflegt werden. + * Die semantischen Farbrollen aus §2.1 kommen aus `@easytree/ui` (siehe + * Einbindung im Dateikopf); die aelteren --color-*-Namen bleiben als Aliasse, + * damit Bestandsstile nicht doppelt gepflegt werden. */ :root { - /* Basisdesign v2.0 §2.1 — hell */ - --eyt-bg-canvas: #f6f4ef; - --eyt-bg-surface: #ffffff; - --eyt-text-primary: #1d1b18; - --eyt-text-secondary: #5b564e; - --eyt-border-default: #d8d4cb; - --eyt-action-primary: #1e5231; - --eyt-action-primary-contrast: #ffffff; - --eyt-state-published-bg: #e1ebe2; - --eyt-state-published-text: #1e5231; - --eyt-state-draft-text: #7a5300; - --eyt-state-draft-bg: #f4e8ce; - --eyt-state-danger-text: #9b2c1f; - --eyt-state-danger-bg: #f7e3df; - --eyt-state-info-text: #155e75; - --eyt-state-info-bg: #deedf2; - /* Aliasse fuer Bestandsstile */ --color-bg: var(--eyt-bg-canvas); --color-text: var(--eyt-text-primary); @@ -41,21 +27,8 @@ @media (prefers-color-scheme: dark) { :root { - --eyt-bg-canvas: #161513; - --eyt-bg-surface: #211f1c; - --eyt-text-primary: #f1efe9; - --eyt-text-secondary: #b0aaa0; - --eyt-border-default: #3a3733; - --eyt-action-primary: #8fc7a2; - --eyt-action-primary-contrast: #161513; - --eyt-state-published-bg: #22352a; - --eyt-state-published-text: #8fc7a2; - --eyt-state-draft-text: #e3b95b; - --eyt-state-draft-bg: #3a2f17; - --eyt-state-danger-text: #f0a08f; - --eyt-state-danger-bg: #41211c; - --eyt-state-info-text: #7cc1db; - --eyt-state-info-bg: #1c3540; + /* Die Farbrollen kommen aus @easytree/ui; hier bleibt nur, was die + Anwendung selbst besitzt. */ --color-focus: #7cc1db; } } @@ -570,15 +543,15 @@ body { caption-side: top; text-align: left; padding-block-end: var(--eyt-space-2, 0.5rem); - color: var(--eyt-color-text-muted, #555); + color: var(--eyt-text-secondary); font-size: 0.875rem; } /* * Der Werkbankrahmen der Planung (EYT-140 M6). * - * Nur Anordnung — Farben kommen ausschliesslich aus den bestehenden Tokens - * oben in dieser Datei, es kommt kein einziges neues Farbliteral dazu. Die + * Nur Anordnung — Farben kommen ausschliesslich aus den Rollen von + * `@easytree/ui`, es kommt kein einziges neues Farbliteral dazu. Die * Bedienleiste bekommt hier erstmals ueberhaupt eine Darstellung: bis M6 trug * `.eyt-date-range` eine Klasse ohne jede Regel, die Bedienelemente standen * also als nackte Absaetze untereinander. From 08fed37906db2d7b67ca2044b8d93290b4655208 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 14:33:55 +0200 Subject: [PATCH 7/8] test(web): EYT-80 Browser belegt die Basisdesign-Tokens auf /, /planung und /kosten --- apps/web/e2e/shell-smoke.spec.ts | 86 ++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/apps/web/e2e/shell-smoke.spec.ts b/apps/web/e2e/shell-smoke.spec.ts index cd2a29f..ac0b8b8 100644 --- a/apps/web/e2e/shell-smoke.spec.ts +++ b/apps/web/e2e/shell-smoke.spec.ts @@ -410,3 +410,89 @@ for (const route of ROUTEN) { expect(results.violations, `${route.pfad}: ${JSON.stringify(results.violations)}`).toEqual([]); }); } + +/** + * Die Tokens erreichen die GEBAUTE Anwendung (EYT-80 Inkrement 2). + * + * Die Datei-Waechter in `apps/web/test/basisdesign-tokens.test.ts` und + * `packages/ui/test/basisdesign-tokens.test.ts` lesen Quelltext. Sie koennen + * nicht sehen, ob Next den `@import` aufloest, ob das Paket seine CSS-Datei + * ueberhaupt ausliefert oder ob ein Bundler sie als nebenwirkungsfrei + * wegwirft. Dieser Fall liest den BERECHNETEN Wert im Chromium. + * + * Verglichen wird die FARBE, nicht die Schreibweise. Gemessen 27.08.2026 + * verkuerzt der Minifier des Produktionsbaus `#ffffff` zu `#fff` — und + * `getComputedStyle` normalisiert den Wert einer Custom Property NICHT, weil + * sie bis zur Verwendung ein beliebiges Token ist. Ein Zeichenkettenvergleich + * haette hier also den Minifier geprueft und nicht die Farbe. Darum wird der + * rohe Wert einer Sonde als `color` zugewiesen und von Chromium selbst als + * `rgb(…)` zurueckgelesen: das ist unabhaengig von Schreibweise, Minifier und + * kuenftigen Formatwechseln. + * + * Drei Routen, weil die Zusage „auf den realen Produktflaechen" lautet und + * nicht „auf der Startseite". Im `web-smoke` laeuft keine API; alle drei + * antworten trotzdem mit 200 und tragen dieselbe CSS-Datei. Geprueft werden + * hier die TOKENWERTE, nicht der angemeldete Zustand — der gehoert dem + * auth-journey. + * + * Hellmodus: Playwright startet ohne `colorScheme`-Angabe, und der Standard + * ist `light`. Die Dunkelwerte sind hier bewusst NICHT gemessen; sie haengen + * an `packages/ui/test/basisdesign-tokens.test.ts`. + */ +const KANONISCHE_ROLLEN: ReadonlyArray = [ + ["--eyt-bg-canvas", "rgb(246, 244, 239)"], + ["--eyt-bg-surface", "rgb(255, 255, 255)"], + ["--eyt-text-primary", "rgb(29, 27, 24)"], + ["--eyt-text-secondary", "rgb(91, 86, 78)"], + ["--eyt-border-default", "rgb(216, 212, 203)"], + ["--eyt-action-primary", "rgb(30, 82, 49)"], + ["--eyt-state-published-bg", "rgb(225, 235, 226)"], + ["--eyt-state-draft-text", "rgb(122, 83, 0)"], + ["--eyt-state-draft-bg", "rgb(244, 232, 206)"], + ["--eyt-state-danger-text", "rgb(155, 44, 31)"], + ["--eyt-state-danger-bg", "rgb(247, 227, 223)"], + ["--eyt-state-info-text", "rgb(21, 94, 117)"], + ["--eyt-state-info-bg", "rgb(222, 237, 242)"], +]; + +for (const pfad of ["/", "/planung", "/kosten"]) { + test(`${pfad} liefert die kanonischen Basisdesign-Tokens aus`, async ({ page }) => { + await page.goto(pfad); + await expect(page.getByRole("main")).toBeVisible(); + + const gemessen = await page.evaluate( + (namen) => { + const wurzel = getComputedStyle(document.documentElement); + const sonde = document.createElement("span"); + document.body.append(sonde); + try { + return Object.fromEntries( + namen.map((name) => { + const roh = wurzel.getPropertyValue(name).trim(); + // Erst leeren, dann setzen: lehnt der CSS-Parser den Wert ab, + // bleibt `style.color` leer — und `angenommen` deckt genau den + // Fall auf, in dem eine fehlende Rolle sonst die geerbte Farbe + // der Sonde gemessen haette und zufaellig gruen waere. + sonde.style.color = ""; + sonde.style.color = roh; + const angenommen = sonde.style.color !== ""; + return [name, { roh, angenommen, farbe: getComputedStyle(sonde).color }]; + }), + ); + } finally { + sonde.remove(); + } + }, + KANONISCHE_ROLLEN.map(([name]) => name), + ); + + for (const [name, farbe] of KANONISCHE_ROLLEN) { + const wert = gemessen[name]; + expect(wert?.roh, `${pfad}: ${name} ist nicht deklariert`).toBeTruthy(); + expect(wert?.angenommen, `${pfad}: ${name} = "${wert?.roh}" ist keine gueltige Farbe`).toBe( + true, + ); + expect(wert?.farbe, `${pfad}: ${name} (roh: "${wert?.roh}")`).toBe(farbe); + } + }); +} From 75e7840b57140946785a181de3be8b8df36d9d74 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 23:34:58 +0200 Subject: [PATCH 8/8] test(EYT-80): drei Falschgruen-Klassen der Basisdesign-Waechter geschlossen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PO-Review PR #96 wies drei Umgehungswege in den NEUEN Waechtern nach; alle drei sind vor der Reparatur als gruen reproduziert und danach als rot belegt: 1. packages/ui: Die Teilung am ersten @media war strukturell unsicher — ein spaeter :root-Block NACH dem Dunkelblock ueberschreibt im echten Browser den Hellmodus, landete im Test aber im DUNKEL-Abschnitt und blieb gruen. Jetzt zerlegt ein deterministischer Klammerzaehler die Datei vollstaendig: genau ein heller :root-Block, genau ein @media (prefers-color-scheme: dark)-Block mit genau einem inneren :root, sonst nichts; jede Abweichung (Zusatzblock, fremder Selektor, Nesting, Doppeldeklaration, Resttext) steht namentlich in STRUKTURFEHLER. 2. packages/ui: Die Deklarations-Regex war selektorblind — Dunkeltokens unter .dunkel statt :root blieben gruen, eine Streudeklaration unter fremdem Selektor wurde stumm Teil der Tokenkarte. HELL/DUNKEL entstehen jetzt NUR aus den beiden korrekt aufgehaengten :root-Koerpern. 3. apps/web: Das Farbliteral-Gate sah nur Hex — rgb()/hsl()/oklch()/ Farbnamen kamen durch. Drei Schichten ohne handgepflegte Farbnamensliste: Hex exakt die zwei Fokusfarben, die geschlossene Menge der CSS- Farbfunktionen verboten, und jedes nackte Wort eines Deklarationswerts gegen den jsdom-CSS-Parser als Farbwort geprueft (mit Selbsttest der Sonde: tomato rot, solid nicht). Zusaetzlich liefert shell-smoke jetzt auch --eyt-action-primary-contrast und --eyt-state-published-text im gebauten Chromium nach. Produkt-CSS unveraendert: packages/ui/src/basisdesign-v2.css und apps/web/app/globals.css sind byte-identisch zu 08fed37. Gegenmutationen ausgefuehrt (je: eingespielt, rot gemessen, revertiert): A spaeter :root-Override nach dem Dunkelblock; B Dunkeltokens unter .dunkel sowie Streudeklaration unter .hinweis; C rgb()-Fassung der verworfenen Penpot-Kombination #6D786F/#F0F4EF sowie Farbname tomato. Co-Authored-By: Claude Fable 5 --- apps/web/e2e/shell-smoke.spec.ts | 5 + apps/web/test/basisdesign-tokens.test.ts | 76 +++++++++- packages/ui/test/basisdesign-tokens.test.ts | 157 +++++++++++++++++--- 3 files changed, 214 insertions(+), 24 deletions(-) diff --git a/apps/web/e2e/shell-smoke.spec.ts b/apps/web/e2e/shell-smoke.spec.ts index ac0b8b8..fc6f740 100644 --- a/apps/web/e2e/shell-smoke.spec.ts +++ b/apps/web/e2e/shell-smoke.spec.ts @@ -446,7 +446,12 @@ const KANONISCHE_ROLLEN: ReadonlyArray = [ ["--eyt-text-secondary", "rgb(91, 86, 78)"], ["--eyt-border-default", "rgb(216, 212, 203)"], ["--eyt-action-primary", "rgb(30, 82, 49)"], + // Auch die repo-eigene und die abgeleitete Rolle werden AUSGELIEFERT — + // dass ihre Werte richtig SIND, bewacht packages/ui; dass sie im gebauten + // Browser ankommen, kann nur dieser Fall sehen (PO-Review PR #96). + ["--eyt-action-primary-contrast", "rgb(255, 255, 255)"], ["--eyt-state-published-bg", "rgb(225, 235, 226)"], + ["--eyt-state-published-text", "rgb(30, 82, 49)"], ["--eyt-state-draft-text", "rgb(122, 83, 0)"], ["--eyt-state-draft-bg", "rgb(244, 232, 206)"], ["--eyt-state-danger-text", "rgb(155, 44, 31)"], diff --git a/apps/web/test/basisdesign-tokens.test.ts b/apps/web/test/basisdesign-tokens.test.ts index d30b9ce..b9b6951 100644 --- a/apps/web/test/basisdesign-tokens.test.ts +++ b/apps/web/test/basisdesign-tokens.test.ts @@ -46,14 +46,80 @@ describe("Basisdesign-Tokens kommen aus @easytree/ui (EYT-80)", () => { expect(verwendungen.length).toBeGreaterThan(20); }); - it("fuehrt ausser den beiden Fokusfarben kein Farbliteral mehr", () => { + describe("fuehrt ausser den beiden Fokusfarben keine eigene Farbquelle mehr", () => { /* * `--color-focus` ist bewusst KEINE §2.1-Rolle: die Baseline kennt keine * Fokusfarbe, sie fordert nur 3:1 zur angrenzenden Flaeche (EYT-12). Sie - * bleibt darum anwendungseigen — aber sie ist die EINZIGE Ausnahme, und - * dieser Fall haelt die Liste kurz. + * bleibt darum anwendungseigen — aber sie ist die EINZIGE Ausnahme. + * + * Der Vertrag lautet „keine eigene Palette", nicht „kein Hex": ein reines + * Hex-Muster liess rgb(), hsl(), oklch() und Farbnamen durch (PO-Review + * PR #96). Drei Schichten schliessen das, ohne eine handgepflegte Liste + * aller ~148 CSS-Farbnamen: (1) Hex-Literale bleiben exakt die zwei + * Fokusfarben; (2) die geschlossene Menge der CSS-Farbfunktionen darf + * nicht vorkommen; (3) jedes nackte Wort aus einem Deklarationswert wird + * dem CSS-Parser von jsdom als `color` angeboten — nimmt er es an, ist es + * ein Farbwort. `var(--…)` zerfaellt beim Zerlegen in „var" und den + * Tokennamen, beides keine Farbwoerter; ein Farbname im var()-Fallback + * bleibt dadurch sichtbar. */ - const literale = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((t) => t[0].toLowerCase()); - expect([...new Set(literale)].sort()).toEqual(["#1d4ed8", "#7cc1db"]); + const deklarationswerte = [...css.matchAll(/[{;]\s*[-\w]+\s*:\s*([^;{}]+)/g)].map((t) => + (t[1] ?? "").trim(), + ); + + it("liest ueberhaupt Deklarationswerte — sonst waeren die Schichten leer und trivial gruen", () => { + expect(deklarationswerte.length).toBeGreaterThan(50); + }); + + it("Hex-Literale: exakt die zwei Fokusfarben", () => { + const literale = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((t) => t[0].toLowerCase()); + expect([...new Set(literale)].sort()).toEqual(["#1d4ed8", "#7cc1db"]); + }); + + it("keine funktionale Farbnotation (rgb, hsl, oklch, color-mix, …)", () => { + const farbfunktionen = deklarationswerte.flatMap((wert) => + [ + ...wert.matchAll( + /(?:^|[^\w-])(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix|color|light-dark|device-cmyk)\s*\(/g, + ), + ].map((t) => `${t[1] ?? ""}(…) in "${wert.slice(0, 60)}"`), + ); + expect(farbfunktionen).toEqual([]); + }); + + it("kein CSS-Farbname in einem Deklarationswert", () => { + /* + * CSS-weite Schluesselwoerter und die zwei neutralen Farbwoerter sind + * keine eigene Palette — sie verweisen auf die Kaskade, nicht auf einen + * Farbwert. + */ + const neutral = new Set([ + "transparent", + "currentcolor", + "inherit", + "initial", + "unset", + "revert", + ]); + const sonde = document.createElement("span"); + const istFarbwort = (wort: string): boolean => { + sonde.style.color = ""; + sonde.style.color = wort; + return sonde.style.color !== ""; + }; + // Die Sonde erkennt Farben — sonst misst der Filter darunter nichts. + expect(istFarbwort("tomato")).toBe(true); + expect(istFarbwort("solid")).toBe(false); + + const farbwoerter = [ + ...new Set( + deklarationswerte + .flatMap((wert) => wert.split(/[\s,()/"']+/)) + .filter((token) => /^[a-zA-Z]+$/.test(token)) + .map((token) => token.toLowerCase()), + ), + ].filter((wort) => !neutral.has(wort) && istFarbwort(wort)); + expect(farbwoerter, `Farbnamen in Deklarationswerten: ${farbwoerter.join(", ")}`).toEqual([]); + }); }); }); diff --git a/packages/ui/test/basisdesign-tokens.test.ts b/packages/ui/test/basisdesign-tokens.test.ts index d352453..38b3e60 100644 --- a/packages/ui/test/basisdesign-tokens.test.ts +++ b/packages/ui/test/basisdesign-tokens.test.ts @@ -25,18 +25,136 @@ const rohCss = readFileSync(join(process.cwd(), "src/basisdesign-v2.css"), "utf8 */ const css = rohCss.replace(/\/\*[\s\S]*?\*\//g, ""); -/** Deklarationen (`--eyt-x: #abc`), NICHT Verwendungen (`var(--eyt-x)`). */ -function deklarationen(abschnitt: string): Map { +/* + * Frueher wurde die Datei am ERSTEN `@media` geteilt und jede + * `--eyt-`-Deklaration selektorblind eingesammelt. Beides war strukturell + * unsicher (PO-Review PR #96): ein spaeter `:root`-Block NACH dem Dunkelblock + * ueberschreibt im echten Browser den Hellmodus, landete hier aber im + * DUNKEL-Abschnitt und blieb gruen; und eine Deklaration unter einem fremden + * Selektor (`.dunkel { … }`) zaehlte, als stuende sie in `:root`. Deshalb wird + * die Struktur jetzt VOLLSTAENDIG zerlegt statt geteilt: die kanonische Datei + * besteht aus genau einem hellen `:root`-Block, gefolgt von genau einem + * `@media (prefers-color-scheme: dark)`-Block mit genau einem inneren `:root` + * — und aus nichts anderem. Jede Abweichung bekommt einen benannten Eintrag in + * STRUKTURFEHLER. Kein allgemeiner CSS-Parser als Abhaengigkeit: die erlaubte + * Form ist endlich, und ein Klammerzaehler prueft sie deterministisch. + */ +interface Block { + prelude: string; + koerper: string; +} + +function zerlegeTopLevel(quelle: string): { bloecke: Block[]; rest: string } { + const bloecke: Block[] = []; + let ausserhalb = ""; + let i = 0; + while (i < quelle.length) { + if (quelle[i] === "{") { + let tiefe = 1; + let j = i + 1; + while (j < quelle.length && tiefe > 0) { + if (quelle[j] === "{") tiefe += 1; + if (quelle[j] === "}") tiefe -= 1; + j += 1; + } + if (tiefe > 0) { + // Unbalancierte Klammer: alles ab hier wird als Rest sichtbar. + ausserhalb += quelle.slice(i); + break; + } + bloecke.push({ + prelude: ausserhalb.replace(/\s+/g, " ").trim(), + koerper: quelle.slice(i + 1, j - 1), + }); + ausserhalb = ""; + i = j; + } else { + ausserhalb += quelle[i]; + i += 1; + } + } + return { bloecke, rest: ausserhalb.trim() }; +} + +const STRUKTURFEHLER: string[] = []; + +/** Ein reiner Tokenblock: nur `--eyt-*`-Deklarationen, jede genau einmal. */ +function tokenzeilen(koerper: string, kontext: string): Map { const gefunden = new Map(); - for (const treffer of abschnitt.matchAll(/(?:^|[;{])\s*(--eyt-[\w-]+)\s*:\s*([^;}]+)/g)) { - gefunden.set(treffer[1] ?? "", (treffer[2] ?? "").trim().toLowerCase()); + for (const roh of koerper.split(";")) { + const anweisung = roh.trim(); + if (anweisung === "") continue; + // Ein verschachtelter Selektor im :root traegt Klammern und faellt hier + // durch — CSS-Nesting kann eine Deklaration also nicht mehr einschleusen. + const treffer = /^(--eyt-[\w-]+)\s*:\s*([^;{}]+)$/.exec(anweisung); + if (treffer === null) { + STRUKTURFEHLER.push(`${kontext}: keine reine --eyt-Deklaration: "${anweisung.slice(0, 60)}"`); + continue; + } + const name = treffer[1] ?? ""; + if (gefunden.has(name)) { + // Doppelt im selben Block: die letzte gewaenne leise. + STRUKTURFEHLER.push(`${kontext}: ${name} doppelt deklariert`); + } + gefunden.set(name, (treffer[2] ?? "").trim().toLowerCase()); } return gefunden; } -const grenze = css.indexOf("@media"); -const HELL = deklarationen(grenze >= 0 ? css.slice(0, grenze) : css); -const DUNKEL = deklarationen(grenze >= 0 ? css.slice(grenze) : ""); +const { bloecke, rest } = zerlegeTopLevel(css); +if (rest !== "") { + STRUKTURFEHLER.push(`Text ausserhalb der beiden Bloecke: "${rest.slice(0, 60)}"`); +} +if (bloecke.length !== 2) { + STRUKTURFEHLER.push( + `${bloecke.length} Top-Level-Bloecke statt 2: ${bloecke + .map((b) => b.prelude || "(leer)") + .join(" · ")}`, + ); +} + +const hellBlock = bloecke[0]; +if (hellBlock !== undefined && hellBlock.prelude !== ":root") { + STRUKTURFEHLER.push(`erster Block ist "${hellBlock.prelude}" statt ":root"`); +} + +let dunkelKoerper: string | undefined; +const dunkelAussen = bloecke[1]; +if (dunkelAussen !== undefined) { + if (dunkelAussen.prelude !== "@media (prefers-color-scheme: dark)") { + STRUKTURFEHLER.push( + `zweiter Block ist "${dunkelAussen.prelude}" statt "@media (prefers-color-scheme: dark)"`, + ); + } else { + const innen = zerlegeTopLevel(dunkelAussen.koerper); + if (innen.rest !== "") { + STRUKTURFEHLER.push(`Text im Medienblock ausserhalb von :root: "${innen.rest.slice(0, 60)}"`); + } + const innerer = innen.bloecke[0]; + if (innen.bloecke.length === 1 && innerer !== undefined && innerer.prelude === ":root") { + dunkelKoerper = innerer.koerper; + } else { + STRUKTURFEHLER.push( + `der Medienblock traegt ${innen.bloecke.length} innere Bloecke (${innen.bloecke + .map((b) => b.prelude || "(leer)") + .join(" · ")}) statt genau einem :root`, + ); + } + } +} + +/* + * HELL/DUNKEL entstehen NUR aus den beiden korrekt aufgehaengten + * :root-Koerpern. Eine Deklaration unter einem fremden Selektor kann die + * Karten also nicht mehr erreichen — sie steht stattdessen namentlich in + * STRUKTURFEHLER. + */ +const HELL = + hellBlock !== undefined && hellBlock.prelude === ":root" + ? tokenzeilen(hellBlock.koerper, "hell") + : new Map(); +const DUNKEL = + dunkelKoerper === undefined ? new Map() : tokenzeilen(dunkelKoerper, "dunkel"); /** * Confluence 8814623 §2.1 — die dreizehn genehmigten Rollen. @@ -123,20 +241,21 @@ describe("Basisdesign v2.0 §2.1 — kanonische Tokenquelle (EYT-80)", () => { expect(DUNKEL.size, "dunkel").toBe(erwartet); }); - it("traegt genau einen Medienblock, und zwar den Dunkelmodus", () => { + it("besteht aus genau zwei Bloecken: :root, dann @media (prefers-color-scheme: dark) > :root", () => { /* - * `HELL` und `DUNKEL` teilen die Datei am ERSTEN `@media`. Ohne diese - * Zusicherung ist das eine Annahme statt einer Zusage. Der leise Fall ist - * NICHT ein fehlender Dunkelblock — der faellt oben beim Zaehlen auf —, - * sondern ein ZWEITER, spaeterer Medienblock, der eine `--eyt-`-Rolle neu - * setzt: `deklarationen` laesst den letzten gewinnen, und beide Gates - * messen dann einen Wert, der im Dunkelblock gar nicht steht. - * `apps/web/app/globals.css` traegt heute drei weitere Medienbloecke, die - * Form ist also nicht hypothetisch. + * DIE strukturelle Zusage, auf der alle Karten oben stehen. Der leise + * Fall ist nicht der fehlende Dunkelblock — der faellt beim Zaehlen auf —, + * sondern JEDER Weg, auf dem eine Deklaration ausserhalb der beiden + * :root-Koerper die echte Kaskade aendert, waehrend die Karten sie anders + * oder gar nicht sehen: ein spaeter :root-Ueberschreiber NACH dem + * Medienblock (wirkt im echten Hellmodus, landete frueher im + * DUNKEL-Abschnitt), ein zweiter Medienblock, Tokens unter einem fremden + * Selektor, ein verschachtelter Selektor im :root, eine doppelte + * Deklaration. Jede dieser Formen steht namentlich in STRUKTURFEHLER, + * und die Reihenfolge hell → dunkel ist mitbewacht — nur so gewinnt der + * Dunkelblock die Kaskade bei gleicher Spezifitaet. */ - expect([...css.matchAll(/@media[^{]*/g)].map((m) => m[0].trim())).toEqual([ - "@media (prefers-color-scheme: dark)", - ]); + expect(STRUKTURFEHLER).toEqual([]); }); it("deklariert beide Modi deckungsgleich und alle genehmigten Rollen", () => {