Skip to content

Commit 5a40026

Browse files
authored
Merge pull request #96 from DYAI2025/feat/eyt-80-basisdesign-tokens
feat(ui): EYT-80 zentrale Basisdesign-v2-Tokens und deterministisches Kontrastgate
2 parents 3f98d76 + 75e7840 commit 5a40026

7 files changed

Lines changed: 694 additions & 41 deletions

File tree

‎apps/web/app/globals.css‎

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,17 @@
1+
/* Die kanonische Tokenbasis gehoert @easytree/ui (EYT-80). */
2+
@import "@easytree/ui/basisdesign-v2.css";
3+
14
/*
25
* easyTree Web-Shell — Basisdesign v2.0 „Werkbank & Feld — ruhig verdichtet"
36
* (Confluence 8814623, verbindliche Tokenbasis). System-Font-Stack, bewusst
47
* KEINE Google-Font-Imports: Build muss offline funktionieren.
58
*
6-
* Die semantischen Tokens tragen die Palette aus §2.1; die aelteren
7-
* --color-*-Namen bleiben als Aliasse bestehen, damit Bestandsstile nicht
8-
* doppelt gepflegt werden.
9+
* Die semantischen Farbrollen aus §2.1 kommen aus `@easytree/ui` (siehe
10+
* Einbindung im Dateikopf); die aelteren --color-*-Namen bleiben als Aliasse,
11+
* damit Bestandsstile nicht doppelt gepflegt werden.
912
*/
1013

