Files
dogfather-universe/server/helfer-kachel-echtfarbe.mjs
T
DogFatherGitandClaude Opus 5 e12392a289 Die Kachelfarben kommen jetzt WIRKLICH auf dem Bildschirm an
Filipe: "ich raste aus im ernst, was verstehst du nicht darunter wenn
ich sage das alle kacheln eine andere farbe haben sollen ich seh
immer wieder sehr viele die einfach komplett die gleiche farbe haben."

ER HATTE RECHT, UND MEINE PRUEFUNG WAR TROTZDEM GRUEN.
Der Grund war ein Messfehler, und zwar meiner: pruef-kachelfarben las
`--ton` aus start.css -- die ROHFARBE. Die lag zwischen allen 31
Kacheln weit auseinander (0,0978 in OKLab). Nur steht sie so nie auf
dem Bildschirm.

GEMESSEN an echten Bildpunkten (neu: tools/kachel-echtfarbe.mjs):
  Rohfarben:              0,0978 auseinander
  Auf dem Bildschirm:     0,0141
  Es kamen an:            14,4 Prozent
  Fuer ein Auge gleich:   ACHT Paare

Drei Stellen haben die Farbe geschluckt, alle in .kachel:
  1. Eine Wolke mit 16 Prozent -- in immer DEMSELBEN Blau, auf jeder
     Kachel. Der staerkste Gleichmacher der ganzen Wand. Sie traegt
     jetzt den Ton der Kachel.
  2. Die Vignette legte bis zu 55 Prozent Dunkelblau darueber.
  3. Der Grund trug den Ton zu 24 Prozent und ging ab 58 Prozent in
     fast reines Schwarz. Zwei Drittel jeder Kachel waren damit
     dieselbe Farbe, egal welcher Ton darueber stand.

Danach: 51,1 Prozent kamen an, NULL ununterscheidbare Paare.

UND DANN HAETTE ICH EINEN FEHLER GEGEN EINEN ANDEREN GETAUSCHT.
Gemessen an jedem einzelnen Kacheltext: VORHER war 1 von 31 unter
4,5:1, DANACH 27 von 31. Also genau das, was Filipe schon zweimal
gemeldet hat ("man bekommt fast nichts gelesen"). Kein Messwert hat
es angezeigt -- pruef-buehne nimmt Stichproben und traf die
Kacheltexte nicht.

Behoben mit einem Lesesaum: ein dunkler Verlauf VON UNTEN, der genau
den Streifen abdunkelt, auf dem gelesen wird, und die oberen zwei
Drittel in Ruhe laesst. Dasselbe Mittel, mit dem jede Bildunterschrift
auf einem Foto lesbar gemacht wird.

  Texte unter 4,5:1:   1 -> 27 -> 0
  Farbe kommt an:      14,4 % -> 51,1 % -> 36,5 %

