/* =================================================================== Die 21 Kachelfarben -- gerechnet, nicht gewaehlt. ANLASS (08.09.2026). Filipe: "viele kacheln haben noch fast die gleiche farben, ähneln sich sehr und ich will dass du komplett eskalierst ... alle seine eigenen farben und so dass sie sich nicht ähneln. sehr wichtig nicht ähneln!!!!" Er hat recht, und es liess sich messen. Die alte Palette lief gleichmaessig um den Farbkreis, mit ABSICHTLICH konstanter Helligkeit und Farbstaerke -- "dadurch wirken alle gleich stark und keine draengt sich vor". Der Gedanke war gut und hat genau den Fehler erzeugt, den Filipe sieht: Kleinster Abstand zweier Toene: 0,033 (Dateien gegen Creator) Paare unter 0,05 (kaum trennbar): 21 von 210 Spanne der Helligkeit: 0,002 Bei 21 Farben auf EINEM Ring mit gleicher Helligkeit bleibt als einziger Unterschied der Farbton. Nachbarn liegen dann zwangslaeufig dicht beieinander -- 360 Grad geteilt durch 21 sind 17 Grad, und 17 Grad Farbton sieht man schlicht nicht. WAS SICH AENDERT: Helligkeit und Farbstaerke variieren jetzt mit. Zwei Farben mit aehnlichem Farbton stehen dadurch trotzdem weit auseinander, weil die eine hell und satt und die andere dunkel und ruhig ist. Der Abstand bekommt eine zweite und dritte Dimension. GERECHNET WIRD IN OKLAB. Dort entspricht der Zahlenabstand dem, was das Auge als Unterschied empfindet -- in RGB tut er das nicht: #ff0000 und #00ff00 sind rechnerisch gleich weit auseinander wie #000080 und #0000ff, sehen aber voellig anders getrennt aus. DREI BEDINGUNGEN, die jede Farbe halten muss: 1. LESBAR. Mindestens 4,5:1 gegen den dunklen Grund (#0a121e). Eine Kachelfarbe traegt auch Text und Zahlen. 2. AUGENSCHONEND (Hausregel). Farbstaerke gedeckelt -- satt ja, Neon nein. Deshalb liegt die Obergrenze der Buntheit bei 0,17 und nicht am Rand des Darstellbaren. 3. TRENNBAR. Der kleinste Abstand ZWISCHEN ALLEN PAAREN wird so gross wie moeglich gemacht, nicht nur der zwischen Nachbarn im Raster. Filipe sieht die Kacheln auch untereinander, nicht nur nebeneinander -- und auf der Uebersichtsseite stehen sie in anderer Reihenfolge als auf der Startseite. Aufrufen mit: node tools/kachel-farben.mjs Die Ausgabe wird nach start.css uebertragen (Abschnitt "Kacheln"). ACHTUNG -- TON 21 IST SEIT DEM 09.09.2026 VON HAND GESETZT. Filipe wollte die Scout-Pipeline ausdruecklich in Knallrot ("die farbe von dieser kategorie soll knall rot sein"). In start.css steht dort #ff1f2e; dieses Werkzeug wuerde beim naechsten Lauf wieder Rosa vorschlagen. NICHT EINFACH UEBERNEHMEN, sondern die Ausgabe fuer Ton 21 verwerfen. Der Wunsch ist geprueft und nicht willkuerlich: #ff1f2e liegt in OKLab 0,1228 von seinem naechsten Nachbarn entfernt -- weiter als das engste vorhandene Paar im Satz (0,0973) -- und hat 5,01:1 Kontrast. Wer den Satz neu rechnet, muss diese Farbe als FESTEN Punkt vorgeben und die uebrigen zwanzig darum herum verteilen; sonst ist der naechste Lauf eine stille Ruecknahme einer ausdruecklichen Entscheidung. =================================================================== */ /* ---------- Farbenlehre ------------------------------------------- */ const zuLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); const zuSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); function oklabAusRgb(r, g, b) { 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, ]; } /** OKLCH -> sRGB. Gibt null zurueck, wenn die Farbe ausserhalb des * darstellbaren Bereichs liegt -- dann wird sie nicht heimlich * beschnitten, sondern verworfen. Ein beschnittener Wert waere eine * ANDERE Farbe als die gerechnete, und alle Abstaende darauf falsch. */ function ausOklch(L, C, Hgrad) { const h = (Hgrad * Math.PI) / 180; const a = C * Math.cos(h), bb = C * Math.sin(h); const l_ = L + 0.3963377774 * a + 0.2158037573 * bb; const m_ = L - 0.1055613458 * a - 0.0638541728 * bb; const s_ = L - 0.0894841775 * a - 1.2914855480 * bb; const l = l_ ** 3, m = m_ ** 3, s = s_ ** 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 kanaele = [r, g, b].map(zuSrgb); if (kanaele.some((c) => c < -0.002 || c > 1.002)) return null; const bytes = kanaele.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255)); return { hex: "#" + bytes.map((n) => n.toString(16).padStart(2, "0")).join(""), oklab: oklabAusRgb(...bytes.map((n) => zuLinear(n / 255))), leucht: 0.2126 * zuLinear(bytes[0] / 255) + 0.7152 * zuLinear(bytes[1] / 255) + 0.0722 * zuLinear(bytes[2] / 255), }; } const abstand = (x, y) => Math.hypot( x.oklab[0] - y.oklab[0], x.oklab[1] - y.oklab[1], x.oklab[2] - y.oklab[2]); /* Der Grund, auf dem die Kacheln liegen. */ const GRUND_LEUCHT = 0.2126 * zuLinear(0x0a / 255) + 0.7152 * zuLinear(0x12 / 255) + 0.0722 * zuLinear(0x1e / 255); const kontrast = (f) => (f.leucht + 0.05) / (GRUND_LEUCHT + 0.05); /* ---------- Die Kacheln, in fester Reihenfolge --------------------- */ const KACHELN = [ "Dashboard", "Zahlen", "LIVE", "Chat", "Schutz", "Community", "Reports", "Kalender", "Bibliothek", "Steckbrief", "Personen", "Content", "Aufgaben", "Technik", "Calls", "Start-Check", "Agentur", "Automationen", "Dateien", "Creator-Profile", "Scout", ]; /* Wo eine Kachel inhaltlich hingehoert. Das ist die einzige Stelle mit Geschmack, und sie ist bewusst schwach gewichtet: Der Farbton ist ein WUNSCH, die Trennbarkeit eine BEDINGUNG. Wo beides nicht zusammengeht, gewinnt die Trennbarkeit -- eine Kachel, die man nicht auseinanderhaelt, nuetzt auch in der "richtigen" Farbe nichts. */ const WUNSCHTON = { "LIVE": 25, // rot -- "on air" "Technik": 95, // gelb wie ein Warnschild "Reports": 150, // gruen wie ein Ergebnis "Personen": 70, // golden wie ein Schluessel "Kalender": 320, // pink, hebt den Tagesplan heraus "Dashboard": 250, // blau, der Ausgangspunkt }; /* ---------- Kandidaten ---------------------------------------------- */ const KANDIDATEN = []; for (let H = 0; H < 360; H += 3) { for (const L of [0.60, 0.66, 0.72, 0.78, 0.84]) { /* Die groesste Farbstaerke suchen, die bei diesem Ton und dieser Helligkeit noch darstellbar ist -- gedeckelt bei 0,17 (augenschonend, siehe Kopf). Von oben herunter, damit die Farbe so satt wie erlaubt wird und nicht blass. */ for (let C = 0.17; C >= 0.05; C -= 0.005) { const f = ausOklch(L, C, H); if (!f) continue; if (kontrast(f) < 4.5) break; // zu dunkel fuer Text KANDIDATEN.push({ ...f, L, C, H }); break; } } } /* ---------- Auswahl: den kleinsten Abstand so gross wie moeglich ---- Erst gierig (nimm immer die Farbe, die am weitesten von allen schon gewaehlten weg ist), dann verbessern: Jede gewaehlte Farbe wird probeweise gegen jede andere getauscht; wird der kleinste Abstand dadurch groesser, bleibt der Tausch. Das laeuft, bis keine Verbesserung mehr kommt. Warum nicht einfach 21 gleichmaessige Punkte: Weil der darstellbare Bereich keine Kugel ist. Gelb vertraegt viel Helligkeit und wenig Buntheit, Blau umgekehrt. Ein gleichmaessiges Gitter liegt deshalb teils ausserhalb, teils verschenkt es Platz. */ const anzahl = KACHELN.length; let gewaehlt = [KANDIDATEN.reduce((a, b) => (a.L * 100 + a.H < b.L * 100 + b.H ? a : b))]; while (gewaehlt.length < anzahl) { let beste = null, besterWert = -1; for (const k of KANDIDATEN) { if (gewaehlt.includes(k)) continue; const naechster = Math.min(...gewaehlt.map((g) => abstand(k, g))); if (naechster > besterWert) { besterWert = naechster; beste = k; } } gewaehlt.push(beste); } const kleinster = (menge) => { let m = Infinity; for (let i = 0; i < menge.length; i++) for (let j = i + 1; j < menge.length; j++) m = Math.min(m, abstand(menge[i], menge[j])); return m; }; let verbessert = true, runden = 0; while (verbessert && runden < 60) { verbessert = false; runden++; for (let i = 0; i < gewaehlt.length; i++) { const jetzt = kleinster(gewaehlt); for (const k of KANDIDATEN) { if (gewaehlt.includes(k)) continue; const probe = gewaehlt.slice(); probe[i] = k; if (kleinster(probe) > jetzt + 1e-9) { gewaehlt = probe; verbessert = true; break; } } } } /* ---------- Zuordnung: Wunschton, wo er ohne Verlust geht ---------- */ const frei = gewaehlt.slice(); const zuteilung = new Map(); /* Erst die Kacheln mit Wunsch -- jede bekommt die freie Farbe, deren Ton ihrem Wunsch am naechsten liegt. */ for (const [name, wunsch] of Object.entries(WUNSCHTON)) { if (!KACHELN.includes(name)) continue; let beste = 0, bestAbw = Infinity; frei.forEach((f, i) => { const abw = Math.min(Math.abs(f.H - wunsch), 360 - Math.abs(f.H - wunsch)); if (abw < bestAbw) { bestAbw = abw; beste = i; } }); zuteilung.set(name, frei.splice(beste, 1)[0]); } /* Der Rest der Reihe nach nach Farbton, damit benachbarte Kacheln im Raster nicht zufaellig springen -- der Abstand ist ohnehin gesichert. */ frei.sort((a, b) => a.H - b.H); for (const name of KACHELN) if (!zuteilung.has(name)) zuteilung.set(name, frei.shift()); /* ---------- Nachweis ------------------------------------------------ */ const ergebnis = KACHELN.map((n) => ({ name: n, ...zuteilung.get(n) })); const min = kleinster(ergebnis); const Ls = ergebnis.map((f) => f.oklab[0]); let unter5 = 0, paare = 0; for (let i = 0; i < ergebnis.length; i++) for (let j = i + 1; j < ergebnis.length; j++) { paare++; if (abstand(ergebnis[i], ergebnis[j]) < 0.05) unter5++; } console.log("ALT (gemessen): kleinster Abstand 0,0328 · 21 von 210 Paaren unter 0,05" + " · Helligkeitsspanne 0,002\n"); console.log(`NEU: kleinster Abstand ${min.toFixed(4)} · ${unter5} von ${paare}` + ` Paaren unter 0,05 · Helligkeitsspanne ${(Math.max(...Ls) - Math.min(...Ls)).toFixed(3)}\n`); const schlechtester = Math.min(...ergebnis.map(kontrast)); console.log(`Schlechtester Kontrast gegen den Grund: ${schlechtester.toFixed(2)}:1` + ` (Grenze 4,5) -> ${schlechtester >= 4.5 ? "haelt" : "HAELT NICHT"}\n`); console.log("/* Die Nummern bleiben an ihren Kacheln -- eine Farbe gehoert zu"); console.log(" einem Bereich, nicht zu einem Platz im Raster. */"); ergebnis.forEach((f, i) => { const n = `[data-ton="${i + 1}"]`.padEnd(16); console.log(`${n}{ --ton: ${f.hex}; } /* ${f.name} */`); }); /* DIE ZAHLEN GEHOEREN IN DIE BEDINGUNG, nicht nur in den Bericht: Ein Lauf, der eine schlechtere Palette ausgibt als die alte, waere sonst nicht von einem guten zu unterscheiden. */ if (min <= 0.0328) { console.error(`\nFEHLER: Der kleinste Abstand (${min.toFixed(4)}) ist nicht besser als` + ` der alte (0,0328). Nichts uebernehmen.`); process.exit(1); } if (schlechtester < 4.5) { console.error(`\nFEHLER: Eine Farbe liegt unter 4,5:1 gegen den Grund.`); process.exit(1); } if (ergebnis.length !== 21) { console.error(`\nFEHLER: ${ergebnis.length} Farben statt 21.`); process.exit(1); } console.log(`\n${ergebnis.length} Farben, alle Bedingungen erfuellt.`);