Der Prototyp hatte eine eigene Seitenleiste mit den fünf Analyse-Seiten als Menüpunkten. Die Software hat eine andere Navigation, in der Analytics noch gar nicht vorkommt. Beides zusammen ergab kein Bild davon, wo die Analyse später sitzt. Jetzt: - Die Seitenleiste spiegelt die von HiveGrid Cloud — dieselbe Komponente (SidebarWithSections aus der Cloud-Fassung portiert, inkl. der dortigen lokalen Fixes: Collapse-Breite, verdrahteter Toggle, Logo-Slot, Hover-Sweep), dieselben Gruppen, dieselbe Reihenfolge, dasselbe Logo. - 'Analytics' ist ein einzelner Menüpunkt, gleichrangig neben 'Dashboard'. - Die fünf Analyse-Seiten sind darin über Reiter erreichbar, nicht als Unterpunkte in der Navigation. Zwei Navigationsebenen für dieselbe Sache würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus SettingsHubShell (Link um Tabs.Item), damit jeder Reiter eine echte Adresse hat und mittelklickbar bleibt. - Routen: /analytics, /analytics/finanzen, /auftraege, /kunden, /team. '/' leitet auf /analytics; das Dashboard liegt in der Software unter '/'. - Alle übrigen Einträge sind Kontext: Sie zeigen den Ort in der fertigen Anwendung und melden beim Klick, dass sie zur Software gehören. Kopfzeile und Filterleiste sind aus den fünf Seiten in die Analytics-Hülle gewandert — sie sind für alle gleich, und der Filterzustand gilt bereichsweit. Nebenbei: @radix-ui/react-slot war eine Phantom-Dependency (nur transitiv über @subframe/core vorhanden, vom portierten IconButton direkt importiert) und ist jetzt deklariert. verify-layout prüfte noch die alten Routen und lief damit auf 404-Seiten, die naturgemäß keine Kollisionen haben — Routen korrigiert und eine Absicherung ergänzt, die bei Statuscode >= 400 abbricht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
151 lines
5.8 KiB
JavaScript
151 lines
5.8 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 = [
|
||
"/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.`);
|