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`. -->
-
-
+
+
-
+