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:
1 parent
0875ae2688
commit
351a26efa4
10 files changed
+232
-23
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
Reference in new issue
Block a user