/* ===================================================================== pruef-chatkachel.mjs — Bild hinter dem Chat, Kachel für jeden Account Filipe, 20.09.2026: „ich will auch ein krasses hintergrundbild für den treff chat. und die texte sollen immer in einer kachel sein. und jeder account soll seine kachel personalisieren können." --------------------------------------------------------------------- DIE EINE FRAGE, AN DER SO ETWAS SCHEITERT: LESBARKEIT Ein Bild hinter einem Chat geht nur, WENN der Text auf einer deckenden Kachel steht. Sonst liegt Schrift auf einem Foto — an der dunklen Stelle lesbar, an der hellen nicht, und beim Scrollen wechselt es. Deshalb wird hier nicht geprüft, ob ein Bild da ist, sondern ob die Kachel DECKT: gemessen am Alphawert, nicht geschätzt. Und alle zwölf Töne müssen gleich gut tragen. Ein freier Farbwähler hätte genau hier sein Problem: Mit ihm kann sich jemand unlesbar machen, und sehen müssen es die anderen. AUFRUF: node server/pruef-chatkachel.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eigenerPort } from "./helfer-port.mjs"; import { notbremse } from "./helfer-notbremse.mjs"; const PORT = await eigenerPort(import.meta, "pruef-chatkachel"); const ordner = mkdtempSync(join(tmpdir(), "ws-kachel-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; 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"); notbremse(250_000, "pruef-chatkachel"); await new Promise((r) => setTimeout(r, 900)); const BASIS = `http://127.0.0.1:${PORT}`; process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; console.log("\n=== Chat: Bild dahinter, Kachel davor ===\n"); /* ---- 1. Die Töne, ohne Browser ----------------------------------- */ const { CHAT_KACHELN, kachelPruefen } = await import("./workspace-chat.js"); ok(CHAT_KACHELN.length >= 8, `es gibt genug Töne zur Auswahl (${CHAT_KACHELN.length})`); /* ALLE GLEICH DUNKEL. Nur dann ist der Kontrast zur Schrift bei jedem gleich, und niemand kann sich unlesbar machen. Gerechnet wird die relative Helligkeit nach WCAG — nicht der Augenschein. */ const helligkeit = (hex) => { const z = hex.replace("#", ""); const teil = [0, 2, 4].map((i) => parseInt(z.slice(i, i + 2), 16) / 255); const lin = teil.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; }; const kontrast = (a, b) => { const [h, d] = [helligkeit(a), helligkeit(b)].sort((x, y) => y - x); return (h + 0.05) / (d + 0.05); }; const SCHRIFT = "#e8f1fa"; /* die Textfarbe des Hauses */ let schlechtester = 99, schlechtesterName = ""; for (const k of CHAT_KACHELN) { const c = kontrast(SCHRIFT, k.farbe); if (c < schlechtester) { schlechtester = c; schlechtesterName = k.name; } } /* 7:1 ist die strengere WCAG-Stufe (AAA für Fließtext). Ein Chat ist die Seite, auf der am längsten gelesen wird — hier die niedrigere Schwelle zu nehmen wäre am falschen Ende gespart. */ ok(schlechtester >= 7, `auch der hellste Ton trägt die Schrift noch (${schlechtesterName}: ${schlechtester.toFixed(1)}:1, nötig 7)`); const spanne = Math.max(...CHAT_KACHELN.map((k) => helligkeit(k.farbe))) / Math.max(1e-6, Math.min(...CHAT_KACHELN.map((k) => helligkeit(k.farbe)))); ok(spanne < 6, `die Töne liegen nah beieinander (Faktor ${spanne.toFixed(1)}) — keiner sticht heraus`); /* KEINE DOPPELTE FARBE. Zwei gleiche Töne wären zwei Namen für dieselbe Kachel — man wählt den zweiten und sieht keinen Unterschied. */ const farben = new Set(CHAT_KACHELN.map((k) => k.farbe.toLowerCase())); ok(farben.size === CHAT_KACHELN.length, `kein Ton doppelt (${farben.size} von ${CHAT_KACHELN.length})`); /* Die Prüfung am Wert selbst — sie ist der Riegel gegen alles, was nicht aus der Liste kommt. */ ok(kachelPruefen("moos") === "moos", "ein bekannter Ton kommt durch"); ok(kachelPruefen("rgb(255,0,0)") === null, "Gegenprobe: ein erfundener Wert nicht — sonst stünde CSS aus der Datenbank in der Seite"); ok(kachelPruefen(null) === null, "und 'nichts eingestellt' ist ein gültiger Zustand"); /* ---- 2. Am echten Bildschirm ------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const mach = (name, rolle, code) => { const s = randomBytes(16).toString("hex"); const h = scryptSync(code, s, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); return d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + " VALUES (?,?,?,?,?,1,?)").run(name, rolle, h, s, 32768, new Date().toISOString()).lastInsertRowid; }; mach("Filipe", "admin", "CODE-DOGI-0001"); const idLuna = mach("Luna", "manager", "CODE-LUNA-0002"); d.close(); const anmelden = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, body: JSON.stringify({ rolle, code }), }); return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); }; const kFilipe = await anmelden("admin", "CODE-DOGI-0001"); const kLuna = await anmelden("manager", "CODE-LUNA-0002"); ok(!!kFilipe && !!kLuna, "beide angemeldet"); /* Ein Gespräch und zwei Nachrichten. */ const raumA = await fetch(BASIS + "/workspace/api/chat/direkt", { method: "POST", headers: { "Content-Type": "application/json", Cookie: kFilipe, Origin: BASIS }, body: JSON.stringify({ mit: idLuna }), }); const raum = (await raumA.json()).raum_id; ok(!!raum, `ein Gespräch steht (${raum})`); const schreib = async (keks, text) => fetch(BASIS + `/workspace/api/chat/raeume/${raum}/nachrichten`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: keks, Origin: BASIS }, body: JSON.stringify({ text }), }); await schreib(kFilipe, "Servus, hier ist Filipe."); await schreib(kLuna, "Und hier Luna."); /* Luna stellt eine Farbe ein. */ const setzen = await fetch(BASIS + "/workspace/api/chat/kachel", { method: "PUT", headers: { "Content-Type": "application/json", Cookie: kLuna, Origin: BASIS }, body: JSON.stringify({ kachel: "moos" }), }); ok(setzen.ok, `Luna stellt ihre Kachel ein (${setzen.status})`); const boese = await fetch(BASIS + "/workspace/api/chat/kachel", { method: "PUT", headers: { "Content-Type": "application/json", Cookie: kLuna, Origin: BASIS }, body: JSON.stringify({ kachel: "url(javascript:0)" }), }); ok(boese.status === 400, `Gegenprobe: ein erfundener Wert wird abgelehnt (${boese.status}) — eine Regel, die nur im Formular steht, ist keine Regel`); /* FILIPE SIEHT LUNAS FARBE — das ist der Sinn der Sache. */ const verlauf = await (await fetch(BASIS + `/workspace/api/chat/raeume/${raum}/nachrichten`, { headers: { Cookie: kFilipe } })).json(); const vonLuna = (verlauf.nachrichten || []).find((n) => n.von === "Luna"); ok(vonLuna?.von_kachel === "moos", `Filipe bekommt Lunas Kachel mitgeliefert (${vonLuna?.von_kachel})`); const vonFilipe = (verlauf.nachrichten || []).find((n) => n.von === "Filipe"); ok(!vonFilipe?.von_kachel, "und seine eigene bleibt Standard, solange er nichts einstellt"); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); try { const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); await ctx.addCookies(kFilipe.split("; ").filter(Boolean).map((t) => { const [name, ...rest] = t.split("="); return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; })); const s = await ctx.newPage(); const konsole = []; s.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); s.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); await s.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); await s.waitForTimeout(1400); await s.click(".chat-raum"); await s.waitForTimeout(1200); /* ==== DIE EIGENTLICHE MESSUNG: SCHEINT DAS BILD DURCH? =========== Der erste Anlauf las `backgroundColor` und pruefte den Alphawert. Das war falsch, und zwar auf eine Art, die fast durchgegangen waere: Eine Kachel mit einem VERLAUF hat `backgroundColor: rgba(0,0,0,0)` -- der Verlauf steht in `background-image`. Die Pruefung meldete „durchsichtig" bei einer Kachel, die voellig deckt, und haette umgekehrt eine halbtransparente Verlaufskachel durchgelassen. GEMESSEN WIRD JETZT DIE SACHE SELBST: zweimal dasselbe Bildpunkt- feld, einmal mit und einmal ohne Buehnenbild. Deckt die Kachel, sind beide Aufnahmen an dieser Stelle identisch. Scheint das Bild durch, unterscheiden sie sich. DIE GEGENPROBE IST EINGEBAUT und kostet nichts: An der Flaeche ZWISCHEN den Kacheln MUSS sich etwas aendern. Tut sie das nicht, misst der Vergleich gar nichts -- etwa weil das Bild in dieser Umgebung nie geladen wurde. Genau die Sorte stiller Erfolg, vor der die Hausregel zum dritten Ausgang warnt. */ const punkte = await s.evaluate(() => { const b = document.querySelector('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase') || document.querySelector(".chat-nachricht__blase"); const v = document.querySelector(".chat__verlauf"); if (!b || !v) return null; const rb = b.getBoundingClientRect(), rv = v.getBoundingClientRect(); return { /* Mitten in der Kachel -- nicht am Rand, dort liegen Kante und abgerundete Ecke. */ kachel: { x: Math.round(rb.left + rb.width / 2), y: Math.round(rb.top + rb.height / 2) }, /* Und ein Fleck im Verlauf, auf dem sicher keine Kachel liegt: ganz rechts aussen, wo die eigenen Nachrichten nicht hinreichen. */ frei: { x: Math.round(rv.right - 6), y: Math.round(rv.top + rv.height / 2) }, }; }); ok(!!punkte, "Kachel und freie Fläche gefunden"); /* Playwright kann einen Ausschnitt aufnehmen -- ein Bildpunkt reicht. */ const punktFarbe = async (pkt) => { const bild = await s.screenshot({ clip: { x: pkt.x, y: pkt.y, width: 1, height: 1 }, type: "png", }); /* Der Farbwert steckt im PNG. Statt es zu entpacken, wird die AUFNAHME selbst verglichen: Zwei Aufnahmen desselben Bildpunkts sind byteweise gleich, wenn die Farbe gleich ist. Das ist genau die Frage, und sie braucht keinen Decoder. */ return bild.toString("base64"); }; const mitBild = { kachel: await punktFarbe(punkte.kachel), frei: await punktFarbe(punkte.frei) }; await s.addStyleTag({ content: "body.start::before { display: none !important; }" }); await s.waitForTimeout(350); const ohneBild = { kachel: await punktFarbe(punkte.kachel), frei: await punktFarbe(punkte.frei) }; /* ZUERST DIE GEGENPROBE. Ohne sie waere die Hauptmessung wertlos. */ ok(mitBild.frei !== ohneBild.frei, "Gegenprobe: auf der freien Fläche ändert sich etwas, wenn das Bild weg ist — es ist also wirklich da"); ok(mitBild.kachel === ohneBild.kachel, mitBild.kachel === ohneBild.kachel ? "auf der Kachel ändert sich NICHTS — sie deckt, das Bild kommt nicht durch die Schrift" : "das Bühnenbild scheint durch die Kachel — dort läge Schrift auf einem Foto"); /* Die Fläche darum MUSS durchlassen, sonst gäbe es gar kein Bild zu sehen -- das ist die Hälfte des Wunsches. */ const flaeche = await s.evaluate(() => { const c = getComputedStyle(document.querySelector(".chat")).backgroundColor; /* ZWEI SCHREIBWEISEN, NICHT EINE (20.09.2026). Der erste Anlauf suchte nur `rgba(...)`. Chrome gibt das Ergebnis von `color-mix()` aber als `color(srgb r g b / a)` zurueck -- die Suche fand nichts, der Rueckfall war „Alpha 1", und die Pruefung meldete „deckt" bei einer Flaeche, die durchscheint. Ein Messfehler, der genau das Gegenteil behauptet. */ const alphaVon = (farbe) => { const rgba = farbe.match(/rgba?\(([^)]+)\)/); if (rgba) { const t = rgba[1].split(",").map((x) => parseFloat(x)); return t.length > 3 ? t[3] : 1; } const srgb = farbe.match(/color\([^/)]*\/\s*([\d.]+)\s*\)/); if (srgb) return parseFloat(srgb[1]); return farbe === "transparent" ? 0 : 1; }; return { alpha: alphaVon(c), roh: c, buehne: getComputedStyle(document.body, "::before").backgroundImage }; }); ok(flaeche.alpha < 0.98, `die Fläche um die Kacheln lässt durch (Alpha ${flaeche.alpha.toFixed(2)}, ${flaeche.roh})`); ok(/\.webp|\.png|\.jpg/i.test(flaeche.buehne || ""), `und dahinter steht wirklich ein Bild (…${(flaeche.buehne || "").slice(-30)}`); const lage = await s.evaluate(() => ({ blasen: document.querySelectorAll(".chat-nachricht__blase").length, knopf: !!document.querySelector(".chat__kachelknopf:not([hidden])"), })); ok(lage.blasen >= 2, `die Nachrichten stehen in Kacheln (${lage.blasen})`); ok(lage.knopf, "der Knopf für die eigene Kachel steht da"); /* Und er lässt sich bedienen. */ await s.click(".chat__kachelknopf"); await s.waitForTimeout(400); const wahl = await s.evaluate(() => { const dlg = document.querySelector("#kachel-wahl[open]"); const toene = [...document.querySelectorAll(".chat-kachelwahl__ton")]; return { offen: !!dlg, toene: toene.length, mitNamen: toene.filter((t) => (t.getAttribute("aria-label") || "").length > 2).length, hoch: toene.length ? Math.round(toene[0].getBoundingClientRect().height) : 0, }; }); ok(wahl.offen, "die Auswahl geht auf"); ok(wahl.toene === CHAT_KACHELN.length, `mit allen ${CHAT_KACHELN.length} Tönen (${wahl.toene})`); ok(wahl.mitNamen === wahl.toene, `jeder trägt seinen Namen (${wahl.mitNamen}) — wer Farben schlecht unterscheidet, wählt sonst blind`); ok(wahl.hoch >= 44, `und ist am Finger treffbar (${wahl.hoch} px)`); /* Eine andere wählen und nachsehen, ob die eigenen Kacheln sofort umspringen -- ohne Neuladen. */ /* DIE FARBE STECKT IM VERLAUF, nicht in `backgroundColor`. Auch das war erst falsch: Eine Kachel mit `background: linear-gradient(...)` hat `backgroundColor: rgba(0,0,0,0)` -- der Vergleich lieferte zweimal „durchsichtig" und meldete „springt nicht um" bei einer Kachel, die sehr wohl umsprang. Beides wird gelesen und aneinandergehaengt; dann ist es egal, welche der beiden Stellen die Farbe traegt. */ const kachelBild = () => s.evaluate(() => { const b = document.querySelector('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase'); const c = getComputedStyle(b); return c.backgroundColor + " | " + c.backgroundImage; }); const vorher = await kachelBild(); await s.click('.chat-kachelwahl__ton[data-wert="wein"]'); await s.waitForTimeout(600); const nachher = await kachelBild(); ok(vorher !== nachher, `die eigenen Kacheln springen sofort um (${vorher.slice(0, 46)} → ${nachher.slice(0, 46)})`); /* UND ES IST GESPEICHERT, nicht nur gemalt. */ const nachSetzen = await (await fetch(BASIS + "/workspace/api/chat/kacheln", { headers: { Cookie: kFilipe } })).json(); ok(nachSetzen.meine === "wein", `und die Wahl ist gespeichert, nicht nur gemalt (${nachSetzen.meine})`); ok(konsole.length === 0, konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 80) : "keine Fehler im Browser"); await ctx.close(); } finally { await browser.close(); } console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(fehler ? 1 : 0);