/* ===================================================================== EIN TON JE ART -- UND DER BEWEIS, DASS MAN SIE UNTERSCHEIDET Aufruf: node tools/art-farben.mjs (nur messen) node tools/art-farben.mjs schreiben (CSS erzeugen) --------------------------------------------------------------------- WARUM ÜBERHAUPT Alle Etiketten trugen denselben blauen Ton. Auf „Regeln & Hilfe" stehen siebzehn Karten untereinander, auf dem Treff acht -- und das Auge hatte nichts, woran es sich festhalten konnte. Man liest dann jede Überschrift, statt die Seite zu überfliegen. --------------------------------------------------------------------- NACH BEDEUTUNG, NICHT NACH REIHENFOLGE Der Ton folgt dem, was die Art SAGT: Lob grün, „etwas stimmt nicht" warmrot, Fragen blau, Regeln golden. Deshalb trägt `frage` auf drei Brettern denselben Ton -- wer das Muster einmal gelernt hat, soll es überall wiedererkennen. Eine Vergabe nach Position („die erste Art wird blau") würde genau das verhindern. --------------------------------------------------------------------- UND DESHALB MISST DIESES WERKZEUG ZWEIERLEI 1. ABSTAND: Zwei Arten, die auf DEMSELBEN Brett vorkommen, müssen im OKLab-Raum weit genug auseinanderliegen. Über Bretter hinweg ist eine Wiederholung dagegen erlaubt -- man sieht nie zwei Bretter gleichzeitig. 2. KONTRAST: Jeder Ton muss auf dem dunklen Grund lesbar sein. Ein hübscher Ton, den man nicht lesen kann, ist kein Ton. Ohne diese Messung wäre die Farbwahl eine Behauptung. Bei den Kacheln hat genau dieselbe Rechnung sieben Töne aussortiert, die „eigentlich verschieden aussahen". ===================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const HIER = dirname(fileURLToPath(import.meta.url)); const CSS = join(HIER, "..", "workspace", "assets", "css", "bereich.css"); /* Der Grund, auf dem die Etiketten liegen. */ const GRUND = "#0a121e"; /* ---------- Farbrechnung (aus tools/kachel-farben.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 abstand = (a, b) => { const [x, y] = [oklab(a), oklab(b)]; return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); }; const leuchte = (hex) => { const [r, g, b] = srgb(hex).map(linear); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const kontrast = (a, b) => { const [x, y] = [leuchte(a), leuchte(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; /* ===================================================================== DIE TÖNE -- hier steht nur die BEDEUTUNG, also der Farbwinkel Helligkeit und Buntheit sucht das Werkzeug selbst (siehe unten). WARUM NICHT VON HAND: Genau das war der erste Versuch -- feste Helligkeit 0.80 für alle, Buntheit nach Gefühl. Ergebnis: vier Töne lagen ausserhalb des darstellbaren Bereichs, drei Paare zu nah beieinander. Nach dem Nachbessern waren es fünf zu nahe Paare: Wer die Buntheit eines Violetts senkt, schiebt es näher an alles andere Gedämpfte heran. Zwei Zahlen von Hand zu stimmen, während eine dritte Bedingung mitläuft, geht nicht auf -- das ist eine Suche, keine Wahl. Der Farbwinkel dagegen IST die Entscheidung und bleibt von Hand: Lob grün, „etwas stimmt nicht" warmrot, Fragen blau. ===================================================================== */ const TOENE = { /* Begrüßen, fragen, loben, melden -- der Treff */ hallo: 200, // Türkis: freundlich, offen frage: 245, // Blau: die neutrale Frage lob: 140, // Grün: etwas war gut problem: 25, // Warmrot: hier stimmt etwas nicht /* Das Anschlagbrett -- was gilt */ ansage: 50, // Gold: eine Ansage des Teams regel: 100, // Oliv-Gold: was fest gilt hinweis: 255, // Blaugrau: zur Kenntnis danke: 340, // Rosé: Dank an die Community /* Was ansteht -- Termine */ stream: 300, // Violett: die Sendung event: 345, // Magenta: der besondere Abend turnier: 45, // Orange: Wettkampf pause: 250, // fast grau: es fällt aus /* Die Wunschliste */ technik: 220, // Stahlblau: Geräte und Ton gemeinsam: 165, // Grün-Türkis: miteinander sonstiges: 300, // gedämpft: alles Übrige /* Highlights */ clip: 285, // Violett: bewegtes Bild bild: 195, // Türkis: Standbild fanart: 325, // Rosé: selbst gemacht moment: 60, // Bernstein: der Augenblick /* Regeln & Hilfe */ hilfe: 155, // Grün: hier wird geholfen folge: 5, // Rot: was passiert, wenn /* Mitmachen */ gesucht: 85, // Gold: wir suchen lust: 135, // Grün: ich hätte Lust }; /* Welche Arten auf DEMSELBEN Brett stehen -- nur hier muss der Abstand stimmen. Abgeleitet aus den Brettern selbst, nicht von Hand gepflegt: eine Liste, die niemand pflegt, kann nicht veralten. */ const BRETTER = { treff: ["hallo", "frage", "lob", "problem"], anschlag: ["ansage", "regel", "hinweis", "danke"], ansteht: ["stream", "event", "turnier", "pause"], wunsch: ["stream", "technik", "gemeinsam", "sonstiges"], highlight: ["clip", "bild", "fanart", "moment"], regeln: ["regel", "frage", "hilfe", "folge"], mitmachen: ["gesucht", "lust", "frage"], }; /* Ab hier gilt ein Unterschied als erkennbar. Bei den Kacheln war die Grenze 0.14; Etiketten sind kleiner und stehen direkt untereinander, deshalb hier etwas strenger. */ const MIN_ABSTAND = 0.10; /* WCAG AA für kleine Schrift. */ const MIN_KONTRAST = 4.5; /* --------------------------------------------------------------------- MESSEN --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- DIE SUCHE Für jede Art steht der Farbwinkel fest. Gesucht werden Helligkeit und Buntheit -- so bunt wie möglich, aber nur, wenn der Ton 1. in sRGB liegt, 2. auf dunklem Grund lesbar ist und 3. von jeder Art, die auf DEMSELBEN Brett steht, weit genug entfernt bleibt. Die Arten werden nach Anzahl der Nachbarn sortiert: Wer die meisten Bedingungen erfüllen muss, wählt zuerst. Umgekehrt bliebe für ihn am Ende nichts übrig -- dieselbe Überlegung wie bei den Kacheln. --------------------------------------------------------------------- */ const nachbarn = {}; for (const arten of Object.values(BRETTER)) { for (const a of arten) { nachbarn[a] = nachbarn[a] || new Set(); for (const b of arten) if (b !== a) nachbarn[a].add(b); } } const hex = {}; let fehlt = 0; const reihenfolge = Object.keys(TOENE) .sort((a, b) => (nachbarn[b]?.size || 0) - (nachbarn[a]?.size || 0)); for (const art of reihenfolge) { const H = TOENE[art]; /* SO GEDECKT WIE MOEGLICH, nicht so bunt wie moeglich. Der erste Versuch suchte die kraeftigste Fassung -- heraus kamen Toene wie #35f695 und #f584fd: Neongruen und Neonviolett. Das widerspricht der Hausregel („gedeckte statt reisserisch-grelle Neon-Optik"), und auf einem Brett mit siebzehn Karten waere es eine Leuchtreklame. Gesucht wird deshalb die LEISESTE Farbe, die noch reicht: gerade bunt genug, um sie von den Nachbarn zu unterscheiden, und gerade hell genug, um lesbar zu sein. Buntheit ist hier eine Kostenstelle, kein Ziel. Helligkeit von der Mitte aus nach aussen: 0.78 ist die Vorgabe; nur wo sie nicht reicht, wird abgewichen. */ let bester = null, besteBuntheit = 9; const helligkeiten = [0.78]; for (let d = 0.01; d <= 0.10; d += 0.01) helligkeiten.push(0.78 + d, 0.78 - d); for (const Lv of helligkeiten) { for (let C = 0.03; C <= 0.20; C += 0.005) { const f = ausOklch(Lv, C, H); if (!f) continue; if (kontrast(f, GRUND) < MIN_KONTRAST) continue; let passt = true; for (const n of (nachbarn[art] || [])) { if (hex[n] && abstand(f, hex[n]) < MIN_ABSTAND) { passt = false; break; } } if (!passt) continue; /* Der erste Treffer je Helligkeit ist schon der leiseste. */ if (C < besteBuntheit) { besteBuntheit = C; bester = f; } break; } /* Reicht die Vorgabe-Helligkeit, wird gar nicht weitergesucht. */ if (bester && besteBuntheit <= 0.08) break; } if (!bester) { console.log(`FEHL ${art}: kein Ton gefunden, der alle drei Bedingungen erfuellt`); fehlt++; continue; } hex[art] = bester; } console.log("=== Kontrast auf dunklem Grund ==="); let zuDunkel = 0; for (const [art, f] of Object.entries(hex)) { const k = kontrast(f, GRUND); if (k < MIN_KONTRAST) { zuDunkel++; console.log(` FEHL ${art.padEnd(10)} ${f} ${k.toFixed(2)}`); } } console.log(zuDunkel ? ` ${zuDunkel} Ton/Töne zu dunkel` : ` alle ${Object.keys(hex).length} Töne lesbar`); console.log(""); console.log("=== Abstand INNERHALB eines Bretts ==="); let zuNah = 0, paare = 0; for (const [brett, arten] of Object.entries(BRETTER)) { let kleinster = 9, wo = ""; for (let i = 0; i < arten.length; i++) { for (let j = i + 1; j < arten.length; j++) { if (!hex[arten[i]] || !hex[arten[j]]) continue; paare++; const d = abstand(hex[arten[i]], hex[arten[j]]); if (d < kleinster) { kleinster = d; wo = `${arten[i]}/${arten[j]}`; } if (d < MIN_ABSTAND) { zuNah++; console.log(` FEHL ${brett}: ${arten[i]} und ${arten[j]} nur ${d.toFixed(3)} auseinander`); } } } console.log(` ${brett.padEnd(11)} engstes Paar ${wo.padEnd(22)} ${kleinster.toFixed(3)}`); } /* --------------------------------------------------------------------- STIMMT DAS CSS NOCH MIT DIESER RECHNUNG UEBEREIN? Ohne diese Frage prueft das Werkzeug nur sich selbst. Jemand aendert einen Ton von Hand im CSS -- weil er dunkler aussehen soll --, und die Messung hier bleibt gruen, weil sie ihre eigene Tabelle misst und nicht das, was ausgeliefert wird. Genau die Art Pruefung, die immer bestaetigt. --------------------------------------------------------------------- */ let abweichend = 0, verglichen = 0; if (process.argv[2] !== "schreiben") { let css = ""; try { css = readFileSync(CSS, "utf8"); } catch { css = ""; } if (!css) { console.log(""); console.log(" -- bereich.css nicht lesbar -- nicht abgeglichen"); } else { console.log(""); console.log("=== Stimmt das CSS mit dieser Rechnung ueberein? ==="); for (const [art, f] of Object.entries(hex)) { const treffer = new RegExp( `\\.marke-art\\[data-art="${art}"\\]\\s*\\{\\s*--art-ton:\\s*(#[0-9a-f]{6})`, "i").exec(css); verglichen++; if (!treffer) { abweichend++; console.log(` FEHL ${art.padEnd(10)} steht gar nicht im CSS`); } else if (treffer[1].toLowerCase() !== f.toLowerCase()) { abweichend++; console.log(` FEHL ${art.padEnd(10)} CSS ${treffer[1]} statt ${f}`); } } /* Die Anzahl gehoert in die Bedingung -- sonst waere „0 Abweichungen" auch bei leerer Tabelle wahr. */ console.log(verglichen >= 20 && abweichend === 0 ? ` alle ${verglichen} Toene im CSS stimmen` : ` ${abweichend} von ${verglichen} weichen ab -- „node tools/art-farben.mjs schreiben" hilft`); } } console.log(""); if (fehlt || zuDunkel || zuNah || abweichend || (process.argv[2] !== "schreiben" && verglichen < 20)) { console.log(`NICHT IN ORDNUNG -- ${fehlt} ausserhalb, ${zuDunkel} zu dunkel, ` + `${zuNah} zu nah, ${abweichend} weichen vom CSS ab`); process.exit(1); } console.log(`ALLES IN ORDNUNG -- ${Object.keys(hex).length} Töne, ${paare} Paare geprüft`); /* --------------------------------------------------------------------- SCHREIBEN --------------------------------------------------------------------- */ if (process.argv[2] === "schreiben") { const zeilen = Object.entries(hex).map(([art, f]) => `.marke-art[data-art="${art}"] { --art-ton: ${f}; }`); const block = ` /* ---- EIN TON JE ART (18.09.2026) ---------------------------------- Erzeugt von tools/art-farben.mjs -- NICHT von Hand ändern. Das Werkzeug misst zweierlei und lässt sich nicht überreden: den Abstand zwischen zwei Arten auf DEMSELBEN Brett (OKLab, ab ${MIN_ABSTAND}) und den Kontrast auf dunklem Grund (ab ${MIN_KONTRAST}:1). Warum überhaupt: Alle Etiketten trugen denselben blauen Ton. Bei siebzehn Karten untereinander hatte das Auge nichts, woran es sich festhalten konnte -- man liest dann jede Überschrift, statt zu überfliegen. Der Ton folgt der BEDEUTUNG, nicht der Reihenfolge: Lob grün, „etwas stimmt nicht" warmrot, Fragen blau. Deshalb trägt die Art „frage" auf drei Brettern denselben Ton. */ .marke-art[data-art] { background: color-mix(in srgb, var(--art-ton) 16%, transparent); color: var(--art-ton); } ${zeilen.join("\n")} `; let css = readFileSync(CSS, "utf8"); const marke = "/* ---- EIN TON JE ART (18.09.2026) ---"; const i = css.indexOf(marke); if (i >= 0) css = css.slice(0, i).replace(/\n+$/, "\n"); writeFileSync(CSS, css + block, "utf8"); console.log(`\n${zeilen.length} Töne nach bereich.css geschrieben.`); }