Files
DogFatherGitandClaude Opus 5 10a075cc33 Gestaltung: Hoehensystem -- mehrschichtige Schatten und Lichtkante
Bis hierher trug jede Karte EINEN Schatten. Das ist solide und genau der
Grund, warum eine Oberflaeche "ordentlich" statt "teuer" wirkt.

Echtes Licht erzeugt zwei Dinge gleichzeitig: einen engen, dunklen
Kontaktschatten an der Kante -- er sagt dem Auge, dass etwas AUFLIEGT --
und einen weiten, weichen Umgebungsschatten, der sagt, wie HOCH es
darueber schwebt. Nur einen von beiden zu setzen sieht aus wie ein
Aufkleber; beide zusammen ergeben einen Gegenstand.

Dazu die LICHTKANTE: eine 1 Pixel hohe, sehr schwache weisse Linie an der
Oberkante. Sie tut so, als kaeme das Licht von oben und braeche sich an
der Kante. Das ist das meistuebersehene Detail in dunklen Oberflaechen
und dasjenige mit der groessten Wirkung -- ohne sie wirkt eine Karte wie
ein Loch im Hintergrund, mit ihr wie ein Stueck Material.

Drei Stufen, mehr braucht es nicht: ruhende Flaechen, hervorgehobene
Flaechen, Schwebendes. Dazu eine vierte, umgekehrte fuer Eingabefelder:
Die liegen nicht AUF der Flaeche, sie sind hineingeschnitten -- oben
dunkel, unten hell.

Weil es die gemeinsamen Bausteine sind, wirkt es sofort auf allen 13
Seiten: Hauptseite, Portal und Verwaltung.

Dazu Feinheiten, die einzeln niemand bemerkt und in Summe den
Unterschied machen: Uebergaenge auf 160-180 ms verkuerzt (alles darueber
wirkt beim Ueberfahren traege), eigene Rueckmeldung beim Druecken,
optische Laufweitenkorrektur fuer grosse Ueberschriften (-.028em bei h1;
das Auge sieht bei 48px mehr Weissraum zwischen Buchstaben als bei 16px),
text-wrap: balance gegen einzelne Woerter in der letzten Zeile.

EIN FEHLER BEIM EINBAU, GEMESSEN STATT UEBERSEHEN

Nach dem ersten Durchgang hatten die Karten ihre Lichtkante, der
Hauptknopf nicht. Grund: Die Grundregel lautet
".wd .wd-btn--haupt, .wd-btn--haupt" -- der erste Teil zaehlt ZWEI
Klassen. Mein Nachtrag zaehlte eine und verlor, obwohl er spaeter steht.
Dieselbe Falle wie am 22.08.2026 bei ".wd a" gegen ".wd-btn--haupt".
Aufgefallen nur, weil die Pruefung die Lichtkanten ZAEHLT.

PRUEFWERKZEUGE ANGEPASST

diagnose.html ist jetzt ausgenommen. Sie traegt bewusst alles fest in
sich -- eigene Farben, keine Uebersetzung -- weil sie funktionieren muss,
wenn genau das kaputt ist, was sie untersucht. Sie an den Regeln der
eigentlichen Seite zu messen erzeugte zwei Meldungen, die man auf Dauer
wegsieht. Und irgendwann sieht man dann auch eine echte weg.

ALLES GRUEN: Gestaltung 0 Befunde (12 Seiten x 5 Sprachen gegen
WCAG 2.2 AA), Designsystem 5, Verwaltung 69, Portal 32.

Versionsstempel und Cache auf v17.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 18:37:17 +02:00

471 lines
21 KiB
JavaScript

