467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
174 lines
6.7 KiB
JavaScript
174 lines
6.7 KiB
JavaScript
#!/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).`);
|