From 351a26efa41df28d8a4c2bdf00ffad6882470cb9 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 29 Aug 2026 19:32:55 +0200 Subject: [PATCH] =?UTF-8?q?fix(layout):=20kollidierenden=20und=20=C3=BCber?= =?UTF-8?q?laufenden=20Text=20beseitigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX --- README.md | 6 + docs/DESIGN-SYSTEM.md | 35 ++++- docs/PRUEFFRAGEN.md | 11 +- .../dashboard/components/cards/AlertList.tsx | 4 +- .../dashboard/components/cards/ChartCard.tsx | 4 +- .../dashboard/components/cards/StatZeile.tsx | 2 +- .../components/charts/AuslastungsBalken.tsx | 34 +++-- .../dashboard/components/charts/Donut.tsx | 16 +- package.json | 4 +- scripts/verify-layout.mjs | 139 ++++++++++++++++++ 10 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 scripts/verify-layout.mjs diff --git a/README.md b/README.md index a434662..425c292 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md index 08e4c0d..cdda7c5 100644 --- a/docs/DESIGN-SYSTEM.md +++ b/docs/DESIGN-SYSTEM.md @@ -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: diff --git a/docs/PRUEFFRAGEN.md b/docs/PRUEFFRAGEN.md index 2379c1f..b2613c0 100644 --- a/docs/PRUEFFRAGEN.md +++ b/docs/PRUEFFRAGEN.md @@ -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 diff --git a/features/dashboard/components/cards/AlertList.tsx b/features/dashboard/components/cards/AlertList.tsx index 32f5d5d..10a1643 100644 --- a/features/dashboard/components/cards/AlertList.tsx +++ b/features/dashboard/components/cards/AlertList.tsx @@ -76,10 +76,10 @@ export function AlertList({ - + {eintrag.text} - + {eintrag.detail} diff --git a/features/dashboard/components/cards/ChartCard.tsx b/features/dashboard/components/cards/ChartCard.tsx index bbfb536..e0875e6 100644 --- a/features/dashboard/components/cards/ChartCard.tsx +++ b/features/dashboard/components/cards/ChartCard.tsx @@ -74,7 +74,7 @@ export function ChartCard({
-

+

{titel}

{hinweis ? ( @@ -87,7 +87,7 @@ export function ChartCard({ ) : null}
{untertitel ? ( -

+

{untertitel}

) : null} diff --git a/features/dashboard/components/cards/StatZeile.tsx b/features/dashboard/components/cards/StatZeile.tsx index 6ea7c48..71527be 100644 --- a/features/dashboard/components/cards/StatZeile.tsx +++ b/features/dashboard/components/cards/StatZeile.tsx @@ -22,7 +22,7 @@ export function StatZeile({ > {eintraege.map((eintrag) => (
- + {eintrag.label} diff --git a/features/dashboard/components/charts/AuslastungsBalken.tsx b/features/dashboard/components/charts/AuslastungsBalken.tsx index 61dc975..305378c 100644 --- a/features/dashboard/components/charts/AuslastungsBalken.tsx +++ b/features/dashboard/components/charts/AuslastungsBalken.tsx @@ -43,7 +43,7 @@ export function AuslastungsBalken({ for (let t = min; t <= max; t += 10) ticks.push(t); return ( -
+
{sichtbar.map((zeile) => { const farbe = zeile.wert < SCHWELLEN.frei @@ -113,15 +113,29 @@ export function AuslastungsBalken({
- {ticks.map((t) => ( - - {t} - - ))} + {/* 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 ( + + {t} + + ); + })} %
diff --git a/features/dashboard/components/charts/Donut.tsx b/features/dashboard/components/charts/Donut.tsx index dfe7417..b782cdd 100644 --- a/features/dashboard/components/charts/Donut.tsx +++ b/features/dashboard/components/charts/Donut.tsx @@ -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 ( -
-
+
+
+
-
    +
      {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) }} /> - + {segment.name} @@ -113,6 +118,7 @@ export function Donut({ ); })}
    +
); } diff --git a/package.json b/package.json index 5ad853f..575c447 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/verify-layout.mjs b/scripts/verify-layout.mjs new file mode 100644 index 0000000..f79d604 --- /dev/null +++ b/scripts/verify-layout.mjs @@ -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.`);