Assessor_2.0/tests/design-language-check.mjs

355 lines
15 KiB
JavaScript

/*
* 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 Segment-/Kachelvertrag erlaubt genau den gemeinsamen,
* dezenten Kachelschatten. Segmente selbst bleiben flach. */
matches: line => line.includes('box-shadow') && !/box-shadow:\s*none|shadow-overlay|shadow-popover|inset|var\(--(?:donut-(?:rack|card)-shadow|tile-card-shadow)\)/.test(line),
remedy: 'Nur echte Overlays und Kacheln verwenden ihre gemeinsamen Schattentokens; Segmente und Panels grenzen ueber Haarlinien ab.',
budgets: new Map([
['components.css', 2],
['datahub.css', 2],
['import-workflow.css', 1],
['layout.css', 1],
['legacy-views.css', 0]
])
},
{
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.',
// Das gemeinsame transparente Logo benoetigt keinen Theme-Sonderfall mehr.
budgets: new Map()
}
];
/* 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*var\(--type-h1-size\);[^}]*font-weight:\s*var\(--fw-semibold\);[^}]*line-height:\s*1\.1;[^}]*letter-spacing:\s*-\.04em;/,
'layout.css muss die Bereichsbezeichnungen aller regulaeren Kopfpanels wie den Mandantentitel darstellen.'
);
assert.match(
shellLayout,
/\.brand\s*\{[^}]*min-height:\s*112px;[^}]*padding:\s*6px 14px 6px 16px;[^}]*margin-top:\s*0;/,
'layout.css muss die Marke in allen Ansichten fest auf der Datahub-Position halten.'
);
assert.doesNotMatch(
shellLayout,
/\.app-shell[^,{]* \.brand\s*\{/,
'Ansichtsspezifische Brand-Geometrie ist nicht erlaubt, damit Logo und Schrift beim Ansichtswechsel nicht springen.'
);
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.`
);