Files
Dashboard/scripts/verify-layout.mjs
T
dejanandClaude Opus 5 7183fad27a feat(dashboard): Platzhalterseite statt Hinweis-Toast
Der Menüpunkt 'Dashboard' löste bisher nur einen Toast aus. Ein
Navigationseintrag, der nirgendwohin führt, lässt den Leser im Unklaren, ob
etwas fehlt oder etwas kaputt ist.

Jetzt führt er auf /dashboard: ein Leerzustand nach dem Hausmuster
(ui/components/EmptyState), der benennt, warum hier nichts steht, was später
hinkommt (drei Rollen-Layouts, Widgets teils aus Analytics, teils nicht — offene
HR-Anträge, Aufgaben, Termine) und wo der Zuschnitt dokumentiert ist. Mit einem
Weg zurück nach Analytics.

Dabei:
- EmptyState aus der Cloud-Fassung nachgezogen; unsere Kopie hatte den
  action-Slot noch nicht.
- PageHeader bekommt 'standardAktionen': Auf einer leeren Seite gibt es nichts
  zu aktualisieren oder zu exportieren, also stehen dort auch keine
  Schaltflächen dafür.
- Die Variante 'software' in NavZiel entfällt — jeder Eintrag führt jetzt auf
  eine echte Seite, der Toast-Zweig in der AppShell war damit toter Code.
- verify-layout deckt /dashboard mit ab (jetzt 7 Seiten x 7 Breiten).

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

152 lines
5.8 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 = [
"/dashboard",
"/analytics",
"/analytics/finanzen",
"/analytics/auftraege",
"/analytics/kunden",
"/analytics/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) {
const antwort = await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
if (!antwort || antwort.status() >= 400) {
console.error(`✗ ${pfad} antwortet mit ${antwort?.status() ?? "keiner Antwort"} — Route existiert nicht.`);
process.exit(1);
}
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.`);