VERTRAULICH MELDEN IST JETZT KNALLROT.
Filipe: "nicht die kachel draussen soll rot sein sondern die kachel
vertraulich melden nur die soll knall rot sein!!!" Ich hatte screen5
und screen6 vertauscht. Ringtausch ohne neue Farbe:
  Vertraulich melden  28 -> 38  (#ff1a1a, knallrot)
  Draussen            38 -> 39  (die Farbe, die Regeln & Hilfe hatte)
  Regeln & Hilfe      39 -> 28  (das frei gewordene Gruen)

NEU, UND DER EIGENTLICHE LERNEFFEKT:
server/helfer-kachel-echtfarbe.mjs misst beides in EINER Messung --
Farbunterschied UND Lesbarkeit. Wer sie trennt, verbessert das eine
und verliert das andere; genau das ist heute passiert. Benutzt von
der Pruefung und vom Werkzeug, damit es nur eine Fassung gibt.

pruef-kachelfarben 18/0 (war 13), davon fuenf am echten Bildschirm:
  - kein Paar sieht auf dem Bildschirm gleich aus (0,0357)
  - mindestens ein Drittel der Rohfarbe kommt an (36,5 %)
  - jeder Kacheltext erreicht 4,5:1 (schlechtester: 4,80:1)
Mit drittem Ausgang: kein Browser heisst "konnte nicht nachsehen",
nicht "in Ordnung".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 10:26:49 +02:00

301 lines
14 KiB
JavaScript

/* =====================================================================
helfer-kachel-echtfarbe.mjs — welche Farbe eine Kachel WIRKLICH hat
---------------------------------------------------------------------
Filipe am 22.09.2026: „ich seh immer wieder sehr viele die einfach
komplett die gleiche farbe haben ich will dass sie wirklich alle
alle alle komplett unterschiedlich sind."
Er hatte recht, und `pruef-kachelfarben` war trotzdem grün. Der
Grund war ein Messfehler: Sie las `--ton` aus `start.css` — die
ROHFARBE. Die lag zwischen allen 31 Kacheln weit auseinander
(0,0978 in OKLab). Nur steht sie so nie auf dem Bildschirm: Die
Kachel zeichnet sie als Verlauf unter einem Sternenfeld, einer
Vignette und (bis zu diesem Tag) einer fest blauen Wolke.
Gemessen kamen 14,4 Prozent an. Acht Paare waren für ein Auge
nicht mehr zu unterscheiden.
WARUM DER DURCHSCHNITT ÜBER DIE GANZE KACHEL
Eine Einzelmessung an einem Punkt träfe vielleicht einen Stern,
einen Rand oder das Zeichen. Gemittelt wird über alle Bildpunkte —
das ist ungefähr das, was ein Auge aus zwei Metern Entfernung
sieht, und genau darum geht es.
WARUM DAS HIER STEHT UND NICHT ZWEIMAL
`tools/kachel-echtfarbe.mjs` zeigt die Liste zum Ansehen,
`server/pruef-kachelfarben.mjs` prüft sie. Zwei Fassungen derselben
Messung altern unterschiedlich — dann sagt die eine „gleich" und
die andere „verschieden", und man glaubt der bequemeren.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, execFileSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { createServer } from "node:https";
import { request as httpAnfrage } from "node:http";
const HIER = dirname(fileURLToPath(import.meta.url));
const CREW = "crew.dogfather-universe.com";
/* ---- Farbrechnung (OKLab, wie in den Farbwerkzeugen) --------------- */
const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
export function oklab([R, G, B]) {
const [r, g, b] = [R / 255, G / 255, B / 255].map(linear);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
];
}
const abstand = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const ausHex = (hex) => (/^#[0-9a-f]{6}$/i.test(hex)
? [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) : null);
export const alsHex = (c) => "#" + c.map((x) => x.toString(16).padStart(2, "0")).join("");
/* UNTER 0,02 SIEHT EIN AUGE KEINEN UNTERSCHIED MEHR.
In OKLab entspricht 1 Einheit ungefähr dem ganzen Helligkeitsbereich;
0,02 ist damit die Grenze, ab der zwei Flächen nebeneinander noch
als „zwei Farben" gelesen werden. Darunter ist es eine. */
export const NICHT_UNTERSCHEIDBAR = 0.02;
/**
* Fotografiert die Kachelwand und liest die echten Bildpunkte aus.
*
* Gibt IMMER ein Ergebnis zurück — auch wenn es nicht messen konnte.
* `moeglich: false` mit Grund ist der dritte Ausgang: nicht „in
* Ordnung", nicht „kaputt", sondern „konnte nicht nachsehen".
*/
export async function pruefeEchteFarben({ port = 4369, breite = 1500 } = {}) {
let chromium;
try {
({ chromium } = await import("playwright-core"));
} catch {
return { moeglich: false, grund: "playwright-core ist nicht da" };
}
const ordner = mkdtempSync(join(tmpdir(), "ws-echtfarbe-"));
const DB = join(ordner, "workspace.db");
let kind = null, vorbau = null, browser = null;
try {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(port),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let oben = false;
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try {
const a = await fetch(`http://127.0.0.1:${port}/workspace/`, { redirect: "manual" });
if (a.status < 500) { oben = true; break; }
} catch { /* noch nicht oben */ }
}
if (!oben) return { moeglich: false, grund: `der Server kam auf Port ${port} nicht hoch` };
const d = new DatabaseSync(DB);
const salz = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salz, 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, salz, 32768, new Date().toISOString());
d.close();
const keks = await new Promise((fertig, schief) => {
const daten = JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" });
const a = httpAnfrage({ host: "127.0.0.1", port, path: "/workspace/api/anmelden",
method: "POST",
headers: { "content-type": "application/json", Host: CREW,
"content-length": Buffer.byteLength(daten) } }, (antwort) => {
let t = ""; antwort.on("data", (x) => { t += x; });
antwort.on("end", () => fertig((antwort.headers["set-cookie"] || [])
.join("; ").split(";")[0]));
});
a.on("error", schief); a.end(daten);
});
if (!keks) return { moeglich: false, grund: "die Anmeldung hat keinen Keks geliefert" };
/* Chromium zwingt crew.… per eingebauter HSTS-Liste auf https --
deshalb derselbe selbst unterschriebene Vorbau wie überall. */
const zKey = join(ordner, "k.pem"), zCrt = join(ordner, "z.pem");
try {
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", zKey, "-out", zCrt, "-days", "2", "-subj", `/CN=${CREW}`,
"-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
} catch {
return { moeglich: false, grund: "openssl ist nicht da (fuer den https-Vorbau)" };
}
const HTTPS_PORT = port + 1;
vorbau = createServer({ key: readFileSync(zKey), cert: readFileSync(zCrt) },
(an, ab) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port, path: an.url, method: an.method,
headers: { ...an.headers, host: CREW },
}, (antwort) => { ab.writeHead(antwort.statusCode, antwort.headers); antwort.pipe(ab); });
weiter.on("error", () => { try { ab.destroy(); } catch { /* Zugabe */ } });
an.on("error", () => {});
an.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
browser = await chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1:${HTTPS_PORT}`],
ignoreHTTPSErrors: true,
});
const ctx = await browser.newContext({
viewport: { width: breite, height: 1000 }, ignoreHTTPSErrors: true });
const [n, w] = keks.split("=");
await ctx.addCookies([{ name: n, value: w, domain: CREW, path: "/" }]);
const seite = await ctx.newPage();
await seite.goto(`https://${CREW}/workspace/start.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const beschriftung = await seite.evaluate(() => [...document.querySelectorAll(".kachel")]
.map((k) => ({
name: (k.querySelector(".kachel__name") || k).textContent.trim().split("\n")[0],
ton: getComputedStyle(k).getPropertyValue("--ton").trim(),
})));
/* WO DER TEXT STEHT -- fuer den Lesbarkeitsteil weiter unten. */
const textlagen = await seite.evaluate(() => [...document.querySelectorAll(".kachel")]
.map((k) => {
const t = k.querySelector(".kachel__unter") || k.querySelector(".kachel__name");
if (!t) return null;
const r = t.getBoundingClientRect(), kr = k.getBoundingClientRect();
return {
x: Math.round(r.x - kr.x), y: Math.round(r.y - kr.y),
b: Math.round(r.width), h: Math.round(r.height),
farbe: getComputedStyle(t).color,
};
}));
const zeiger = await seite.$$(".kachel");
const liste = [];
for (let i = 0; i < zeiger.length; i++) {
const png = (await zeiger[i].screenshot({ type: "png" })).toString("base64");
/* Der Browser zählt selbst -- ein PNG hier von Hand zu lesen
wäre ein eigenes Werkzeug, und eines mehr, das altert. */
const mittel = await seite.evaluate(async (daten) => {
const bild = new Image();
await new Promise((f) => { bild.onload = f; bild.src = "data:image/png;base64," + daten; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d");
g.drawImage(bild, 0, 0);
const p = g.getImageData(0, 0, c.width, c.height).data;
let r = 0, gr = 0, bl = 0, z = 0;
for (let k = 0; k < p.length; k += 4) { r += p[k]; gr += p[k + 1]; bl += p[k + 2]; z++; }
return [Math.round(r / z), Math.round(gr / z), Math.round(bl / z)];
}, png);
/* WIE LESBAR IST DER TEXT DARAUF?
============================================================
Das ist die Frage, die am 22.09.2026 beinahe untergegangen
waere. Die Farben kraeftiger zu machen war richtig -- gemessen
stieg der Anteil, der ankommt, von 14,4 auf 51,1 Prozent.
Nur waren damit 27 von 31 Kacheltexten unter 4,5:1; vorher
war es EINER. Ein Fehler gegen einen anderen getauscht, und
zwar genau der, den Filipe schon zweimal gemeldet hat ("man
bekommt fast nichts gelesen").
Gemessen wird der HAEUFIGSTE Farbwert hinter dem Text, nicht
der hellste: Der hellste Punkt IST der Text (ein erster Anlauf
meldete darum bei allen 31 einen Kontrast von 1,00 -- also
einen unmoeglichen Wert). Der Text deckt nur einen Bruchteil
der Flaeche; der haeufigste Wert ist der Grund. */
const l = textlagen[i];
let kontrast = null;
if (l && l.b > 4 && l.h > 4) {
const grund = await seite.evaluate(async ([daten, lage]) => {
const bild = new Image();
await new Promise((f) => { bild.onload = f; bild.src = "data:image/png;base64," + daten; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d");
g.drawImage(bild, 0, 0);
const x = Math.max(0, lage.x), y = Math.max(0, lage.y);
const bb = Math.min(lage.b, c.width - x), hh = Math.min(lage.h, c.height - y);
if (bb <= 0 || hh <= 0) return null;
const p = g.getImageData(x, y, bb, hh).data;
const zaehler = new Map();
for (let k = 0; k < p.length; k += 4) {
const sch = ((p[k] >> 3) << 10) | ((p[k + 1] >> 3) << 5) | (p[k + 2] >> 3);
zaehler.set(sch, (zaehler.get(sch) || 0) + 1);
}
let haeufig = 0, wie = -1;
for (const [sch, n2] of zaehler) if (n2 > wie) { wie = n2; haeufig = sch; }
return [((haeufig >> 10) & 31) << 3, ((haeufig >> 5) & 31) << 3, (haeufig & 31) << 3];
}, [png, l]);
if (grund) {
const tf = (l.farbe.match(/\d+/g) || [255, 255, 255]).slice(0, 3).map(Number);
const hell = (c) => {
const [r, g2, b] = c.map((x) => {
const v = x / 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g2 + 0.0722 * b;
};
const [a1, a2] = hell(tf) > hell(grund) ? [hell(tf), hell(grund)] : [hell(grund), hell(tf)];
kontrast = (a1 + 0.05) / (a2 + 0.05);
}
}
liste.push({ ...beschriftung[i], echt: mittel, kontrast });
}
/* ---- Auswerten ---------------------------------------------- */
let engstes = { d: Infinity, a: "", b: "" };
let engstesRoh = Infinity;
let ununterscheidbar = 0, paare = 0;
for (let i = 0; i < liste.length; i++) {
for (let j = i + 1; j < liste.length; j++) {
paare++;
const dE = abstand(oklab(liste[i].echt), oklab(liste[j].echt));
if (dE < engstes.d) engstes = { d: dE, a: liste[i].name, b: liste[j].name };
if (dE < NICHT_UNTERSCHEIDBAR) ununterscheidbar++;
const ra = ausHex(liste[i].ton), rb = ausHex(liste[j].ton);
if (ra && rb) engstesRoh = Math.min(engstesRoh, abstand(oklab(ra), oklab(rb)));
}
}
const mitKontrast = liste.filter((k) => k.kontrast !== null);
const zuDunkel = mitKontrast.filter((k) => k.kontrast < 4.5);
return {
moeglich: true,
anzahl: liste.length,
/* Wie viele Kacheltexte nicht lesbar sind -- und welche. */
textGemessen: mitKontrast.length,
textZuWenig: zuDunkel.length,
textSchlechteste: mitKontrast.length
? mitKontrast.reduce((a, b) => (a.kontrast < b.kontrast ? a : b))
: null,
textNamen: zuDunkel.slice(0, 5).map((k) => `${k.name} (${k.kontrast.toFixed(2)}:1)`),
liste,
engstes,
engstesRoh,
ununterscheidbar,
paare,
/* Wie viel von der Rohfarbe auf dem Bildschirm ankommt. Faellt
das unter ein Drittel, nuetzt die schoenste Rohfarbe nichts. */
anteil: engstesRoh > 0 ? (engstes.d / engstesRoh) * 100 : 0,
};
} catch (f) {
return { moeglich: false, grund: f?.message || String(f) };
} finally {
try { if (browser) await browser.close(); } catch { /* Zugabe */ }
try { if (vorbau) await new Promise((r) => vorbau.close(r)); } catch { /* Zugabe */ }
try { if (kind) kind.kill(); } catch { /* Zugabe */ }
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
}
}