1114
:root {
12-
/* Basisdesign v2.0 §2.1 — hell */
13-
--eyt-bg-canvas: #f6f4ef;
14-
--eyt-bg-surface: #ffffff;
15-
--eyt-text-primary: #1d1b18;
16-
--eyt-text-secondary: #5b564e;
17-
--eyt-border-default: #d8d4cb;
18-
--eyt-action-primary: #1e5231;
19-
--eyt-action-primary-contrast: #ffffff;
20-
--eyt-state-published-bg: #e1ebe2;
21-
--eyt-state-published-text: #1e5231;
22-
--eyt-state-draft-text: #7a5300;
23-
--eyt-state-draft-bg: #f4e8ce;
24-
--eyt-state-danger-text: #9b2c1f;
25-
--eyt-state-danger-bg: #f7e3df;
26-
--eyt-state-info-text: #155e75;
27-
--eyt-state-info-bg: #deedf2;
28-
2915
/* Aliasse fuer Bestandsstile */
3016
--color-bg: var(--eyt-bg-canvas);
3117
--color-text: var(--eyt-text-primary);
@@ -41,21 +27,8 @@
4127

4228
@media (prefers-color-scheme: dark) {
4329
:root {
44-
--eyt-bg-canvas: #161513;
45-
--eyt-bg-surface: #211f1c;
46-
--eyt-text-primary: #f1efe9;
47-
--eyt-text-secondary: #b0aaa0;
48-
--eyt-border-default: #3a3733;
49-
--eyt-action-primary: #8fc7a2;
50-
--eyt-action-primary-contrast: #161513;
51-
--eyt-state-published-bg: #22352a;
52-
--eyt-state-published-text: #8fc7a2;
53-
--eyt-state-draft-text: #e3b95b;
54-
--eyt-state-draft-bg: #3a2f17;
55-
--eyt-state-danger-text: #f0a08f;
56-
--eyt-state-danger-bg: #41211c;
57-
--eyt-state-info-text: #7cc1db;
58-
--eyt-state-info-bg: #1c3540;
30+
/* Die Farbrollen kommen aus @easytree/ui; hier bleibt nur, was die
31+
Anwendung selbst besitzt. */
5932
--color-focus: #7cc1db;
6033
}
6134
}
@@ -570,15 +543,15 @@ body {
570543
caption-side: top;
571544
text-align: left;
572545
padding-block-end: var(--eyt-space-2, 0.5rem);
573-
color: var(--eyt-color-text-muted, #555);
546+
color: var(--eyt-text-secondary);
574547
font-size: 0.875rem;
575548
}
576549

577550
/*
578551
* Der Werkbankrahmen der Planung (EYT-140 M6).
579552
*
580-
* Nur Anordnung — Farben kommen ausschliesslich aus den bestehenden Tokens
581-
* oben in dieser Datei, es kommt kein einziges neues Farbliteral dazu. Die
553+
* Nur Anordnung — Farben kommen ausschliesslich aus den Rollen von
554+
* `@easytree/ui`, es kommt kein einziges neues Farbliteral dazu. Die
582555
* Bedienleiste bekommt hier erstmals ueberhaupt eine Darstellung: bis M6 trug
583556
* `.eyt-date-range` eine Klasse ohne jede Regel, die Bedienelemente standen
584557
* also als nackte Absaetze untereinander.

‎apps/web/e2e/shell-smoke.spec.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,94 @@ for (const route of ROUTEN) {
410410
expect(results.violations, `${route.pfad}: ${JSON.stringify(results.violations)}`).toEqual([]);
411411
});
412412
}
413+
414+
/**
415+
* Die Tokens erreichen die GEBAUTE Anwendung (EYT-80 Inkrement 2).
416+
*
417+
* Die Datei-Waechter in `apps/web/test/basisdesign-tokens.test.ts` und
418+
* `packages/ui/test/basisdesign-tokens.test.ts` lesen Quelltext. Sie koennen
419+
* nicht sehen, ob Next den `@import` aufloest, ob das Paket seine CSS-Datei
420+
* ueberhaupt ausliefert oder ob ein Bundler sie als nebenwirkungsfrei
421+
* wegwirft. Dieser Fall liest den BERECHNETEN Wert im Chromium.
422+
*
423+
* Verglichen wird die FARBE, nicht die Schreibweise. Gemessen 27.08.2026
424+
* verkuerzt der Minifier des Produktionsbaus `#ffffff` zu `#fff` — und
425+
* `getComputedStyle` normalisiert den Wert einer Custom Property NICHT, weil
426+
* sie bis zur Verwendung ein beliebiges Token ist. Ein Zeichenkettenvergleich
427+
* haette hier also den Minifier geprueft und nicht die Farbe. Darum wird der
428+
* rohe Wert einer Sonde als `color` zugewiesen und von Chromium selbst als
429+
* `rgb(…)` zurueckgelesen: das ist unabhaengig von Schreibweise, Minifier und
430+
* kuenftigen Formatwechseln.
431+
*
432+
* Drei Routen, weil die Zusage „auf den realen Produktflaechen" lautet und
433+
* nicht „auf der Startseite". Im `web-smoke` laeuft keine API; alle drei
434+
* antworten trotzdem mit 200 und tragen dieselbe CSS-Datei. Geprueft werden
435+
* hier die TOKENWERTE, nicht der angemeldete Zustand — der gehoert dem
436+
* auth-journey.
437+
*
438+
* Hellmodus: Playwright startet ohne `colorScheme`-Angabe, und der Standard
439+
* ist `light`. Die Dunkelwerte sind hier bewusst NICHT gemessen; sie haengen
440+
* an `packages/ui/test/basisdesign-tokens.test.ts`.
441+
*/
442+
const KANONISCHE_ROLLEN: ReadonlyArray<readonly [string, string]> = [
443+
["--eyt-bg-canvas", "rgb(246, 244, 239)"],
444+
["--eyt-bg-surface", "rgb(255, 255, 255)"],
445+
["--eyt-text-primary", "rgb(29, 27, 24)"],
446+
["--eyt-text-secondary", "rgb(91, 86, 78)"],
447+
["--eyt-border-default", "rgb(216, 212, 203)"],
448+
["--eyt-action-primary", "rgb(30, 82, 49)"],
449+
// Auch die repo-eigene und die abgeleitete Rolle werden AUSGELIEFERT —
450+
// dass ihre Werte richtig SIND, bewacht packages/ui; dass sie im gebauten
451+
// Browser ankommen, kann nur dieser Fall sehen (PO-Review PR #96).
452+
["--eyt-action-primary-contrast", "rgb(255, 255, 255)"],
453+
["--eyt-state-published-bg", "rgb(225, 235, 226)"],
454+
["--eyt-state-published-text", "rgb(30, 82, 49)"],
455+
["--eyt-state-draft-text", "rgb(122, 83, 0)"],
456+
["--eyt-state-draft-bg", "rgb(244, 232, 206)"],
457+
["--eyt-state-danger-text", "rgb(155, 44, 31)"],
458+
["--eyt-state-danger-bg", "rgb(247, 227, 223)"],
459+
["--eyt-state-info-text", "rgb(21, 94, 117)"],
460+
["--eyt-state-info-bg", "rgb(222, 237, 242)"],
461+
];
462+
463+
for (const pfad of ["/", "/planung", "/kosten"]) {
464+
test(`${pfad} liefert die kanonischen Basisdesign-Tokens aus`, async ({ page }) => {
465+
await page.goto(pfad);
466+
await expect(page.getByRole("main")).toBeVisible();
467+
468+
const gemessen = await page.evaluate(
469+
(namen) => {
470+
const wurzel = getComputedStyle(document.documentElement);
471+
const sonde = document.createElement("span");
472+
document.body.append(sonde);
473+
try {
474+
return Object.fromEntries(
475+
namen.map((name) => {
476+
const roh = wurzel.getPropertyValue(name).trim();
477+
// Erst leeren, dann setzen: lehnt der CSS-Parser den Wert ab,
478+
// bleibt `style.color` leer — und `angenommen` deckt genau den
479+
// Fall auf, in dem eine fehlende Rolle sonst die geerbte Farbe
480+
// der Sonde gemessen haette und zufaellig gruen waere.
481+
sonde.style.color = "";
482+
sonde.style.color = roh;
483+
const angenommen = sonde.style.color !== "";
484+
return [name, { roh, angenommen, farbe: getComputedStyle(sonde).color }];
485+
}),
486+
);
487+
} finally {
488+
sonde.remove();
489+
}
490+
},
491+
KANONISCHE_ROLLEN.map(([name]) => name),
492+
);
493+
494+
for (const [name, farbe] of KANONISCHE_ROLLEN) {
495+
const wert = gemessen[name];
496+
expect(wert?.roh, `${pfad}: ${name} ist nicht deklariert`).toBeTruthy();
497+
expect(wert?.angenommen, `${pfad}: ${name} = "${wert?.roh}" ist keine gueltige Farbe`).toBe(
498+
true,
499+
);
500+
expect(wert?.farbe, `${pfad}: ${name} (roh: "${wert?.roh}")`).toBe(farbe);
501+
}
502+
});
503+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* `apps/web` FUEHRT keine eigene Palette mehr (EYT-80 Inkrement 2).
3+
*
4+
* Zwei Haelften, beide noetig:
5+
* 1. `globals.css` bindet die kanonische Datei aus `@easytree/ui` ein — und
6+
* zwar als ERSTE Anweisung, weil `@import` in CSS nur vor jeder Regel
7+
* gilt und ein spaeteres schweigend ignoriert wird.
8+
* 2. `globals.css` DEKLARIERT keine `--eyt-*`-Rolle mehr. Verwendungen
9+
* (`var(--eyt-…)`) bleiben ausdruecklich erlaubt und sind der Normalfall
10+
* — geprueft wird die linke Seite eines Doppelpunkts, nicht das Wort.
11+
*
12+
* Die dritte Haelfte — dass die Werte im GEBAUTEN Browser wirklich ankommen —
13+
* kann eine Datei-Lesung nicht leisten und steht in
14+
* `apps/web/e2e/shell-smoke.spec.ts`.
15+
*
16+
* Nicht ueber `import.meta.url`: unter vitest/jsdom kein file://-Schema.
17+
* `process.cwd()` ist apps/web.
18+
*/
19+
import { readFileSync } from "node:fs";
20+
import { join } from "node:path";
21+
22+
import { describe, expect, it } from "vitest";
23+
24+
const rohCss = readFileSync(join(process.cwd(), "app/globals.css"), "utf8");
25+
const css = rohCss.replace(/\/\*[\s\S]*?\*\//g, "");
26+
27+
describe("Basisdesign-Tokens kommen aus @easytree/ui (EYT-80)", () => {
28+
it("bindet die kanonische Tokendatei als erste Anweisung ein", () => {
29+
const ersteAnweisung = css.trim().split(";")[0]?.trim();
30+
expect(ersteAnweisung).toBe('@import "@easytree/ui/basisdesign-v2.css"');
31+
});
32+
33+
it("deklariert selbst keine einzige --eyt-Rolle mehr", () => {
34+
const eigene = [...css.matchAll(/(?:^|[;{])\s*(--eyt-[\w-]+)\s*:/g)].map((t) => t[1]);
35+
expect(eigene, `eigene Tokendeklarationen: ${eigene.join(", ")}`).toEqual([]);
36+
});
37+
38+
it("benutzt die Rollen weiterhin — sonst waere die Abwesenheit oben trivial", () => {
39+
/*
40+
* Ohne diesen Fall waere ein `globals.css` ohne JEDE Farbe gruen: keine
41+
* Deklaration, kein Befund. Die Zahl ist bewusst grosszuegig; sie soll den
42+
* Totalverlust fangen, nicht jede Umbenennung. Gemessen 27.08.2026: 58
43+
* Verwendungen.
44+
*/
45+
const verwendungen = [...css.matchAll(/var\(\s*--eyt-[\w-]+/g)];
46+
expect(verwendungen.length).toBeGreaterThan(20);
47+
});
48+
49+
describe("fuehrt ausser den beiden Fokusfarben keine eigene Farbquelle mehr", () => {
50+
/*
51+
* `--color-focus` ist bewusst KEINE §2.1-Rolle: die Baseline kennt keine
52+
* Fokusfarbe, sie fordert nur 3:1 zur angrenzenden Flaeche (EYT-12). Sie
53+
* bleibt darum anwendungseigen — aber sie ist die EINZIGE Ausnahme.
54+
*
55+
* Der Vertrag lautet „keine eigene Palette", nicht „kein Hex": ein reines
56+
* Hex-Muster liess rgb(), hsl(), oklch() und Farbnamen durch (PO-Review
57+
* PR #96). Drei Schichten schliessen das, ohne eine handgepflegte Liste
58+
* aller ~148 CSS-Farbnamen: (1) Hex-Literale bleiben exakt die zwei
59+
* Fokusfarben; (2) die geschlossene Menge der CSS-Farbfunktionen darf
60+
* nicht vorkommen; (3) jedes nackte Wort aus einem Deklarationswert wird
61+
* dem CSS-Parser von jsdom als `color` angeboten — nimmt er es an, ist es
62+
* ein Farbwort. `var(--…)` zerfaellt beim Zerlegen in „var" und den
63+
* Tokennamen, beides keine Farbwoerter; ein Farbname im var()-Fallback
64+
* bleibt dadurch sichtbar.
65+
*/
66+
const deklarationswerte = [...css.matchAll(/[{;]\s*[-\w]+\s*:\s*([^;{}]+)/g)].map((t) =>
67+
(t[1] ?? "").trim(),
68+
);
69+
70+
it("liest ueberhaupt Deklarationswerte — sonst waeren die Schichten leer und trivial gruen", () => {
71+
expect(deklarationswerte.length).toBeGreaterThan(50);
72+
});
73+
74+
it("Hex-Literale: exakt die zwei Fokusfarben", () => {
75+
const literale = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((t) => t[0].toLowerCase());
76+
expect([...new Set(literale)].sort()).toEqual(["#1d4ed8", "#7cc1db"]);
77+
});
78+
79+
it("keine funktionale Farbnotation (rgb, hsl, oklch, color-mix, …)", () => {
80+
const farbfunktionen = deklarationswerte.flatMap((wert) =>
81+
[
82+
...wert.matchAll(
83+
/(?:^|[^\w-])(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix|color|light-dark|device-cmyk)\s*\(/g,
84+
),
85+
].map((t) => `${t[1] ?? ""}(…) in "${wert.slice(0, 60)}"`),
86+
);
87+
expect(farbfunktionen).toEqual([]);
88+
});
89+
90+
it("kein CSS-Farbname in einem Deklarationswert", () => {
91+
/*
92+
* CSS-weite Schluesselwoerter und die zwei neutralen Farbwoerter sind
93+
* keine eigene Palette — sie verweisen auf die Kaskade, nicht auf einen
94+
* Farbwert.
95+
*/
96+
const neutral = new Set([
97+
"transparent",
98+
"currentcolor",
99+
"inherit",
100+
"initial",
101+
"unset",
102+
"revert",
103+
]);
104+
const sonde = document.createElement("span");
105+
const istFarbwort = (wort: string): boolean => {
106+
sonde.style.color = "";
107+
sonde.style.color = wort;
108+
return sonde.style.color !== "";
109+
};
110+
// Die Sonde erkennt Farben — sonst misst der Filter darunter nichts.
111+
expect(istFarbwort("tomato")).toBe(true);
112+
expect(istFarbwort("solid")).toBe(false);
113+
114+
const farbwoerter = [
115+
...new Set(
116+
deklarationswerte
117+
.flatMap((wert) => wert.split(/[\s,()/"']+/))
118+
.filter((token) => /^[a-zA-Z]+$/.test(token))
119+
.map((token) => token.toLowerCase()),
120+
),
121+
].filter((wort) => !neutral.has(wort) && istFarbwort(wort));
122+
expect(farbwoerter, `Farbnamen in Deklarationswerten: ${farbwoerter.join(", ")}`).toEqual([]);
123+
});
124+
});
125+
});

‎packages/ui/package.json‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010
".": {
1111
"types": "./dist/index.d.ts",
1212
"default": "./dist/index.js"
13-
}
13+
},
14+
"./basisdesign-v2.css": "./dist/basisdesign-v2.css"
1415
},
1516
"files": [
1617
"dist"
1718
],
18-
"sideEffects": false,
19+
"sideEffects": [
20+
"*.css"
21+
],
1922
"scripts": {
20-
"build": "tsc -p tsconfig.build.json",
23+
"build": "tsc -p tsconfig.build.json && cp src/basisdesign-v2.css dist/basisdesign-v2.css",
2124
"lint": "eslint .",
2225
"typecheck": "tsc -p tsconfig.json --noEmit",
2326
"test": "vitest run"

‎packages/ui/src/basisdesign-v2.css‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Basisdesign v2.0 §2.1 — die verbindliche Tokenbasis (Confluence 8814623).
3+
*
4+
* DIESE Datei ist die einzige Quelle der semantischen Farbrollen. `apps/web`
5+
* haelt keine zweite Fassung mehr, sondern bindet diese hier ein; damit gibt es
6+
* keine Abweichung mehr zu bewachen, weil es keine zweite Liste mehr gibt.
7+
*
8+
* Bewacht wird sie von `test/basisdesign-tokens.test.ts`: dort steht die
9+
* Confluence-Tabelle als ERWARTUNG, hier stehen die Werte, und jedes Paar, das
10+
* als normaler Kleintext vorkommt, muss 4.5:1 erreichen.
11+
*
12+
* Zwei Rollen stehen NICHT in §2.1: `--eyt-state-published-text` ist eine
13+
* Ableitung aus `--eyt-action-primary`, `--eyt-action-primary-contrast` ist
14+
* repo-eigen gewaehlt. Der Test haelt beide fest; ausgegeben werden sie nie
15+
* als „genehmigte Werte“.
16+
*
17+
* Kein Layout, keine Klassen, keine Schrift: das Paket besitzt die WERTE, die
18+
* Anwendung besitzt die Gestaltung (`apps/web/app/globals.css`).
19+
*/
20+
21+
:root {
22+
--eyt-bg-canvas: #f6f4ef;
23+
--eyt-bg-surface: #ffffff;
24+
--eyt-text-primary: #1d1b18;
25+
--eyt-text-secondary: #5b564e;
26+
--eyt-border-default: #d8d4cb;
27+
--eyt-action-primary: #1e5231;
28+
--eyt-action-primary-contrast: #ffffff;
29+
--eyt-state-published-bg: #e1ebe2;
30+
--eyt-state-published-text: #1e5231;
31+
--eyt-state-draft-text: #7a5300;
32+
--eyt-state-draft-bg: #f4e8ce;
33+
--eyt-state-danger-text: #9b2c1f;
34+
--eyt-state-danger-bg: #f7e3df;
35+
--eyt-state-info-text: #155e75;
36+
--eyt-state-info-bg: #deedf2;
37+
}
38+
39+
/*
40+
* Dunkelmodus folgt dem SYSTEM (Basisdesign §7: „Dark Mode folgt System oder
41+
* expliziter Nutzereinstellung, nicht einer ungeprueften automatischen
42+
* Annahme“). Jede Rolle wird vollstaendig neu gesetzt — kein Teilsatz, damit
43+
* keine Rolle unbemerkt im Hellwert stehen bleibt.
44+
*/
45+
@media (prefers-color-scheme: dark) {
46+
:root {
47+
--eyt-bg-canvas: #161513;
48+
--eyt-bg-surface: #211f1c;
49+
--eyt-text-primary: #f1efe9;
50+
--eyt-text-secondary: #b0aaa0;
51+
--eyt-border-default: #3a3733;
52+
--eyt-action-primary: #8fc7a2;
53+
--eyt-action-primary-contrast: #161513;
54+
--eyt-state-published-bg: #22352a;
55+
--eyt-state-published-text: #8fc7a2;
56+
--eyt-state-draft-text: #e3b95b;
57+
--eyt-state-draft-bg: #3a2f17;
58+
--eyt-state-danger-text: #f0a08f;
59+
--eyt-state-danger-bg: #41211c;
60+
--eyt-state-info-text: #7cc1db;
61+
--eyt-state-info-bg: #1c3540;
62+
}
63+
}

0 commit comments

Comments
 (0)