/* ===================================================================== Prueft den Bildschmuck im Verwaltungsbereich. Die eigentliche Frage ist nicht "sieht man das Bild?", sondern: Kostet es Lesbarkeit? Die Verwaltung ist ein Arbeitsplatz -- ein Motiv, das hinter einer Zahlenspalte liegt, ist genau die Art von Schoenheit, die ein Werkzeug unbrauchbar macht. Seit der Bereichs-Signatur kommt eine zweite Frage dazu: Sind die sechs Bereiche wirklich unterscheidbar, oder sieht nur der Code so aus? Deshalb wird jeder Reiter einzeln angeklickt und gemessen. ===================================================================== */ import { chromium } from "playwright"; import http from "http"; import fs from "fs"; import path from "path"; const TYP = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg", }; const fehlt = []; const srv = http.createServer((q, r) => { const u = decodeURIComponent(q.url.split("?")[0]); const p = path.join(process.cwd(), u); if (fs.existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" }); return r.end(fs.readFileSync(p)); } fehlt.push(u); r.writeHead(404); r.end(""); }); let ok = 0, fehl = 0; const pruefe = (name, gut, info) => { if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } }; function leuchtkraft([r, g, b]) { const f = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } function verhaeltnis(a, b) { const l1 = leuchtkraft(a), l2 = leuchtkraft(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } const BEREICHE = ["uebersicht", "anfragen", "projekte", "kunden", "zahlungen", "postfach"]; /* Misst, wie stark der dunkle Grund in einem Bereich schwankt. Hohe Schwankung heisst: Da liegt ein Bild unter der Schrift. */ async function unruheAn(seite, wahl) { const bild = await seite.locator(wahl).first().screenshot(); return seite.evaluate(async (daten) => { const bl = await fetch("data:image/png;base64," + daten).then((r) => r.blob()); const bm = await createImageBitmap(bl); const c = new OffscreenCanvas(bm.width, bm.height); const x = c.getContext("2d"); x.drawImage(bm, 0, 0); const d = x.getImageData(0, 0, bm.width, bm.height).data; const grund = []; for (let i = 0; i < d.length; i += 4) { const h = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; if (h < 110) grund.push(h); } if (grund.length < 50) return null; const m = grund.reduce((a, b) => a + b, 0) / grund.length; return Math.round(Math.sqrt(grund.reduce((a, b) => a + (b - m) ** 2, 0) / grund.length)); }, bild.toString("base64")); } srv.listen(4404, async () => { const browser = await chromium.launch(); /* ================================================================= TEIL 1 — die Anmeldemaske ================================================================= */ for (const [gr, vp] of [["Computer", { width: 1440, height: 950 }], ["Handy", { width: 390, height: 844 }]]) { console.log(`\n===== ANMELDUNG (${gr}) =====`); const seite = await browser.newPage({ viewport: vp }); await seite.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await seite.waitForTimeout(1400); const login = await seite.evaluate(() => { const el = document.querySelector("#vw-anmeldung"); if (!el) return null; el.removeAttribute("hidden"); const vor = getComputedStyle(el, "::before"); const treffer = vor.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); return { datei: treffer ? treffer[1] : "(keins)", deckkraft: parseFloat(vor.opacity) }; }); pruefe(`${gr}: die Anmeldekarte hat ein Motiv`, login && login.datei.includes("wappen"), login && login.datei); await seite.waitForTimeout(500); const messen = async () => { const bild = await seite.locator("#vw-anmeldung p.wd-klein").first().screenshot(); return seite.evaluate(async (daten) => { const bl = await fetch("data:image/png;base64," + daten).then((r) => r.blob()); const bm = await createImageBitmap(bl); const c = new OffscreenCanvas(bm.width, bm.height); const x = c.getContext("2d"); x.drawImage(bm, 0, 0); const d = x.getImageData(0, 0, bm.width, bm.height).data; const grund = []; for (let i = 0; i < d.length; i += 4) { const h = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; if (h < 110) grund.push(h); } if (grund.length < 50) return null; const m = grund.reduce((a, b) => a + b, 0) / grund.length; return Math.round(Math.sqrt(grund.reduce((a, b) => a + (b - m) ** 2, 0) / grund.length)); }, bild.toString("base64")); }; const mitBild = await messen(); /* Die Vergleichsmessung ist der Kern. Ein fester Grenzwert waere geraten: Schon die weichgezeichneten Buchstabenkanten allein erzeugen eine Schwankung um 12. Gefragt ist, wie viel Unruhe DAS MOTIV hinzufuegt. */ await seite.evaluate(() => document.querySelector("#vw-anmeldung").classList.remove("wd-motiv", "wd-motiv--wappen")); await seite.waitForTimeout(400); const ohneBild = await messen(); await seite.waitForTimeout(200); const zusatz = (mitBild ?? 99) - (ohneBild ?? 0); pruefe(`${gr}: das Motiv macht den Textgrund kaum unruhiger`, mitBild !== null && ohneBild !== null && zusatz <= 5, `mit ${mitBild}, ohne ${ohneBild} → plus ${zusatz}`); await seite.close(); } /* ================================================================= TEIL 2 — die sechs Bereichs-Signaturen ================================================================= */ const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } }); const skriptfehler = []; seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); await seite.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await seite.waitForTimeout(1400); await seite.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await seite.waitForTimeout(500); /* Erst EINMAL echt klicken, um zu beweisen, dass der Reiter die Signatur ueberhaupt umschaltet. Danach geht das nicht mehr: Beim Klick laedt die Seite Daten, merkt dass niemand angemeldet ist, und klappt den Arbeitsbereich korrekterweise wieder zu. Das ist die Zugangspruefung, kein Fehler -- aber es beendet den Rundgang. Deshalb wird der Bereich danach direkt gesetzt, so wie der Klick-Listener es auch tut. Geprueft wird dabei das Stilblatt, und das kennt nur das Kennzeichen am Wurzelelement. */ await seite.click('[data-tab="anfragen"]'); await seite.waitForTimeout(900); const nachKlick = await seite.evaluate(() => document.documentElement.getAttribute("data-vw-bereich")); console.log("\n===== SCHALTET DER REITER UM? ====="); pruefe("ein echter Reiterklick setzt den Bereich", nachKlick === "anfragen", nachKlick); /* Zurueck in den sichtbaren Zustand fuer die Messreihe. */ await seite.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await seite.waitForTimeout(300); const gesehen = []; for (const bereich of BEREICHE) { console.log(`\n===== BEREICH: ${bereich} =====`); /* Der Datenabruf laeuft im Hintergrund weiter und klappt den Arbeitsbereich verzoegert wieder zu (Zugangspruefung). Deshalb vor JEDER Messung erneut aufmachen, nicht nur einmal am Anfang. */ await seite.evaluate((b) => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); document.documentElement.setAttribute("data-vw-bereich", b); document.querySelectorAll("[data-tab]").forEach((x) => x.classList.toggle("vw-an", x.dataset.tab === b)); }, bereich); await seite.waitForTimeout(500); const d = await seite.evaluate(() => { const wurzel = document.documentElement.getAttribute("data-vw-bereich"); const s = document.querySelector(".vw-schmuck"); const st = getComputedStyle(s); const treffer = st.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); const reiter = document.querySelector(".vw-filter-knopf.vw-an"); const rs = getComputedStyle(reiter); const titel = document.querySelector("#vw-titel"); /* Untergrund der Seite suchen -- die Reiterflaeche ist halbdurchsichtig und muss darueber verrechnet werden. */ const seitenGrund = getComputedStyle(document.body).backgroundColor .match(/[\d.]+/g).slice(0, 3).map(Number); return { wurzel, datei: treffer ? treffer[1] : "(keins)", deckkraft: parseFloat(st.opacity), blick: st.backgroundPosition, reiterText: reiter.textContent.trim().slice(0, 14), reiterFarbe: rs.color.match(/[\d.]+/g).slice(0, 3).map(Number), reiterFlaeche: rs.backgroundColor.match(/[\d.]+/g), titelFarbe: getComputedStyle(titel).color.match(/[\d.]+/g).slice(0, 3).map(Number), seitenGrund, lumen: getComputedStyle(document.querySelector("#vw-bereich")).getPropertyValue("--vw-farbe").trim(), }; }); pruefe(` Kennzeichen steht am Wurzelelement`, d.wurzel === bereich, d.wurzel); gesehen.push({ bereich, datei: d.datei, farbe: d.lumen, blick: d.blick }); /* Der aktive Reiter muss lesbar bleiben -- auch im Bereich "Kunden", dessen Leitfarbe Prism Indigo als SCHRIFT durchfaellt. Genau deshalb faerbt die Leitfarbe dort nur die Flaeche. */ const a = d.reiterFlaeche.length === 4 ? parseFloat(d.reiterFlaeche[3]) : 1; const flaeche = d.seitenGrund.map((u, i) => Math.round(Number(d.reiterFlaeche[i]) * a + u * (1 - a))); const v = verhaeltnis(d.reiterFarbe, flaeche); pruefe(` der aktive Reiter bleibt lesbar (AA = 4,5:1)`, v >= 4.5, v.toFixed(2) + ":1"); const vt = verhaeltnis(d.titelFarbe, d.seitenGrund); pruefe(` der Titel bleibt lesbar`, vt >= 4.5, vt.toFixed(2) + ":1"); /* Frueher stand hier "Deckkraft unter 20 %". Dieser Massstab ist hinfaellig, seit das Motiv nach rechts gerueckt ist: Es darf jetzt kraeftig sein, WEIL es nicht mehr hinter der Schrift liegt. Eine Zahl allein sagt darueber nichts. Geprueft wird stattdessen das, worauf es ankommt -- wie ruhig der Grund unter der Reiterzeile ist, mit Motiv gegen ohne Motiv. */ const offen = () => seite.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await offen(); const grundMit = await unruheAn(seite, ".vw-tabs"); await seite.evaluate(() => { document.querySelector(".vw-schmuck").style.visibility = "hidden"; }); await offen(); await seite.waitForTimeout(350); const grundOhne = await unruheAn(seite, ".vw-tabs"); await seite.evaluate(() => { document.querySelector(".vw-schmuck").style.visibility = ""; }); await seite.waitForTimeout(250); const mehr = (grundMit ?? 99) - (grundOhne ?? 0); pruefe(` der Grund unter den Reitern bleibt ruhig`, grundMit !== null && grundOhne !== null && mehr <= 6, `mit ${grundMit}, ohne ${grundOhne} → plus ${mehr}`); } /* ---- Die Kernfrage: sind die sechs wirklich unterscheidbar? ---- */ console.log("\n===== SIND DIE BEREICHE UNTERSCHEIDBAR? ====="); for (const g of gesehen) console.log(` ${g.bereich.padEnd(11)} ${g.datei.padEnd(22)} ${g.farbe}`); const farben = new Set(gesehen.map((g) => g.farbe)); pruefe("jeder Bereich hat eine eigene Leitfarbe", farben.size === 6, farben.size + " von 6 verschieden"); /* Motive duerfen sich wiederholen -- es gibt fuenf Bilder fuer sechs Bereiche. Aber dann MUSS der Ausschnitt ein anderer sein, sonst sehen zwei Bereiche gleich aus. */ const doppelt = {}; for (const g of gesehen) (doppelt[g.datei] ||= []).push(g); let ausschnittOk = true, hinweis = []; for (const [datei, liste] of Object.entries(doppelt)) { if (liste.length < 2) continue; const blicke = new Set(liste.map((l) => l.blick)); if (blicke.size !== liste.length) { ausschnittOk = false; hinweis.push(datei); } else hinweis.push(`${datei}: ${liste.length}x, aber je anderer Ausschnitt`); } pruefe("mehrfach genutzte Motive zeigen einen anderen Ausschnitt", ausschnittOk, hinweis.join("; ") || "kein Motiv doppelt"); /* ---- Und die alte Regel gilt weiter ---- */ const stoerung = await seite.evaluate(() => { const schlimm = []; for (const el of document.querySelectorAll("table, .wd-tabelle, .vw-liste")) { let p = el; while (p && p !== document.body) { if (p.classList && (p.classList.contains("wd-motiv") || p.classList.contains("vw-schmuck"))) { schlimm.push(el.tagName); break; } p = p.parentElement; } } return schlimm; }); pruefe("kein Motiv hinter Tabellen oder Listen", stoerung.length === 0, stoerung.join(", ") || "keine"); pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || ""); await seite.evaluate(() => { document.documentElement.setAttribute("data-vw-bereich", "uebersicht"); document.querySelectorAll("[data-tab]").forEach((x) => x.classList.toggle("vw-an", x.dataset.tab === "uebersicht")); }); await seite.waitForTimeout(700); await seite.screenshot({ path: "pruef-vw-schmuck-computer.png" }); await seite.close(); /* ---- Bewegung aus, wenn der Nutzer das so eingestellt hat ---- */ console.log("\n===== REDUZIERTE BEWEGUNG ====="); const ruhig = await browser.newPage({ viewport: { width: 1440, height: 950 }, reducedMotion: "reduce" }); await ruhig.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await ruhig.waitForTimeout(1200); await ruhig.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await ruhig.click('[data-tab="projekte"]'); await ruhig.waitForTimeout(700); /* Der Klick klappt den Bereich zu (Zugangspruefung) -- fuer die Messung wieder aufmachen. Das Kennzeichen hat der Klick gesetzt. */ await ruhig.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await ruhig.waitForTimeout(300); const bewegung = await ruhig.evaluate(() => { const s = document.querySelector(".vw-schmuck"); const st = getComputedStyle(s); return { animation: st.animationName, sichtbar: parseFloat(st.opacity), bereich: document.documentElement.getAttribute("data-vw-bereich"), }; }); pruefe("keine Animation bei reduzierter Bewegung", bewegung.animation === "none", bewegung.animation); /* Wichtig: abschalten heisst NICHT verschwinden. Das Motiv muss da sein, nur ohne Bewegung. */ pruefe("das Motiv ist trotzdem sichtbar", bewegung.sichtbar > 0.05, Math.round(bewegung.sichtbar * 100) + " %"); pruefe("der Bereichswechsel wirkt trotzdem", bewegung.bereich === "projekte", bewegung.bereich); await ruhig.close(); /* ---- Handy ---- */ console.log("\n===== HANDY ====="); const handy = await browser.newPage({ viewport: { width: 390, height: 844 } }); await handy.goto("http://127.0.0.1:4404/webdesign/verwaltung.html"); await handy.waitForTimeout(1200); await handy.evaluate(() => { document.querySelector("#vw-anmeldung").setAttribute("hidden", ""); document.querySelector("#vw-bereich").removeAttribute("hidden"); }); await handy.evaluate(() => { document.documentElement.setAttribute("data-vw-bereich", "kunden"); }); await handy.waitForTimeout(600); const hd = await handy.evaluate(() => { const st = getComputedStyle(document.querySelector(".vw-schmuck")); const t = st.backgroundImage.match(/img\/([a-z0-9-]+\.webp)/i); return { datei: t ? t[1] : "(keins)", deckkraft: parseFloat(st.opacity) }; }); pruefe("Handy: kleine Bildfassung", hd.datei.includes("-klein"), hd.datei); const ueberlauf = await handy.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); pruefe("Handy: nichts laeuft seitlich ueber", ueberlauf <= 1, ueberlauf + "px"); await handy.screenshot({ path: "pruef-vw-schmuck-handy.png" }); await handy.close(); const verpasst = fehlt.filter((f) => /\.(webp|jpg|png|css|js|svg)$/.test(f)); pruefe("keine fehlende Datei", verpasst.length === 0, verpasst.join(", ") || "keine"); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); await browser.close(); srv.close(); process.exit(fehl ? 1 : 0); });