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

+6
View File
@@ -50,6 +50,7 @@ ui/ Subframe-Komponentenbibliothek (unverändert über
docs/ Design-System, KPI-Katalog, Navigation, Datenmodell,
Herkunft, Zielsystem, Leitplanken, Prüffragen
scripts/verify-carbon.mjs gleicht chartTheme.ts gegen die Carbon-Referenz ab
scripts/verify-layout.mjs misst Textkollisionen über alle Seiten und Breiten
```
**Wichtigste Dateien für den Einbau:** `features/dashboard/data/calculations.ts`
@@ -90,8 +91,13 @@ schnellste Weg, sich zu orientieren.
```bash
npm run verify:carbon # Palette, neutrale Werte, Tooltip, Achsenraster gegen IBM Carbon
npm run verify:layout # kollidierender oder überlaufender Text, 6 Seiten × 7 Fensterbreiten
```
`verify:layout` braucht den laufenden Dev-Server. Es misst jede Textzeile im
Browser, weil Layoutfehler nur in bestimmten Fensterbreiten auftreten und auf
einem Screenshot deshalb unsichtbar bleiben.
Die Referenzwerte stehen mit Quelle und Version in
[`docs/carbon-referenz.json`](docs/carbon-referenz.json), gezogen aus dem
ausgelieferten Stylesheet von `@carbon/charts` — nicht aus der Dokumentation,
+34 -1
View File
@@ -269,7 +269,40 @@ Ringsegmente werden hervorgehoben (die übrigen auf 35–45 % Deckkraft).
Tabellenzeilen bekommen **keinen** Hover, solange sie nicht anklickbar sind —
ein Hover ohne Ziel verspricht eine Interaktion, die es nicht gibt.
## 9. Fokus und Kontrast
## 9. Textabstände und Umbruch
Zwei Regeln, die Carbon nicht als Zahl nennt, ohne die eine Oberfläche aber
kaputtgeht:
**Mindestens 8 px zwischen benachbarten Textelementen** (Carbon-Spacing-Token
`$spacing-03`, der kleinste Abstand der Skala). Alles darunter liest sich als
zusammengehörig, bei 0 px kleben Beschriftung und Wert aneinander.
**Kein Text läuft aus seinem Kasten.** Die häufigste Ursache ist
`min-w-0` zusammen mit `grow` ohne `truncate` oder `break-words`: Der Kasten
schrumpft auf null, der Text bleibt stehen und läuft in das Nachbarelement.
Deshalb trägt jedes Textfeld, das schrumpfen kann, entweder `truncate` (wenn
Kürzen vertretbar ist) oder `break-words` (wenn nicht). Dateipfade und lange
Einzelwörter brauchen zwingend `break-words` — sie haben keine Leerzeichen zum
Umbrechen.
**Legenden weichen nach unten aus.** Carbon sagt, die Legende gehört rechts
neben das Diagramm, „when space is plentiful". Ist der Platz nicht reichlich,
rutscht sie unter das Diagramm, statt Namen und Werte ineinanderlaufen zu
lassen. Gesteuert über **Container-Queries** (`@container` / `@md:`), nicht über
den Viewport: Dieselbe Karte nimmt mal ein Drittel und mal die halbe
Seitenbreite ein — der Viewport weiß davon nichts.
**Nachprüfbar gemacht:** `npm run verify:layout` lädt alle sechs Seiten in
sieben Fensterbreiten (1024–1920) und misst jede Textzeile. Kollisionen unter
8 px und Text, der seinen Kasten verlässt, brechen den Lauf ab.
Warum das ein eigener Prüfer sein muss: Diese Fehler treten **nur in bestimmten
Breiten** auf. Die Ringlegende auf der Finanzseite kollidierte bei 1280 und
1440 px und war bei 1600 px unauffällig — auf einem Screenshot also unsichtbar.
Auge und Einzelbild reichen dafür nicht.
## 10. Fokus und Kontrast
**Fokus** ist eine einzige Regel in `app/globals.css` statt `focus:`-Klassen an
jedem Element:
+10 -1
View File
@@ -55,7 +55,16 @@ Handlungsliste braucht je Zeile einen Betrag und ein Verb.
---
## 4. Raster und Zustände
## 4. Layout, Raster und Zustände
**Vor jedem Review `npm run verify:layout` laufen lassen.** Der Prüfer findet
kollidierenden und überlaufenden Text über sieben Fensterbreiten — das ist die
eine Fehlerklasse, die man mit dem Auge zuverlässig übersieht, weil sie nur in
bestimmten Breiten auftritt.
Von Hand: das Fenster einmal von 1024 auf 1920 px aufziehen und dabei auf
Ringlegenden, Achsenbeschriftungen und Tabellenköpfe achten.
- Erzeugt eine Karte tote Fläche, weil die Nachbarkarte höher ist?
Gleiche Höhe ist nur bei gleicher Struktur richtig (Kennzahlenreihe), nicht bei
@@ -76,10 +76,10 @@ export function AlertList({
</span>
<span className="flex min-w-0 grow flex-col">
<span className="text-[13px] font-medium leading-snug text-text-neutral-primary">
<span className="min-w-0 break-words text-[13px] font-medium leading-snug text-text-neutral-primary">
{eintrag.text}
</span>
<span className="text-[12px] leading-snug text-text-neutral-tertiary">
<span className="min-w-0 break-words text-[12px] leading-snug text-text-neutral-tertiary">
{eintrag.detail}
</span>
</span>
@@ -74,7 +74,7 @@ export function ChartCard({
<header className="flex flex-none items-start justify-between gap-3 px-4 pb-3 pt-4">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<h2 className="truncate text-[14px] font-semibold leading-tight text-text-neutral-primary">
<h2 className="min-w-0 break-words text-[14px] font-semibold leading-tight text-text-neutral-primary">
{titel}
</h2>
{hinweis ? (
@@ -87,7 +87,7 @@ export function ChartCard({
) : null}
</div>
{untertitel ? (
<p className="line-clamp-2 text-[12px] leading-snug text-text-neutral-tertiary">
<p className="line-clamp-2 break-words text-[12px] leading-snug text-text-neutral-tertiary">
{untertitel}
</p>
) : null}
@@ -22,7 +22,7 @@ export function StatZeile({
>
{eintraege.map((eintrag) => (
<div key={eintrag.label} className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-[0.06em] text-text-neutral-tertiary">
<span className="break-words text-[11px] uppercase tracking-[0.06em] text-text-neutral-tertiary">
{eintrag.label}
</span>
<span className="text-[14px] font-semibold tabular-nums text-text-neutral-primary">
@@ -43,7 +43,7 @@ export function AuslastungsBalken({
for (let t = min; t <= max; t += 10) ticks.push(t);
return (
<div className="flex flex-col gap-2">
<div className="@container flex flex-col gap-2">
{sichtbar.map((zeile) => {
const farbe =
zeile.wert < SCHWELLEN.frei
@@ -113,15 +113,29 @@ export function AuslastungsBalken({
<div className="grid grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3 pt-1">
<span />
<span className="relative block h-4">
{ticks.map((t) => (
<span
key={t}
className="absolute top-0 -translate-x-1/2 text-[11px] tabular-nums text-text-neutral-tertiary"
style={{ left: `${pos(t)}%` }}
>
{t}
</span>
))}
{/* Beschriftungsdichte staffelt sich mit der Containerbreite: ganz
schmal nur die Enden, dann die 100-%-Marke, dann jede zweite, dann
alle. Container-Query, weil dieselbe Karte mal ein Drittel und mal
die volle Seitenbreite einnimmt — der Viewport weiß davon nichts. */}
{ticks.map((t, index) => {
const stufe =
t === min || t === max
? ""
: t === 100
? "hidden @sm:inline"
: index % 2 === 1
? "hidden @lg:inline"
: "hidden @md:inline";
return (
<span
key={t}
className={`absolute top-0 -translate-x-1/2 text-[11px] tabular-nums text-text-neutral-tertiary ${stufe}`}
style={{ left: `${pos(t)}%` }}
>
{t}
</span>
);
})}
</span>
<span className="text-right text-[11px] text-text-neutral-tertiary">%</span>
</div>
+11 -5
View File
@@ -13,7 +13,11 @@ import { DONUT, farbeFuer, gruppiereRest } from "../../utils/chartTheme";
* - maximal 5 Segmente, Rest als "Sonstige" (gruppiereRest)
* - größtes Segment startet oben, dann im Uhrzeigersinn absteigend
* - Gesamtwert steht in der Mitte, sonst muss man die Segmente addieren
* - Legende rechts mit Wert, einzeilig — nicht im Chart
* - Legende rechts mit Wert — Carbon: „positioned to the right of the chart
* when space is plentiful". Ist der Platz nicht reichlich, rutscht sie unter
* den Ring, statt Namen und Werte ineinanderlaufen zu lassen. Gesteuert über
* eine Container-Query, weil dieselbe Karte mal ein Drittel und mal die halbe
* Seitenbreite einnimmt — der Viewport weiß davon nichts.
* - Klick auf einen Legendeneintrag hebt das Segment hervor
*/
export function Donut({
@@ -45,8 +49,9 @@ export function Donut({
const gesamt = segmente.reduce((summe, s) => summe + s.value, 0);
return (
<div className="grid grid-cols-[168px_minmax(0,1fr)] items-center gap-5">
<div className="relative" style={{ height: hoehe }}>
<div className="@container">
<div className="flex flex-col items-center gap-4 @md:grid @md:grid-cols-[168px_minmax(0,1fr)] @md:items-center @md:gap-5">
<div className="relative w-full max-w-[220px] @md:max-w-none" style={{ height: hoehe }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@@ -84,7 +89,7 @@ export function Donut({
</div>
</div>
<ul className="flex flex-col gap-1.5">
<ul className="flex w-full flex-col gap-1.5">
{segmente.map((segment, index) => {
const anteil = gesamt > 0 ? (segment.value / gesamt) * 100 : 0;
return (
@@ -99,7 +104,7 @@ export function Donut({
className="mt-1 h-2.5 w-2.5 flex-none rounded-sm"
style={{ background: farbeFuer(segment.name, index) }}
/>
<span className="min-w-0 grow text-[12px] leading-snug text-text-neutral-secondary">
<span className="min-w-0 grow break-words text-[12px] leading-snug text-text-neutral-secondary">
{segment.name}
</span>
<span className="flex-none whitespace-nowrap text-[12px] tabular-nums text-text-neutral-primary">
@@ -113,6 +118,7 @@ export function Donut({
);
})}
</ul>
</div>
</div>
);
}
+3 -1
View File
@@ -8,7 +8,8 @@
"build": "next build",
"start": "next start -p 4300",
"lint": "eslint",
"verify:carbon": "node scripts/verify-carbon.mjs"
"verify:carbon": "node scripts/verify-carbon.mjs",
"verify:layout": "node scripts/verify-layout.mjs"
},
"dependencies": {
"@subframe/core": "^1.155.0",
@@ -25,6 +26,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"playwright-core": "^1.57.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+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.`);