/* ===================================================================== Ä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.`);