#!/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", ]; // Von Handy bis großem Schirm. Die schmalen Breiten sind die interessanten: // Dort fährt die Seitenleiste über den Inhalt, das Raster wird einspaltig und // die Filterleiste bricht um. const BREITEN = [375, 414, 640, 768, 834, 1024, 1280, 1440, 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, }); } } } // Die Seite selbst darf nie seitlich scrollen, ein waagerechter // Scrollbalken auf Dokumentebene ist immer ein Fehler, kein Kompromiss. const doc = document.documentElement; const seitenUeberlauf = doc.scrollWidth > doc.clientWidth + 1 ? doc.scrollWidth - doc.clientWidth : 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, seitenUeberlauf }; }; 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, seitenUeberlauf } = await seite.evaluate(messen, MIN_ABSTAND); if (seitenUeberlauf > 0) { const key = `S|${pfad}|${breite}`; gesammelt.set(key, { art: "Seitenüberlauf", text: `${pfad} läuft um ${seitenUeberlauf}px seitlich über`, wo: "document", um: seitenUeberlauf, seiten: new Set([pfad]), breiten: new Set([breite]), }); } 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}"` : f.art === "Seitenüberlauf" ? `SEITE SCROLLT (+${f.um}px): ${f.text}` : `Ü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 Text, kein Überlauf: ${SEITEN.length} Seiten × ${BREITEN.length} Breiten (${BREITEN[0]}–${BREITEN.at(-1)} px).`);