Filipe, 01.10.2026: „mach alles los." Damit auch der offene Punkt von gestern: „Zugetragen" ist beim Aufmachen einer Karte die Vorgabe. DER ALTE EINWAND BLEIBT GUELTIG -- er steht jetzt in der Bedingung statt im Weg. Er lautete: „Ein Filter, der beim Öffnen schon etwas versteckt, lässt einen Punkte suchen, die gestern noch da waren." Richtig, und er trifft genau EINEN Fall: den, in dem gar nichts zugetragen ist. Dann zeigte „Zugetragen" eine LEERE Karte, und eine leere Karte sieht aus wie ein Fehler. Also: Hat dieser Mensch zugetragene Punkte, steht der Filter darauf. Hat er keine, steht er auf „Alle". Beides ist eine Auskunft, keines ist eine Suche. UND ER WIRD JE MENSCH NEU ENTSCHIEDEN. Bisher blieb der Filter ueber den Personenwechsel hinweg stehen -- richtig, solange er eine Erwartungsstufe meinte („wer Fortgeschritten gewaehlt hat, will das weiter sehen"). „Zugetragen" ist eine Aussage UEBER DIESE PERSON; sie mitzunehmen waere die falsche Frage. GEMESSEN, beide Faelle: Diene (2 zugetragen) -> Filter „Zugetragen", 2 Punkte VanVan (nichts) -> Filter „Alle", 68 Punkte ein Klick auf „Alle" -> wieder 68 Die Messung fragt jetzt, WAS DASTEHT, bevor jemand etwas anfasst. Vorher klickte sie auf den Filter und zaehlte nach -- seit er die Vorgabe ist, haette derselbe Klick ihn AUSgeschaltet. Sie haette das Gegenteil gemessen und trotzdem eine Zahl gemeldet. ZWEI NACHZUEGLER AUS DEM SICHT-WEG VON GESTERN `pruef-werdegang` wurde rot: „403 Forbidden" im Browser, ohne Adresse. Die Meldung sagte nicht, WORAN sie scheitert -- also sagt sie es jetzt (`403 /workspace/api/chat/sicht`). Die Ursache lag in der Messumgebung, nicht im Programm: Der HTTPS-Vorbau der Pruefung reicht den Host OHNE Port weiter, waehrend der Browser seine Herkunft MIT Port schickt. `gleicheHerkunft` vergleicht beides und antwortet folgerichtig 403. mess-reaktion macht es seit Langem richtig; zwei Dateien nicht. Aufgefallen ist es erst jetzt, weil der Sicht-Weg der erste zustandsaendernde POST ist, der auf JEDER Seite laeuft. Live stimmen Herkunft und Host ueberein (beide ohne Port, Caddy reicht den Host durch) -- nachgemessen. GEPRUEFT pruef-werdegang 103/0 (war 103/1), pruef-entwicklung 79/0, pruef-entwicklung-kacheln 34/0, mess-vanvan-karte ohne ein einziges ACHTUNG, pruef-zwischenspeicher 34/0. Co-Authored-By: Claude Opus 5 <[email protected]>
314 lines
15 KiB
JavaScript
314 lines
15 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";
|
|
import { zertifikatBauen } from "./helfer-openssl.mjs";
|
|
|
|
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");
|
|
/* HIER BLEIBT DAS try/catch -- anders als an den 28 anderen
|
|
Stellen. Diese Datei hat einen dritten Ausgang: Sie sagt
|
|
„moeglich: false" samt Grund, statt den ganzen Lauf
|
|
abzubrechen. Ein fehlender https-Vorbau ist hier kein Befund
|
|
ueber die Farben, sondern „konnte nicht nachsehen".
|
|
|
|
Seit dem 29.09.2026 sucht `zertifikatBauen` openssl auch
|
|
ausserhalb des PATH -- der Fall sollte damit nicht mehr
|
|
eintreten. Der Ausgang bleibt trotzdem stehen: Eine Sicherung
|
|
abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
|
|
Anfang des naechsten stillen Fehlschlags. */
|
|
try {
|
|
zertifikatBauen(zKey, zCrt, CREW);
|
|
} catch (f) {
|
|
return { moeglich: false, grund: `openssl: ${f.message.split("\n")[0]}` };
|
|
}
|
|
|
|
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,
|
|
/* MIT PORT (01.10.2026) -- siehe pruef-werdegang: Ohne
|
|
ihn vergleicht `gleicheHerkunft` Herkunft und Host und
|
|
antwortet 403. */
|
|
headers: { ...an.headers, host: `${CREW}:${HTTPS_PORT}` },
|
|
}, (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 */ }
|
|
}
|
|
}
|