Skip to content
Merged
49 changes: 11 additions & 38 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions apps/web/e2e/shell-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,94 @@ 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<readonly [string, string]> = [
["--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)"],
// 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)"],
["--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);
}
});
}
125 changes: 125 additions & 0 deletions apps/web/test/basisdesign-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* `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);
});

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.
*
* 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 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([]);
});
});
});
9 changes: 6 additions & 3 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 63 additions & 0 deletions packages/ui/src/basisdesign-v2.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading