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
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-29 19:32:55 +02:00
1 parent 0875ae2688
commit 351a26efa4
10 files changed
+232 -23

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
#!/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.`);