/* ===================================================================== Die neuen Seiten — angesehen, nicht nur geprüft (11.09.2026) HIESS BIS HEUTE pruef-treff-seiten.mjs. Mit den zwei Katalogseiten (Entwicklung, Talente) stimmte der Name nicht mehr -- und ein Dateiname, der etwas anderes verspricht als sein Inhalt, ist die Sorte Kleinigkeit, wegen der jemand die falsche Datei oeffnet. pruef-treff und pruef-treff-werkzeuge messen, was der Server ANTWORTET. Diese Datei sieht nach, was auf dem Bildschirm LANDET. Der Unterschied ist nicht theoretisch. Am 06.09.2026 hat in diesem Haus eine Kopfleiste zwölf Seiten lang den falschen Knopf getroffen, und keine einzige Schnittstellenprüfung hat es bemerkt — sie waren alle grün. Gefunden hat es ein Blick. GEPRÜFT WIRD, WAS MAN NICHT ERRATEN KANN: * Steht überhaupt Text da? Eine Seite, deren Skript stillschweigend abbricht, sieht in der Antwort tadellos aus und ist leer. * Sind die Platzhalter ersetzt? Die Zahlen im Regeltext kommen vom Server; bleibt "…" stehen, hat der Abruf nicht funktioniert. * Liegt etwas übereinander? Zwei Bedienelemente, die sich überlappen, sind auf dem Handy die Sorte Fehler, bei der man auf A tippt und B bekommt. * Lässt sich der Text lesen? Kontrast auf ECHTEN Bildpunkten gemessen, nicht aus den Farbwerten gerechnet — die sagen nichts über das, was hinter einer halbdurchsichtigen Fläche liegt. * Und auf 390 px Breite: Bricht etwas aus dem Bild heraus? Aufrufen mit: node server/pruef-neue-seiten.mjs ===================================================================== */ import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-neue-seiten"); const ordner = mkdtempSync(join(tmpdir(), "ws-treffs-")); 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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; import { heuteLokal } from "./helfer-tag.mjs"; await import("./index.js"); notbremse(240_000, "pruef-neue-seiten"); await new Promise((r) => setTimeout(r, 700)); 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 melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const BASIS = `http://127.0.0.1:${PORT}`; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const ALT = new Date(Date.now() - 40 * 86400000).toISOString(); function anlegen(name, rolle, code, erstellt = jetzt) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, 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, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), erstellt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001", ALT); /* ETWAS ZUM ANSEHEN. Eine leere Seite sieht aufgeräumt aus und beweist nichts -- die Fälle auf der Moderationsseite entstehen erst, wenn es eine Meldung und eine Maßnahme gibt. */ d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)" + " VALUES ('treff','hallo','Hallo, ich bin die Kessi','Freue mich, hier zu sein!',?,?,?)") .run(heuteLokal(), jetzt, idKessi); const beitrag = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.close(); function roh(pfad, kopf = {}, koerper = null, methode = null) { if (methode === "DELETE") koerper = null; return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (koerper ? "POST" : "GET"), headers: { ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); } /* Eine Meldung und eine Maßnahme anlegen -- über die echten Wege. */ /* alter_ok: Die Tuer fragt die Community seit dem 15.09.2026 beim ersten Hereinkommen nach dem Alter. */ const anm = await roh("/workspace/api/anmelden", {}, { rolle: "gast", code: "CODE-GAST-0001", alter_ok: true }); const kGast = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); const anm2 = await roh("/workspace/api/anmelden", {}, { rolle: "admin", code: "CODE-DOGI-0001" }); const kDogi = [].concat(anm2.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); await roh(`/workspace/api/treff/eintrag/${beitrag}/melden`, { cookie: kGast }, { grund: "Da steht eine Handynummer drin, die da nicht hingehört." }); await roh("/workspace/api/treff/massnahme", { cookie: kDogi }, { person_id: idKessi, art: "hinweis", grund: "Bitte keine Nummern posten – einmalig, kein Drama." }); /* ETWAS AUF DEN KATALOGSEITEN. Ohne eine Person im Team ist die Entwicklungsseite leer, ohne einen Kandidaten der Trichter -- und eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen. UND ZWAR AUF MEHREREN STUFEN. Der Trichter zeigt je Stufe einen ANDEREN naechsten Schritt; steht nur ein Kandidat auf der ersten, bleiben die Knoepfe der anderen vier ungesehen. Genau dort sitzen seit dem 11.09. die zwei neuen: der Vorlagentext auf "Beobachtet" und "Ins Team holen · Zugang anlegen" auf "Probe". */ /* EINMAL ANKLOPFEN, DAMIT ES DIE TABELLEN GIBT. Sie entstehen beim ersten angemeldeten Aufruf des Bereichs, nicht beim Start -- ein INSERT davor liefe gegen eine Tabelle, die es noch nicht gibt. */ await roh("/workspace/api/talente/lage", { cookie: kDogi }); const kat = await import("./workspace-entwicklungs-punkte.js"); const talKat = await import("./workspace-talent-punkte.js"); const dd = new DatabaseSync(process.env.WORKSPACE_DB); let idHand = null; { const person = (name, rolle, code) => { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); return dd.prepare("SELECT last_insert_rowid() AS id").get().id; }; person("Frida", "modi", "CODE-MODI-0001"); idHand = person("Rieke", "hand", "CODE-HAND-0001"); const talent = (titel, text) => { dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)" + " VALUES ('talente','ruhig',?,?,?,?,?,?)") .run(titel, text, heuteLokal(), jetzt, idDogi, "Im Stream am Dienstag"); return dd.prepare("SELECT last_insert_rowid() AS id").get().id; }; talent("Kessi aus dem Chat", "Faellt seit Wochen auf."); const idBeobachtet = talent("Malin aus dem Chat", "Haelt den Chat ruhig."); const idProbe = talent("Tino aus dem Chat", "Laeuft seit vier Wochen mit."); const stufe = dd.prepare( "INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id) VALUES (?,?,?,?)"); stufe.run(idBeobachtet, "beobachtet", jetzt, idDogi); stufe.run(idProbe, "probe", jetzt, idDogi); /* MERKMALE fuer den Vorlagentext -- er wird daraus gebaut. Ohne sie stuende dort der Satz fuer "noch nichts beobachtet", und die neue Gestaltung waere zwar sichtbar, aber nicht in ihrer laengsten Fassung. Gemessen werden soll die laengste. */ const merk = dd.prepare( "INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)"); for (const m of talKat.TALENT_ALLE.slice(0, 3)) { merk.run(idBeobachtet, m.schluessel, idDogi, jetzt); } /* DIE EIGENE KARTE DER RECHTEN HAND. Sie oeffnet sich erst, wenn jeder Beurteiler gesetzt hat -- ueber sie urteilt nur DogFather, also reicht eine Zeile je Punkt. Sechs gesetzte und der Rest offen: So stehen beide Zustaende nebeneinander auf dem Bildschirm. */ const stand = dd.prepare( "INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)" + " VALUES (?,?,?,?,?,?)"); const stufen = ["laeuft", "waechst", "laeuft", "hakt", "laeuft", "waechst"]; kat.ENTWICKLUNG_FREMD.slice(0, 6).forEach((p, i) => { stand.run(idHand, p.schluessel, idDogi, stufen[i], stufen[i] === "hakt" ? "Zweimal zu spaet zur Schicht." : null, jetzt); }); } dd.close(); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const BILDER = join(ordner, "bilder"); mkdirSync(BILDER, { recursive: true }); /* Kontrast auf ECHTEN Bildpunkten: Die Schrift liegt teils über halbdurchsichtigen Flächen und über der Bühne; aus den Farbwerten im CSS lässt sich der tatsächliche Kontrast deshalb nicht ausrechnen. */ const KONTRAST = `(() => { const leuchte = (r, g, b) => { const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const misch = (v, h) => v.map((x, i) => x * v[3] + h[i] * (1 - v[3])); /* ==== ZWEI FARBFORMATE, NICHT EINS (25.09.2026) ================== Hier stand nur \`rgba?(...)\`. Chromium gibt eine Farbe, die aus \`color-mix()\` entstanden ist, aber als \`color(srgb 0.37 0.78 0.97)\` zurueck -- mit Werten von 0 bis 1 statt 0 bis 255 und ohne Kommas. Im Haus ist \`color-mix\` ueberall im Einsatz. WAS DAS ANGERICHTET HAT: Der Sprunglink auf treff-regeln.html traegt eine hellblaue Flaeche und dunklen Text -- ein Kontrast von rund 10:1. Die Messung konnte seine Flaeche nicht lesen, ging zum Elternteil weiter (fast schwarz) und rechnete dunklen Text gegen dunklen Grund: 1,07. Ein Befund, der wie ein blinder Text aussah und keiner war. Das ist die unangenehmste Sorte Fehlalarm: Er zeigt auf eine echte Stelle, nennt eine plausible Zahl -- und schickt einen in ein CSS, an dem nichts ist. Gefunden habe ich es, indem ich die Farben an Ort und Stelle ausgelesen habe, statt der Meldung zu glauben. DIE GEGENPROBE STEHT UNTEN: Ein erfundenes \`color(srgb ...)\` muss als Farbe erkannt werden, und ein unbekanntes Format muss weiterhin NICHTS liefern -- sonst faengt die Messung an, Werte zu erfinden. */ const farbe = (f) => { const m = f.match(/rgba?\\(([^)]+)\\)/); if (m) { const t = m[1].split(",").map(Number); return [t[0], t[1], t[2], t.length > 3 ? t[3] : 1]; } /* color(srgb r g b / a) -- Anteile von 0 bis 1, durch Leerzeichen getrennt, das Alpha hinter einem Schraegstrich. */ const c = f.match(/color\\(\\s*srgb\\s+([^)]+)\\)/); if (c) { const roh = c[1].split("/"); const t = roh[0].trim().split(/\\s+/).map(Number); const a = roh.length > 1 ? Number(roh[1]) : 1; if (t.length >= 3 && t.every((x) => Number.isFinite(x))) { return [t[0] * 255, t[1] * 255, t[2] * 255, Number.isFinite(a) ? a : 1]; } } return null; }; const grund = (e) => { let n = e; while (n && n !== document.documentElement) { const t = farbe(getComputedStyle(n).backgroundColor); if (t && t[3] > 0.92) return [t[0], t[1], t[2], 1]; n = n.parentElement; } return null; }; const schlecht = []; const geraten = []; let gemessen = 0; for (const e of document.querySelectorAll("p, li, h1, h2, h3, td, th, span, a, button")) { const t = (e.textContent || "").trim(); if (!t) continue; /* ELEMENTE MIT KINDERN WERDEN UEBERSPRUNGEN, und das ist richtig: Ihr textContent ist der Text der Kinder, die einzeln drankommen. Es war aber ein STILLER Aussetzer -- ein

mit einem Link darin fiel heraus, und der eigene Text daneben wurde nie gemessen. Genau so ist die Fusszeile der Talente-Seite durch die Pruefung gekommen, waehrend sie auf der Buehne lag. Jetzt zaehlt der EIGENE Text: Hat ein Element neben seinen Kindern eigene Zeichen, wird es mitgemessen. */ const eigen = [...e.childNodes] .filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(""); if (e.children.length && eigen.length < 3) continue; const k = e.getBoundingClientRect(); if (k.width < 4 || k.height < 4) continue; const s = getComputedStyle(e); if (s.visibility === "hidden" || s.display === "none" || Number(s.opacity) < 0.2) continue; const v = farbe(s.color); if (!v) continue; /* DRITTER AUSGANG: konnte nicht nachsehen. Findet sich über diesem Text keine deckende Fläche, dann liegt er auf der Bühne — einem Foto. Was dahinter liegt, lässt sich aus den Farbwerten NICHT ausrechnen, und eine angenommene Hintergrundfarbe wäre genau der grüne Haken über einer falschen Voraussetzung. Genau so ist am 11.09.2026 die Rechtetafel durch die Messung gekommen, während im Bildschirmfoto Chilischoten hinter den Zeilen standen. */ const hinten = grund(e); if (!hinten) { geraten.push(t.slice(0, 34)); continue; } const vorn = misch(v, hinten); const l1 = leuchte(...vorn), l2 = leuchte(...hinten); const k2 = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); gemessen++; const gross = parseFloat(s.fontSize) >= 24 || (parseFloat(s.fontSize) >= 18.66 && Number(s.fontWeight) >= 700); if (k2 < (gross ? 3 : 4.5)) schlecht.push(t.slice(0, 34) + " (" + k2.toFixed(2) + ")"); } /* GEGENPROBE IM MESSPUNKT SELBST -- sie kostet nichts und beweist, dass der Leser beide Formate kann und nicht raet. */ const probe = { rgb: farbe("rgb(10, 18, 30)"), rgba: farbe("rgba(255, 255, 255, 0.5)"), srgb: farbe("color(srgb 0.367529 0.782588 0.967059)"), srgbAlpha: farbe("color(srgb 1 0 0 / 0.25)"), unsinn: farbe("wasauchimmer"), }; return { gemessen, schlecht, geraten, probe }; })()`; /* WAS ABSICHTLICH ÜBEREINANDERLIEGT, IST KEIN FEHLER (11.09.2026). Der erste Entwurf meldete auf allen drei Seiten dasselbe: "Meine SichtKessi / Meine Sicht". Das ist der Umschalter in der Kopfleiste — ein selbst gebautes Bedienelement, unter dem ein echtes