/* ===================================================================== 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;