diff --git a/server/pruef-kachelfarben.mjs b/server/pruef-kachelfarben.mjs new file mode 100644 index 00000000..435a31fb --- /dev/null +++ b/server/pruef-kachelfarben.mjs @@ -0,0 +1,229 @@ +/* ===================================================================== + pruef-kachelfarben.mjs — Keine zwei Kacheln sehen gleich aus + --------------------------------------------------------------------- + Filipe, 22.09.2026: „es sind viele kacheln die noch ähnliche farbe + haben, alle die sich ähnlich sind bitte immer verändern bis auf eins + … alle kacheln sollen eine andere farbe haben." Und: „diese zwei + kacheln sollen egal wie dan auch so bleiben wie hier erwartet." + + WARUM DAS EINE PRÜFUNG BRAUCHT UND NICHT NUR EINEN LAUF + Die Farben einmal auseinanderzuziehen war eine Rechnung. Sie in + einem halben Jahr noch auseinander zu haben, ist die schwierigere + Aufgabe: Wer morgen eine Kachel anlegt, greift sich eine Tonnummer + — und merkt nicht, dass sie neben einer anderen liegt. Auf dem + Bildschirm sieht das nicht nach einem Fehler aus, sondern nach zwei + Kacheln, die man verwechselt. + + Und die zwei festgelegten Töne kann ein späterer Farblauf jederzeit + überschreiben. Eine Festlegung, die nur in einem Kommentar steht, + ist eine Bitte; eine, die rot wird, ist eine Regel. + + VIER FRAGEN + 1. GETRENNT — kein Paar benutzter Töne liegt näher beieinander als + die Schwelle. + 2. EINDEUTIG — keine Ansicht (Rolle x Haus) zeigt zwei Kacheln mit + derselben Farbe. + 3. FESTGELEGT — Ton 28 trägt „Vertraulich melden", Ton 38 ist das + knallige Rot von „Draußen". + 4. LESBAR UND NICHT BLASS — 4,5:1 gegen den Grund, Buntheit über + der gemessenen Schwelle. + + Welche Töne benutzt werden, wird ABGELEITET (`bereicheFuer` / + `zusatzBereicheFuer` über alle Rollen und beide Häuser), nicht + aufgezählt. Eine zweite Liste wäre die, die veraltet. + ===================================================================== */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const CSS = join(HIER, "..", "workspace", "assets", "css", "start.css"); +const GRUND = "#0a121e"; + +/* DIE SCHWELLE IST GEMESSEN, NICHT GERATEN. + Vor dem Lauf vom 22.09.2026 lag das engste Paar der benutzten Töne + bei 0,0741 („Was ansteht" neben dem neuen Rot von „Draußen") — + das war der Zustand, den Filipe als „ähnlich" gemeldet hat. + Danach: 0,1028. 0,090 liegt dazwischen: Der heutige Stand hat + Luft, ein Rückfall auf „sieht gleich aus" schlägt an. */ +const MINDEST_ABSTAND = 0.090; + +/* Der Median der Buntheit aller Töne lag bei 0,168, die fünf + blassesten zwischen 0,064 und 0,088. Geprüft werden nur die + BENUTZTEN — unter den unbenutzten stehen noch blasse, die niemand + sieht und die beim nächsten Gebrauch ohnehin neu gerechnet werden. */ +const MINDEST_BUNTHEIT = 0.12; + +/* Was festgelegt ist — mit dem Grund, aus dem es festgelegt wurde. */ +const FESTGELEGT = [ + { ton: 28, kachel: "Vertraulich melden", + warum: "dieselbe Farbe wie fuer Regeln & Hilfe -- wer Hilfe sucht, landet bei einem von beiden" }, + { ton: 38, kachel: "Draußen", hex: "#ff1a1a", + warum: "knalliges Rot, traegt ab 20 Uhr den LIVE-Punkt" }, +]; + +let ok = 0, fehl = 0; +const pruef = (name, bedingung, zusatz = "") => { + if (bedingung) { ok++; console.log(` OK ${name}`); } + else { fehl++; console.log(` FEHL ${name}${zusatz ? " — " + zusatz : ""}`); } +}; + +/* ---- Farbrechnung (OKLab, wie in den Farbwerkzeugen) --------------- */ +const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); +function oklab(hex) { + const [r, g, b] = srgb(hex).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 lum = (hex) => { + const [r, g, b] = srgb(hex).map(linear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const kontrast = (hex) => (lum(hex) + 0.05) / (lum(GRUND) + 0.05); +const buntheit = (hex) => { const [, a, b] = oklab(hex); return Math.hypot(a, b); }; +const abstand = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + +console.log("\n===== KACHELFARBEN =====\n"); + +/* ---- start.css lesen: vollständig oder gar nicht ------------------- */ +const css = readFileSync(CSS, "utf8"); +const roh = new Set([...css.matchAll(/\[data-ton="(\d+)"\]/g)].map((m) => Number(m[1]))); +const farben = new Map(); +for (const zeile of css.split("\n")) { + const m = zeile.match(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/); + if (m) farben.set(Number(m[1]), m[2].toLowerCase()); +} +/* DER DRITTE AUSGANG. Liest die Prüfung nicht alle Töne, misst sie + gegen einen Stand, den es nicht gibt — und wäre womöglich grün. */ +if (roh.size !== farben.size) { + console.error(`KONNTE NICHT NACHSEHEN: ${roh.size} Toene stehen in start.css, ` + + `gelesen wurden ${farben.size}.`); + console.error(" Vermutlich hat sich die Schreibweise der Zeile geaendert."); + process.exit(2); +} +console.log(` (${farben.size} Toene in start.css gelesen)`); + +/* ---- Welche Töne ein Mensch wirklich sieht (abgeleitet) ------------ */ +const w = await import("./workspace.js"); +const rollen = ["admin", "hand", "linke", "modi", "gast", + "manager", "scout", "creator", "spicy"]; +const benutzt = new Map(); /* ton -> Set(Namen) */ +const ansichten = []; +for (const r of rollen) { + for (const haus of ["crew", "agentur"]) { + const p = { id: 1, rolle: r, name: "X" }; + let k = []; + try { + k = [...(w.bereicheFuer(p, haus) || []), ...(w.zusatzBereicheFuer(p, haus) || [])]; + } catch { continue; } + const hier = new Map(); + for (const kachel of k) { + if (!kachel || kachel.ton == null) continue; + if (!benutzt.has(kachel.ton)) benutzt.set(kachel.ton, new Set()); + benutzt.get(kachel.ton).add(kachel.name); + if (!hier.has(kachel.ton)) hier.set(kachel.ton, []); + hier.get(kachel.ton).push(kachel.name); + } + if (hier.size) ansichten.push({ rolle: r, haus, toene: hier }); + } +} + +/* DIE ZAHL GEHÖRT IN DIE BEDINGUNG. Fände diese Prüfung null benutzte + Töne, wäre alles Folgende trivial grün — und bliebe es für immer. */ +pruef(`es gibt benutzte Kachelfarben (${benutzt.size} Toene, ${ansichten.length} Ansichten)`, + benutzt.size >= 20 && ansichten.length >= 5, + `nur ${benutzt.size} Toene in ${ansichten.length} Ansichten gefunden`); + +/* ---- 1. Getrennt ---------------------------------------------------- */ +console.log("\n=== Kein Paar sieht gleich aus ==="); +{ + const liste = [...benutzt.keys()] + .filter((n) => farben.has(n)) + .map((n) => ({ n, hex: farben.get(n), lab: oklab(farben.get(n)) })); + pruef(`jeder benutzte Ton ist in start.css definiert (${liste.length}/${benutzt.size})`, + liste.length === benutzt.size, + "eine Kachel zeigt auf eine Tonnummer, die es nicht gibt"); + + let eng = { d: Infinity, a: null, b: null }; + for (let i = 0; i < liste.length; i++) { + for (let j = i + 1; j < liste.length; j++) { + const d = abstand(liste[i].lab, liste[j].lab); + if (d < eng.d) eng = { d, a: liste[i], b: liste[j] }; + } + } + const namen = (t) => [...(benutzt.get(t.n) || [])].join("/"); + pruef(`das engste Paar liegt ueber ${MINDEST_ABSTAND} (${eng.d.toFixed(4)}: ` + + `${namen(eng.a)} <-> ${namen(eng.b)})`, + eng.d >= MINDEST_ABSTAND, + `${eng.d.toFixed(4)} zwischen Ton ${eng.a?.n} und ${eng.b?.n}`); +} + +/* ---- 2. Eindeutig je Ansicht --------------------------------------- */ +console.log("\n=== Keine Ansicht zeigt zwei gleiche ==="); +{ + const treffer = []; + for (const a of ansichten) { + for (const [ton, namen] of a.toene) { + if (namen.length > 1) treffer.push(`${a.rolle}/${a.haus}: Ton ${ton} = ${namen.join(" + ")}`); + } + } + pruef(`keine Ansicht zeigt zwei Kacheln mit derselben Farbe (${ansichten.length} geprueft)`, + treffer.length === 0, treffer.join(" | ")); +} + +/* ---- 3. Festgelegt -------------------------------------------------- */ +console.log("\n=== Was festgelegt ist, bleibt ==="); +for (const f of FESTGELEGT) { + const traeger = [...(benutzt.get(f.ton) || [])]; + pruef(`Ton ${f.ton} traegt "${f.kachel}" (${f.warum})`, + traeger.includes(f.kachel), + `Ton ${f.ton} traegt stattdessen: ${traeger.join(", ") || "nichts"}`); + if (f.hex) { + pruef(` und hat die Farbe ${f.hex} (jetzt ${farben.get(f.ton)})`, + farben.get(f.ton) === f.hex, + `${farben.get(f.ton)} statt ${f.hex}`); + } +} + +/* ---- 4. Lesbar und nicht blass -------------------------------------- */ +console.log("\n=== Lesbar und kraeftig ==="); +{ + const dunkel = [], blass = []; + for (const n of benutzt.keys()) { + const hex = farben.get(n); + if (!hex) continue; + if (kontrast(hex) < 4.5) dunkel.push(`Ton ${n} ${hex} (${kontrast(hex).toFixed(2)}:1)`); + if (buntheit(hex) < MINDEST_BUNTHEIT) blass.push(`Ton ${n} ${hex} (${buntheit(hex).toFixed(3)})`); + } + pruef(`alle ${benutzt.size} benutzten Toene erreichen 4,5:1 gegen den Grund`, + dunkel.length === 0, dunkel.join(", ")); + pruef(`keiner ist blasser als ${MINDEST_BUNTHEIT}`, + blass.length === 0, blass.join(", ")); +} + +/* ---- Gegenprobe: kann diese Pruefung ueberhaupt rot werden? --------- */ +console.log("\n=== Gegenprobe ==="); +{ + /* Zwei erfundene Toene, die fast gleich sind. Fände die Rechnung sie + nicht, waere jede gruene Meldung oben wertlos. */ + const a = oklab("#3366cc"), b = oklab("#3467cd"); + pruef(`zwei fast gleiche Farben werden als zu nah erkannt ` + + `(${abstand(a, b).toFixed(4)} < ${MINDEST_ABSTAND})`, + abstand(a, b) < MINDEST_ABSTAND); + const weit = oklab("#ff1a1a"), weit2 = oklab("#11aadb"); + pruef(`zwei deutlich verschiedene nicht (${abstand(weit, weit2).toFixed(4)})`, + abstand(weit, weit2) >= MINDEST_ABSTAND); + pruef("ein zu dunkler Ton wuerde auffallen (#101820)", kontrast("#101820") < 4.5); + pruef("ein blasser Ton wuerde auffallen (#ffe5ae)", buntheit("#ffe5ae") < MINDEST_BUNTHEIT); +} + +console.log(`\n ${ok} bestanden, ${fehl} fehlgeschlagen\n`); +process.exit(fehl ? 1 : 0); diff --git a/server/workspace.js b/server/workspace.js index 39eec9d6..11b707df 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1343,7 +1343,11 @@ const TREFF_BEREICHE = [ Das Brett gibt es weiter; treff-regeln.html verlinkt darauf. Ein Ort fuer die Frage, nicht zwei. */ - zeichen: "schutz", ton: 28, ziel: "treff-regeln.html", szene: "studio" }, + /* EIGENER TON SEIT 22.09.2026. Ton 28 traegt jetzt "Vertraulich + melden" -- ausdruecklicher Wunsch, dort gesetzt. Zwei Kacheln + mit derselben Farbe in derselben Ansicht waeren genau das, was + screen4 abschaffen soll. */ + zeichen: "schutz", ton: 39, ziel: "treff-regeln.html", szene: "studio" }, ]; /* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY @@ -1462,7 +1466,15 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "linke", "admin"]); const HILFE_KACHEL = { gruppe: "Für dich", gruppeUnter: "Deine eigene Seite", name: "Vertraulich melden", unter: "Nur DogFather und die rechte Hand", - zeichen: "schutz", ton: 39, ziel: "hilfe.html", szene: "studio", + /* DIESELBE FARBE WIE "REGELN & HILFE" -- AUSDRUECKLICH SO GEWOLLT + (22.09.2026, Filipe: "die farbe dieser kachel soll die farbe von + der kachel: regeln & hilfe werden ... und diese zwei kacheln + sollen egal wie dan auch so bleiben wie hier erwartet"). + + Beide gehoeren zusammen: Wer Hilfe sucht, landet bei einem von + beiden. Ton 28 ist damit GESETZT -- die Farbverteilung von + screen4 fasst ihn nicht an, sie weicht ihm aus. */ + zeichen: "schutz", ton: 28, ziel: "hilfe.html", szene: "studio", }; /* DAS TEAM SIEHT DEN TREFF MIT -- als eigene Gruppe unter seinen diff --git a/tools/farben-blick.mjs b/tools/farben-blick.mjs new file mode 100644 index 00000000..65954ff6 --- /dev/null +++ b/tools/farben-blick.mjs @@ -0,0 +1,219 @@ +/* ===================================================================== + DIE KACHELFARBEN MIT ECHTEN AUGEN ANSEHEN (22.09.2026) + --------------------------------------------------------------------- + `pruef-kachelfarben.mjs` rechnet Abstände in OKLab. Das sagt, dass + keine zwei Farben zu nah beieinander liegen — es sagt NICHT, ob die + Wand gut aussieht. Am 22.09. hat der Blick mit echten Augen schon + drei Dinge gefunden, die keine Messung gemeldet hätte. + + Dieses Werkzeug nimmt die Startseite auf, als DogFather und als + Zuschauer, am Rechner und am Handy — und misst dabei das eine, was + sich sonst nur im Bild zeigt: ob zwei NEBENEINANDER liegende + Kacheln ähnlich aussehen. Zwei ähnliche Farben an entgegengesetzten + Enden der Wand fallen niemandem auf; zwei nebeneinander schon. + + AUFRUF + node tools/farben-blick.mjs + BREITE=390 node tools/farben-blick.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } 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 { execFileSync } from "node:child_process"; +import { chromium } from "playwright-core"; + +const HIER = join(dirname(fileURLToPath(import.meta.url)), "..", "server"); +const PORT = Number(process.env.PORT_BLICK || 4366); +const BASIS = `http://127.0.0.1:${PORT}`; +const BREITE = Number(process.env.BREITE || 1500); +const ordner = mkdtempSync(join(tmpdir(), "ws-farben-")); +const DB = join(ordner, "workspace.db"); +let kind = null; + +async function start() { + 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 aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + for (let i = 0; i < 80; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (kind.exitCode !== null) break; + try { + const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); + if (a.status < 500) return; + } catch { /* noch nicht oben */ } + } + throw new Error("Server startete nicht:\n" + aus.slice(-1500)); +} + +function personAnlegen(name, rolle, code) { + const d = new DatabaseSync(DB); + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, 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(name, rolle, hash, salz, 32768, new Date().toISOString()); + d.close(); +} + +function rohAnfrage(pfad, host, koerper) { + return new Promise((fertig, schief) => { + const daten = JSON.stringify(koerper); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: "POST", + headers: { "content-type": "application/json", Host: host, + "content-length": Buffer.byteLength(daten) }, + }, (antwort) => { + let text = ""; + antwort.on("data", (d) => { text += d; }); + antwort.on("end", () => fertig({ + code: antwort.statusCode, text, + keks: (antwort.headers["set-cookie"] || []).join("; ").split(";")[0], + })); + }); + a.on("error", schief); + a.end(daten); + }); +} + +async function anmelden(rolle, code, host) { + let a = await rohAnfrage("/workspace/api/anmelden", host, { rolle, code }); + if (/alter/i.test(a.text)) { + a = await rohAnfrage("/workspace/api/anmelden", host, { rolle, code, alter_ok: true }); + } + if (a.code !== 200) throw new Error(`Anmeldung ${rolle} -> ${a.code} ${a.text.slice(0, 160)}`); + return a.keks; +} + +/* Chromium zwingt crew.dogfather-universe.com per eingebauter + HSTS-Liste auf https. Deshalb derselbe selbst unterschriebene + Vorbau wie in pruef-handy-teamdogi.mjs -- eine Nachbildung waere die + zweite Fassung, die anders altert. */ +function schluesselUndZeugnis() { + const k = join(ordner, "k.pem"), z = join(ordner, "z.pem"); + execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", k, "-out", z, "-days", "2", "-subj", "/CN=crew.dogfather-universe.com", + "-addext", "subjectAltName=DNS:crew.dogfather-universe.com"], + { stdio: "ignore" }); + return { k, z }; +} + +await start(); +personAnlegen("Filipe", "admin", "CODE-DOGI-0001"); +personAnlegen("Jonas", "gast", "CODE-GAST-0001"); + +const { k, z } = schluesselUndZeugnis(); +const { readFileSync } = await import("node:fs"); +const vorbau = createServer({ key: readFileSync(k), cert: readFileSync(z) }, (an, ab) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: an.url, method: an.method, + headers: { ...an.headers, host: "crew.dogfather-universe.com" }, + }, (antwort) => { ab.writeHead(antwort.statusCode, antwort.headers); antwort.pipe(ab); }); + /* Beim Aufraeumen bricht der Browser laufende Anfragen ab. Das ist + kein Befund -- ohne diesen Zuhoerer beendet Node den Lauf mit + einem Fehler, NACHDEM die Bilder schon fertig sind. */ + weiter.on("error", () => { try { ab.destroy(); } catch {} }); + an.on("error", () => {}); + an.pipe(weiter); +}); +const HTTPS_PORT = PORT + 1; +await new Promise((r) => vorbau.listen(HTTPS_PORT, r)); + +const browser = await chromium.launch({ + args: [`--host-resolver-rules=MAP crew.dogfather-universe.com 127.0.0.1:${HTTPS_PORT}`], + ignoreHTTPSErrors: true, +}); + +const rollen = [ + { rolle: "admin", code: "CODE-DOGI-0001", wer: "DogFather" }, + { rolle: "gast", code: "CODE-GAST-0001", wer: "Community" }, +]; + +for (const r of rollen) { + const keks = await anmelden(r.rolle, r.code, "crew.dogfather-universe.com"); + const ctx = await browser.newContext({ + viewport: { width: BREITE, height: 1000 }, + ignoreHTTPSErrors: true, + isMobile: BREITE < 700, hasTouch: BREITE < 700, + }); + const [name, wert] = keks.split("="); + await ctx.addCookies([{ name, value: wert, domain: "crew.dogfather-universe.com", path: "/" }]); + const s = await ctx.newPage(); + await s.goto("https://crew.dogfather-universe.com/workspace/start.html", + { waitUntil: "networkidle" }); + await s.waitForTimeout(600); + + /* WAS LIEGT NEBENEINANDER? Das ist die Frage, die eine Tabelle von + Abständen nicht beantwortet. */ + const bild = await s.evaluate(() => { + const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + const oklab = (rgb) => { + const [r, g, b] = rgb.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 s2 = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s2, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s2, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s2]; + }; + const kacheln = [...document.querySelectorAll(".kachel")].map((k) => { + const r = k.getBoundingClientRect(); + const roh = getComputedStyle(k).getPropertyValue("--ton").trim(); + let rgb = null; + if (/^#[0-9a-f]{6}$/i.test(roh)) { + rgb = [1, 3, 5].map((i) => parseInt(roh.slice(i, i + 2), 16) / 255); + } + return { + name: (k.querySelector(".kachel__name") || k).textContent.trim().split("\n")[0], + ton: roh, lab: rgb ? oklab(rgb) : null, + x: Math.round(r.x), y: Math.round(r.y), + b: Math.round(r.width), h: Math.round(r.height), + }; + }); + /* Nachbarn: alles, was sich horizontal oder vertikal beruehrt. */ + const nah = []; + const d = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + for (let i = 0; i < kacheln.length; i++) { + for (let j = i + 1; j < kacheln.length; j++) { + const A = kacheln[i], B = kacheln[j]; + if (!A.lab || !B.lab) continue; + const waagerecht = Math.abs(A.y - B.y) < 8 && Math.abs(A.x - B.x) < A.b + B.b + 40; + const senkrecht = Math.abs(A.x - B.x) < 8 && Math.abs(A.y - B.y) < A.h + B.h + 40; + if (!waagerecht && !senkrecht) continue; + nah.push({ a: A.name, b: B.name, d: d(A.lab, B.lab) }); + } + } + nah.sort((p, q) => p.d - q.d); + return { anzahl: kacheln.length, ohneTon: kacheln.filter((k) => !k.lab).length, nah: nah.slice(0, 5) }; + }); + + console.log(`\n${r.wer} bei ${BREITE} px: ${bild.anzahl} Kacheln` + + (bild.ohneTon ? `, ${bild.ohneTon} OHNE Farbe` : "")); + for (const n of bild.nah) { + console.log(` ${n.d.toFixed(4)} ${n.a} neben ${n.b}` + + (n.d < 0.09 ? " <<< sehen sich aehnlich" : "")); + } + + const pfad = join("C:/Users/qciga/Documents/Obelix/DogFather", + `farben-${r.rolle}-${BREITE}.png`); + await s.screenshot({ path: pfad, fullPage: true }); + console.log(` Bild: ${pfad}`); + await ctx.close(); +} + +await browser.close(); +await new Promise((r) => vorbau.close(r)); +if (kind) kind.kill(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } diff --git a/tools/kachel-farben-entzerren.mjs b/tools/kachel-farben-entzerren.mjs new file mode 100644 index 00000000..074a2c1a --- /dev/null +++ b/tools/kachel-farben-entzerren.mjs @@ -0,0 +1,341 @@ +/* ===================================================================== + ÄHNLICHE KACHELFARBEN AUSEINANDERZIEHEN (22.09.2026) + --------------------------------------------------------------------- + Filipe: „es sind viele kacheln die noch ähnliche farbe haben, alle + die sich ähnlich sind bitte immer verändern bis auf eins und farben + nehmen die wir noch nicht haben, alle kacheln sollen eine andere + farbe haben." + + WARUM ES DIE ZWEI VORHANDENEN WERKZEUGE NICHT SCHON TUN + `kachel-farben.mjs` rechnet ALLE Töne neu — auch die zwei, die + Filipe am 22.09. ausdrücklich festgelegt hat („diese zwei kacheln + sollen egal wie dan auch so bleiben wie hier erwartet"). Es wäre + das richtige Verfahren mit dem falschen Ergebnis. + + `kachel-farbe-einzeln.mjs` zieht EINEN Ton nach — man müsste selbst + wissen, welchen, und nach jedem Lauf neu messen, welcher jetzt der + engste ist. Genau das macht diese Datei von selbst. + + DER UNTERSCHIED, AUF DEN ES ANKOMMT: NUR BENUTZTE TÖNE ZÄHLEN + In `start.css` stehen 42 Töne, benutzt werden 30. Beide anderen + Werkzeuge rechnen gegen alle 42 — sie weichen also Farben aus, die + kein Mensch je sieht, und machen dadurch die Abstände zwischen den + Farben, die man WIRKLICH nebeneinander sieht, unnötig klein. + + Welche Töne benutzt werden, wird aus den Kachellisten ABGELEITET + (`bereicheFuer` / `zusatzBereicheFuer` über alle Rollen und beide + Häuser), nicht danebengeschrieben. Eine zweite Liste wäre die, die + beim nächsten neuen Bereich vergessen wird. + + DAS VERFAHREN + Solange sich der kleinste Abstand verbessern lässt: den engsten + nicht gesetzten Ton herausnehmen und dorthin legen, wo er am + weitesten von allen übrigen entfernt ist. Von jedem ähnlichen Paar + ändert sich also genau EINER — wie gewünscht. + + Bei einem Paar, in dem BEIDE gesetzt sind, wird nichts geändert und + das Werkzeug sagt es. Der dritte Ausgang: in Ordnung / nicht in + Ordnung / konnte hier nichts tun, mit Grund. + + AUFRUF + node tools/kachel-farben-entzerren.mjs nur zeigen + node tools/kachel-farben-entzerren.mjs --schreiben eintragen + ===================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const CSS = join(HIER, "..", "workspace", "assets", "css", "start.css"); +const GRUND = "#0a121e"; + +/* DIE ZWEI, DIE FILIPE AM 22.09.2026 FESTGELEGT HAT. + Sie stehen hier mit Begründung, damit ein späterer Lauf sie nicht + für einen Zufall hält: + 28 — „Vertraulich melden", dieselbe Farbe wie „Regeln & Hilfe" + war; wer Hilfe sucht, landet bei einem von beiden. + 38 — „Draußen", knalliges Rot. Es trägt ab 20 Uhr den LIVE-Punkt; + Rot ist dort die Aussage, nicht die Dekoration. */ +const GESETZT = new Set([28, 38]); + +/* ---- Farbrechnung (gleich wie in kachel-farbe-einzeln.mjs) --------- */ +const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const zurueck = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); +const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + +function oklab(hex) { + const [r, g, b] = srgb(hex).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, + ]; +} + +function ausOklch(L, C, Hgrad) { + const h = (Hgrad * Math.PI) / 180; + const a = C * Math.cos(h), b2 = C * Math.sin(h); + const l_ = (L + 0.3963377774 * a + 0.2158037573 * b2) ** 3; + const m_ = (L - 0.1055613458 * a - 0.0638541728 * b2) ** 3; + const s_ = (L - 0.0894841775 * a - 1.2914855480 * b2) ** 3; + const r = +4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_; + const g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_; + const b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_; + const kanal = (x) => { + const v = zurueck(x); + if (v < -0.001 || v > 1.001) return null; + return Math.round(Math.min(1, Math.max(0, v)) * 255); + }; + const [R, G, B] = [kanal(r), kanal(g), kanal(b)]; + if (R === null || G === null || B === null) return null; + return "#" + [R, G, B].map((x) => x.toString(16).padStart(2, "0")).join(""); +} + +const lum = (hex) => { + const [r, g, b] = srgb(hex).map(linear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const kontrast = (hex) => (lum(hex) + 0.05) / (lum(GRUND) + 0.05); +const abstand = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + +/* ---- start.css lesen, vollständig oder gar nicht ------------------- */ +function lies(css) { + const aus = []; + for (const zeile of css.split("\n")) { + const m = zeile.match(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/); + if (!m) continue; + const wofuer = (zeile.match(/\/\*\s*([^*]+?)\s*\*\//) || [])[1] || ""; + aus.push({ n: Number(m[1]), hex: m[2].toLowerCase(), lab: oklab(m[2]), zeile, wofuer }); + } + return aus; +} + +function vollstaendigOderAbbruch(css) { + const roh = new Set([...css.matchAll(/\[data-ton="(\d+)"\]/g)].map((m) => Number(m[1]))); + const gelesen = lies(css); + if (roh.size !== gelesen.length) { + console.error(`ABBRUCH: ${roh.size} Toene stehen in der Datei, gelesen wurden ${gelesen.length}.`); + console.error(" Die Rechnung waere gegen einen Stand gelaufen, den es nicht gibt."); + process.exit(2); + } + return gelesen; +} + +/* ---- Welche Töne ein Mensch wirklich sieht (ABGELEITET) ------------ */ +async function benutzteToene() { + const w = await import("../server/workspace.js"); + const rollen = ["admin", "hand", "linke", "modi", "gast", + "manager", "scout", "creator", "spicy"]; + /* Je Ansicht (Rolle x Haus) merken, welche Töne darin vorkommen. + Zwei Kacheln mit demselben Ton sind nur dann ein Problem, wenn sie + in DERSELBEN Ansicht stehen -- sonst sieht sie niemand nebeneinander. */ + const ansichten = []; + const alle = new Map(); /* ton -> Set(Kachelnamen) */ + for (const r of rollen) { + for (const haus of ["crew", "agentur"]) { + const p = { id: 1, rolle: r, name: "X" }; + let k = []; + try { + k = [...(w.bereicheFuer(p, haus) || []), ...(w.zusatzBereicheFuer(p, haus) || [])]; + } catch { continue; } + const hier = new Map(); + for (const kachel of k) { + if (!kachel || kachel.ton == null) continue; + if (!alle.has(kachel.ton)) alle.set(kachel.ton, new Set()); + alle.get(kachel.ton).add(kachel.name); + if (!hier.has(kachel.ton)) hier.set(kachel.ton, []); + hier.get(kachel.ton).push(kachel.name); + } + if (hier.size) ansichten.push({ rolle: r, haus, toene: hier }); + } + } + return { alle, ansichten }; +} + +/* ---- Die beste freie Stelle im Farbraum ---------------------------- */ +/* NICHT BLASS (22.09.2026). + + Der erste Lauf schlug fuer "Dateien" ein #ffe5ae vor -- Buntheit + 0,076, ein helles Creme. Rechnerisch war es die beste Stelle im + Farbraum; auf dem Bildschirm ist es das, was Filipe seit Wochen + abschafft ("die kacheln sind viel zu hell, man bekommt fast nichts + gelesen"), und das Gegenteil von "uebelst geil". + + Die Schwelle ist GEMESSEN, nicht geraten: Der Median der Buntheit + aller vorhandenen Toene liegt bei 0,168, die fuenf blassesten + zwischen 0,064 und 0,088. 0,12 laesst alles zu, was heute als + kraeftig durchgeht, und schliesst genau die blassen aus. */ +const MINDEST_BUNTHEIT = 0.12; + +function beste(andere) { + let treffer = null, wert = -1; + for (let H = 0; H < 360; H += 1) { + for (let L = 0.56; L <= 0.94; L += 0.01) { + for (let C = 0.30; C >= 0.04; C -= 0.004) { + const hex = ausOklch(L, C, H); + if (!hex) continue; + if (kontrast(hex) < 4.5) break; + /* Mehr Buntheit geht bei diesem L/H nicht -- dann taugt die + ganze Kombination nicht, statt sie blass zu nehmen. */ + if (C < MINDEST_BUNTHEIT) break; + const lab = oklab(hex); + let naechster = Infinity; + for (const v of andere) naechster = Math.min(naechster, abstand(lab, v.lab)); + if (naechster > wert) { wert = naechster; treffer = { hex, lab }; } + break; + } + } + } + return { ...treffer, abstand: wert }; +} + +function engstesPaar(liste) { + let best = { d: Infinity, a: null, b: null }; + for (let i = 0; i < liste.length; i++) { + for (let j = i + 1; j < liste.length; j++) { + const d = abstand(liste[i].lab, liste[j].lab); + if (d < best.d) best = { d, a: liste[i], b: liste[j] }; + } + } + return best; +} + +/* ===================================================================== */ +const schreiben = process.argv.includes("--schreiben"); +let css = readFileSync(CSS, "utf8"); +const inDatei = vollstaendigOderAbbruch(css); +const { alle, ansichten } = await benutzteToene(); + +console.log(`${inDatei.length} Toene in start.css, ${alle.size} davon benutzt.`); + +/* Zwei Kacheln mit demselben Ton IN DERSELBEN Ansicht -- das ist der + Fall, den ein Mensch als "zwei gleiche" sieht. */ +let doppelt = 0; +for (const a of ansichten) { + for (const [ton, namen] of a.toene) { + if (namen.length > 1) { + console.log(` DOPPELT bei ${a.rolle}/${a.haus}: Ton ${ton} = ${namen.join(" + ")}`); + doppelt++; + } + } +} +console.log(doppelt === 0 + ? " Keine Ansicht zeigt zwei Kacheln mit derselben Farbe." + : ` ${doppelt} Doppelbelegungen -- die muessen von Hand getrennt werden.`); + +/* Ab hier wird nur noch mit den BENUTZTEN gerechnet. */ +let liste = inDatei.filter((t) => alle.has(t.n)); +const start = engstesPaar(liste); +console.log(`\nEngstes Paar der benutzten: ${start.d.toFixed(4)}` + + ` Ton ${start.a.n} (${[...alle.get(start.a.n)].join("/")})` + + ` <-> Ton ${start.b.n} (${[...alle.get(start.b.n)].join("/")})`); + +const geaendert = []; +for (let runde = 1; runde <= 40; runde++) { + const p = engstesPaar(liste); + /* WELCHEN DER BEIDEN? Den, der NICHT gesetzt ist. Sind beide + gesetzt, ist hier nichts zu tun -- dann sagt es das Werkzeug, + statt eine Festlegung zu uebergehen. */ + const kandidat = GESETZT.has(p.a.n) ? (GESETZT.has(p.b.n) ? null : p.b) : p.a; + if (!kandidat) { + console.log(`\nBeide Toene des engsten Paares sind gesetzt (${p.a.n}, ${p.b.n}).`); + console.log(" Hier aendert das Werkzeug nichts -- das ist die Festlegung."); + break; + } + const andere = liste.filter((t) => t.n !== kandidat.n); + const neu = beste(andere); + /* NICHT SCHLECHTER ALS VORHER: Nur uebernehmen, wenn der kleinste + Abstand des GANZEN Satzes danach groesser ist. Sonst dreht sich + das Werkzeug im Kreis. */ + const probe = liste.map((t) => (t.n === kandidat.n ? { ...t, hex: neu.hex, lab: neu.lab } : t)); + const nachher = engstesPaar(probe).d; + if (nachher <= p.d + 1e-6) { + console.log(`\nKeine Verbesserung mehr moeglich (${p.d.toFixed(4)} bleibt).`); + break; + } + console.log(` Runde ${runde}: Ton ${kandidat.n} (${[...alle.get(kandidat.n)].join("/")})` + + ` ${kandidat.hex} -> ${neu.hex} Minimum ${p.d.toFixed(4)} -> ${nachher.toFixed(4)}`); + geaendert.push({ n: kandidat.n, alt: kandidat.hex, neu: neu.hex, zeile: kandidat.zeile }); + liste = probe; +} + +/* --------------------------------------------------------------------- + ZWEITE RUNDE: DIE BLASSEN KRAEFTIG MACHEN + --------------------------------------------------------------------- + Die erste Runde optimiert den ABSTAND. Ein Ton kann dabei weit weg + von allen anderen liegen und trotzdem blass sein -- dann fasst sie + ihn nie an. Gemessen am 22.09.2026 blieben so drei benutzte Toene + unter 0,12 stehen (#feb9b8, #a2c6fe, #b4f1fe), und genau darueber + klagt Filipe: "die kacheln sind viel zu hell, man bekommt fast + nichts gelesen". + + Hier wird jeder blasse benutzte Ton neu gerechnet -- aber nur + uebernommen, wenn der kleinste Abstand des GANZEN Satzes dadurch + nicht sinkt. Kraeftiger werden darf nichts kosten. */ +const blassRunde = []; +for (const t of [...liste]) { + if (GESETZT.has(t.n)) continue; + const [, a, b] = t.lab; + const c = Math.hypot(a, b); + if (c >= MINDEST_BUNTHEIT) continue; + const andere = liste.filter((x) => x.n !== t.n); + const vorher = engstesPaar(liste).d; + const neu = beste(andere); + const probe = liste.map((x) => (x.n === t.n ? { ...x, hex: neu.hex, lab: neu.lab } : x)); + const nachher = engstesPaar(probe).d; + /* KRAEFTIG SCHLAEGT EIN PAAR TAUSENDSTEL ABSTAND. + Erster Entwurf verlangte, dass der Abstand gar nicht sinkt -- + damit blieben zwei blasse Toene stehen, und der gemessene Preis + waere 0,1028 -> 0,1010 gewesen: 1,7 Prozent, auf dem Bildschirm + nicht zu sehen. Filipes Klage ueber helle Kacheln ist dagegen + konkret und wiederholt. + + Die Untergrenze ist dieselbe Zahl, die auch die Pruefung + verlangt (0,090), mit etwas Luft. Darunter wird nicht getauscht -- + dann waere aus einem blassen Paar ein aehnliches geworden. */ + if (nachher < 0.095) { + console.log(` BLASS Ton ${t.n} (${[...alle.get(t.n)].join("/")}) ${t.hex} ` + + `bleibt -- kraeftiger ginge nur auf Kosten des Abstands ` + + `(${vorher.toFixed(4)} -> ${nachher.toFixed(4)})`); + continue; + } + const [, na, nb] = neu.lab; + console.log(` BLASS Ton ${t.n} (${[...alle.get(t.n)].join("/")}) ${t.hex} -> ${neu.hex}` + + ` Buntheit ${c.toFixed(3)} -> ${Math.hypot(na, nb).toFixed(3)}` + + `, Minimum ${vorher.toFixed(4)} -> ${nachher.toFixed(4)}`); + blassRunde.push({ n: t.n, alt: t.hex, neu: neu.hex, zeile: t.zeile }); + geaendert.push({ n: t.n, alt: t.hex, neu: neu.hex, zeile: t.zeile }); + liste = probe; +} +if (blassRunde.length) console.log(` ${blassRunde.length} blasse Toene kraeftiger gemacht.`); + +const ende = engstesPaar(liste); +console.log(`\nEngstes Paar danach: ${ende.d.toFixed(4)}` + + ` (${start.d.toFixed(4)} vorher, Faktor ${(ende.d / start.d).toFixed(2)})`); +console.log(`Geaendert: ${geaendert.length} Toene.`); + +let schlecht = 0; +for (const t of liste) if (kontrast(t.hex) < 4.5) schlecht++; +console.log(schlecht === 0 + ? "Alle benutzten Toene erreichen 4,5:1 gegen den Grund." + : `ACHTUNG: ${schlecht} Toene unter 4,5:1.`); + +if (!schreiben) { + console.log("\n(nur gezeigt -- mit --schreiben eintragen)"); + process.exit(0); +} + +for (const g of geaendert) { + const neueZeile = g.zeile.replace(g.alt, g.neu).replace(g.alt.toUpperCase(), g.neu); + if (!css.includes(g.zeile)) { + console.error(`ABBRUCH: Zeile fuer Ton ${g.n} nicht mehr auffindbar.`); + process.exit(2); + } + css = css.replace(g.zeile, neueZeile); +} +writeFileSync(CSS, css); +console.log(`\n${geaendert.length} Zeilen in start.css geschrieben.`); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 7839f09e..cdaa5bbd 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +