407 lines
18 KiB
JavaScript
407 lines
18 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', 3],
|
|
['layout.css', 2]
|
|
])
|
|
},
|
|
{
|
|
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.'
|
|
);
|
|
|
|
/* Nutzerkorrektur 2026-09-20: Rechteckige, klickbare Bedienelemente teilen
|
|
* denselben leichten 4-px-Radius. Spaeter geladene View-Regeln duerfen den
|
|
* Grundvertrag nicht wieder eckig oder staerker gerundet machen. Fachliche
|
|
* Kreisformen und quadratische Inhaltskacheln sind keine Control-Buttons. */
|
|
const baseControls = withoutComments(read(join(styleDirectory, 'base.css')));
|
|
const datahubControls = withoutComments(read(join(styleDirectory, 'datahub.css')));
|
|
const componentControls = withoutComments(read(join(styleDirectory, 'components.css')));
|
|
const importControls = withoutComments(read(join(styleDirectory, 'import-workflow.css')));
|
|
const graphControls = withoutComments(read(join(styleDirectory, 'obsidian-graph.css')));
|
|
assert.match(
|
|
baseControls,
|
|
/button, \.btn, \.icon-btn, \.tab-btn, \.bookmark-btn, \.results-btn\s*\{[^}]*border-radius:\s*var\(--btn-radius\);/,
|
|
'Die gemeinsame Buttonbasis muss --btn-radius verwenden.'
|
|
);
|
|
for (const [source, selector] of [
|
|
[shellLayout, '\\.tab-btn'],
|
|
[shellLayout, '\\.theme-switcher-btn'],
|
|
[shellLayout, '\\.sidebar-tab-btn'],
|
|
[dynamicFilterLayout, '\\.search-tools \\.btn\\.standard-filter-btn'],
|
|
[datahubControls, '\\.client-info-card-toggle'],
|
|
[datahubControls, '\\.client-priority-context button'],
|
|
[componentControls, '\\.results-window-close'],
|
|
[importControls, '\\.dialog-window > header button'],
|
|
[graphControls, '\\.obsidian-graph-reset-view']
|
|
]) {
|
|
assert.match(
|
|
source,
|
|
new RegExp(`${selector}[^\\{]*\\{[^}]*border-radius:\\s*var\\(--btn-radius\\);`),
|
|
`${selector} muss den gemeinsamen --btn-radius verwenden.`
|
|
);
|
|
}
|
|
assert.match(
|
|
datahubControls,
|
|
/button\.client-overall-ring\s*\{[^}]*border-radius:\s*50%;/,
|
|
'Der klickbare Overall-Score muss als fachliche Kreisform rund bleiben.'
|
|
);
|
|
assert.doesNotMatch(
|
|
datahubControls,
|
|
/\.client-source-overview\b/,
|
|
'Source Overview darf nicht mehr als eigenstaendiger Kachelbereich gerendert werden.'
|
|
);
|
|
assert.match(
|
|
datahubControls,
|
|
/\.client-environment-sources li > button\s*\{[^}]*cursor:\s*pointer;/,
|
|
'Klickbare Quellenzeilen im Environment Detail muessen als Drilldown erkennbar bleiben.'
|
|
);
|
|
assert.match(
|
|
datahubControls,
|
|
/\.client-environment-sources li > div,\s*\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/,
|
|
'Source Overview muss im Environment Detail als transparente Liste statt als Kacheln erscheinen.'
|
|
);
|
|
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.`
|
|
);
|