Files
Dashboard/scripts/verify-layout.mjs
T
dejanandClaude Opus 5 351a26efa4 fix(layout): kollidierenden und überlaufenden Text beseitigen
Gemeldeter Fehler: In der Ringlegende klebten Bezeichnung und Wert aneinander
("Gestellt189,1 T€"). Ursache war eine eigene Regression — beim Beheben der
abgeschnittenen Legendennamen war das truncate entfallen, ohne Ersatz. Damit
schrumpft die Namensspalte durch min-w-0 + grow auf null Breite, und der Text
läuft sichtbar über seinen Kasten hinaus:

  1280 px  Kastenbreite 0 px, Text 43 px darüber hinaus
  1440 px  Kastenbreite 25 px, Text 18 px darüber hinaus
  1600 px  unauffällig

Genau deshalb war es auf den Screenshots nicht zu sehen: Der Fehler tritt nur in
bestimmten Fensterbreiten auf.

Neu: scripts/verify-layout.mjs + npm run verify:layout. Lädt alle sechs Seiten in
sieben Breiten (1024–1920) und misst jede Textzeile über Range.getClientRects.
Gemessen wird auf Textzeilen, nicht auf Elementkästen — sonst melden benachbarte
Tabellenzellen mit Innenabstand falsch positiv. Mindestabstand 8 px = Carbon
$spacing-03. Der Lauf fand 110 Fehler; alle behoben:

- Ringlegende: Container-Query statt fester Zweispaltigkeit. Legende sitzt rechts
  vom Ring, wenn Platz ist, sonst darunter — Carbon: "positioned to the right of
  the chart when space is plentiful". Namen brechen um statt überzulaufen.
- Auslastungs-Achse: Beschriftungsdichte staffelt sich über drei Container-Stufen,
  vorher überlappten die Zahlen in schmalen Karten um bis zu 16 px.
- Handlungsbedarf und Kennzahl-Labels: break-words statt Überlauf.
- Kartentitel und -untertitel: break-words, weil Dateipfade keine Leerzeichen
  zum Umbrechen haben.

Dokumentiert als DESIGN-SYSTEM.md §9 mit Begründung, warum das ein gemessener
Prüfer sein muss und keine Sichtprüfung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 19:32:55 +02:00

