diff --git a/tools/art-farben.mjs b/tools/art-farben.mjs new file mode 100644 index 00000000..8a15544f --- /dev/null +++ b/tools/art-farben.mjs @@ -0,0 +1,357 @@ +/* ===================================================================== + 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.`); +} diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 4ab004d7..8375e538 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609181858", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609182010", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609181858", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609182010", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181858", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609182010", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181858", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609182010", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 2bf69abe..c7c8f30e 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1504,3 +1504,43 @@ } .starthilfe__beispiel:hover { border-color: var(--akzent); } .starthilfe__beispiel:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +/* ---- 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 + 0.1) und den Kontrast auf dunklem Grund (ab 4.5: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); +} +.marke-art[data-art="frage"] { --art-ton: #a8baca; } +.marke-art[data-art="regel"] { --art-ton: #c2b981; } +.marke-art[data-art="stream"] { --art-ton: #bab3c8; } +.marke-art[data-art="hallo"] { --art-ton: #90e1e6; } +.marke-art[data-art="lob"] { --art-ton: #90b888; } +.marke-art[data-art="problem"] { --art-ton: #e4a59f; } +.marke-art[data-art="ansage"] { --art-ton: #e2ccc0; } +.marke-art[data-art="hinweis"] { --art-ton: #a9b9ce; } +.marke-art[data-art="danke"] { --art-ton: #c592b3; } +.marke-art[data-art="event"] { --art-ton: #f8bcdd; } +.marke-art[data-art="turnier"] { --art-ton: #d69b81; } +.marke-art[data-art="pause"] { --art-ton: #7ba2cc; } +.marke-art[data-art="technik"] { --art-ton: #8ed7ef; } +.marke-art[data-art="gemeinsam"] { --art-ton: #86c4a8; } +.marke-art[data-art="sonstiges"] { --art-ton: #dbcaff; } +.marke-art[data-art="clip"] { --art-ton: #b5b5ca; } +.marke-art[data-art="bild"] { --art-ton: #8adbdb; } +.marke-art[data-art="fanart"] { --art-ton: #b88dba; } +.marke-art[data-art="moment"] { --art-ton: #deac83; } +.marke-art[data-art="hilfe"] { --art-ton: #a4deb7; } +.marke-art[data-art="folge"] { --art-ton: #e8a5b4; } +.marke-art[data-art="gesucht"] { --art-ton: #ceb480; } +.marke-art[data-art="lust"] { --art-ton: #bfdbb1; } diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 751793f7..68cf7822 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -331,7 +331,12 @@ k.dataset.status = e.status; const kopf = el('div', 'eintrag-kopf'); - kopf.append(el('span', 'marke-art', einstellung.arten[e.art] || e.art)); + const marke = el('span', 'marke-art', einstellung.arten[e.art] || e.art); + /* Die Art als Merkmal am Element -- daran haengt der Ton (siehe + tools/art-farben.mjs). Kein Klassenname je Art: Der waere eine + zweite Liste, die mit der ersten auseinanderlaeuft. */ + if (e.art) marke.dataset.art = e.art; + kopf.append(marke); /* DIE VERTRAULICHE ZEILE SAGT ES SELBST (10.09.2026). Sie kommt ohnehin nur bei DogFather und beim Schreiber an -- der diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 71cb20f2..7e608cc3 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - +
@@ -291,10 +291,10 @@ - - - - - + + + + +