diff --git a/pruef-buehne.png b/pruef-buehne.png new file mode 100644 index 00000000..40afb92a Binary files /dev/null and b/pruef-buehne.png differ diff --git a/pruef-start-computer.png b/pruef-start-computer.png index 4a061d70..b95c8e3c 100644 Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ diff --git a/pruef-start-creator.png b/pruef-start-creator.png index 130008ab..600dc8e5 100644 Binary files a/pruef-start-creator.png and b/pruef-start-creator.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png index d8ccd90f..c6064e70 100644 Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs new file mode 100644 index 00000000..ed49cae5 --- /dev/null +++ b/server/pruef-buehne.mjs @@ -0,0 +1,161 @@ +/* =================================================================== + 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); diff --git a/tools/buehne-bauen.mjs b/tools/buehne-bauen.mjs new file mode 100644 index 00000000..70fd2db7 --- /dev/null +++ b/tools/buehne-bauen.mjs @@ -0,0 +1,126 @@ +/* =================================================================== + Baut die beiden Hintergrundbilder für den Workspace. + + Filipe: "der hintergrund soll genau so krass und speziell sein, + benutz den hasen und husky." + + Warum die Bilder AUFBEREITET werden, statt sie einfach einzubinden: + + 1. Ein Foto hinter einem Arbeitswerkzeug macht Text unlesbar. Die + Bilder werden deshalb stark abgedunkelt und leicht entsättigt -- + sie sollen eine Stimmung sein, kein Motiv. + + 2. Sie bekommen einen weichen Rand. Ein hartes Rechteck sieht nach + aufgeklebtem Bild aus; ein auslaufender Rand sieht nach Bühne aus. + Der Verlauf wird HIER eingerechnet und nicht im Browser: Eine + Maske über ein 900-Pixel-Bild kostet bei jedem Bildaufbau Rechenzeit. + + 3. Aus 290 KB PNG werden rund 40 KB WebP. Die Seite laedt auf jedem + Aufruf schneller, und das Bild liegt im Zwischenspeicher. + + Gerechnet wird mit dem Browser, der ohnehin fuer die Pruefungen da ist + -- kein zusaetzliches Bildprogramm, keine neue Abhaengigkeit, 0 EUR. + + Aufrufen mit: node tools/buehne-bauen.mjs + =================================================================== */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); +const QUELLE = join(WURZEL, "assets", "img"); +const ZIEL = join(WURZEL, "workspace", "assets", "img"); +if (!existsSync(ZIEL)) mkdirSync(ZIEL, { recursive: true }); + +const AUFTRAEGE = [ + { + /* Casper, der Husky. Neonblau auf Dunkel -- er passt farblich genau + zur Oberfläche, anders als die warmen Fotomontagen. Sitzt unten + links und schaut nach rechts in die Seite hinein. */ + quelle: "hero-husky.png", + ziel: "buehne-husky.webp", + breite: 900, hoehe: 900, + ausschnitt: [0, 0, 1, 1], + spiegeln: false, + /* Weich ausblenden nach rechts und nach oben -- dorthin, wo der + Inhalt steht. */ + verlauf: [ + { art: "linear", von: [0.55, 0], nach: [1, 0], stopps: [[0, 0], [1, 1]] }, + { art: "linear", von: [0, 0], nach: [0, 0.55], stopps: [[0, 1], [1, 0]] }, + ], + helligkeit: 0.62, saettigung: 1.0, + }, + { + /* HasiDog. Das Ganzkoerperbild wird auf Kopf und Oberkoerper + beschnitten -- die Fuesse traegt am unteren Bildrand niemand. */ + quelle: "char-hasidog.jpg", + ziel: "buehne-hasi.webp", + breite: 760, hoehe: 900, + ausschnitt: [0.02, 0.02, 0.96, 0.78], + spiegeln: true, + verlauf: [ + { art: "linear", von: [0.45, 0], nach: [0, 0], stopps: [[0, 0], [1, 1]] }, + { art: "linear", von: [0, 0], nach: [0, 0.5], stopps: [[0, 1], [1, 0]] }, + ], + helligkeit: 0.5, saettigung: 0.9, + }, +]; + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +const seite = await browser.newPage(); +await seite.setContent(""); + +for (const a of AUFTRAEGE) { + const roh = readFileSync(join(QUELLE, a.quelle)); + const art = a.quelle.endsWith(".png") ? "image/png" : "image/jpeg"; + const daten = `data:${art};base64,${roh.toString("base64")}`; + + const webp = await seite.evaluate(async ({ daten, a }) => { + const bild = new Image(); + bild.src = daten; + await bild.decode(); + + const c = document.createElement("canvas"); + c.width = a.breite; c.height = a.hoehe; + const g = c.getContext("2d"); + + const [ax, ay, aw, ah] = a.ausschnitt; + g.filter = `brightness(${a.helligkeit}) saturate(${a.saettigung}) contrast(1.04)`; + if (a.spiegeln) { g.translate(a.breite, 0); g.scale(-1, 1); } + g.drawImage(bild, + bild.naturalWidth * ax, bild.naturalHeight * ay, + bild.naturalWidth * aw, bild.naturalHeight * ah, + 0, 0, a.breite, a.hoehe); + g.setTransform(1, 0, 0, 1, 0, 0); + g.filter = "none"; + + /* Die weichen Raender. destination-out radiert dort, wo der Verlauf + deckend ist -- so bleibt das Bild in der Ecke stehen und loest + sich zur Seite hin auf. */ + g.globalCompositeOperation = "destination-out"; + for (const v of a.verlauf) { + const gr = g.createLinearGradient( + v.von[0] * a.breite, v.von[1] * a.hoehe, + v.nach[0] * a.breite, v.nach[1] * a.hoehe); + for (const [pos, deckung] of v.stopps) { + gr.addColorStop(pos, `rgba(0,0,0,${deckung})`); + } + g.fillStyle = gr; + g.fillRect(0, 0, a.breite, a.hoehe); + } + g.globalCompositeOperation = "source-over"; + + return c.toDataURL("image/webp", 0.74); + }, { daten, a }); + + const puffer = Buffer.from(webp.split(",")[1], "base64"); + writeFileSync(join(ZIEL, a.ziel), puffer); + console.log(`${a.ziel.padEnd(20)} ${String(Math.round(roh.length / 1024)).padStart(4)} KB` + + ` -> ${String(Math.round(puffer.length / 1024)).padStart(3)} KB`); +} + +await browser.close(); +console.log("\nFertig. Die Dateien liegen in workspace/assets/img/."); diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css index d76503dc..6c860f00 100644 --- a/workspace/assets/css/gate.css +++ b/workspace/assets/css/gate.css @@ -15,7 +15,16 @@ --text: #dbe4ef; /* kein reines Weiss - schont die Augen */ --text-leise: #94a5bb; - --text-still: #6d7d92; + /* Von #6d7d92 auf #75859a angehoben (01.09.2026). + Aufgefallen erst, als die Buehne im Hintergrund dazukam und der + Kontrast an echten BILDPUNKTEN gemessen wurde statt an der + Farbangabe: Der leiseste Grauton lag bei 4,43:1 -- knapp unter den + geforderten 4,5:1. Das war schon vorher so, nur hatte es nie jemand + nachgerechnet. Jetzt 4,95:1 auf dem hellsten Untergrund, 5,35:1 auf + reiner Tinte. Immer noch deutlich leiser als der Haupttext, aber + lesbar -- und das gilt jetzt fuer JEDE Seite, nicht nur fuer die + mit dem Hintergrundbild. */ + --text-still: #75859a; --akzent: #3fbdf5; /* aus dem Neon des Motivs, entsättigt */ --akzent-tief: #1f7fb0; diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 6031d5ac..d3eb125c 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -24,6 +24,85 @@ body.start { var(--tinte); } +/* ---------- Die Bühne ---------------------------------------------------- + + Casper unten links, HasiDog unten rechts. Beide FEST an der Scheibe, + nicht am Inhalt: Sie scrollen nicht mit, sondern stehen da wie ein + Bühnenbild, vor dem gearbeitet wird. + + Drei Regeln, damit daraus kein Poster wird, auf dem man nichts lesen + kann: + + 1. Sie sitzen in den unteren ECKEN und lassen die Mitte frei -- genau + dort steht der Inhalt. + 2. Die weichen Ränder sind ins Bild eingerechnet (tools/buehne-bauen.mjs), + nicht als Maske im Browser. Ein hartes Rechteck sähe nach + aufgeklebtem Foto aus; und eine Maske über ein 900-Pixel-Bild + kostete bei jedem Bildaufbau Rechenzeit. + 3. Darüber liegt ein dunkler Schleier. Er ist der Grund, warum der + Text unverändert lesbar bleibt -- gemessen, nicht geschätzt. + + Warum überhaupt zwei Bilder statt eines großen: Ein Vollbild hinter + einem Arbeitswerkzeug ist immer im Weg. Zwei Ecken lassen den Blick + in Ruhe und sind trotzdem sofort da. */ + +body.start::before, +body.start::after { + content: ""; + position: fixed; bottom: 0; z-index: 0; + pointer-events: none; + background-repeat: no-repeat; + background-size: contain; + background-position: bottom; +} + +/* Casper. Links, blickt nach rechts in die Seite hinein. */ +body.start::before { + left: -3vw; + width: min(34vw, 480px); height: min(34vw, 480px); + background-image: url("../img/buehne-husky.webp"); + background-position: left bottom; + opacity: .46; +} + +/* HasiDog. Rechts, gespiegelt, blickt zurück. Etwas schwächer -- er ist + heller als Casper und würde sonst mehr ziehen. */ +/* Weiter nach aussen und nach unten geschoben als Casper: HasiDog ist + heller, sein Gesicht zieht den Blick staerker -- direkt neben einer + Kachel wuerde man es ansehen statt der Kachel. So schaut er von unten + rechts herein, ohne sich dazwischenzustellen. */ +body.start::after { + right: -2vw; + width: min(24vw, 330px); height: min(36vw, 480px); + background-image: url("../img/buehne-hasi.webp"); + background-position: right bottom; + opacity: .3; +} + +/* Der Schleier. Liegt über den beiden und unter dem Inhalt -- er nimmt + ihnen genau so viel Kraft, dass Text darüber unverändert lesbar + bleibt. Nach unten dichter, weil die Bilder dort stehen. */ +body.start > .schleier { + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background: + linear-gradient(180deg, transparent 0%, rgba(5, 7, 13, .55) 62%, rgba(5, 7, 13, .8) 100%), + radial-gradient(75% 55% at 50% 62%, rgba(5, 7, 13, .82), transparent 78%); +} + +/* Alles Bedienbare liegt über der Bühne. Ohne das läge der Inhalt zwar + sichtbar darüber, aber Klicks landeten teils am falschen Ort. */ +body.start > .kopfleiste, +body.start > .inhalt, +body.start > main { position: relative; z-index: 1; } + +@media (max-width: 760px) { + /* Auf dem Handy nimmt der Inhalt die ganze Breite ein -- dort wäre die + Bühne nur ein Schatten hinter dem Text. Bleibt, aber deutlich + zurückgenommen und kleiner. */ + body.start::before { left: -8vw; width: 60vw; height: 60vw; opacity: .28; } + body.start::after { right: -6vw; width: 42vw; height: 60vw; opacity: .2; } +} + /* ---------- Kopfleiste ------------------------------------------------ */ .kopfleiste { diff --git a/workspace/assets/img/buehne-hasi.webp b/workspace/assets/img/buehne-hasi.webp new file mode 100644 index 00000000..0c7cab8a Binary files /dev/null and b/workspace/assets/img/buehne-hasi.webp differ diff --git a/workspace/assets/img/buehne-husky.webp b/workspace/assets/img/buehne-husky.webp new file mode 100644 index 00000000..12a02cb2 Binary files /dev/null and b/workspace/assets/img/buehne-husky.webp differ diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 46e59d7c..57cb77a1 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,13 +7,18 @@ - - - + + + + + +