/* * Pruefer der ASSESSOR-Designsprache. * * Zweck: verhindern, dass neue Oberflaechenteile am Designsystem vorbei * gebaut werden und dadurch auf ein Fremddesign zurueckfallen. Der Pruefer * ersetzt Disziplin durch eine Zusage, die im `npm test` scheitert. * * Geprueft wird: * 1. Rohe Farbwerte sind nur in Token-Definitionen erlaubt (--x: #hex). * In gewoehnlichen Deklarationen (background, color, border ...) sind * sie verboten - dort gehoert var(--token) hin. * 2. Jedes referenzierte Token muss definiert sein. Genau das hat zuvor * gefehlt: --panel-hover wurde benutzt, existierte aber nicht, und der * betroffene Zustand rendete ersatzlos. * 3. Keine Schriftgroesse unter 11px, der kleinsten Stufe der Designsprache * (`--fs-micro`, nur fuer Versal-Badges, Spaltenkoepfe und Zaehler). * Geprueft werden auch die Token-Definitionen selbst (`--ui-*size` und * `--fs-*`) und die Untergrenze von clamp(). * 4. React-Komponenten setzen keine Inline-Styles. * 5. Kein `!important`. * 6. Kein Radius ueber 4px (`--radius-md`). Panels und Shell tragen 0. * 7. Kein Schatten ausserhalb der Overlay-Rollen. Panels, Tiles, Zeilen, * Buttons und Tabellen tragen keinen. * 8. Keine seitenspezifische Ueberschreibung (`.page-x .btn { ... }`). * 9. Kein Light-Mode-Fix per Selektor-Override. Fehlt im hellen Skin ein * Wert, fehlt eine semantische Rolle - der Skin wird in der Tokendatei * befuellt, nicht per `[data-theme="light"] .klasse` nachkorrigiert. * * Die Regeln 5 bis 9 stammen aus der Verbotsliste der Designsprache * (.claude/skills/assessor-ui/references/review-checklist.md, Abschnitt 1). * Sie lagen dort als grep-Empfehlung vor; als Zusage im `npm test` erodieren * sie nicht ueber lange Arbeitssitzungen hinweg. * * Die Ausnahmelisten bilden den noch nicht umgestellten Altbestand ab. Sie * duerfen ausschliesslich schrumpfen: jede Zahl ist eine Obergrenze, kein * Freibrief. Sinkt der Wert einer Datei, wird die Grenze mitgesenkt - so * kann keine Datei stillschweigend zurueckwachsen. */ import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import { extname, join, resolve } from 'node:path'; const root = resolve(import.meta.dirname, '..'); const styleDirectory = join('src', 'styles'); /* Dateien, die die Designsprache selbst definieren und deshalb rohe Werte * tragen duerfen - allerdings nur innerhalb von Token-Deklarationen. */ const tokenSources = new Set(['tokens.css', 'themes.css', 'ui-tokens.css']); /* Leer: seit Schritt 1 der Designsprache traegt keine Stilddatei mehr einen * rohen Farbwert ausserhalb einer Token-Deklaration. Die Liste bleibt als * Mechanik bestehen - ein neuer Eintrag waere ein Rueckschritt und muesste * begruendet werden. */ const maximumRawColorDeclarations = new Map(); const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(/; const tokenDeclaration = /^\s*--[a-z0-9-]+\s*:/; /* Verbotsliste der Designsprache. Jede Regel zaehlt Zeilen je Stildatei; * nicht genannte Dateien haben die Obergrenze 0. Auch diese Zahlen duerfen * nur sinken. */ const prohibitions = [ { label: 'ein !important', /* Spezifitaet gehoert in den Selektor oder in die Layer-Reihenfolge. * `!important` gewinnt gegen beides und macht die Kaskade unlesbar. */ matches: line => line.includes('!important'), remedy: 'Regel korrigieren statt uebersteuern - bei Layer-Konflikten die Reihenfolge in layers.css pruefen.', budgets: new Map([ ['components.css', 1], ['datahub.css', 2], ['mitre-graph.css', 2] ]) }, { label: 'einen Radius ueber 4px', matches: line => /border-radius:\s*([5-9]|[1-9][0-9])px|border-radius:\s*[0-9.]+rem/.test(line), remedy: 'Erlaubt sind --radius-xs (2px), --radius-sm (3px) und --radius-md (4px); Panels und Shell tragen 0.', budgets: new Map([ ['import-workflow.css', 4], ['layout.css', 7] ]) }, { label: 'einen Schatten ausserhalb der Overlay-Rollen', /* `inset` bleibt ausgenommen: eingesenkte Wertfelder sind Flaechen- * modellierung, kein schwebendes Element. */ /* Der bindende Donut-Vertrag vom 2026-08-05 traegt zwei eigene, * tokenisierte Tiefenschatten. Sie sind Teil der Nutzerreferenz und * ausschliesslich an DonutMetricCard/Rack gebunden. */ matches: line => line.includes('box-shadow') && !/shadow-overlay|shadow-popover|inset|var\(--donut-(?:rack|card)-shadow\)/.test(line), remedy: 'Schatten tragen nur echte Overlays (--shadow-overlay, --shadow-popover). Panels grenzen ueber Haarlinien ab.', budgets: new Map([ ['components.css', 6], ['datahub.css', 2], ['import-workflow.css', 1], ['layout.css', 7], ['legacy-views.css', 6] ]) }, { label: 'eine seitenspezifische Ueberschreibung', /* Bewusst nur `.page-*`: die Verbotsliste nennt zusaetzlich `.view-*` * und `.screen-*`, im Bestand sind das aber Komponentennamen * (`.view-tabs`, `.view-table`) und keine Seitenskopierung. */ matches: line => /\.page-[a-z-]+\s+[.[a-zA-Z#*]/.test(line), remedy: 'Eine Komponente sieht auf jeder Seite gleich aus. Fehlt eine Variante, gehoert sie in die Komponente.', budgets: new Map() }, { label: 'einen Light-Mode-Fix per Selektor', /* Der reine Wurzelblock (`html[data-theme="light"] {`) ist erlaubt - * dort stehen die Tokenwerte des hellen Skins. Verboten ist die * nachtraegliche Korrektur einzelner Klassen. */ matches: line => /\[data-theme="light"\]\s*[.[a-zA-Z#*]/.test(line), remedy: 'Beide Skins rendern aus denselben semantischen Rollen. Fehlt ein Wert, fehlt eine Rolle in der Tokendatei.', budgets: new Map([ /* Was bleibt, sind vier Regeln zum Logotausch (`.brand-logo-*`, `.empty-brand-logo-*`): sie schalten Deckkraft um, nicht Farbe. Ein zweifarbiges Logo ist keine semantische Rolle. */ ['components.css', 2], ['layout.css', 2] ]) } ]; /* Kommentare zaehlen nicht als Verstoss - die Designsprache benennt ihre * eigenen Verbote im Dateikopf. Ersetzt wird laengenerhaltend, damit die * gemeldete Zeilennummer stimmt. */ function withoutComments(source) { return source.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } /* Untergrenze der Schriftgroesse. rem wird mit der Standardwurzel von 16px * verglichen; kleinere Wurzeln sind eine bewusste Nutzereinstellung und * sollen nicht gegen diese Grenze laufen. * * 11px statt vormals 12px: die Designsprache (.claude/skills/assessor-ui) * sieht mit `--fs-micro` eine 11px-Stufe fuer Versal-Badges, Spaltenkoepfe * und Zaehler vor. Die Stufe ist die kleinste des Systems; darunter gibt es * keine erlaubte Groesse. Fliesstext und Tabellenzellen bleiben bei 12px * und mehr. */ const MINIMUM_FONT_PIXELS = 11; const REFERENCE_ROOT_PIXELS = 16; function read(relativePath) { return readFileSync(join(root, relativePath), 'utf8'); } function styleFiles() { return readdirSync(join(root, styleDirectory)) .filter(name => extname(name) === '.css') .sort(); } function sourceFiles(directory) { return readdirSync(join(root, directory), { withFileTypes: true }).flatMap(entry => { const relativePath = join(directory, entry.name); if (entry.isDirectory()) return sourceFiles(relativePath); return ['.ts', '.tsx'].includes(extname(entry.name)) ? [relativePath] : []; }); } /* Eine Deklaration je Zeile genuegt: die Stilddateien sind durchgehend * einzeilig je Regel geschrieben. Kommentare bleiben unberuecksichtigt. */ function rawColorDeclarations(source) { return source .split('\n') .map((line, index) => ({ line: line.replace(/\/\*.*?\*\//g, ''), number: index + 1 })) .filter(entry => rawColor.test(entry.line) && !tokenDeclaration.test(entry.line)); } const definedTokens = new Set(); const usedTokens = new Map(); for (const name of styleFiles()) { const source = read(join(styleDirectory, name)); for (const match of source.matchAll(/(--[a-z0-9-]+)\s*:/g)) definedTokens.add(match[1]); for (const match of source.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) { if (!usedTokens.has(match[1])) usedTokens.set(match[1], `${name}`); } } for (const name of styleFiles()) { const source = read(join(styleDirectory, name)); const violations = rawColorDeclarations(source); const allowed = maximumRawColorDeclarations.get(name) ?? 0; if (tokenSources.has(name)) { assert.equal( violations.length, 0, `${name} darf rohe Farbwerte nur in Token-Deklarationen tragen. Betroffene Zeilen: ${violations.map(entry => entry.number).join(', ')}` ); continue; } assert.ok( violations.length <= allowed, `${name} enthaelt ${violations.length} rohe Farbwerte, erlaubt sind hoechstens ${allowed}. ` + `Neue Oberflaechen muessen var(--token) aus src/styles/tokens.css verwenden. ` + `Betroffene Zeilen: ${violations.map(entry => entry.number).slice(0, 12).join(', ')}` ); assert.ok( violations.length === allowed || allowed === 0, `${name} ist auf ${violations.length} rohe Farbwerte gesunken. Bitte die Obergrenze in ` + `tests/design-language-check.mjs auf ${violations.length} senken, damit sie nicht zurueckwachsen kann.` ); } /* Verbotsliste: dieselbe ausschliesslich schrumpfende Mechanik wie oben. */ let remainingProhibited = 0; for (const rule of prohibitions) { for (const name of styleFiles()) { const lines = withoutComments(read(join(styleDirectory, name))).split('\n'); const violations = lines .map((line, index) => ({ line, number: index + 1 })) .filter(entry => rule.matches(entry.line)); const allowed = rule.budgets.get(name) ?? 0; remainingProhibited += Math.min(violations.length, allowed); assert.ok( violations.length <= allowed, `${name} enthaelt ${violations.length} Zeilen mit dem Muster "${rule.label}", erlaubt sind hoechstens ${allowed}. ` + `${rule.remedy} ` + `Betroffene Zeilen: ${violations.map(entry => entry.number).slice(0, 12).join(', ')}` ); assert.ok( violations.length === allowed || allowed === 0, `${name} traegt nur noch ${violations.length} mal "${rule.label}". Bitte die Obergrenze in ` + `tests/design-language-check.mjs auf ${violations.length} senken, damit sie nicht zurueckwachsen kann.` ); } } /* Schriftgroessen: jeder absolute Wert in einer font-size-Deklaration und in * jedem Typografie-Token muss die Untergrenze einhalten. Bei clamp() gilt das * fuer alle Glieder, womit insbesondere die Untergrenze abgedeckt ist. */ for (const name of styleFiles()) { const source = read(join(styleDirectory, name)); source.split('\n').forEach((line, index) => { const declarations = [ ...line.matchAll(/font-size\s*:\s*([^;}]+)/g), ...line.matchAll(/--ui-[a-z-]*size\s*:\s*([^;}]+)/g), ...line.matchAll(/--fs-[a-z0-9-]+\s*:\s*([^;}]+)/g) ]; for (const declaration of declarations) { for (const measure of declaration[1].matchAll(/([0-9.]+)(px|rem)/g)) { const pixels = measure[2] === 'rem' ? Number(measure[1]) * REFERENCE_ROOT_PIXELS : Number(measure[1]); assert.ok( pixels >= MINIMUM_FONT_PIXELS, `${name}:${index + 1} setzt die Schriftgroesse ${measure[0]} (${pixels}px) und liegt damit unter der ` + `Untergrenze von ${MINIMUM_FONT_PIXELS}px. Bitte eine Stufe der Skala aus src/styles/tokens.css verwenden.` ); } } }); } for (const [token, origin] of usedTokens) { assert.ok( definedTokens.has(token), `${origin} verwendet das Token ${token}, das nirgends definiert ist. ` + `Nicht definierte Tokens rendern ersatzlos - bitte in src/styles/tokens.css erganzen.` ); } for (const relativePath of sourceFiles('src')) { assert.doesNotMatch( read(relativePath), /style=\{\{/, `${relativePath} verwendet einen Inline-Style. Darstellung gehoert in eine Klasse in src/styles.` ); } /* Work-Kopfvertrag: Zaehler und Werkzeuge bilden im gemeinsamen Container * eine Zeile. Ein flaechenfuellendes Toolbar-Flex hatte Snapshot und folgende * Controls je nach Breite weit nach rechts verschoben. */ const shellLayout = withoutComments(read(join(styleDirectory, 'layout.css'))); const dynamicFilterLayout = withoutComments(read(join(styleDirectory, 'dynamic-filters.css'))); const searchFilterControls = read(join('src', 'features', 'search-filter', 'SearchFilterBar.tsx')); assert.match( shellLayout, /\.reader-toolbar\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;/, 'layout.css muss Zaehler und Werkzeugleiste ueber .reader-toolbar in derselben Flexzeile halten.' ); assert.match( shellLayout, /\.toolbar\s*\{[^}]*flex:\s*0\s+0\s+auto;/, 'layout.css darf die Work-Werkzeugleiste nicht flaechenfuellend wachsen lassen.' ); assert.match( shellLayout, /\.topbar\s*\{[^}]*overflow:\s*hidden;/, 'layout.css muss den statischen Kopf ohne eigenen Scrollbereich halten.' ); assert.match( shellLayout, /\.app-header-panel\s*\{[^}]*flex:\s*1\s+1\s+0;/, 'layout.css muss das Header-Panel auf die verfuegbare Viewportbreite schrumpfen lassen.' ); assert.match( shellLayout, /\.status-line-tools\s*\{[^}]*position:\s*absolute;[^}]*left:\s*50%;[^}]*transform:\s*translateX\(-50%\);/, 'layout.css muss das Suchfeld in der unteren Kopfzeile horizontal zentrieren.' ); assert.match(searchFilterControls, /placeholder="Search"/, 'Das Freitextfeld muss als Search bezeichnet sein.'); assert.doesNotMatch(searchFilterControls, /Filter text/, 'Die alte Bezeichnung Filter text darf nicht sichtbar bleiben.'); assert.match( shellLayout, /\.area-topbar strong\s*\{[^}]*font-size:\s*calc\(var\(--fs-xl\) \* 4\);[^}]*font-weight:\s*var\(--fw-semibold\);[^}]*line-height:\s*1\.15;[^}]*letter-spacing:\s*-\.04em;/, 'layout.css muss die Bereichsbezeichnungen aller regulaeren Kopfpanels wie den Mandantentitel darstellen.' ); assert.match( shellLayout, /\.app-shell:not\(\.app-shell-work\) \.brand\s*\{[^}]*min-height:\s*112px;[^}]*padding-block:\s*6px;[^}]*margin-top:\s*0;/, 'layout.css muss die Marke mittig zur horizontalen Achse der grossen Bereichsbezeichnung ausrichten.' ); assert.doesNotMatch( shellLayout, /\.area-topbar strong\s*\{[^}]*text-transform:\s*uppercase;/, 'layout.css muss die normale Schreibweise der Bereichsbezeichnungen bewahren.' ); assert.match( dynamicFilterLayout, /\.view-bookmark-list\s*\{[^}]*overflow:\s*visible;/, 'dynamic-filters.css muss die Bookmark-Liste ohne eigenen Scrollbereich halten.' ); assert.match( dynamicFilterLayout, /\.view-bookmark-item\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*auto;/, 'dynamic-filters.css darf View-Bookmarks nicht auf eine feste Breite kuerzen.' ); assert.doesNotMatch( dynamicFilterLayout, /\.view-bookmark-entry\s*\{[^}]*(?:overflow:\s*hidden|text-overflow:\s*ellipsis)/, 'dynamic-filters.css darf die Beschriftung eines View-Bookmarks nicht abschneiden.' ); assert.match( dynamicFilterLayout, /\.view-bookmark-item\s*\{[^}]*height:\s*var\(--btn-h\);/, 'dynamic-filters.css muss den Bookmark-Container mindestens so hoch wie seine Buttons halten.' ); assert.match( dynamicFilterLayout, /\.view-bookmark-entry\s*\{[^}]*height:\s*var\(--btn-h\);/, 'dynamic-filters.css muss Bookmark-Buttons ohne vertikales Abschneiden darstellen.' ); console.log( `Designsprache geprueft: ${styleFiles().length} Stilddateien, ${definedTokens.size} Tokens, ` + `${[...maximumRawColorDeclarations.values()].reduce((sum, value) => sum + value, 0)} rohe Farbwerte ` + `und ${remainingProhibited} verbotene Muster im Restbestand.` );