140 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Layout-Prüfer: findet kollidierenden und überlaufenden Text.
*
* Warum: Textkollisionen treten nur in bestimmten Fensterbreiten auf. Ein
* Screenshot bei 1600 px zeigt sie nicht, bei 1280 px schon. Auge und
* Einzelscreenshot reichen dafür nicht — das muss gemessen werden.
*
* Gemessen wird auf Textzeilen (Range.getClientRects), nicht auf Elementkästen:
* Sonst melden benachbarte Tabellenzellen mit Innenabstand falsch positiv.
*
* Mindestabstand 8 px = Carbon Spacing-Token $spacing-03, der kleinste Abstand,
* den Carbon zwischen benachbarten Textelementen vorsieht.
*
* Aufruf: npm run verify:layout (Dev-Server muss laufen)
*/
import { chromium } from "playwright-core";
const BASIS = process.env.DASHBOARD_URL ?? "http://localhost:4300";
const SEITEN = ["/", "/finanzen", "/auftraege", "/kunden", "/team", "/komponenten"];
const BREITEN = [1024, 1152, 1280, 1366, 1440, 1600, 1920];
const MIN_ABSTAND = 8;
const EXE =
process.env.CHROME_PFAD ??
`${process.env.HOME}/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell`;
const messen = (MIN) => {
const zeilen = [];
const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n;
while ((n = lauf.nextNode())) {
if (!n.textContent.trim()) continue;
const el = n.parentElement;
if (!el || el.closest("svg")) continue;
const st = getComputedStyle(el);
if (st.visibility === "hidden" || st.opacity === "0") continue;
// Absichtlich bündige Flächen (Segment-Control) ausnehmen
if (el.closest("[data-buendig]")) continue;
// Nächster Blockcontainer — Wörter innerhalb eines Satzes sind keine Kollision
let block = el;
while (block && block !== document.body && getComputedStyle(block).display.startsWith("inline")) {
block = block.parentElement;
}
const bereich = document.createRange();
bereich.selectNodeContents(n);
for (const r of bereich.getClientRects()) {
if (r.width < 1 || r.height < 1) continue;
zeilen.push({ r, text: n.textContent.trim().slice(0, 30), block, el });
}
}
const pfad = (el) => {
const t = [];
for (let x = el; x && x !== document.body && t.length < 3; x = x.parentElement) {
const k = typeof x.className === "string" && x.className
? "." + x.className.trim().split(/\s+/).slice(0, 2).join(".")
: "";
t.unshift(x.tagName.toLowerCase() + k);
}
return t.join(" > ");
};
const treffer = [];
for (let i = 0; i < zeilen.length; i++) {
for (let j = i + 1; j < zeilen.length; j++) {
const a = zeilen[i], b = zeilen[j];
if (a.block === b.block) continue; // gleicher Textfluss
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
const va = a.r, vb = b.r;
if (!(va.top < vb.bottom - 3 && vb.top < va.bottom - 3)) continue;
const luecke = vb.left >= va.left ? vb.left - va.right : va.left - vb.right;
if (luecke < MIN) {
treffer.push({
luecke: Math.round(luecke * 10) / 10,
a: a.text, b: b.text, wo: pfad(a.el),
ueberlappt: luecke < 0,
});
}
}
}
// Text, der aus seinem eigenen Kasten läuft
const ueberlauf = [...document.querySelectorAll("body *")]
.filter((el) => {
if (el.closest("svg") || el.children.length) return false;
const st = getComputedStyle(el);
if (st.overflow !== "visible" || !el.textContent.trim()) return false;
return el.scrollWidth > el.clientWidth + 1;
})
.map((el) => ({ text: el.textContent.trim().slice(0, 30), wo: pfad(el), um: el.scrollWidth - el.clientWidth }));
return { treffer, ueberlauf };
};
const browser = await chromium.launch({ executablePath: EXE });
const gesammelt = new Map();
for (const breite of BREITEN) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1200 } });
const seite = await ctx.newPage();
for (const pfad of SEITEN) {
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
await seite.waitForTimeout(500);
const { treffer, ueberlauf } = await seite.evaluate(messen, MIN_ABSTAND);
for (const t of treffer) {
const key = `K|${t.a}|${t.b}|${t.wo}`;
if (!gesammelt.has(key)) gesammelt.set(key, { art: "Kollision", ...t, seiten: new Set(), breiten: new Set() });
gesammelt.get(key).seiten.add(pfad);
gesammelt.get(key).breiten.add(breite);
gesammelt.get(key).luecke = Math.min(gesammelt.get(key).luecke, t.luecke);
}
for (const u of ueberlauf) {
const key = `U|${u.text}|${u.wo}`;
if (!gesammelt.has(key)) gesammelt.set(key, { art: "Überlauf", ...u, seiten: new Set(), breiten: new Set() });
gesammelt.get(key).seiten.add(pfad);
gesammelt.get(key).breiten.add(breite);
}
}
await ctx.close();
}
await browser.close();
const liste = [...gesammelt.values()].sort((a, b) => (a.luecke ?? -99) - (b.luecke ?? -99));
for (const f of liste) {
const kopf = f.art === "Kollision"
? `${f.ueberlappt ? "ÜBERLAPPT" : "zu eng"} (${f.luecke}px): "${f.a}" ⟷ "${f.b}"`
: `ÜBERLAUF (+${f.um}px): "${f.text}"`;
console.log(` ${kopf}`);
console.log(` ${f.wo}`);
console.log(` ${[...f.seiten].join(" ")} · ${[...f.breiten].join(" ")}`);
}
if (liste.length) {
console.error(`\n✗ ${liste.length} Layoutfehler. Mindestabstand ist ${MIN_ABSTAND} px (Carbon $spacing-03).`);
process.exit(1);
}
console.log(`✓ Kein kollidierender oder überlaufender Text — ${SEITEN.length} Seiten × ${BREITEN.length} Breiten.`);