diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css
index c72f5c21..97c5792f 100644
--- a/assets/css/webdesign.css
+++ b/assets/css/webdesign.css
@@ -263,7 +263,7 @@ body.wd::after {
.wd-eyebrow {
display: inline-block;
- font-size: .74rem;
+ font-size: 0.8rem;
letter-spacing: .18em;
text-transform: uppercase;
font-weight: 800;
@@ -799,7 +799,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.wd-marke img { width: 42px; height: 42px; object-fit: contain; }
.wd-marke-text { display: flex; flex-direction: column; line-height: 1.1; }
.wd-marke-oben { font-family: var(--font-head); font-weight: 800; font-size: 1rem; color: var(--wd-text); letter-spacing: .02em; }
-.wd-marke-unten { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--wd-blau); font-weight: 700; }
+.wd-marke-unten { font-size: 0.75rem; letter-spacing: .18em; text-transform: uppercase; color: var(--wd-blau); font-weight: 700; }
.wd-nav { display: flex; align-items: center; gap: .15rem; margin-left: auto; }
.wd-nav a {
@@ -897,7 +897,23 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.wd-fuss { border-top: 1px solid var(--wd-rand); background: rgba(5, 7, 11, .8); padding: 2.6rem 0 2rem; margin-top: 3rem; }
.wd-fuss-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 2rem; }
@media (max-width: 780px) { .wd-fuss-grid { grid-template-columns: 1fr; gap: 1.5rem; } }
-.wd-fuss h4 { font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; color: var(--wd-blau); margin-bottom: .8rem; }
+/* Die Spaltenueberschriften der Fusszeile sind jetzt h2 statt h4.
+
+ Grund: Sie folgten im Dokument auf die h2 der Seite -- ein Sprung von
+ h2 direkt auf h4. Wer sich mit einem Screenreader durch die
+ Ueberschriften bewegt, hoert dabei eine Gliederung, die es nicht gibt,
+ und vermutet uebersprungene Abschnitte (WCAG 1.3.1). Auf allen 13
+ Seiten in allen 5 Sprachen -- also 42 Stellen.
+
+ Die Klasse haelt die Optik unveraendert: Die Ebene sagt etwas ueber
+ die STRUKTUR, die Groesse ueber die GEWICHTUNG. Das sind zwei
+ verschiedene Dinge, und man soll das eine nicht ueber das andere
+ erzwingen muessen. */
+.wd-fuss .wd-fuss-h, .wd-fuss h4 {
+ font-size: .78rem; letter-spacing: .14em; text-transform: uppercase;
+ color: var(--wd-blau); margin: 0 0 .8rem; font-weight: 700;
+ font-family: var(--font-head);
+}
.wd-fuss ul li { margin-bottom: .1rem; }
/* Fußzeilenlinks waren nur so hoch wie ihre Zeile (20px) und damit auf dem
Handy praktisch nicht sicher zu treffen — sie liegen zudem dicht
@@ -1092,7 +1108,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.po-vorschau-etikett {
position: absolute; top: .7rem; right: .8rem;
- font-size: .66rem; letter-spacing: .14em; text-transform: uppercase;
+ font-size: 0.75rem; letter-spacing: .14em; text-transform: uppercase;
font-weight: 800; color: var(--wd-text-soft);
padding: .2rem .55rem; border-radius: 999px;
background: rgba(5,7,11,.7); border: 1px solid var(--wd-rand);
@@ -1123,7 +1139,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.po-vorschau-zeile { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; font-size: .82rem; color: var(--wd-text-soft); }
.po-vorschau-marke {
display: inline-flex; align-items: center; gap: .3rem;
- padding: .18rem .55rem; border-radius: 999px; font-size: .74rem; font-weight: 700;
+ padding: .18rem .55rem; border-radius: 999px; font-size: 0.8rem; font-weight: 700;
background: rgba(127,208,232,.12); color: var(--wd-blau);
}
.po-vorschau-schritt { margin: .7rem 0 0; font-size: .88rem; color: var(--wd-text-mid); opacity: .8; }
@@ -1453,7 +1469,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.po-aufg-gruppe { margin-bottom: 1.1rem; }
.po-aufg-kopf {
display: flex; align-items: baseline; gap: .5rem;
- font-size: .74rem; letter-spacing: .13em; text-transform: uppercase;
+ font-size: 0.8rem; letter-spacing: .13em; text-transform: uppercase;
margin-bottom: .45rem; padding-bottom: .35rem;
border-bottom: 1px solid var(--wd-rand);
}
@@ -1515,7 +1531,7 @@ button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgba(232,
.po-aufg-marke {
display: inline-block; white-space: nowrap;
- font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
+ font-size: 0.75rem; letter-spacing: .05em; text-transform: uppercase;
padding: .14rem .45rem; border-radius: 999px;
}
.po-aufg-marke--ok { background: rgba(111,214,163,.13); color: var(--wd-ok); }
@@ -1523,7 +1539,7 @@ button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgba(232,
.po-aufg-marke--du { background: rgba(232,200,119,.16); color: var(--wd-gold); }
.po-aufg-nicht {
display: inline-block; margin-left: .4rem;
- font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
+ font-size: 0.75rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px;
background: rgba(255,157,157,.12); color: var(--wd-fehler);
}
@@ -1677,7 +1693,9 @@ button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgba(232,
.rl-text ul { margin: 0 0 .9rem; padding-left: 1.2rem; }
.rl-text li { margin-bottom: .35rem; }
.rl-text code {
- font-size: .86em; padding: .1rem .35rem; border-radius: 5px;
+ /* min() statt eines reinen em-Werts: In einem ohnehin kleineren Absatz
+ rutschte der Code sonst auf 11,2px -- unter die Lesbarkeitsgrenze. */
+ font-size: max(.78rem, .86em); padding: .1rem .35rem; border-radius: 5px;
background: rgba(127,208,232,.09); color: var(--wd-blau);
}
diff --git a/assets/js/wd-core.js b/assets/js/wd-core.js
index 9472c075..2c977f8d 100644
--- a/assets/js/wd-core.js
+++ b/assets/js/wd-core.js
@@ -488,11 +488,11 @@
'
' + t("fuss_claim") + "
" +
"" +
"" +
- '
' + t("fuss_bereiche") + "
" +
+ '
' + t("fuss_bereiche") + "
" +
"
" +
"
" +
"" +
- '
' + t("fuss_zugang") + "
" +
+ '
' + t("fuss_zugang") + "
" +
"
" +
'- ' + t("fuss_portal") + "
" +
'- ' + t("fuss_verwaltung") + "
" +
diff --git a/pruef-aufgaben-computer.png b/pruef-aufgaben-computer.png
new file mode 100644
index 00000000..a2b58cc1
Binary files /dev/null and b/pruef-aufgaben-computer.png differ
diff --git a/pruef-aufgaben-handy.png b/pruef-aufgaben-handy.png
new file mode 100644
index 00000000..c4a7f6b5
Binary files /dev/null and b/pruef-aufgaben-handy.png differ
diff --git a/pruef-design.mjs b/pruef-design.mjs
new file mode 100644
index 00000000..22a3f20a
--- /dev/null
+++ b/pruef-design.mjs
@@ -0,0 +1,457 @@
+/* =====================================================================
+ 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;
diff --git a/pruef-detail-computer.png b/pruef-detail-computer.png
new file mode 100644
index 00000000..f50d112d
Binary files /dev/null and b/pruef-detail-computer.png differ
diff --git a/pruef-detail-handy.png b/pruef-detail-handy.png
new file mode 100644
index 00000000..87e6b34a
Binary files /dev/null and b/pruef-detail-handy.png differ
diff --git a/pruef-portal-aufgaben-computer.png b/pruef-portal-aufgaben-computer.png
new file mode 100644
index 00000000..42f9e199
Binary files /dev/null and b/pruef-portal-aufgaben-computer.png differ
diff --git a/pruef-portal-aufgaben-handy.png b/pruef-portal-aufgaben-handy.png
new file mode 100644
index 00000000..3f5e9a15
Binary files /dev/null and b/pruef-portal-aufgaben-handy.png differ
diff --git a/pruef-portal-postfach-computer.png b/pruef-portal-postfach-computer.png
new file mode 100644
index 00000000..ca23ae39
Binary files /dev/null and b/pruef-portal-postfach-computer.png differ
diff --git a/pruef-portal-postfach-handy.png b/pruef-portal-postfach-handy.png
new file mode 100644
index 00000000..a4706738
Binary files /dev/null and b/pruef-portal-postfach-handy.png differ
diff --git a/pruef-postfach-computer.png b/pruef-postfach-computer.png
new file mode 100644
index 00000000..9105e209
Binary files /dev/null and b/pruef-postfach-computer.png differ
diff --git a/pruef-postfach-handy.png b/pruef-postfach-handy.png
new file mode 100644
index 00000000..a3fe6e23
Binary files /dev/null and b/pruef-postfach-handy.png differ
diff --git a/pruef-rechtliches.png b/pruef-rechtliches.png
new file mode 100644
index 00000000..d9c23ed9
Binary files /dev/null and b/pruef-rechtliches.png differ
diff --git a/pruef-widerruf-computer.png b/pruef-widerruf-computer.png
new file mode 100644
index 00000000..d5a44e51
Binary files /dev/null and b/pruef-widerruf-computer.png differ
diff --git a/pruef-widerruf-handy.png b/pruef-widerruf-handy.png
new file mode 100644
index 00000000..be574d54
Binary files /dev/null and b/pruef-widerruf-handy.png differ
diff --git a/webdesign/ablauf.html b/webdesign/ablauf.html
index 906f7cba..33090fa9 100644
--- a/webdesign/ablauf.html
+++ b/webdesign/ablauf.html
@@ -13,8 +13,8 @@
-
-
+
+