/* =====================================================================
pruef-design.mjs — Gestaltungsprüfung über ALLE Seiten und Sprachen
"Sieht gut aus" ist Geschmack und lässt sich nicht prüfen. Was sich
prüfen lässt, ist handwerkliche Qualität — und die ist bei einer
Webdesign-Seite zugleich das Verkaufsargument: Wer selbst schludert,
kann schlecht Sorgfalt verkaufen.
Gemessen wird gegen WCAG 2.2 AA, den Standard, auf den auch das
Barrierefreiheitsstärkungsgesetz verweist:
1.4.3 Text mindestens 4,5:1 Kontrast (grosser Text 3:1)
1.4.11 Bedienelemente und Umrandungen mindestens 3:1
2.4.7 Tastaturfokus muss SICHTBAR sein
2.5.8 Klickflächen mindestens 24x24 CSS-Pixel
1.3.1 Überschriftenebenen ohne Sprünge
3.1.1 lang-Attribut passt zur angezeigten Sprache
1.1.1 Bilder haben eine Textalternative
Alles davon rechnet der Browser selbst aus — es wird nichts geschätzt.
===================================================================== */
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join, extname } from "node:path";
const W = process.cwd();
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
const srv = createServer((q, r) => {
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
if (existsSync(p) && !p.endsWith("/")) {
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
return r.end(readFileSync(p));
}
r.writeHead(404).end("");
});
await new Promise((r) => srv.listen(4329, r));
/* diagnose.html ist bewusst ausgenommen.
Sie ist das Werkzeug fuer den Fall, dass die Seite NICHT laeuft, und
traegt deshalb alles fest in sich: eigene Farben, eigenes Layout,
keine externe CSS- oder JS-Datei, keine Uebersetzung. Genau das ist
ihr Zweck -- eine Diagnoseseite, die an derselben kaputten
Stilvorlage scheitert wie das Problem, das sie untersuchen soll,
waere wertlos.
Sie deshalb an den Regeln fuer die eigentliche Seite zu messen, wuerde
zwei Meldungen erzeugen, die man auf Dauer wegsieht -- und irgendwann
sieht man dann auch eine echte weg. */
const SEITEN = readdirSync(join(W, "webdesign"))
.filter((f) => f.endsWith(".html") && f !== "diagnose.html");
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
const funde = [];
function fund(seite, sprache, art, text) {
funde.push({ seite, sprache, art, text });
}
/* Die Messfunktion läuft IM Browser — nur dort kennt man die
tatsächlich berechneten Farben, Grössen und Schriftarten. */
const MESSEN = () => {
/* ---- Farbwerkzeuge: WCAG-Kontrast exakt nach Formel ---- */
function zuRgb(farbe) {
const m = farbe.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const t = m[1].split(",").map((x) => parseFloat(x.trim()));
return { r: t[0], g: t[1], b: t[2], a: t.length > 3 ? t[3] : 1 };
}
function ueberlagern(vorn, hinten) {
if (vorn.a >= 1) return vorn;
return {
r: vorn.r * vorn.a + hinten.r * (1 - vorn.a),
g: vorn.g * vorn.a + hinten.g * (1 - vorn.a),
b: vorn.b * vorn.a + hinten.b * (1 - vorn.a),
a: 1,
};
}
function leuchtdichte(c) {
const f = [c.r, c.g, c.b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
}
function kontrast(a, b) {
const l1 = leuchtdichte(a), l2 = leuchtdichte(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/* Der tatsächliche Hintergrund: nach oben durchsuchen, bis eine
deckende Fläche kommt, und halbdurchsichtige Schichten überlagern.
Nur die eigene Hintergrundfarbe zu nehmen wäre falsch — fast alle
Karten hier sind halbtransparent. */
/* Nur die OBERSTE Hintergrundebene zaehlt.
Mehrere Ebenen werden in CSS in der angegebenen Reihenfolge
gestapelt -- die ERSTE liegt oben. Die Karten hier nutzen genau das
fuer ihren Verlaufsrahmen:
background: linear-gradient(...Kartenflaeche...) padding-box,
linear-gradient(...heller Rahmen...) border-box;
Der helle Rahmenverlauf ist im Textbereich gar nicht sichtbar, er
scheint nur aussen am Rand durch. Wer alle Ebenen zusammenrechnet,
haelt den Text faelschlich fuer hellblau hinterlegt -- und genau das
hat 820 Fehlalarme erzeugt, mit Werten wie "3,45:1" fuer Text, der
in Wahrheit bei ueber 7:1 liegt.
Trennt an Kommas der obersten Ebene, ohne die Kommas INNERHALB der
Klammern eines Verlaufs zu zerschneiden. */
function ersteEbene(bild) {
let tiefe = 0;
for (let i = 0; i < bild.length; i++) {
const z = bild[i];
if (z === "(") tiefe++;
else if (z === ")") tiefe--;
else if (z === "," && tiefe === 0) return bild.slice(0, i);
}
return bild;
}
function echterHintergrund(el) {
/* Sammelt die Schichten von innen nach aussen. Eine Schicht ist
entweder eine Farbe oder ein Verlauf (dann als Liste seiner
Farbstopps).
Der erste Entwurf verwarf Verlaufsstopps mit wenig Deckkraft --
genau die, aus denen die Hinweiskaesten bestehen (9 % und 4 %
Gold). Uebrig blieb "nicht messbar" fuer 72 Textstellen. Richtig
ist, sie wie jede andere halbdurchsichtige Schicht auf den
Untergrund zu rechnen. */
const schichten = [];
let n = el;
let grund = { r: 5, g: 7, b: 11, a: 1 };
while (n && n !== document.documentElement) {
const st = getComputedStyle(n);
if (st.backgroundImage && st.backgroundImage !== "none") {
const oben = ersteEbene(st.backgroundImage);
const stopps = (oben.match(/rgba?\([^)]+\)/g) || []).map(zuRgb).filter(Boolean);
if (stopps.length) schichten.push({ verlauf: stopps });
}
const bg = zuRgb(st.backgroundColor);
if (bg && bg.a > 0) {
schichten.push({ farbe: bg });
if (bg.a >= 1) { grund = bg; schichten.pop(); break; }
}
n = n.parentElement;
}
/* Von aussen nach innen zusammenrechnen. Bei einem Verlauf werden
BEIDE Extremstopps weiterverfolgt -- am Ende zaehlt der
unguenstigste Fall. So bleibt die Aussage streng, ohne zu raten. */
let kandidaten = [grund];
for (let i = schichten.length - 1; i >= 0; i--) {
const sch = schichten[i];
if (sch.farbe) {
kandidaten = kandidaten.map((k) => ueberlagern(sch.farbe, k));
} else {
const naechste = [];
for (const k of kandidaten) {
for (const stopp of sch.verlauf) naechste.push(ueberlagern(stopp, k));
}
/* Nur hellsten und dunkelsten weiterfuehren, sonst waechst die
Zahl der Kombinationen mit jeder Schicht. */
naechste.sort((a, b) => leuchtdichte(a) - leuchtdichte(b));
kandidaten = naechste.length > 2 ? [naechste[0], naechste[naechste.length - 1]] : naechste;
}
}
return kandidaten.length === 1 ? kandidaten[0] : { verlauf: kandidaten };
}
/* Absichtlich vom Bildschirm geschobene Elemente (Sprunglink,
versteckte Auswahlfelder hinter Kacheln) sind KEINE Fehler. Sie
erscheinen nur im Zugriffsbaum bzw. beim Tabben und sind dann
sichtbar. */
function abgeschoben(el) {
const st = getComputedStyle(el);
if (parseFloat(st.opacity) === 0) return true;
if (st.clipPath && st.clipPath !== "none" && st.clipPath.indexOf("inset(50%") === 0) return true;
if (st.clip && st.clip !== "auto") return true;
const r = el.getBoundingClientRect();
if (r.bottom < 0 || r.right < 0) return true;
if (r.width <= 2 && r.height <= 2) return true;
return false;
}
const ergebnis = { kontrast: [], ziele: [], fokus: [], ueberschriften: [], bilder: [],
labels: [], lang: null, klein: [] };
/* ---- 1.4.3 Textkontrast ---- */
const textKnoten = [];
const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let k;
while ((k = lauf.nextNode())) {
const t = (k.nodeValue || "").trim();
if (t.length < 2) continue;
const el = k.parentElement;
if (!el || el.closest("script, style, noscript")) continue;
const st = getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || parseFloat(st.opacity) === 0) continue;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
if (!textKnoten.includes(el)) textKnoten.push(el);
}
ergebnis.verlauf = [];
for (const el of textKnoten) {
if (abgeschoben(el)) continue;
const st = getComputedStyle(el);
/* VERLAUFSSCHRIFT: Bei background-clip:text ist die Schriftfarbe
durchsichtig und der Verlauf IST der Text. Die Schriftfarbe mit
dem Hintergrund zu vergleichen ergibt zwangslaeufig 1:1 -- das
war der letzte verbliebene Fehlalarm. Stattdessen werden die
Farbstopps des Verlaufs gegen den Seitenhintergrund gerechnet. */
const verlaufsSchrift = (st.webkitBackgroundClip === "text" || st.backgroundClip === "text");
if (verlaufsSchrift) {
const stopps = (ersteEbene(st.backgroundImage).match(/rgba?\([^)]+\)/g) || []).map(zuRgb).filter(Boolean);
const grund = { r: 5, g: 7, b: 11, a: 1 };
const werte = stopps.map((c) => kontrast(ueberlagern(c, grund), grund));
const schlechtester = werte.length ? Math.min.apply(null, werte) : null;
const gr = parseFloat(st.fontSize);
const noetigV = (gr >= 24 || (parseInt(st.fontWeight,10) >= 700 && gr >= 18.66)) ? 3 : 4.5;
if (schlechtester !== null && schlechtester < noetigV) {
ergebnis.kontrast.push({ text: (el.textContent||"").trim().slice(0,45),
wert: Math.round(schlechtester*100)/100, noetig: noetigV,
groesse: Math.round(gr*10)/10, grund: "Verlaufsschrift",
klasse: (el.className||"").toString().slice(0,40) });
}
continue;
}
const vg = zuRgb(st.color);
if (!vg) continue;
const hg = echterHintergrund(el);
if (!hg) {
/* Verlauf ohne lesbare Farbstopps -- selten, dann von Hand. */
ergebnis.verlauf.push({ text: (el.textContent || "").trim().slice(0, 40),
klasse: (el.className || "").toString().slice(0, 40) });
continue;
}
let kw, hgBeschreibung;
if (hg.verlauf) {
/* Ungünstigster Stopp des Verlaufs. */
kw = Math.min.apply(null, hg.verlauf.map((c) => kontrast(ueberlagern(vg, c), c)));
hgBeschreibung = "Verlauf";
} else {
kw = kontrast(ueberlagern(vg, hg), hg);
hgBeschreibung = "Flaeche";
}
const groesse = parseFloat(st.fontSize);
const fett = parseInt(st.fontWeight, 10) >= 700;
/* "Grosser Text" nach WCAG: ab 18,66px fett oder ab 24px normal. */
const gross = groesse >= 24 || (fett && groesse >= 18.66);
const noetig = gross ? 3 : 4.5;
if (kw < noetig) {
ergebnis.kontrast.push({
text: (el.textContent || "").trim().slice(0, 45),
wert: Math.round(kw * 100) / 100, noetig, groesse: Math.round(groesse * 10) / 10,
grund: hgBeschreibung,
klasse: (el.className || "").toString().slice(0, 40),
});
}
/* Schriftgrössen unter 12px sind auf dem Handy kaum lesbar. */
if (groesse < 12) {
ergebnis.klein.push({ groesse: Math.round(groesse * 10) / 10,
klasse: (el.className || "").toString().slice(0,40) || el.tagName.toLowerCase(),
text: (el.textContent || "").trim().slice(0, 25) });
}
}
/* ---- 2.5.8 Klickflächen ---- */
document.querySelectorAll("a, button, input, select, textarea, [role=button]").forEach((el) => {
const st = getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden") return;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return;
/* Verlinkte Wörter mitten im Fliesstext sind ausgenommen — die Regel
zielt auf eigenständige Bedienelemente. */
if (el.tagName === "A" && st.display === "inline") return;
/* Auswahlfelder, die absichtlich hinter einer Kachel liegen: Die
Kachel IST die Klickflaeche, das Feld selbst soll winzig sein.
Beim ersten Lauf wurden dadurch sechs einwandfreie Kacheln
gemeldet. */
if (abgeschoben(el)) return;
if (r.width < 24 || r.height < 24) {
ergebnis.ziele.push({ tag: el.tagName.toLowerCase(),
text: (el.textContent || el.value || "").trim().slice(0, 28),
b: Math.round(r.width), h: Math.round(r.height),
klasse: (el.className || "").toString().slice(0, 40) });
}
});
/* ---- 1.3.1 Überschriftenebenen ---- */
/* getClientRects() statt display: Eine Ueberschrift IN einem
ausgeblendeten Abschnitt hat selbst weiterhin display:block --
dadurch wurden im Portal drei h1 gezaehlt, obwohl immer nur eine
sichtbar ist (Anmeldung / Uebersicht / Projektansicht). */
const hs = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
.filter((h) => h.getClientRects().length > 0)
.map((h) => ({ n: parseInt(h.tagName[1], 10), t: (h.textContent || "").trim().slice(0, 35) }));
ergebnis.ueberschriften = { anzahlH1: hs.filter((h) => h.n === 1).length, spruenge: [] };
for (let i = 1; i < hs.length; i++) {
if (hs[i].n - hs[i - 1].n > 1) {
ergebnis.ueberschriften.spruenge.push(`h${hs[i - 1].n} -> h${hs[i].n} bei "${hs[i].t}"`);
}
}
/* ---- 1.1.1 Bilder ---- */
document.querySelectorAll("img").forEach((img) => {
if (!img.hasAttribute("alt")) {
ergebnis.bilder.push((img.getAttribute("src") || "").split("/").pop());
}
});
/* ---- Formularfelder brauchen eine Beschriftung ---- */
document.querySelectorAll("input, select, textarea").forEach((el) => {
if (el.type === "hidden") return;
const hat = (el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`))
|| el.closest("label")
|| el.getAttribute("aria-label")
|| el.getAttribute("aria-labelledby");
if (!hat) ergebnis.labels.push(el.id || el.name || el.type);
});
/* ---- 3.1.1 lang-Attribut ---- */
ergebnis.lang = document.documentElement.getAttribute("lang");
return ergebnis;
};
const browser = await chromium.launch();
const seite = await browser.newPage({ viewport: { width: 390, height: 844 } });
await seite.route("**/webdesign/admin/**", (r) => r.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], verlaeufe: [], widerrufe: [] } }));
await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } }));
await seite.route("**/webdesign/api-ausweis", (r) => r.fulfill({ json: {} }));
await seite.addInitScript(() => { sessionStorage.setItem("wd-sitzung-aktiv", "1"); });
console.log("Messung läuft — " + SEITEN.length + " Seiten x " + SPRACHEN.length + " Sprachen, Handy-Breite 390px\n");
let fokusGeprueft = false;
for (const datei of SEITEN) {
for (const sprache of SPRACHEN) {
await seite.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sprache)})`);
await seite.goto("http://127.0.0.1:4329/webdesign/" + datei);
await seite.waitForTimeout(320);
const m = await seite.evaluate(MESSEN);
for (const k of m.kontrast) {
fund(datei, sprache, "kontrast",
`${k.wert}:1 statt ${k.noetig}:1 — ${k.groesse}px auf ${k.grund} "${k.text}" [${k.klasse}]`);
}
for (const z of m.ziele) {
fund(datei, sprache, "klickflaeche",
`${z.tag} ${z.b}x${z.h}px "${z.text}" [${z.klasse}]`);
}
for (const s of m.klein) fund(datei, sprache, "schriftgroesse", `${s.groesse}px [${s.klasse}]`);
for (const s of m.ueberschriften.spruenge) fund(datei, sprache, "ueberschrift", s);
if (m.ueberschriften.anzahlH1 !== 1) {
fund(datei, sprache, "ueberschrift", `${m.ueberschriften.anzahlH1} h1 statt genau einer`);
}
for (const v of m.verlauf) fund(datei, sprache, "verlauf-manuell", `"${v.text}" [${v.klasse}]`);
for (const b of m.bilder) fund(datei, sprache, "bild-alt", b);
for (const l of m.labels) fund(datei, sprache, "feld-ohne-label", l);
/* lang muss der angezeigten Sprache folgen — sonst liest der
Screenreader französischen Text mit deutscher Aussprache vor. */
const erwartet = sprache === "de-CH" ? ["de-CH", "de"] : [sprache];
if (!erwartet.includes(m.lang)) {
fund(datei, sprache, "lang-attribut", `lang="${m.lang}" bei Sprache ${sprache}`);
}
}
/* ---- 2.4.7 Fokus: einmal je Seite, auf Deutsch ---- */
await seite.addInitScript(`localStorage.setItem("dogi-sprache", "de")`);
await seite.goto("http://127.0.0.1:4329/webdesign/" + datei);
await seite.waitForTimeout(300);
const ohneFokus = await seite.evaluate(() => {
const raus = [];
/* ÜBERBLENDUNGEN ABSCHALTEN, bevor gemessen wird.
Ohne das entsteht ein Fehlalarm, den man nicht erraet:
getComputedStyle liefert waehrend einer laufenden Ueberblendung
den ZWISCHENWERT. Nach blur() verblasst der Fokusring ueber
0,16 s -- misst man sofort, sieht man ihn noch, und "vorher" ist
identisch mit "nachher". Das Codefeld der Verwaltung wurde
dadurch als "ohne sichtbaren Fokus" gemeldet, obwohl sein Ring
einwandfrei ist. Geprueft mit el.matches(":focus") === false bei
gleichzeitig noch sichtbarem Schatten.
Die Regel wird nur fuer die Messung gesetzt und beeinflusst die
Seite selbst nicht. */
const aus = document.createElement("style");
aus.textContent = "*,*::before,*::after{transition:none !important;animation:none !important}";
document.head.appendChild(aus);
const bedien = Array.from(document.querySelectorAll("a[href], button, input, select, textarea"))
.filter((el) => {
const st = getComputedStyle(el);
const r = el.getBoundingClientRect();
if (parseFloat(st.opacity) === 0) return false;
if (r.width <= 2 && r.height <= 2) return false;
return st.display !== "none" && st.visibility !== "hidden" && r.width > 0 && r.height > 0;
}).slice(0, 25);
for (const el of bedien) {
/* ERST loslassen, dann messen. Ohne das entsteht ein Fehlalarm:
Die Anmeldemaske der Verwaltung setzt den Fokus beim Laden
selbst ins Codefeld. Der "Vorher"-Zustand war damit bereits der
fokussierte -- es aenderte sich nichts, und der Test meldete
einen fehlenden Fokusrahmen, den es nie gab. */
el.blur();
const vorher = getComputedStyle(el).outlineStyle + "|" + getComputedStyle(el).boxShadow;
el.focus();
const st = getComputedStyle(el);
const nachher = st.outlineStyle + "|" + st.boxShadow;
const sichtbar = (st.outlineStyle !== "none" && parseFloat(st.outlineWidth) > 0)
|| nachher !== vorher;
if (!sichtbar) {
raus.push(el.tagName.toLowerCase() + '.' + (el.className||'').toString().slice(0,35) + ' id=' + (el.id||'-') + ' typ=' + (el.type||'-'));
}
el.blur();
}
aus.remove();
return raus;
});
for (const f of ohneFokus) fund(datei, "de", "fokus-unsichtbar", f);
fokusGeprueft = true;
}
await browser.close();
srv.close();
/* ---- Bericht ---- */
const nachArt = {};
for (const f of funde) (nachArt[f.art] = nachArt[f.art] || []).push(f);
const ARTEN = {
kontrast: "WCAG 1.4.3 — Textkontrast zu niedrig",
klickflaeche: "WCAG 2.5.8 — Klickfläche unter 24x24px",
"fokus-unsichtbar": "WCAG 2.4.7 — Tastaturfokus nicht sichtbar",
ueberschrift: "WCAG 1.3.1 — Überschriftenstruktur",
"bild-alt": "WCAG 1.1.1 — Bild ohne Textalternative",
"feld-ohne-label": "WCAG 3.3.2 — Formularfeld ohne Beschriftung",
"lang-attribut": "WCAG 3.1.1 — lang passt nicht zur Sprache",
schriftgroesse: "Schrift unter 12px",
"verlauf-manuell": "Text auf Farbverlauf — von Hand zu pruefen (kein Fehler)",
};
for (const [art, titel] of Object.entries(ARTEN)) {
const liste = nachArt[art] || [];
if (!liste.length) { console.log("ok " + titel + " — nichts gefunden"); continue; }
/* Gleiche Fundstelle auf mehreren Sprachen nur EINMAL zeigen, sonst
erschlägt einen die Liste und man übersieht das Wesentliche. */
const einmalig = new Map();
for (const f of liste) {
const s = f.seite + " :: " + f.text;
if (!einmalig.has(s)) einmalig.set(s, []);
einmalig.get(s).push(f.sprache);
}
console.log(`\nFEHL ${titel} — ${einmalig.size} Stellen`);
let n = 0;
for (const [s, sprachen] of einmalig) {
if (n++ >= 12) { console.log(` … und ${einmalig.size - 12} weitere`); break; }
const [seite, ...rest] = s.split(" :: ");
console.log(` ${seite} ${rest.join(" :: ")}${sprachen.length < 5 ? " [" + sprachen.join(",") + "]" : ""}`);
}
}
const gesamt = new Set(funde.map((f) => f.seite + f.art + f.text)).size;
console.log(`\n===== ${gesamt} eindeutige Fundstellen =====`);
process.exitCode = gesamt ? 1 : 0;