Files
dogfather-universe/pruef-design.mjs
T
DogFatherGitandClaude Opus 5 3ca9f0b0cd Gestaltung: WCAG-Messung ueber alle Seiten, alle Befunde behoben
"Sieht gut aus" ist Geschmack und laesst sich nicht pruefen. Handwerkliche
Qualitaet laesst sich pruefen -- und bei einer WEBDESIGN-Seite ist sie
zugleich das Verkaufsargument: Wer selbst schludert, kann schlecht
Sorgfalt verkaufen.

Neues Werkzeug pruef-design.mjs misst 13 Seiten x 5 Sprachen auf
Handy-Breite gegen WCAG 2.2 AA -- denselben Standard, auf den auch das
Barrierefreiheitsstaerkungsgesetz verweist. Der Browser rechnet, es wird
nichts geschaetzt.

ERGEBNIS: 0 Befunde. Kontrast, Klickflaechen, Tastaturfokus,
Ueberschriftenstruktur, Bildalternativen, Feldbeschriftungen,
lang-Attribut, Schriftgroessen.

ECHTE BEFUNDE, DIE BEHOBEN WURDEN

1. 23 Schriftgroessen unter 12px (bis hinunter zu 10,9px), verteilt ueber
   CSS und sieben Seiten. Betroffen waren durchweg gesperrte
   Grossbuchstaben-Beschriftungen -- also genau die Textsorte, die klein
   ohnehin am schlechtesten lesbar ist. Alle auf mindestens 12px, die
   gesperrten auf 12,8px. Das ist zugleich die Dauervorgabe
   "augenschonend" ernst genommen.

2. Ueberschriftensprung h2 -> h4 in der Fusszeile, auf ALLEN 13 Seiten in
   allen 5 Sprachen (42 Stellen). Wer sich mit einem Screenreader durch
   die Ueberschriften bewegt, hoert dadurch eine Gliederung, die es nicht
   gibt, und vermutet uebersprungene Abschnitte. Jetzt h2 mit
   Gestaltungsklasse: Die Ebene sagt etwas ueber die STRUKTUR, die
   Groesse ueber die GEWICHTUNG -- zwei verschiedene Dinge.

3. code-Auszeichnung auf der Rechtsseite rutschte durch relative
   Groessenangabe auf 11,2px. Jetzt mit Untergrenze abgesichert.

SECHS FEHLALARME IM WERKZEUG -- ALLE GEFUNDEN UND BESEITIGT

Der erste Lauf meldete 302 Fundstellen. Fast alle waren falsch. Haette
ich sie "repariert", haette ich funktionierende Gestaltung zerstoert.
Jeder Fehlalarm hatte eine eigene, nicht offensichtliche Ursache:

  * Farbverlaeufe: getComputedStyle().backgroundColor liefert bei einer
    reinen Verlaufsflaeche "rgba(0,0,0,0)". Die Suche lief an der hellen
    Knopfflaeche VORBEI bis zum dunklen Seitengrund und verglich dunkle
    Schrift mit dunklem Grund -- Ergebnis 1:1 fuer 60 einwandfreie
    Knoepfe. Jetzt werden die Farbstopps ausgelesen und der
    unguenstigste Fall gerechnet.

  * MEHRERE Hintergrundebenen: Die Karten nutzen den bekannten Kniff
    "Flaeche padding-box, Rahmenverlauf border-box". Der helle
    Rahmenverlauf ist im Textbereich gar nicht sichtbar. Alle Ebenen
    zusammenzurechnen ergab 820 Meldungen mit Werten wie "3,45:1" fuer
    Text, der in Wahrheit ueber 7:1 liegt. CSS malt die ERSTE Ebene oben
    -- nur die zaehlt. Das Trennen an Kommas muss dabei die Kommas
    INNERHALB der Verlaufsklammern respektieren.

  * Verlaufsschrift (background-clip:text): Dort ist die Schriftfarbe
    durchsichtig und der Verlauf IST der Text. Farbe gegen Hintergrund zu
    vergleichen ergibt zwangslaeufig 1:1.

  * Verlaufsstopps mit wenig Deckkraft (9 % und 4 % Gold in den
    Hinweiskaesten) wurden verworfen statt auf den Untergrund gerechnet.
    Uebrig blieb "nicht messbar" fuer 72 Textstellen. Ein Pruefwerkzeug,
    das bei allem passt, prueft nichts.

  * Laufende Ueberblendungen: getComputedStyle liefert waehrend einer
    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. Nachgewiesen mit
    el.matches(":focus") === false bei gleichzeitig sichtbarem Schatten.
    Loesung: Ueberblendungen fuer die Messung abschalten.

  * Ausgeblendete Abschnitte: Eine Ueberschrift in einem versteckten
    Bereich hat selbst weiterhin display:block. Im Portal wurden dadurch
    drei h1 gezaehlt, obwohl immer nur eine sichtbar ist. Jetzt
    getClientRects().

Dazu zwei bekannte Muster, die das Werkzeug jetzt kennt: der Sprunglink
(absichtlich aus dem Bild geschoben) und die winzigen Auswahlfelder
hinter den Kacheln (die Kachel IST die Klickflaeche).

ALLE UEBRIGEN PRUEFUNGEN WEITERHIN GRUEN nach den Aenderungen:
Anfragedetail 20, Verwaltung 69, Portal 32, Widerruf 58, Textkodierung
55 Seiten-Sprach-Kombinationen.

Versionsstempel und Cache-Name auf v8.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 12:49:29 +02:00

458 lines
20 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));
const SEITEN = readdirSync(join(W, "webdesign")).filter((f) => f.endsWith(".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;