Filipe: "der hintergrund soll genau so krass und speziell sein, benutz
den hasen und husky."
Gefunden in assets/img: hero-husky.png (Casper, neonblau auf dunkel --
farblich genau die Oberflaeche) und char-hasidog.jpg (HasiDog). Der
Sticker "HasiDog & Casper" hat bestaetigt, dass die beiden zusammen
gehoeren.
Beide sitzen FEST an der Scheibe in den unteren Ecken -- sie scrollen
nicht mit, sondern stehen da wie ein Buehnenbild, vor dem gearbeitet
wird. Die Mitte bleibt frei, dort steht der Inhalt. HasiDog ist weiter
nach aussen geschoben und schwaecher als Casper: Er ist heller, sein
Gesicht zieht den Blick staerker, und direkt neben einer Kachel wuerde
man ihn ansehen statt der Kachel.
AUFBEREITET STATT EINGEBUNDEN (tools/buehne-bauen.mjs):
* stark abgedunkelt und leicht entsaettigt -- Stimmung, kein Motiv
* weiche Raender ins Bild EINGERECHNET. Ein hartes Rechteck saehe nach
aufgeklebtem Foto aus; und eine Maske ueber ein 900-Pixel-Bild kostet
bei jedem Bildaufbau Rechenzeit.
* 290 KB PNG -> 41 KB WebP, 110 KB -> 32 KB
Gerechnet mit dem Browser, der ohnehin fuer die Pruefungen da ist -- kein
Bildprogramm, keine neue Abhaengigkeit, 0 EUR.
GEPRUEFT WIRD NICHT, OB ES HUEBSCH IST, sondern ob der Text noch lesbar
ist -- an echten BILDPUNKTEN aus dem fertigen Bildschirmfoto, nicht an
der Farbangabe im Stil. Die weiss naemlich nichts davon, was
dahinterliegt. Gemessen wird der Untergrund direkt NEBEN jedem sichtbaren
Text, auf fuenf Seiten, auf Computer und Handy: bis zu 34 Stellen je
Seite gegen den WCAG-Massstab (4,5:1, bei grosser Schrift 3:1).
UND DAS HAT SOFORT EINEN ALTEN FEHLER GEFUNDEN: Der leiseste Grauton
(--text-still) lag bei 4,43:1 -- knapp UNTER der Norm. Das war schon
lange so, nur hatte es nie jemand nachgerechnet, weil bisher niemand an
echten Bildpunkten gemessen hat. Von #6d7d92 auf #75859a angehoben; jetzt
4,95:1 auf dem hellsten Untergrund. Das gilt fuer JEDE Seite, nicht nur
fuer die mit dem Hintergrundbild.
Schlechtester Wert jetzt: 4,97:1. Alle 30 Messungen bestanden.
Nebenbei eine Falle in den Pruefungen selbst: Ein haengengebliebener
Testserver auf demselben Port fing die Anfragen ab -- der Test sprach mit
einem ALTEN Prozess und dessen anderer Datenbank, und meldete "no such
table". Der Port ist jetzt ein anderer; die Lehre steht hier, weil das
Bild "Server laeuft" trotzdem erscheint und alles richtig aussieht.
Co-Authored-By: Claude Opus 5 <[email protected]>
162 lines
7.5 KiB
JavaScript
162 lines
7.5 KiB
JavaScript
/* ===================================================================
|
|
Pruefung der Buehne (Casper und HasiDog im Hintergrund).
|
|
|
|
Ein Bild hinter einem Arbeitswerkzeug ist der schnellste Weg zu einer
|
|
Oberflaeche, die gut aussieht und unbenutzbar ist. Deshalb wird hier
|
|
NICHT geprueft, ob es huebsch ist, sondern ob der Text noch lesbar
|
|
ist -- und zwar an echten BILDPUNKTEN aus dem fertigen Bild, nicht an
|
|
der Farbangabe im Stil. Die sagt naemlich nichts darueber, was
|
|
dahinterliegt.
|
|
|
|
Massstab WCAG: 4,5:1 fuer normalen Text, 3:1 fuer grossen.
|
|
=================================================================== */
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-buehne-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
process.env.PORT = "4193";
|
|
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
|
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
|
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
|
|
const express = (await import("express")).default;
|
|
const ec = express.response.cookie;
|
|
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
|
await import("./index.js");
|
|
await new Promise((r) => setTimeout(r, 700));
|
|
const BASIS = "http://127.0.0.1:4193";
|
|
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 220_000).unref?.();
|
|
|
|
let fehler = 0;
|
|
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
|
|
await fetch(BASIS + "/workspace/api/anmelden", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
|
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes } = await import("node:crypto");
|
|
|
|
/* Warten, bis der Server die Datenbank wirklich angelegt hat. Der
|
|
Anmeldeversuch oben STOESST das nur an -- Schema und Umstellungen
|
|
laufen danach. Wer sofort weiterschreibt, trifft auf "no such table". */
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
|
} catch (f) {
|
|
if (i === 0 || i === 5) console.log(" warte auf die Datenbank:", f.message);
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
}
|
|
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const jetzt = new Date().toISOString();
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
|
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
|
.run("Filipe", "admin", hash, salt, 32768, jetzt);
|
|
d.close();
|
|
|
|
const { chromium } = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
|
const browser = await chromium.launch();
|
|
|
|
const SEITEN = ["start.html", "uebersicht.html", "personen.html", "content.html", "aufgaben.html"];
|
|
|
|
for (const [breite, hoehe, wie] of [[1440, 950, "Computer"], [390, 780, "Handy"]]) {
|
|
console.log(`\n=== ${wie} (${breite}x${hoehe})`);
|
|
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
|
|
const seite = await kontext.newPage();
|
|
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
|
await seite.click('.rolle[data-rolle="admin"]');
|
|
await seite.fill("#code", "CODE-DOGI-0001");
|
|
await seite.click("button[type=submit]");
|
|
await seite.waitForURL("**/start.html", { timeout: 15000 });
|
|
|
|
for (const s of SEITEN) {
|
|
await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
|
|
await seite.waitForTimeout(800);
|
|
|
|
const da = await seite.evaluate(() => ({
|
|
husky: /buehne-husky/.test(getComputedStyle(document.body, "::before").backgroundImage),
|
|
hasi: /buehne-hasi/.test(getComputedStyle(document.body, "::after").backgroundImage),
|
|
schleier: !!document.querySelector(".schleier"),
|
|
durchlaessig: getComputedStyle(document.querySelector(".schleier") || document.body).pointerEvents,
|
|
}));
|
|
if (s === "start.html") {
|
|
ok(da.husky, "Casper liegt im Hintergrund");
|
|
ok(da.hasi, "HasiDog liegt im Hintergrund");
|
|
}
|
|
ok(da.schleier && da.durchlaessig === "none",
|
|
`${s}: Schleier da und durchlässig für Klicks (${da.durchlaessig})`);
|
|
|
|
/* Der eigentliche Punkt: Kontrast an ECHTEN Bildpunkten aus dem
|
|
fertigen Bild. Gemessen wird der Untergrund direkt NEBEN jedem
|
|
sichtbaren Text -- nicht die Farbangabe im Stil, die weiß nichts
|
|
davon, was dahinterliegt. */
|
|
const bild = await seite.screenshot({ type: "png" });
|
|
const schlimmster = await seite.evaluate(async (b64) => {
|
|
const kanal = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
|
|
const hell = (c) => 0.2126 * kanal(c[0]) + 0.7152 * kanal(c[1]) + 0.0722 * kanal(c[2]);
|
|
const kontrast = (a, b) => {
|
|
const w = [hell(a), hell(b)].sort((x, y) => y - x);
|
|
return (w[0] + 0.05) / (w[1] + 0.05);
|
|
};
|
|
|
|
const bild = new Image();
|
|
bild.src = "data:image/png;base64," + b64;
|
|
await bild.decode();
|
|
const c = document.createElement("canvas");
|
|
c.width = bild.width; c.height = bild.height;
|
|
const g = c.getContext("2d");
|
|
g.drawImage(bild, 0, 0);
|
|
const skala = bild.width / window.innerWidth;
|
|
|
|
let schlecht = null, anzahl = 0;
|
|
const alle = [...document.querySelectorAll("p, h1, h2, h3, span, a, li")]
|
|
.filter((e) => e.children.length === 0 && e.textContent.trim().length > 2);
|
|
for (const e of alle) {
|
|
const r = e.getBoundingClientRect();
|
|
if (r.width < 8 || r.height < 8) continue;
|
|
if (r.top < 0 || r.bottom > window.innerHeight) continue;
|
|
const x = Math.round((r.left + r.width + 4) * skala);
|
|
const y = Math.round((r.top + r.height / 2) * skala);
|
|
if (x < 0 || x >= c.width || y < 0 || y >= c.height) continue;
|
|
const p = g.getImageData(x, y, 1, 1).data;
|
|
const st = getComputedStyle(e);
|
|
const farbe = st.color.match(/\d+/g).slice(0, 3).map(Number);
|
|
const gr = parseFloat(st.fontSize);
|
|
const fett = Number(st.fontWeight) >= 700;
|
|
const grenze = (gr >= 24 || (gr >= 18.66 && fett)) ? 3 : 4.5;
|
|
const v = kontrast(farbe, [p[0], p[1], p[2]]);
|
|
anzahl++;
|
|
if (!schlecht || v / grenze < schlecht.v / schlecht.grenze) {
|
|
schlecht = { v, grenze, text: e.textContent.trim().slice(0, 26), grund: [p[0], p[1], p[2]] };
|
|
}
|
|
}
|
|
return { ...schlecht, anzahl };
|
|
}, bild.toString("base64"));
|
|
|
|
ok(schlimmster.v >= schlimmster.grenze,
|
|
`${s}: schlechtester Kontrast ${schlimmster.v.toFixed(2)}:1 (nötig ${schlimmster.grenze}:1) `
|
|
+ `bei "${schlimmster.text}" auf rgb(${schlimmster.grund.join(",")}) · `
|
|
+ `${schlimmster.anzahl} Stellen gemessen`);
|
|
}
|
|
|
|
if (breite === 1440) {
|
|
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
|
|
await seite.waitForTimeout(800);
|
|
await seite.screenshot({ path: "pruef-buehne.png" });
|
|
}
|
|
await kontext.close();
|
|
}
|
|
|
|
await browser.close();
|
|
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
|
process.exit(fehler === 0 ? 0 : 1);
|