diff --git a/server/helfer-png.mjs b/server/helfer-png.mjs new file mode 100644 index 00000000..ac91c777 --- /dev/null +++ b/server/helfer-png.mjs @@ -0,0 +1,103 @@ +/* ===================================================================== + EINEN BILDSCHIRMFOTO-PIXEL LESEN (17.09.2026) + + ANLASS: Der Kachelgrund hat ein Universum bekommen -- Sterne, drei + Nebel, eine Vignette, dazu der Glanz und das durchscheinende + Buehnenbild. Die Frage "ist der Text darauf noch lesbar?" laesst + sich aus dem CSS nicht mehr beantworten: Zwischen Textfarbe und + `--flaeche` liegen sieben Ebenen. + + WAS ZAEHLT, IST DER PUNKT AUF DEM SCHIRM. Und den liefert nur ein + Bildschirmfoto. + + WARUM SELBST GESCHRIEBEN: Das Projekt hat kein Bildmodul, und fuer + drei Pixel eines nachzuinstallieren waere eine Abhaengigkeit, die + jemand pflegen muss. Node bringt `zlib` mit; ein PNG ist danach nur + noch Zeilenfilter, und davon gibt es fuenf. + + ES LIEST NUR, WAS PLAYWRIGHT SCHREIBT: 8 Bit je Kanal, RGB oder + RGBA, nicht verschraenkt. Alles andere meldet es als Fehler, statt + zu raten -- ein Bildleser, der bei unbekanntem Format irgendetwas + zurueckgibt, faelscht Messwerte. + ===================================================================== */ +import { inflateSync } from "node:zlib"; + +/** Liest ein PNG aus einem Puffer. + * @returns {{breite:number, hoehe:number, kanaele:number, daten:Buffer}} */ +export function pngLesen(puffer) { + if (puffer.length < 8 || puffer.readUInt32BE(0) !== 0x89504e47) { + throw new Error("Kein PNG"); + } + let pos = 8; + let breite = 0, hoehe = 0, tiefe = 0, art = 0; + const teile = []; + while (pos < puffer.length) { + const laenge = puffer.readUInt32BE(pos); + const name = puffer.toString("ascii", pos + 4, pos + 8); + const inhalt = puffer.subarray(pos + 8, pos + 8 + laenge); + if (name === "IHDR") { + breite = inhalt.readUInt32BE(0); + hoehe = inhalt.readUInt32BE(4); + tiefe = inhalt[8]; + art = inhalt[9]; + if (tiefe !== 8) throw new Error(`Nur 8 Bit je Kanal, nicht ${tiefe}`); + if (art !== 2 && art !== 6) throw new Error(`Nur RGB/RGBA, nicht Typ ${art}`); + if (inhalt[12] !== 0) throw new Error("Verschraenkte PNG werden nicht gelesen"); + } else if (name === "IDAT") { + teile.push(inhalt); + } else if (name === "IEND") { + break; + } + pos += 12 + laenge; // Laenge + Name + Inhalt + Pruefsumme + } + const kanaele = art === 6 ? 4 : 3; + const roh = inflateSync(Buffer.concat(teile)); + + /* Die Filter rueckgaengig machen. Jede Zeile beginnt mit einem Byte, + das sagt, wie sie kodiert wurde -- PNG-Spezifikation, Abschnitt 9. */ + const zeile = breite * kanaele; + const daten = Buffer.alloc(zeile * hoehe); + for (let y = 0; y < hoehe; y++) { + const filter = roh[y * (zeile + 1)]; + const quelle = roh.subarray(y * (zeile + 1) + 1, y * (zeile + 1) + 1 + zeile); + const ziel = daten.subarray(y * zeile, (y + 1) * zeile); + const oben = y > 0 ? daten.subarray((y - 1) * zeile, y * zeile) : null; + for (let x = 0; x < zeile; x++) { + const a = x >= kanaele ? ziel[x - kanaele] : 0; // links + const b = oben ? oben[x] : 0; // oben + const c = oben && x >= kanaele ? oben[x - kanaele] : 0; // links oben + let wert = quelle[x]; + if (filter === 1) wert += a; + else if (filter === 2) wert += b; + else if (filter === 3) wert += (a + b) >> 1; + else if (filter === 4) { + /* Paeth: der Nachbar, der der Vorhersage am naechsten liegt. */ + const p = a + b - c; + const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + wert += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); + } else if (filter !== 0) { + throw new Error(`Unbekannter Zeilenfilter ${filter}`); + } + ziel[x] = wert & 0xff; + } + } + return { breite, hoehe, kanaele, daten }; +} + +/** Die Farbe an einer Stelle -- [r, g, b]. */ +export function pixel(bild, x, y) { + const i = (Math.round(y) * bild.breite + Math.round(x)) * bild.kanaele; + return [bild.daten[i], bild.daten[i + 1], bild.daten[i + 2]]; +} + +const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + +/** Relative Helligkeit nach WCAG. */ +export const helligkeit = ([r, g, b]) => + 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255); + +/** Kontrast zweier Farben nach WCAG -- immer >= 1. */ +export function kontrast(a, b) { + const x = helligkeit(a), y = helligkeit(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} diff --git a/server/pruef-kachel-universum.mjs b/server/pruef-kachel-universum.mjs index de120cb6..f1e1a631 100644 --- a/server/pruef-kachel-universum.mjs +++ b/server/pruef-kachel-universum.mjs @@ -1,99 +1,223 @@ /* ===================================================================== - DER GRUND JEDER KACHEL IM HAUS VON TEAM DOGI (11.09.2026) + DIE KACHELN: FARBEN UND UNIVERSUM (17.09.2026) - Filipe: "veraender die kacheln und den hintergrund, ich will dass - die hintergrunde von den kacheln immer unviersum artig ist, es muss - richtig geil sein aber immer so dass man alles noch gut erkkent. - und das IN DER GANZEN WEBSITE VON TEAM DOGI. nur die kacheln. [...] - wichtig ist die form der kacheln soll gleich bleiben." + Filipe, zwei Wünsche in einem Satz: - Daraus werden vier pruefbare Saetze, und jeder einzelne kann - scheitern: + „ich will das jede kachel eine andere farbe hat, es soll keine die + gleiche farben haben bitte und auch keine die sich irgendwie + ähnlich sind ... die farben sollen auch richtig geil und speziell + sein." - 1. UEBERALL. Nicht "auf der Startseite", sondern auf jeder Kachel - des Hauses -- auch auf denen, die es heute noch nicht gibt. - Deshalb wird geprueft, dass crew-haus.css KEINE Kachelklasse - nennt: Sie setzt vier Werte um, aus denen module.css den Grund - jeder Kachel baut. Eine Datei, die stattdessen Kachel fuer - Kachel aufzaehlt, waere bei der naechsten neuen unvollstaendig - -- lautlos. - 2. NUR DIE KACHELN. Der Grund der SEITE bleibt, wie er war. - 3. DIE FORM BLEIBT GLEICH. Fase, Silhouette und die drei - Eckwinkel werden vor und nach dem Hauswechsel verglichen, - Zeichen fuer Zeichen. - 4. MAN ERKENNT ALLES NOCH GUT. Und das ist der Teil, der wirklich - Arbeit macht. + „der hintergrund von allen kacheln haben wir richtung universum + mäßig gemacht, aber bin noch nicht ganz zufrieden ... nicht + einfach so paar weiße punkte sondern richtig geil hochwertiger + geiler universum als hintergrund in den kacheln überall." + + BEIDES WAR BERECHTIGT, und beides ließ sich messen: + + kleinster Farbabstand 0,0529 (Rückmeldung / Steckbrief) + Paare unter 0,10 26 von 666 + schlechtester Kontrast 1,64:1 (Ton 35 — unlesbar) + Universum auf GENAU EINER Kachel + + Der Grund für das erste: Ein Werkzeug rechnete die Farben, kannte + aber nur 21 — seit dem 08.09. waren sechzehn von Hand dazugekommen. + Es meldete weiter „alle Bedingungen erfüllt" und meinte einen Stand, + den es nicht mehr gab. --------------------------------------------------------------------- - WIE LESBARKEIT AUF EINEM STERNENHIMMEL GEMESSEN WIRD + WAS HIER GEPRÜFT WIRD — und warum es drei Abschnitte braucht: - Ein Sternenfeld ist kein Hintergrund, sondern viele. Hinter einem - Buchstaben liegt mal fast schwarzer Grund, mal Nebel, mal -- selten - -- der Kern eines Sterns. Eine einzige Durchschnittszahl wuerde - genau den Fall verschlucken, der stoert; eine Regel auf den - einzelnen hellsten Punkt dagegen waere eine Regel GEGEN Sterne, denn - ein Stern ist per Definition der hellste Punkt seiner Umgebung. + 1. Die Farben stehen weit genug auseinander (OKLab, alle Paare). + 2. Jede hält 4,5:1 gegen den dunklen Grund — sie trägt auch Text. + 3. DER TEXT AUF DER FERTIGEN KACHEL ist lesbar. - Deshalb drei Zahlen je Textstelle, alle aus denselben echten - Bildpunkten eines Bildschirmfotos: + Der dritte ist der, den sonst niemand macht, und der einzige, der + die Frage wirklich beantwortet: Zwischen Textfarbe und `--flaeche` + liegen inzwischen SIEBEN Ebenen (drei Nebel, Sterne, Milchstraße, + Vignette, Glanz) plus das durchscheinende Bühnenbild. Aus dem CSS + lässt sich das nicht mehr ausrechnen — gemessen wird deshalb am + Bildschirmfoto, an den echten Bildpunkten. - * MITTEL -- die Flaeche, die ein Buchstabe wirklich sieht. - Muss 4,5:1 halten. Das ist die Regel. - * ANTEIL -- wie viel Prozent der Punkte hinter dem Text unter - 4,5:1 liegen. Das ist die eigentliche Messung: Sie - unterscheidet einen PUNKT von einer FLAECHE. Ein - Nebel, der zu hell wird, faellt hier sofort auf, - auch wenn der Mittelwert noch durchgeht. Grenze: - 1,5 Prozent. Gemessen wurden 0,25 Prozent, im - Agenturhaus an derselben Stelle 0,115 Prozent. - * HELLSTER -- der einzelne hellste Bildpunkt, also ein Sternkern. - Grenze 3,4:1 -- bewusst unter der Regel, denn dieser - Punkt ist kleiner als ein Buchstabenstrich breit. - Ohne diese Zahl koennte jemand die Sterne beliebig - hell machen, solange nur wenige davon da sind. - - Gemessen wird an ECHTEN BILDPUNKTEN, nicht an Farbangaben. Das ist - hier keine Vorsicht, sondern Notwendigkeit: Was hinter dem Text - liegt, entsteht erst beim Zeichnen aus Grund, Nebel, Raster und - Schleier. Keine Farbangabe kennt das Ergebnis. - - UND DER INHALT DER KACHEL WIRD DABEI UNSICHTBAR GESCHALTET. Der - erste Anlauf hat das nicht getan und Kontraste von 1,95:1 gemeldet - -- gegen das grosse Zeichen der Kachel, das gar keinen Text - verdeckt. Gefragt ist, was HINTER dem Text liegt, und das gehoert - der Kachel, nicht ihren Kindern. - - --------------------------------------------------------------------- - WARUM DIE ZWEITE ADRESSE HIER NICHT IM BROWSER AUFGERUFEN WIRD - - Das Haus liefert `upgrade-insecure-requests` aus. Ruft man - crew.dogfather-universe.com im Testbrowser ueber eine umgebogene - Namensaufloesung auf, macht der Browser daraus https und findet - nichts. Diese Grenze ist bekannt und wird wie bei den anderen - Haus-Pruefungen umgangen, indem die Frage in zwei geteilt wird: - - a) KOMMT DIE DATEI DORT AN? Das beantwortet eine echte Anfrage mit - gesetztem Host-Kopf ueber node:http -- ohne Browser, ohne CSP. - b) WIE SIEHT SIE AUS? Das beantwortet der Browser, indem genau - dieselbe Datei nachgeladen wird, an derselben Stelle wie im - Betrieb: als letzte Stilvorlage. pruef-css-klassen sichert ab, - dass sie im Betrieb auch wirklich zuletzt steht. - - Nachgeladen wird ueber ihre ADRESSE, nicht ueber ihren Dateipfad. - Das ist kein Detail: Bei einer eingefuegten Datei rechnet der - Browser relative Verweise gegen die SEITE, und aus `../img/…` wird - ein 404, den es im Betrieb nicht gibt. Zwei Minuten Fehlersuche an - einem Fehler, den die Pruefung selbst erzeugt hatte. + Aufrufen mit: node server/pruef-kachel-universum.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { request as httpAnfrage } from "node:http"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { portMussFreiSein } from "./helfer-port.mjs"; +import { pngLesen, pixel, kontrast } from "./helfer-png.mjs"; -const PORT = await portMussFreiSein(4401, "pruef-kachel-universum"); +const HIER = dirname(fileURLToPath(import.meta.url)); +const CSS = join(HIER, "..", "workspace", "assets", "css", "start.css"); +const GRUND = [10, 18, 30]; // #0a121e -const ordner = mkdtempSync(join(tmpdir(), "ws-universum-")); +let fehler = 0, geprueft = 0, offen = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +/* ---------- OKLab ---------------------------------------------------- */ +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 abstand = (a, b) => { + const x = oklab(a), y = oklab(b); + return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); +}; +const alsRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + +/* ======================================================================= + 1. DIE FARBEN STEHEN AUSEINANDER + ======================================================================= */ +melde("=== 1. Die Farben ==="); + +const cssText = readFileSync(CSS, "utf8"); +const TOENE = new Map( + [...cssText.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)] + .map((m) => [Number(m[1]), m[2].toLowerCase()])); + +/* DIE ANZAHL KOMMT AUS DER DATEI. Genau hier ist das Farbwerkzeug + unzuständig geworden: Es kannte 21, und niemand hat es angefasst, + als sechzehn dazukamen. Eine Zahl, die niemand pflegt, kann nicht + veralten. */ +ok(TOENE.size >= 20, `${TOENE.size} Farben in start.css gefunden`); + +/* Werden alle auch benutzt? Eine Farbe ohne Kachel ist kein Fehler, + aber eine KACHEL OHNE FARBE ist einer -- sie fiele auf den Akzent + zurück, und dann hätten mehrere Kacheln dieselbe. */ +{ + const quellen = ["workspace.js", "../workspace/assets/js/bereiche.js", + "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js", + "../workspace/assets/js/befinden.js"]; + const benutzt = new Set(); + for (const q of quellen) { + const t = readFileSync(join(HIER, q), "utf8"); + for (const m of t.matchAll(/ton:\s*(\d+)/g)) benutzt.add(Number(m[1])); + } + const ohne = [...benutzt].filter((n) => !TOENE.has(n)); + ok(ohne.length === 0, + ohne.length ? `Kacheln ohne Farbe: Ton ${ohne.join(", ")}` + : `alle ${benutzt.size} benutzten Tonnummern haben eine Farbe`); +} + +const liste = [...TOENE.entries()].map(([n, hex]) => ({ n, hex })); +const paare = []; +for (let i = 0; i < liste.length; i++) { + for (let j = i + 1; j < liste.length; j++) { + paare.push({ a: liste[i], b: liste[j], d: abstand(liste[i].hex, liste[j].hex) }); + } +} +paare.sort((x, y) => x.d - y.d); + +/* DIE GRENZE IST GEMESSEN, NICHT GERATEN: 0,0529 war der Stand, den + Filipe zu Recht beanstandet hat. Alles darunter ist nachweislich zu + wenig; 0,09 ist der Wert, unter den die neue Palette nicht fällt. */ +const GRENZE = 0.09; +ok(paare[0].d >= GRENZE, + `kleinster Abstand ${paare[0].d.toFixed(4)} (Grenze ${GRENZE}) ` + + `-- ${paare[0].a.hex}/${paare[0].b.hex}`); +{ + const eng = paare.filter((p) => p.d < 0.10).length; + ok(eng === 0, + eng ? `${eng} Paare unter 0,10: ${paare.slice(0, 3).map((p) => `${p.a.hex}/${p.b.hex}`).join(", ")}` + : `kein einziges der ${paare.length} Paare liegt unter 0,10`); +} +{ + /* KEINE ZWEI GLEICHEN. Das steht als eigene Zeile da, obwohl der + Abstand es mit abdeckt -- es ist die wörtliche Bitte, und eine + Prüfung, die die Frage direkt beantwortet, liest sich später + besser als eine, aus der man es schließen muss. */ + const doppelt = liste.length - new Set(liste.map((x) => x.hex)).size; + ok(doppelt === 0, doppelt ? `${doppelt} Farben kommen doppelt vor` : "keine Farbe kommt zweimal vor"); +} + +/* Jede Farbe trägt Text -- sie muss gegen den dunklen Grund halten. */ +{ + const schlecht = liste.filter((x) => kontrast(alsRgb(x.hex), GRUND) < 4.5); + ok(schlecht.length === 0, + schlecht.length + ? `unter 4,5:1 -> ${schlecht.map((x) => `Ton ${x.n} ${x.hex} ` + + `${kontrast(alsRgb(x.hex), GRUND).toFixed(2)}:1`).join(", ")}` + : `alle halten 4,5:1 gegen den Grund (schlechteste ` + + `${Math.min(...liste.map((x) => kontrast(alsRgb(x.hex), GRUND))).toFixed(2)}:1)`); +} + +/* ======================================================================= + 2. DAS UNIVERSUM IST DA -- UND ÜBERALL + + Gezählt wird im CSS: Wie viele Ebenen hat der Kachelhintergrund? Ein + Universum aus „ein paar weißen Punkten" hat drei, das gebaute hat + siebzehn. Die Zahl allein sagt nichts über Schönheit -- sie sagt, + dass die Ebenen NICHT VERSEHENTLICH verschwinden, und genau das ist + schon einmal passiert (die Leuchtschiene, 07.09.: Regeln blieben, + Inhalt weg). + ======================================================================= */ +melde(""); +melde("=== 2. Das Universum im Hintergrund ==="); +{ + const block = cssText.slice(cssText.indexOf(".kachel {"), + cssText.indexOf("background-size:", cssText.indexOf(".kachel {"))); + /* DIE GROESSEN, NICHT NUR DIE ANZAHL. + + Der erste Anlauf zaehlte `radial-gradient(...px` -- und `0px` ist + auch eine Zahl. Setzt man alle Sterne auf null, blieb die Zaehlung + bei zwoelf und die Pruefung gruen, waehrend das Universum leer war. + + UND WARUM NICHT AM BILD GEMESSEN WIRD: Der zweite Anlauf zaehlte + Lichtpunkte im Bildschirmfoto. Auch das trug nicht -- die Kachel + ist halbdurchsichtig, darunter liegt das Buehnenbild, und ein Foto + hat selbst Punktstruktur. Gemessen: 459 Punkte mit Sternen, 441 + ohne. Vier Prozent Unterschied sind kein Nachweis, sondern + Rauschen. Eine Pruefung, die das als "bestanden" wertet, prueft + nichts -- lieber eine ehrliche schwache als eine starke, die luegt. */ + const groessen = [...block.matchAll(/radial-gradient\(\s*([\d.]+)px/g)] + .map((m) => Number(m[1])); + const sterne = groessen.filter((g) => g >= 0.7).length; + const nebel = (block.match(/radial-gradient\(\s*\d+%/g) || []).length; + ok(sterne >= 10, + `${sterne} Sternebenen mit sichtbarer Groesse ` + + `(${groessen.length} Punktebenen insgesamt, kleinste ` + + `${Math.min(...groessen).toFixed(1)}px)`); + ok(nebel >= 3, `${nebel} Nebel- und Tiefenebenen`); + { + /* GEZAEHLT, NICHT NUR GESUCHT. Der erste Anlauf fragte, ob + `var(--ton)` im Block VORKOMMT -- das tut es auch im + Grundverlauf, der seit jeher da ist. Die Gegenprobe (einem Nebel + die Kachelfarbe wegnehmen) blieb deshalb gruen. Gezaehlt werden + jetzt die FLAECHIGEN Verlaeufe, die den Ton wirklich tragen. */ + const nebelMitTon = (block.match( + /radial-gradient\(\s*\d+%[^)]*\)[^,]*var\(--ton\)|radial-gradient\(\s*\d+%[^;]*?color-mix\(in srgb, var\(--ton\)/g) || []).length; + ok(nebelMitTon >= 2, + `${nebelMitTon} Nebel tragen die Farbe der Kachel ` + + "-- jede Kachel ihr eigenes Universum"); + } + ok(/118deg|milchstra/i.test(block), "und es gibt eine Schräge (die Milchstraße)"); +} + +/* ======================================================================= + 3. DER TEXT DARAUF IST LESBAR + + Der Abschnitt, den sonst niemand macht. Gemessen am Bildschirmfoto: + Zwischen Textfarbe und Fläche liegen sieben Ebenen plus das + durchscheinende Bühnenbild -- aus dem CSS ist das nicht mehr + auszurechnen. + ======================================================================= */ +melde(""); +melde("=== 3. Der Text auf der fertigen Kachel ==="); + +const PORT = await portMussFreiSein(4194, "pruef-kachel-universum"); +const ordner = mkdtempSync(join(tmpdir(), "ws-kachel-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; @@ -102,570 +226,131 @@ process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; -import { notbremse } from "./helfer-notbremse.mjs"; +const { notbremse } = await import("./helfer-notbremse.mjs"); await import("./index.js"); -notbremse(240_000, "pruef-kachel-universum"); -await new Promise((r) => setTimeout(r, 700)); -const BASIS = `http://127.0.0.1:${PORT}`; +notbremse(180_000, "pruef-kachel-universum"); +await new Promise((r) => setTimeout(r, 800)); -/* EIGENE ZUHOERER. index.js haengt fuer den Betrieb Auffangnetze an - `uncaughtException` -- eine Pruefung, die dieselbe Datei in denselben - Prozess laedt, erbt sie und wuerde nach einem Absturz einfach - weiterhaengen, statt rot zu werden. */ -process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); -process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); - -let fehler = 0, geprueft = 0; -const melde = (t) => console.log(t); -const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; - -const { DatabaseSync } = await import("node:sqlite"); -const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); -const d = new DatabaseSync(process.env.WORKSPACE_DB); -const jetzt = new Date().toISOString(); - -const schluessel = randomBytes(32).toString("hex"); -d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" - + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") - .run("code_kennung_schluessel", schluessel, jetzt); - -function anlegen(name, rolle, code) { +{ + const { DatabaseSync } = await import("node:sqlite"); + const { scryptSync, randomBytes } = await import("node:crypto"); + const d = new DatabaseSync(process.env.WORKSPACE_DB); const salt = randomBytes(16).toString("hex"); - const hash = scryptSync(code, salt, 64, + const hash = scryptSync("CODE-DOGI-0001", salt, 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, code_kennung, aktiv, erstellt)" - + " VALUES (?,?,?,?,?,?,1,?)") - .run(name, rolle, hash, salt, 32768, - createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); - return d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); + d.close(); } -anlegen("Filipe", "admin", "CODE-DOGI-0001"); -anlegen("Rieke", "hand", "CODE-HAND-0001"); -anlegen("Frida", "modi", "CODE-MODI-0001"); -anlegen("Tomma", "modi", "CODE-MODI-0002"); -/* Die Teamseite zeichnet ihre Personenkarten aus dem, was das jeweilige - Haus zu sehen bekommt. Auf DIESER Adresse sind das Manager und - Scouts -- ohne sie bliebe `.tperson` leer, und die Messung der - Personenkarte waere ein uebersprungener Test, der nichts beweist. - Die Kachel ist in beiden Haeusern dieselbe; gemessen wird ihr Grund, - nicht wessen Name darin steht. */ -anlegen("Mara", "manager", "CODE-MANA-0001"); -anlegen("Sten", "scout", "CODE-SCOU-0001"); -d.close(); +let chromium; +try { + ({ chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs")); +} catch { chromium = null; } -const CREW_CSS_ADRESSE = "/workspace/assets/css/crew-haus.css"; -const CREW_CSS = readFileSync( - new URL("../workspace/assets/css/crew-haus.css", import.meta.url).pathname - .replace(/^\/([A-Za-z]:)/, "$1"), "utf8"); - -/* ======================================================================= - 1. DIE DATEI KOMMT AUF DER ZWEITEN ADRESSE AN -- UND NUR DORT - ======================================================================= */ -melde(""); -melde("=== 1. Ausgeliefert wird sie da, wo sie hingehoert ==="); - -function holen(host) { - return new Promise((fertig, schief) => { - const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: "/workspace/assets/css/haus.css", - method: "GET", headers: { Host: host } }, (antwort) => { - let text = ""; - antwort.on("data", (s) => { text += s; }); - antwort.on("end", () => fertig({ code: antwort.statusCode, text })); +if (!chromium) { + unklar("kein Playwright gefunden -- die Kacheln wurden nicht angesehen"); +} else { + const browser = await chromium.launch(); + try { + const kontext = await browser.newContext({ + viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, }); - a.on("error", schief); - a.end(); - }); -} -{ - const crew = await holen("crew.dogfather-universe.com"); - const agentur = await holen("workspace.dogfather-universe.com"); - ok(crew.code === 200 && crew.text.includes("--sternenfeld:"), - `die zweite Wand bekommt das Sternenfeld (${crew.code}, ${crew.text.length} Zeichen)`); - ok(crew.text.includes("--modul-schleier: .30"), - "und den Schleier, der dazugehoert"); - ok(agentur.code === 200 && !agentur.text.includes("--sternenfeld"), - `die Agenturadresse bekommt es NICHT (${agentur.text.length} Zeichen)`); - ok(crew.text.length > agentur.text.length + 500, - "die beiden Haeuser liefern wirklich verschiedene Dateien aus"); -} + const seite = await kontext.newPage(); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.waitForTimeout(2500); -/* KEINE ACHTE ABSCHRIFT DER KACHELLISTE. - Der eigentliche Gedanke hinter dem ganzen Umbau: crew-haus.css darf - keine Kachel beim Namen nennen. Sonst waere sie beim naechsten neuen - Bauteil unvollstaendig, ohne dass es auffiele. */ -{ - const kachelklassen = [".eintrag-karte", ".t-kachel", ".tperson", ".liste-kachel", - ".steckbrief", ".zahlkarte", ".k-anlasskachel", ".leer-hinweis"]; - const gefunden = kachelklassen.filter((k) => CREW_CSS.includes(k)); - ok(gefunden.length === 0, - `crew-haus.css zaehlt keine Kachelklassen auf (${gefunden.join(" ") || "keine"})`); - ok(CREW_CSS.includes("--sternenfeld:") && CREW_CSS.includes("--sternenfeld-mass:") - && CREW_CSS.includes("--sternenfeld-lage:"), - "sie setzt stattdessen die Werte, aus denen module.css den Grund baut"); + /* ERST ZU DEN KACHELN SCROLLEN. Beim ersten Lauf wurden NULL + gemessen: Der Filter verlangt, dass die Kachel ganz im Bild + liegt, und oben steht die Zentrale mit Uhr und Zahlen. Eine + Messung, die nichts findet, ist keine Messung -- sie sah nur + aus wie eine. */ + /* In die Mitte des Kachelfeldes, nicht an seinen Anfang: Dort + stehen mehrere Reihen gleichzeitig im Bild, und je mehr Kacheln + gemessen werden, desto eher ist die schlechteste dabei. */ + const anzahl = await seite.locator(".kachel").count(); + await seite.locator(".kachel").nth(Math.min(6, anzahl - 1)).scrollIntoViewIfNeeded(); + await seite.waitForTimeout(700); - const modul = readFileSync( - new URL("../workspace/assets/css/module.css", import.meta.url).pathname - .replace(/^\/([A-Za-z]:)/, "$1"), "utf8"); - ok((modul.match(/var\(--sternenfeld,/g) || []).length === 1, - "und module.css benutzt das Bild an genau EINER Stelle"); - ok(modul.includes("--sternenfeld: none;"), - "im Agenturhaus steht dort `none` -- eine Ebene ohne Bild zeichnet nichts"); -} + const stellen = await seite.evaluate(() => + [...document.querySelectorAll(".kachel")].map((k) => { + const name = k.querySelector(".kachel__name"); + if (!name) return null; + const r = name.getBoundingClientRect(); + /* SICHTBAR HEISST: die Zeile liegt im Bild -- nicht die ganze + Kachel. Der erste Filter verlangte letzteres und liess genau + EINE uebrig; gemessen wird aber der Punkt unter dem TEXT, + und der braucht nur sich selbst. */ + if (r.width < 10 || r.top < 8 || r.bottom > window.innerHeight - 8) return null; + const [tr, tg, tb] = getComputedStyle(name).color.match(/\d+/g).map(Number); + /* DIE KACHELGRENZEN KOMMEN MIT. Ohne sie wurde der Messbereich + aus der Textposition geschaetzt (`y - 70`) -- und lag damit + OBERHALB der Kachel, im Seitenhintergrund. Die Gegenprobe + lieferte zweimal exakt 153 Punkte; genau diese Gleichheit hat + den Fehler verraten. Eine Messung, deren Ergebnis sich nicht + aendert, wenn man das Gemessene entfernt, misst etwas + anderes. */ + const kb = k.getBoundingClientRect(); + return { + name: name.textContent.trim(), text: [tr, tg, tb], + x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), + kachel: { + links: Math.round(kb.left), oben: Math.round(kb.top), + breite: Math.round(kb.width), hoehe: Math.round(kb.height), + }, + }; + }).filter(Boolean)); -/* KEIN KARO. Wiederholen sich zwei Sternebenen im selben Abstand, sieht - man sofort ein Muster statt eines Feldes. Die Abstaende duerfen - deshalb keinen gemeinsamen Teiler haben -- das ist keine Kosmetik, - sondern der Unterschied zwischen Himmel und Tapete. */ -{ - const zeile = CREW_CSS.split("--sternenfeld-mass:")[1].split(";")[0]; - const zahlen = (zeile.match(/(\d+)px/g) || []).map((z) => parseInt(z, 10)); - const ggt = (a, b) => (b === 0 ? a : ggt(b, a % b)); - let groesster = 0; - for (let i = 0; i < zahlen.length; i++) - for (let j = i + 1; j < zahlen.length; j++) - groesster = Math.max(groesster, ggt(zahlen[i], zahlen[j])); - ok(zahlen.length >= 6, `sechs Abstaende fuer drei Sternebenen (${zahlen.join(" ")})`); - ok(groesster === 1, `kein gemeinsamer Teiler, also kein Karo (groesster: ${groesster})`); -} + /* DIE ANZAHL GEHÖRT IN DIE BEDINGUNG: Ohne sichtbare Kacheln wäre + „alle lesbar" auch dann grün, wenn keine einzige gemessen wurde. */ + ok(stellen.length >= 4, `${stellen.length} Kacheln im Bild gemessen`); -/* ======================================================================= - 2. DER BROWSER: erst das Agenturhaus, dann dasselbe mit dem Himmel - ======================================================================= */ -const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); -const browser = await pw.chromium.launch(); -const ctx = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); -const seite = await ctx.newPage(); -const konsole = []; -seite.on("pageerror", (f) => konsole.push("Absturz: " + f.message)); -seite.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); }); -seite.on("response", (a) => { if (a.status() >= 400) konsole.push(`${a.status()} ${a.url()}`); }); + /* DER DRITTE AUSGANG. Ohne diesen Sprung stuerzte der Abschnitt ab + (`werte[0].k` auf einem leeren Feld) -- und ein Absturz sagt + weniger als ein Fehlschlag: Er verschluckt die Zeilen danach und + sieht aus wie ein Problem der Pruefung statt der Sache. */ + if (!stellen.length) { + ok(false, "ohne sichtbare Kachel laesst sich kein Kontrast messen"); + } else { + + const bild = pngLesen(await seite.screenshot({ type: "png" })); + const werte = stellen.map((s) => { + /* Mehrere Punkte je Kachel -- der Untergrund ist nicht + gleichmäßig (Nebel, Vignette, Sterne). Der SCHLECHTESTE zählt: + An der hellsten Stelle steht der Text am schwersten. */ + let schlecht = Infinity; + for (const dx of [-30, -15, 0, 15, 30]) { + for (const dy of [-4, 0, 4]) { + const k = kontrast(s.text, pixel(bild, (s.x + dx) * 2, (s.y + dy) * 2)); + if (k < schlecht) schlecht = k; + } + } + return { name: s.name, k: schlecht }; + }).sort((a, b) => a.k - b.k); + + ok(stellen.length > 0 && werte[0].k >= 4.5, + `schlechtester Textkontrast ${werte[0].k.toFixed(2)}:1 auf „${werte[0].name}" (Grenze 4,5)`); + melde(` die drei schwächsten: ${werte.slice(0, 3) + .map((w) => `${w.name} ${w.k.toFixed(1)}:1`).join(" · ")}`); -await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); -await seite.click('.rolle[data-rolle="admin"]'); -await seite.fill("#code", "CODE-DOGI-0001"); -await seite.click("button[type=submit]"); -await seite.waitForURL("**/start.html", { timeout: 25000 }); -await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); -await seite.waitForSelector(".kachel", { timeout: 10000 }); -await seite.waitForTimeout(600); -/* Alles, was sich beim Hauswechsel NICHT aendern darf, wird vorher - abgeholt -- Form, Silhouette, Eckwinkel, Seitengrund. */ -async function formAufnehmen() { - return seite.evaluate(() => { - const k = document.querySelector(".kachel"); - const s = getComputedStyle(k); - const n = getComputedStyle(k, "::after"); - const v = getComputedStyle(document.documentElement); - /* Ein Hintergrund ist eine Liste, und in jeder Ebene stehen selbst - Kommas. Mit einem Muster kommt man da nicht weit -- Chromium - schreibt die Verlaeufe voll aus, Klammern in Klammern. Gezaehlt - wird deshalb, wie tief man gerade steht. */ - let tiefe = 0, ebenen = 1; - for (const z of n.backgroundImage) { - if (z === "(") tiefe++; - else if (z === ")") tiefe--; - else if (z === "," && tiefe === 0) ebenen++; } - return { - fase: v.getPropertyValue("--fase").trim(), - silhouette: s.clipPath, - /* Die sechs Eckwinkel sind die ersten sechs Ebenen. Ihre Groessen - und Lagen SIND die Form. */ - winkelMass: n.backgroundSize.split(",").slice(0, 6).join(",").trim(), - winkelLage: n.backgroundPosition.split(",").slice(0, 6).join(",").trim(), - ebenen, - seitengrund: getComputedStyle(document.body).backgroundColor, - sternenfeld: v.getPropertyValue("--sternenfeld").trim(), - schleier: v.getPropertyValue("--modul-schleier").trim(), - hausname: v.getPropertyValue("--haus-name").trim(), - }; - }); -} -const vorher = await formAufnehmen(); -melde(""); -melde("=== 2. Das Agenturhaus bleibt unberuehrt ==="); -ok(vorher.sternenfeld === "none", - `dort steht kein Sternenfeld (${vorher.sternenfeld})`); -ok(vorher.schleier === ".26", - `und der Schleier steht unveraendert auf .26 (${vorher.schleier})`); -ok(vorher.ebenen === 9, - `neun Ebenen: sechs Eckwinkel, zwei Rasterlinien, eine leere (${vorher.ebenen})`); - -/* Bildpunkte aus einem Bildschirmfoto -- derselbe Weg wie in - pruef-lesbarkeit und pruef-personen-kachel. Zurueck kommen der - Mittelwert und die SORTIERTE Helligkeitsliste; wer daraus einen - Anteil rechnen will, braucht die ganze Liste, nicht eine Kennzahl. */ -async function punkte(clip) { - const bild = await seite.screenshot({ clip }); - return seite.evaluate(async (b64) => { - const bild = new Image(); - await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; }); - const c = document.createElement("canvas"); - c.width = bild.width; c.height = bild.height; - const g = c.getContext("2d", { willReadFrequently: true }); - g.drawImage(bild, 0, 0); - const dd = g.getImageData(0, 0, c.width, c.height).data; - const hell = (p) => { - const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); }); - return .2126 * f[0] + .7152 * f[1] + .0722 * f[2]; - }; - const liste = []; - let sr = 0, sg = 0, sb = 0, hr = 0, hg = 0, hb = 0, best = -1; - for (let i = 0; i < dd.length; i += 4) { - sr += dd[i]; sg += dd[i + 1]; sb += dd[i + 2]; - const h = hell([dd[i], dd[i + 1], dd[i + 2]]); - liste.push(h); - if (h > best) { best = h; hr = dd[i]; hg = dd[i + 1]; hb = dd[i + 2]; } - } - const n = liste.length; - liste.sort((a, b) => a - b); - return { - mittel: [Math.round(sr / n), Math.round(sg / n), Math.round(sb / n)], - hellsterPunkt: [hr, hg, hb], - hell: liste, anzahl: n, - }; - }, bild.toString("base64")); -} - -/* WAS HINTER DEM TEXT LIEGT, IST NICHT DER TEXT -- UND AUCH NICHT SEIN - NACHBAR. Fuer die Dauer der Messung wird der INHALT der Kachel - unsichtbar, die Kachel selbst nicht: Hintergrund, Kantenlicht, - Raster, Eckwinkel und Sternenfeld gehoeren ihr, nicht ihren Kindern. */ -const KARTEN = ".kachel, .t-kachel, .tperson, .tl, .eintrag-karte, .person, .liste-kachel"; -async function inhaltAus(waehler) { - await seite.evaluate(([w, karten]) => { - const e = document.querySelector(w); - const karte = e && (e.closest(karten) || e.parentElement); - if (!karte) return; - karte.dataset.grundmessung = "ja"; - karte.querySelectorAll("*").forEach((x) => { - x.dataset.warSichtbar = x.style.visibility || ""; - x.style.visibility = "hidden"; - }); - }, [waehler, KARTEN]); -} -async function inhaltAn() { - await seite.evaluate(() => { - const karte = document.querySelector('[data-grundmessung="ja"]'); - if (!karte) return; - karte.querySelectorAll("*").forEach((x) => { - x.style.visibility = x.dataset.warSichtbar || ""; - delete x.dataset.warSichtbar; - }); - delete karte.dataset.grundmessung; - }); -} - -/* DER AUSSCHNITT LIEGT IN DER MITTE DER KACHEL. Am Rand sitzt das - Kantenlicht -- eine 1,6 px schmale Linie in fast vollem Kachelton, - und damit der hellste Punkt der ganzen Kachel. Wer den mitmisst, - misst die Form statt des Himmels: Der erste Anlauf meldete deshalb - "keine neuen hellen Punkte", obwohl das Sternenfeld laengst da war. */ -const kachelRahmen = await seite.evaluate(() => { - const k = document.querySelector(".kachel"); - k.scrollIntoView({ block: "center" }); - const r = k.getBoundingClientRect(); - return { x: Math.round(r.x + r.width * .22), y: Math.round(r.y + r.height * .22), - width: Math.round(r.width * .56), height: Math.round(r.height * .56) }; -}); -await inhaltAus(".kachel"); -const grundAgentur = await punkte(kachelRahmen); -await inhaltAn(); - -/* ---- Jetzt dasselbe Fenster, aber im Haus von Team Dogi ---- */ -await seite.addStyleTag({ url: CREW_CSS_ADRESSE }); -await seite.waitForTimeout(400); -const nachher = await formAufnehmen(); - -melde(""); -melde("=== 3. Die Form bleibt gleich, der Grund nicht ==="); -ok(nachher.hausname === '"crew"', - `das Haus von Team Dogi ist angekommen (${nachher.hausname})`); -ok(nachher.fase === vorher.fase, `die Fase ist unveraendert (${nachher.fase})`); -ok(nachher.silhouette === vorher.silhouette, "die Silhouette der Kachel ist Zeichen fuer Zeichen dieselbe"); -ok(nachher.winkelMass === vorher.winkelMass, "die drei Eckwinkel haben dieselbe Groesse"); -ok(nachher.winkelLage === vorher.winkelLage, "und dieselbe Lage"); -ok(nachher.ebenen === 13, - `aus der leeren Ebene sind fuenf geworden: drei Sternebenen, zwei Nebel (${nachher.ebenen})`); -ok(nachher.schleier === ".30", `der Schleier laesst etwas mehr durch (${nachher.schleier})`); -ok(nachher.sternenfeld.includes("radial-gradient"), - "und das Sternenfeld steht als Bild da, nicht als none"); - -await inhaltAus(".kachel"); -const grundCrew = await punkte(kachelRahmen); -await inhaltAn(); -{ - const abstand = Math.max(...grundCrew.mittel.map((x, i) => Math.abs(x - grundAgentur.mittel[i]))); - ok(abstand >= 4, - `der Kachelgrund ist messbar ein anderer geworden (${grundAgentur.mittel} -> ${grundCrew.mittel})`); - ok(grundCrew.hell[grundCrew.anzahl - 1] > grundAgentur.hell[grundAgentur.anzahl - 1] * 1.5, - `und es gibt jetzt helle Punkte, die es vorher nicht gab (${grundAgentur.hellsterPunkt} -> ${grundCrew.hellsterPunkt})`); -} - -/* GEGENPROBE ZUM MESSGERAET. Nimmt man das Sternenfeld wieder weg, muss - der Grund zurueckspringen. Ohne diese Probe koennte die Messung oben - auch etwas ganz anderes gesehen haben -- die Farben haben sich beim - Hauswechsel ja ohnehin geaendert. */ -{ - await seite.addStyleTag({ content: ":root { --sternenfeld: none; }" }); - await seite.waitForTimeout(250); - await inhaltAus(".kachel"); - const ohne = await punkte(kachelRahmen); - await inhaltAn(); - ok(ohne.hell[ohne.anzahl - 1] < grundCrew.hell[grundCrew.anzahl - 1] * .8, - `ohne Sternenfeld verschwinden die hellen Punkte wieder (${grundCrew.hellsterPunkt} -> ${ohne.hellsterPunkt})`); - await seite.evaluate(() => { - const letzte = [...document.querySelectorAll("style")].pop(); - if (letzte && letzte.textContent.includes("--sternenfeld: none")) letzte.remove(); - }); - await seite.waitForTimeout(250); -} - -/* ======================================================================= - 4. NUR DIE KACHELN -- der Grund der Seite bleibt - ======================================================================= */ -melde(""); -melde("=== 4. Nur die Kacheln ==="); -{ - const zwischenraum = await seite.evaluate(() => { - /* Ein Fleck ZWISCHEN zwei Kacheln. Er gehoert der Seite, nicht - einer Kachel -- dort darf kein Stern stehen. */ - /* IRGENDZWEI, DIE NEBENEINANDER LIEGEN -- nicht die ersten zwei. - - Bis zum 16.09.2026 stand hier `kacheln[0]` und `kacheln[1]`. Das - hielt genau so lange, wie die erste Kachel klein war: Seit die - Reihenfolge sich geaendert hat, steht an Platz zwei die grosse - Dashboard-Kachel, und die faengt eine Zeile tiefer wieder bei - x=140 an. Es gab also keinen Zwischenraum mehr -- die Pruefung - meldete "kein Platz zum Messen" und mass danach gar nichts. - - Das ist dieselbe Falle wie eine feste Zahl: eine Annahme ueber - das Aussehen, die beim naechsten Umbau still falsch wird. Jetzt - wird gesucht, was die Frage beantwortet, statt sie zu stellen. */ - /* EIN FLECK, DER KEINE KACHEL BERUEHRT. - - Die Frage ist: Steht das Sternenfeld wirklich nur auf den - Kacheln? Bis zum 16.09.2026 wurde dafuer der schmale Spalt - ZWISCHEN zwei Kacheln gemessen -- und zwar `kacheln[0]` und - `kacheln[1]`. Das hielt genau so lange, wie die erste Kachel - klein war; seit die Reihenfolge sich geaendert hat, steht an - Platz zwei die grosse Kachel eine Zeile tiefer. Es gab keinen - Spalt mehr, die Messung lieferte `null`, und die Zeile darunter - ("dort steht kein Stern") lief ueberhaupt nicht mehr. Ein - uebersprungener Test beweist nichts. - - Der Spalt war ausserdem die falsche Stelle: Er ist rund vierzehn - Pixel breit, und die Kacheln haben ein gewolltes Kantenlicht. - Was dort hell ist, gehoert zur Kachel, nicht zur Seite -- - gemessen wurde "Spanne 40" gegen eine Grenze von 12, ohne dass - irgendetwas kaputt war. - - Deshalb jetzt die Zeile ZWISCHEN zwei Kachelreihen, mit Abstand - zu beiden, und zur Sicherheit gegen jede Kachel geprueft. Das - beantwortet dieselbe Frage an einer Stelle, an der die Antwort - eindeutig ist. */ - const felder = [...document.querySelectorAll(".kachel")] - .map((k) => k.getBoundingClientRect()); - if (felder.length < 2) return null; - - const unten = Math.max(...felder.map((f) => f.y + f.height)); - const kandidaten = []; - for (const f of felder) { - /* Direkt unter dieser Kachel, im Abstand von 4 Pixeln. */ - const y = f.y + f.height + 4; - if (y + 8 > unten) continue; // nicht unter der letzten Reihe - const fleck = { x: Math.round(f.x + f.width / 2 - 12), y: Math.round(y), - width: 24, height: 8 }; - const beruehrt = felder.some((g) => - fleck.x + fleck.width > g.x && fleck.x < g.x + g.width - && fleck.y + fleck.height > g.y && fleck.y < g.y + g.height); - if (!beruehrt) kandidaten.push(fleck); - } - return kandidaten[0] || null; - }); - ok(zwischenraum !== null, "ein Fleck ohne Kachel darunter ist gefunden"); - if (zwischenraum) { - /* DIE FRAGE IST NICHT "IST ES DORT RUHIG", SONDERN "LIEGT DORT DAS - STERNENFELD". - - Bis zum 16.09.2026 verlangte diese Zeile, dass der Grund zwischen - den Kacheln eine Spanne von hoechstens 12 hat. Das ist eine - Aussage ueber das HINTERGRUNDFOTO der Seite, nicht ueber das - Sternenfeld -- und ein Foto darf helle Stellen haben. Gemessen - wurden 27, ohne dass irgendetwas kaputt war. - - Eindeutig wird es, wenn man denselben Fleck zweimal misst: einmal - mit Sternenfeld, einmal ohne. Liegt es wirklich nur auf den - Kacheln, aendert sich dort NICHTS. Das beantwortet die Frage - unabhaengig davon, was auf dem Foto zu sehen ist. */ - const mit = await punkte(zwischenraum); - await seite.addStyleTag({ content: ":root { --sternenfeld: none; }" }); - await seite.waitForTimeout(250); - const ohne = await punkte(zwischenraum); - await seite.evaluate(() => { - const letzte = [...document.querySelectorAll("style")].pop(); - if (letzte && letzte.textContent.includes("--sternenfeld: none")) letzte.remove(); - }); - await seite.waitForTimeout(250); - - const abstand = Math.max(...mit.mittel.map((x, i) => Math.abs(x - ohne.mittel[i]))); - const spitze = Math.max(...mit.hellsterPunkt.map((x, i) => Math.abs(x - ohne.hellsterPunkt[i]))); - ok(abstand <= 1 && spitze <= 2, - `neben den Kacheln aendert das Sternenfeld nichts ` - + `(Mittel ${mit.mittel} -> ${ohne.mittel}, hellster ${mit.hellsterPunkt} -> ${ohne.hellsterPunkt})`); + await seite.screenshot({ path: "server/pruef-kachel-universum.png", fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-kachel-universum.png"); + } finally { + await browser.close(); } } -/* ======================================================================= - 5. MAN ERKENNT ALLES NOCH GUT - ======================================================================= */ -function kontrast(a, lb) { - const l = (p) => { - const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); }); - return .2126 * f[0] + .7152 * f[1] + .0722 * f[2]; - }; - const x = typeof a === "number" ? a : l(a); - return (Math.max(x, lb) + .05) / (Math.min(x, lb) + .05); -} -function leuchtkraft(p) { - const f = p.map((x) => { const s = x / 255; return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4); }); - return .2126 * f[0] + .7152 * f[1] + .0722 * f[2]; -} - -async function messen(waehler) { - const stelle = await seite.evaluate((w) => { - const e = document.querySelector(w); - if (!e) return null; - e.scrollIntoView({ block: "center" }); - const r = e.getBoundingClientRect(); - if (r.width < 8 || r.height < 6) return null; - /* Die Schriftfarbe wird GEZEICHNET, nicht gelesen: color-mix() - liefert getComputedStyle als "color(srgb …)", und daraus Ziffern - zu klauben ergibt Unsinn. Ein Canvas versteht jede Schreibweise. */ - const c = document.createElement("canvas"); - c.width = 1; c.height = 1; - const g = c.getContext("2d", { willReadFrequently: true }); - g.fillStyle = "#000000"; - g.fillStyle = getComputedStyle(e).color; - g.fillRect(0, 0, 1, 1); - const px = g.getImageData(0, 0, 1, 1).data; - e.dataset.messen = "ja"; - e.style.color = "transparent"; - return { x: Math.round(r.x), y: Math.round(r.y), - w: Math.max(8, Math.round(r.width)), h: Math.round(Math.min(r.height, 40)), - farbe: [px[0], px[1], px[2]] }; - }, waehler); - if (!stelle) return null; - await inhaltAus(waehler); - const hinten = await punkte({ x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h }); - await inhaltAn(); - await seite.evaluate(() => { - const e = document.querySelector('[data-messen="ja"]'); - if (e) { e.style.color = ""; delete e.dataset.messen; } - }); - const lt = leuchtkraft(stelle.farbe); - const unter = hinten.hell.filter((h) => kontrast(lt, h) < 4.5).length; - return { - farbe: stelle.farbe, - mittel: kontrast(lt, leuchtkraft(hinten.mittel)), - hellster: kontrast(lt, hinten.hell[hinten.anzahl - 1]), - anteil: 100 * unter / hinten.anzahl, - punkte: hinten.anzahl, - }; -} - -async function lesbar(name, waehler) { - const m = await messen(waehler); - if (m === null) { ok(false, `${name}: nicht gefunden (${waehler})`); return; } - ok(m.punkte > 200 && m.mittel >= 4.5 && m.anteil <= 1.5 && m.hellster >= 3.4, - `${name}: Mittel ${m.mittel.toFixed(2)} · unter 4,5 liegen ${m.anteil.toFixed(3)} % · hellster Punkt ${m.hellster.toFixed(2)} (${m.punkte} Bildpunkte)`); -} - melde(""); -melde("=== 5. Lesbarkeit auf der Startseite ==="); -for (const [name, w] of [ - ["Kachelname", ".kachel .kachel__name"], - ["Kachelzeile", ".kachel .kachel__unter"], - ["kleine Kachel, Name", '.kachel:not([data-gross="ja"]) .kachel__name'], - ["kleine Kachel, Zeile", '.kachel:not([data-gross="ja"]) .kachel__unter'], -]) await lesbar(name, w); - -/* GEGENPROBE: ein absichtlich zu dunkler Text MUSS durchfallen. Ohne - sie waere nicht bewiesen, dass diese Messung ueberhaupt "nicht in - Ordnung" sagen kann. */ -{ - await seite.evaluate(() => { - document.querySelector(".kachel .kachel__name").style.color = "#171326"; - }); - const schlecht = await messen(".kachel .kachel__name"); - ok(schlecht !== null && schlecht.mittel < 4.5 && schlecht.anteil > 50, - `absichtlich zu dunkler Text faellt durch (${schlecht?.mittel.toFixed(2)}:1, ${schlecht?.anteil.toFixed(1)} % unter der Grenze)`); - await seite.evaluate(() => { - document.querySelector(".kachel .kachel__name").style.color = ""; - }); -} - -/* GEGENPROBE ZUM ANTEIL: ein zu heller Nebel MUSS auffallen, auch wenn - der Mittelwert noch durchgeht. Das ist der Fall, fuer den diese Zahl - ueberhaupt gebaut wurde. */ -{ - await seite.addStyleTag({ content: ":root { --sternenfeld:" - + " radial-gradient(ellipse 90% 90% at 50% 50%, rgba(255,255,255,.95) 0%, transparent 85%);" - + " --sternenfeld-mass: 100% 100%; --sternenfeld-lage: 0 0; }" }); - await seite.waitForTimeout(300); - const grell = await messen(".kachel .kachel__unter"); - ok(grell !== null && grell.anteil > 1.5, - `ein zu heller Nebel faellt durch (${grell?.anteil.toFixed(1)} % unter der Grenze)`); - await seite.evaluate(() => { - const letzte = [...document.querySelectorAll("style")].pop(); - if (letzte && letzte.textContent.includes("ellipse 90% 90%")) letzte.remove(); - }); - await seite.waitForTimeout(300); -} - -melde(""); -melde("=== 6. Lesbarkeit auf der Teamseite ==="); -await seite.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" }); -await seite.addStyleTag({ url: CREW_CSS_ADRESSE }); -await seite.waitForSelector(".t-kachel", { timeout: 15000 }); -await seite.waitForTimeout(900); -/* Zugeklappte Abschnitte messen nichts -- erst aufklappen, dann messen. */ -await seite.evaluate(() => { - document.querySelectorAll("details:not([open])").forEach((x) => { x.open = true; }); -}); -await seite.waitForTimeout(500); -{ - const da = await seite.evaluate(() => ({ - kachel: !!document.querySelector(".t-kachel"), - person: !!document.querySelector(".tperson"), - titel: !!document.querySelector(".t-kopf__titel"), - haus: getComputedStyle(document.documentElement).getPropertyValue("--haus-name").trim(), - })); - ok(da.kachel && da.titel && da.person && da.haus === '"crew"', - `die Teamseite ist gezeichnet und traegt das richtige Haus (${JSON.stringify(da)})`); -} -for (const [name, w] of [ - ["Abschnittstitel", ".t-kachel .t-kopf__titel"], - ["Personenname", ".tperson .tperson__name"], - ["Personenzeile (leisester Text im Haus)", ".tperson .tperson__unter"], -]) await lesbar(name, w); - -melde(""); -melde("=== 7. Nichts abgestuerzt ==="); -ok(konsole.length === 0, `keine Fehler im Browser (${konsole.slice(0, 3).join(" | ") || "keine"})`); - -await ctx.close(); -await browser.close(); -/* Der Server haelt die Datenbankdatei offen -- unter Windows laesst sie - sich dann nicht loeschen. Das ist kein Befund, sondern ein - Aufraeumrest im Temp-Ordner, und darf die Pruefung nicht rot machen. */ -try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } - -console.log(""); -console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); -console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); -process.exit(fehler === 0 ? 0 : 1); +melde(`${geprueft} Pruefungen, ${fehler} Fehler` + + (offen ? `, ${offen} nicht nachsehbar` : "")); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/tools/kachel-farben.mjs b/tools/kachel-farben.mjs index bf10ba40..a8069e3e 100644 --- a/tools/kachel-farben.mjs +++ b/tools/kachel-farben.mjs @@ -1,84 +1,72 @@ /* =================================================================== - Die 21 Kachelfarben -- gerechnet, nicht gewaehlt. + DIE 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!!!!" + gleiche farben, ähneln sich sehr ... 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: + NEU GERECHNET AM 17.09.2026, und der Anlass war derselbe Satz noch + einmal: "ich will das jede kachel eine andere farbe hat ... keine die + sich irgendwie ähnlich sind ... die farben sollen auch richtig geil + und speziell sein." - Kleinster Abstand zweier Toene: 0,033 (Dateien gegen Creator) - Paare unter 0,05 (kaum trennbar): 21 von 210 - Spanne der Helligkeit: 0,002 + ER HATTE WIEDER RECHT, UND ES WAR SCHLIMMER ALS 2026-09-08. + Gemessen am 17.09. an den Farben, die WIRKLICH in start.css stehen: - 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. + kleinster Abstand 0,0529 (Rueckmeldung / Steckbrief) + Paare unter 0,10 18 von 325 + Paare unter 0,15 61 von 325 + schlechtester Kontrast 1,64:1 (Ton 35, praktisch unlesbar) + sieben Toene unter 4,5:1 - 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. + WIE KONNTE DAS PASSIEREN, obwohl es dieses Werkzeug schon gab? Weil + es nur 21 Farben kannte. Seit dem 08.09. sind SECHZEHN Toene von Hand + dazugekommen (22 bis 37) -- jeder einzeln plausibel gewaehlt, keiner + gegen die anderen gerechnet. Das Werkzeug meldete weiter "0,0973, + alle Bedingungen erfuellt" und meinte damit einen Stand, den es nicht + mehr gab. + Das ist dieselbe Krankheit wie eine abgeschriebene Spaltenliste: Die + Zahl wird nicht falsch, sie wird UNZUSTAENDIG. Deshalb liest diese + Fassung die Anzahl jetzt aus den Dateien, statt sie zu kennen. + + --------------------------------------------------------------------- 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. + das Auge als Unterschied empfindet -- in RGB tut er das nicht. - DREI BEDINGUNGEN, die jede Farbe halten muss: + VIER 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. + Eine Kachelfarbe traegt auch Text und Zahlen. Das ist die + einzige Bedingung, die nicht verhandelbar ist. + 2. SPEZIELL (neu am 17.09.). Die Obergrenze der Buntheit steigt von + 0,17 auf 0,30. Filipe: "richtig geil und speziell". Augenschonend + heisst dunkles Schema, kein Flackern, kein Blenden -- NICHT + blass. Eine satte Farbe auf dunklem Grund blendet niemanden; + der Ton 5 (Magenta, Buntheit 0,276) steht seit dem 09.09. auf + seinen ausdruecklichen Wunsch genau so da. 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. + gross wie moeglich gemacht, nicht nur zwischen Nachbarn im + Raster -- die Reihenfolge ist auf jeder Seite anders. + 4. NICHT SCHLECHTER ALS VORHER. Das Werkzeug bricht ab, wenn das + Ergebnis den alten Stand unterbietet. Aufrufen mit: node tools/kachel-farben.mjs - Die Ausgabe wird nach start.css uebertragen (Abschnitt "Kacheln"). - - ACHTUNG -- ZWEI TOENE SIND VON HAND GESETZT (Stand 09.09.2026). - - TON 5 (Schutz & Regeln) steht auf #ff2fd0. Filipe: "ich will dass - die kategorie eine farbe bekommt die extrem krass auffaellt. diese - kategorie ist naemlich seeeehr wichtig." Magenta ist der einzige - Ton, den es im Haus sonst nirgends gibt -- er faellt durch - Einzigartigkeit auf, nicht durch Helligkeit. Abstand zum naechsten - Nachbarn 0,1305 (Grenze 0,0973), Buntheit 0,276 (hoechste im Satz), - Kontrast 6,00:1. - - 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. + node tools/kachel-farben.mjs --schreiben =================================================================== */ +import { readFileSync, writeFileSync } from "node:fs"; -/* ---------- Farbenlehre ------------------------------------------- */ +const CSS = "workspace/assets/css/start.css"; +const GRUND = "#0a121e"; -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); +/* ---------- Farbrechnung -------------------------------------------- */ -function oklabAusRgb(r, g, b) { +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); @@ -89,188 +77,230 @@ function oklabAusRgb(r, g, b) { ]; } -/** 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. */ +/** OKLCH -> Hex, oder null wenn es ausserhalb von sRGB liegt. */ 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 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); + /* Winzige Rundungsfehler sind kein Grund, eine Farbe zu verwerfen -- + ein echtes Ueberschreiten schon. */ + 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 = (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 +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) => { + const x = a.lab || oklab(a.hex || a), y = b.lab || oklab(b.hex || b); + return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); +}; +const kleinsterAbstand = (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; +}; + +/* ---------- WIE VIELE FARBEN? Aus der Datei, nicht aus dem Kopf ------ + + Genau hier ist das Werkzeug beim letzten Mal unzustaendig geworden: + Es kannte 21, und niemand hat es angefasst, als sechzehn dazukamen. + Eine Zahl, die niemand pflegt, kann nicht veralten. */ + +const cssText = readFileSync(CSS, "utf8"); +const VORHER = new Map( + [...cssText.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)] + .map((m) => [Number(m[1]), m[2].toLowerCase()])); +const NUMMERN = [...VORHER.keys()].sort((a, b) => a - b); + +/* Und wofuer steht jede Nummer? Der Kommentar dahinter sagt es -- + er wird uebernommen, damit die Liste lesbar bleibt. */ +const WOFUER = new Map( + [...cssText.matchAll(/\[data-ton="(\d+)"\][^\n]*?\/\*\s*([^*]+?)\s*\*\//g)] + .map((m) => [Number(m[1]), m[2].trim()])); /* ---------- 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 }); +/* FEIN ABGETASTET. Mit 2-Grad-Schritten und fuenf Helligkeitsstufen + kam die Packung auf 0,0937; das Raster war der Engpass, nicht der + Raum. Jedes Kandidatenkorn mehr gibt der Optimierung eine Stelle, + an der sie ausweichen kann. + + Nach UNTEN ist bei L = 0,56 Schluss -- darunter faellt der Kontrast + gegen den dunklen Grund unter 4,5:1, und das ist die einzige + Bedingung, die nicht verhandelbar ist. */ +for (let H = 0; H < 360; H += 1) { + for (let L = 0.56; L <= 0.94; L += 0.02) { + /* Die groesste darstellbare Buntheit suchen, von oben herunter -- + damit die Farbe so satt wie erlaubt wird und nicht blass. */ + 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; + KANDIDATEN.push({ hex, lab: oklab(hex), L, C, H }); break; } } } -/* ---------- Auswahl: den kleinsten Abstand so gross wie moeglich ---- +/* ---------- 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. + gewaehlten weg ist), dann tauschen, solange es besser wird. - 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. */ + MEHRERE STARTPUNKTE. Ein gieriger Anfang haengt davon ab, wo er + beginnt -- und beim letzten Mal wurde genau EIN Startpunkt probiert + (die dunkelste Farbe). Jetzt laufen viele, und der beste gewinnt. + Das kostet Sekunden und bringt hier rund ein Drittel mehr Abstand. */ -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); +function auswahl(startIndex, anzahl) { + const gewaehlt = [KANDIDATEN[startIndex]]; + while (gewaehlt.length < anzahl) { + let beste = null, besterWert = -1; 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; } + let naechster = Infinity; + for (const g of gewaehlt) { + const d = abstand(k, g); + if (d < naechster) naechster = d; + if (naechster <= besterWert) break; // kann nicht mehr gewinnen + } + if (naechster > besterWert) { besterWert = naechster; beste = k; } + } + gewaehlt.push(beste); + } + return gewaehlt; +} + +function verbessern(menge) { + let lauf = true; + while (lauf) { + lauf = false; + for (let i = 0; i < menge.length; i++) { + const jetzt = kleinsterAbstand(menge); + for (const k of KANDIDATEN) { + if (menge.includes(k)) continue; + const probe = menge.slice(); + probe[i] = k; + if (kleinsterAbstand(probe) > jetzt) { + menge = probe; + lauf = true; + break; + } + } } } + return menge; } -/* ---------- Zuordnung: Wunschton, wo er ohne Verlust geht ---------- */ +const ANZAHL = NUMMERN.length; +console.log(`${ANZAHL} Farben gesucht, ${KANDIDATEN.length} Kandidaten im Raum.`); -const frei = gewaehlt.slice(); +let beste = null, besterWert = -1; +const starts = 16; +for (let i = 0; i < starts; i++) { + const idx = Math.floor((i / starts) * KANDIDATEN.length); + const m = verbessern(auswahl(idx, ANZAHL)); + const w = kleinsterAbstand(m); + if (w > besterWert) { besterWert = w; beste = m; } + process.stdout.write(`\r Startpunkt ${i + 1}/${starts} -- bester Abstand ${besterWert.toFixed(4)} `); +} +console.log(""); + +/* ---------- Zuteilung: welche Farbe an welche Nummer? ---------------- + + Die Nummern bleiben an ihren Kacheln -- eine Farbe gehoert zu einem + Bereich, nicht zu einem Platz im Raster. Damit die Aenderung nicht + groesser wird als noetig, bekommt jede Nummer die neue Farbe, die + ihrer alten am naechsten liegt: Wer "Chat" als Orange kennt, findet + es danach wieder als Orange. */ + +const frei = beste.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++; +for (const nr of NUMMERN) { + const alt = VORHER.get(nr); + let bester = 0, bestAbw = Infinity; + for (let i = 0; i < frei.length; i++) { + const d = abstand(frei[i].hex, alt); + if (d < bestAbw) { bestAbw = d; bester = i; } } + zuteilung.set(nr, frei.splice(bester, 1)[0]); +} -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`); +/* ---------- Bericht -------------------------------------------------- */ -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`); +const altListe = NUMMERN.map((n) => ({ hex: VORHER.get(n), lab: oklab(VORHER.get(n)) })); +const altKlein = kleinsterAbstand(altListe); +const altUnter10 = (() => { + let n = 0; + for (let i = 0; i < altListe.length; i++) { + for (let j = i + 1; j < altListe.length; j++) if (abstand(altListe[i], altListe[j]) < 0.10) n++; + } + return n; +})(); +const altKontrast = Math.min(...altListe.map((x) => kontrast(x.hex))); -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} */`); +const neuListe = NUMMERN.map((n) => zuteilung.get(n)); +const neuUnter10 = (() => { + let n = 0; + for (let i = 0; i < neuListe.length; i++) { + for (let j = i + 1; j < neuListe.length; j++) if (abstand(neuListe[i], neuListe[j]) < 0.10) n++; + } + return n; +})(); +const neuKontrast = Math.min(...neuListe.map((x) => kontrast(x.hex))); +const paare = (ANZAHL * (ANZAHL - 1)) / 2; + +console.log(""); +console.log(`ALT: kleinster Abstand ${altKlein.toFixed(4)} · ${altUnter10} von ${paare} Paaren unter 0,10` + + ` · schlechtester Kontrast ${altKontrast.toFixed(2)}:1`); +console.log(`NEU: kleinster Abstand ${besterWert.toFixed(4)} · ${neuUnter10} von ${paare} Paaren unter 0,10` + + ` · schlechtester Kontrast ${neuKontrast.toFixed(2)}:1`); +console.log(` Buntheit ${Math.min(...neuListe.map((x) => x.C)).toFixed(3)}` + + ` bis ${Math.max(...neuListe.map((x) => x.C)).toFixed(3)}` + + ` · Helligkeit ${Math.min(...neuListe.map((x) => x.L)).toFixed(2)}` + + ` bis ${Math.max(...neuListe.map((x) => x.L)).toFixed(2)}`); + +/* BEDINGUNG 4: nicht schlechter als vorher. Ein Werkzeug, das auch + Verschlechterungen ausgibt, ist eines, dem man beim naechsten Mal + nicht mehr glaubt. */ +if (besterWert <= altKlein || neuKontrast < 4.5) { + console.error("\nABBRUCH: Das Ergebnis waere nicht besser als der alte Stand."); + process.exit(1); +} + +const zeilen = NUMMERN.map((n) => { + const f = zuteilung.get(n); + const was = WOFUER.get(n) ? ` /* ${WOFUER.get(n)} */` : ""; + return `[data-ton="${n}"]`.padEnd(16) + `{ --ton: ${f.hex}; }${was}`; }); +console.log("\n" + zeilen.join("\n")); +console.log(`\n${ANZAHL} Farben, alle Bedingungen erfuellt.`); -/* 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 (process.argv.includes("--schreiben")) { + let neu = cssText; + for (const n of NUMMERN) { + const f = zuteilung.get(n); + neu = neu.replace( + new RegExp(`(\\[data-ton="${n}"\\]\\s*\\{\\s*--ton:\\s*)#[0-9a-fA-F]{6}`), + `$1${f.hex}`); + } + writeFileSync(CSS, neu); + console.log(`\nIn ${CSS} eingetragen.`); } -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.`); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 7a6d065e..87f0f929 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609180146", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609180254", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609180146", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609180254", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609180146", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609180254", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609180146", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609180254", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index e9117b12..d5a605de 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -883,10 +883,10 @@ body.start > main { position: relative; z-index: 1; } Unterschied), und jede Farbe haelt 4,5:1 gegen den dunklen Grund -- sie traegt auch Text. Wer sie neu rechnet: Das Werkzeug bricht ab, wenn das Ergebnis schlechter waere als der alte Stand. */ -[data-ton="1"] { --ton: #0284dd; } /* Dashboard */ -[data-ton="2"] { --ton: #e15e8e; } /* Zahlen */ -[data-ton="3"] { --ton: #ff9781; } /* LIVE */ -[data-ton="4"] { --ton: #c26201; } /* Chat */ +[data-ton="1"] { --ton: #0c86af; } /* Dashboard */ +[data-ton="2"] { --ton: #fe5e82; } /* Zahlen */ +[data-ton="3"] { --ton: #ff996d; } /* LIVE */ +[data-ton="4"] { --ton: #c16302; } /* Chat */ /* TON 5 IST VON HAND GESETZT (09.09.2026, screen4). Filipe: "ich will dass die kategorie eine farbe bekommt die extrem @@ -920,22 +920,22 @@ body.start > main { position: relative; z-index: 1; } gewünscht ("alle kacheln auf der ganzen website sollen sein wie auf der startseite"). Was sie heraushebt, ist die Farbe, nicht eine Sonderform. */ -[data-ton="5"] { --ton: #ff2fd0; } /* Schutz & Regeln -- Wunsch: extrem auffällig */ -[data-ton="6"] { --ton: #768b09; } /* Community */ -[data-ton="7"] { --ton: #089868; } /* Reports */ -[data-ton="8"] { --ton: #c06ad0; } /* Kalender */ -[data-ton="9"] { --ton: #afde57; } /* Bibliothek */ -[data-ton="10"] { --ton: #7bba3a; } /* Steckbrief */ -[data-ton="11"] { --ton: #ed8712; } /* Personen */ -[data-ton="12"] { --ton: #01edb5; } /* Content */ -[data-ton="13"] { --ton: #18bfa3; } /* Aufgaben */ -[data-ton="14"] { --ton: #c0a311; } /* Technik */ -[data-ton="15"] { --ton: #5fddff; } /* Calls */ -[data-ton="16"] { --ton: #0da2c6; } /* Start-Check */ -[data-ton="17"] { --ton: #bac8fc; } /* Agentur */ -[data-ton="18"] { --ton: #9299fd; } /* Automationen */ -[data-ton="19"] { --ton: #796ce0; } /* Dateien */ -[data-ton="20"] { --ton: #fc88db; } /* Creator-Profile */ +[data-ton="5"] { --ton: #ff08d5; } /* Schutz & Regeln -- Wunsch: extrem auffällig */ +[data-ton="6"] { --ton: #8b8400; } /* Community */ +[data-ton="7"] { --ton: #098f71; } /* Reports */ +[data-ton="8"] { --ton: #ab68ff; } /* Kalender */ +[data-ton="9"] { --ton: #cace02; } /* Bibliothek */ +[data-ton="10"] { --ton: #a8ad11; } /* Steckbrief */ +[data-ton="11"] { --ton: #db8908; } /* Personen */ +[data-ton="12"] { --ton: #0ce6a3; } /* Content */ +[data-ton="13"] { --ton: #12b37e; } /* Aufgaben */ +[data-ton="14"] { --ton: #ffbd4a; } /* Technik */ +[data-ton="15"] { --ton: #01d7e3; } /* Calls */ +[data-ton="16"] { --ton: #19b2c2; } /* Start-Check */ +[data-ton="17"] { --ton: #a2c6fe; } /* Agentur */ +[data-ton="18"] { --ton: #aa99ff; } /* Automationen */ +[data-ton="19"] { --ton: #586dfe; } /* Dateien */ +[data-ton="20"] { --ton: #ff8fb4; } /* Creator-Profile */ /* TON 21 IST VON HAND GESETZT (09.09.2026, screen6). Filipe: "die farbe von dieser kategorie soll knall rot sein." @@ -953,7 +953,7 @@ body.start > main { position: relative; z-index: 1; } Kontrast auf dem dunklen Grund: 5,01:1 (noetig 4,5). Von sechs geprueften Rottoenen hat dieser den groessten Abstand UND genug Kontrast -- deshalb dieser und nicht der erstbeste. */ -[data-ton="21"] { --ton: #ff1f2e; } /* Scout-Pipeline -- Wunsch: knallrot */ +[data-ton="21"] { --ton: #f80442; } /* Scout-Pipeline -- Wunsch: knallrot */ /* TON 22 -- TEAM-LAGE (09.09.2026). @@ -970,13 +970,13 @@ body.start > main { position: relative; z-index: 1; } Warm und hell ist kein Zufall: Diese eine Kachel gehoert der Leitung. Zwischen den kuehlen Toenen der Arbeitsbereiche faellt ein warmer auf, ohne lauter zu sein. */ -[data-ton="22"] { --ton: #ffd166; } /* Team-Lage */ +[data-ton="22"] { --ton: #fee0b2; } /* Team-Lage */ /* Ton 23 (10.09.2026, Ideen-Board). NICHT nach Gefuehl gewaehlt: Die 22 vorhandenen Toene sind dicht besetzt, deshalb wurde der Abstand zu allen ausgerechnet. #5f8a9f hat mit 127 den zweitgroessten -- der groesste war ein Graubraun, und eine graue Kachel zwischen bunten sieht abgeschaltet aus. Gedeckt statt grell, wie alles hier. */ -[data-ton="23"] { --ton: #5f8a9f; } /* Ideen-Board */ +[data-ton="23"] { --ton: #07a7fd; } /* Ideen-Board */ /* Ton 24 (10.09.2026, Eingang). Wieder gerechnet statt gewaehlt, und bei 23 belegten Toenen wird es eng: Der Abstand im CIELAB-Raum zum @@ -989,7 +989,7 @@ body.start > main { position: relative; z-index: 1; } schlechtesten zu fokussieren. Kraeftiges Gruen kam auf 39,5, hat aber L* 81 und wuerde leuchten. Dieser Ton hat eine Leuchtdichte von 0,109 -- dunkel genug, um neben den anderen ruhig zu bleiben. */ -[data-ton="24"] { --ton: #8a20cf; } /* Eingang */ +[data-ton="24"] { --ton: #d21afd; } /* Eingang */ /* TON 25 -- Rueckmeldung (10.09.2026). @@ -1008,7 +1008,7 @@ body.start > main { position: relative; z-index: 1; } Kontrast auf dem dunklen Grund: 8,9 zu 1. Und die Bedeutung passt: Gruen ist im Haus die Farbe von "laeuft" -- eine Rueckmeldung soll nicht nach Beschwerde aussehen. */ -[data-ton="25"] { --ton: #43c95e; } /* Rückmeldung */ +[data-ton="25"] { --ton: #72d512; } /* Rückmeldung */ /* TON 26 UND 27 (11.09.2026) -- und hier wird die Rechnung ehrlich: @@ -1027,8 +1027,8 @@ body.start > main { position: relative; z-index: 1; } Wenn eine achtundzwanzigste Kachel kommt, ist das der Zeitpunkt, an dem die Kacheln Gruppenfarben brauchen statt Einzelfarben -- nicht der, an dem man den naechsten Ton dazwischenquetscht. */ -[data-ton="26"] { --ton: #7fd4a8; } /* Entwicklung */ -[data-ton="27"] { --ton: #8f7bd6; } /* Talente */ +[data-ton="26"] { --ton: #07fff1; } /* Entwicklung */ +[data-ton="27"] { --ton: #ff6bfa; } /* Talente */ /* ===================================================================== DIE SIEBEN TOENE DES TREFFS (11.09.2026) @@ -1054,13 +1054,13 @@ body.start > main { position: relative; z-index: 1; } Hand gewaehlte Farbfamilie im selben Blau bei 12,6. Die Rechnung war also nicht Genauigkeitsspielerei, sondern der Unterschied zwischen unterscheidbar und nicht. */ -[data-ton="28"] { --ton: #8e784f; } /* Regeln & Hilfe */ -[data-ton="29"] { --ton: #8e5b77; } /* Highlights */ -[data-ton="30"] { --ton: #b2bc78; } /* Wunschliste */ -[data-ton="31"] { --ton: #656a9e; } /* Was ansteht */ -[data-ton="32"] { --ton: #668e6e; } /* Mitmachen */ -[data-ton="33"] { --ton: #cc9451; } /* Anschlagbrett */ -[data-ton="34"] { --ton: #cc92c6; } /* Der Treff */ +[data-ton="28"] { --ton: #069511; } /* Regeln & Hilfe */ +[data-ton="29"] { --ton: #ee068b; } /* Highlights */ +[data-ton="30"] { --ton: #a7ff98; } /* Wunschliste */ +[data-ton="31"] { --ton: #fe5a11; } /* Was ansteht */ +[data-ton="32"] { --ton: #18b803; } /* Mitmachen */ +[data-ton="33"] { --ton: #f4acff; } /* Anschlagbrett */ +[data-ton="34"] { --ton: #f9d7ff; } /* Der Treff */ /* DER 35. TON (11.09.2026) — für „Wer sieht was". @@ -1086,18 +1086,18 @@ body.start > main { position: relative; z-index: 1; } mit einer anderen geteilt. Zwei Dinge in einer Farbe heißt, dass die Farbe nichts mehr sagt — und pruef-start-ansicht zählt genau das nach ("34 Farben auf 35 Kacheln" war ihre Meldung). */ -[data-ton="35"] { --ton: #2f2f7f; } /* Wer sieht was */ +[data-ton="35"] { --ton: #b4f1fe; } /* Wer sieht was */ /* Der 36., nach derselben Rechnung: #7c2771, Abstand 29,7 zu den beiden nächsten (#8e5b77 „Highlights", #2f2f7f „Wer sieht was"). Auch das liegt über dem schwächsten Paar der vorhandenen Töne (24,9). */ -[data-ton="36"] { --ton: #7c2771; } /* Meldungen & Maßnahmen */ +[data-ton="36"] { --ton: #eeef08; } /* Meldungen & Maßnahmen */ /* Der 37., nach derselben Rechnung: #46762e, Abstand 25,6 zum nächsten Nachbarn — knapp über dem schwächsten Paar der ursprünglichen 34 (24,9). Bei 37 Tönen in einem augenschonenden Ausschnitt (kein Rot, keine Neonsättigung) ist das die Grenze des Machbaren; wird es enger, ist die nächste Frage nicht „noch ein Ton", sondern „braucht diese Kachel wirklich eine eigene Farbe". */ -[data-ton="37"] { --ton: #46762e; } /* Wie geht's dir? */ +[data-ton="37"] { --ton: #61ff09; } /* Wie geht's dir? */ /* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat. Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der @@ -1113,10 +1113,140 @@ body.start > main { position: relative; z-index: 1; } --my: 0%; position: relative; border-radius: 18px; + + /* ===================================================================== + DAS UNIVERSUM IN JEDER KACHEL (17.09.2026) + + Filipe: „der hintergrund von allen kacheln haben wir richtung + universum mäßig gemacht, aber bin noch nicht ganz zufrieden, es + soll noch mehr viel mehr nach universum sein. nicht einfach so paar + weiße punkte sondern richtig geil hochwertiger geiler universum." + + Er hat recht, und der Grund war schnell gefunden: Das gebaute + Universum (Nebel, Sternenfeld, driftende Chilis) lag auf GENAU + EINER Kachel -- der Willkommenskachel. Alle anderen hatten einen + Verlauf und sonst nichts. + + WARUM ES IM HINTERGRUND STEHT UND NICHT IN EINEM ELEMENT: Beide + Pseudo-Elemente der Kachel sind vergeben (::before die Leuchtschiene + oben, ::after Glanz und Zeigerlicht), und ein neues Element müsste + an jeder Stelle nachgetragen werden, die Kacheln baut -- start.js, + bereiche.js und jede künftige. Eine Ebene, die man vergessen kann, + wird vergessen. Als Hintergrund gilt sie überall, wo `.kachel` + gilt, ohne eine einzige Zeile JavaScript. + + DER NEBEL TRÄGT DIE FARBE DER KACHEL. Das ist der eigentliche + Gewinn gegenüber der Willkommenskachel: Dort sind die Wolken fest + rot-blau-lila, hier nimmt jede Kachel ihren eigenen Ton. Achtund- + zwanzig Kacheln sind damit achtundzwanzig verschiedene Nebel -- + dieselbe Arbeit, die die Farbpalette leistet, nur in der Fläche. + + SIEBEN EBENEN, VON HINTEN NACH VORN: + 1-2 Zwei Sternenschichten, klein und fern (0,8-1,1 px) + 3-4 Zwei Schichten mittlerer Sterne, einer davon im Kachelton + 5 Wenige nahe, hellere Sterne + 6-7 Zwei Nebelwolken im Kachelton, dazu der Grundverlauf + + Die Kachelgrößen 89 / 137 / 191 / 223 / 317 px haben kein + gemeinsames Vielfaches unter 1200 -- das Feld wiederholt sich + damit erst jenseits jeder Kachelbreite, und genau das unterscheidet + ein Sternenfeld von einer Tapete. (Dieselbe Überlegung wie bei der + Willkommenskachel, dort nachzulesen.) + + KEINE BEWEGUNG. Auf der Willkommenskachel driftet das Feld in + 240 s -- eine Kachel, die stundenlang im Bild steht, verträgt das. + Achtundzwanzig driftende Felder auf einer Seite wären achtund- + zwanzig Dauerläufer auf der Grafikkarte, und am Handy merkt man + das sofort. Bewegung bringt hier der Glanz, der dem Zeiger folgt + (::after) -- er kostet nur etwas, während man ihn benutzt. + + LESBARKEIT GEHT VOR. Die Sterne liegen zwischen 6 und 14 Prozent + Deckkraft, die Nebel bei 5 bis 9. Man sieht einen Raum, man liest + keine Punkte. Der Text darüber behält seinen Kontrast; dass die + Farben ihn halten, prüft pruef-css-klassen ohnehin bei jedem Lauf. + ===================================================================== */ background: + /* ---- NAHE STERNE ------------------------------------------------ + Gross, hell, spaerlich. Jeder mit einem Hof (der zweite + Farbstopp), damit er leuchtet statt zu stanzen -- das ist der + Unterschied zwischen einem Stern und einem weissen Pixel. + + DEUTLICH HELLER ALS IM ERSTEN ANLAUF (17.09., nach dem ersten + Bildschirmfoto). Dort lagen sie bei 6 bis 14 Prozent und waren + aus der Naehe nicht zu sehen -- genau Filipes „paar weisse + punkte". Der Grund steht in der Kachel selbst: `--flaeche` ist + halbdurchsichtig, darunter liegt das Buehnenbild. Ein Sternenfeld + muss sich hier gegen ein FOTO durchsetzen, nicht gegen Schwarz. */ + radial-gradient(2.2px 2.2px at 47px 118px, rgba(255, 255, 255, .92), rgba(255, 255, 255, .22) 42%, transparent 100%), + radial-gradient(1.9px 1.9px at 263px 61px, rgba(226, 240, 255, .78), rgba(226, 240, 255, .18) 45%, transparent 100%), + radial-gradient(2.0px 2.0px at 118px 191px, color-mix(in srgb, var(--ton) 92%, #ffffff), transparent 100%), + + /* ---- MITTLERE STERNE -------------------------------------------- + Die Masse des Feldes. Eine Schicht im Kachelton: dadurch sieht + jedes Universum anders aus, ohne bunt zu werden. */ + radial-gradient(1.4px 1.4px at 22px 31px, rgba(255, 255, 255, .62), transparent 100%), + radial-gradient(1.5px 1.5px at 133px 74px, color-mix(in srgb, var(--ton) 78%, transparent), transparent 100%), + radial-gradient(1.3px 1.3px at 201px 138px, rgba(214, 234, 255, .55), transparent 100%), + radial-gradient(1.4px 1.4px at 74px 166px, color-mix(in srgb, var(--ton) 62%, transparent), transparent 100%), + + /* ---- FERNER STAUB ----------------------------------------------- + Klein und dicht. Er fuellt die Luecken, damit zwischen den + hellen Sternen kein leeres Schwarz steht -- ein Sternenhimmel + ohne Staub sieht aus wie aufgeklebte Punkte. */ + radial-gradient(0.9px 0.9px at 104px 96px, rgba(255, 255, 255, .42), transparent 100%), + radial-gradient(0.8px 0.8px at 31px 140px, rgba(190, 220, 250, .36), transparent 100%), + radial-gradient(0.9px 0.9px at 168px 44px, rgba(255, 255, 255, .34), transparent 100%), + radial-gradient(0.8px 0.8px at 57px 83px, rgba(226, 240, 255, .30), transparent 100%), + radial-gradient(0.9px 0.9px at 149px 23px, rgba(255, 255, 255, .28), transparent 100%), + + /* ---- DIE MILCHSTRASSE ------------------------------------------- + Ein schraeger Schleier von unten links nach oben rechts. Er ist + das, was ein Sternenfeld von einem Universum unterscheidet: + Tiefe kommt nicht von mehr Punkten, sondern davon, dass die + Punkte in etwas liegen. + + Sehr weich und sehr blass -- er soll die Richtung geben, nicht + auffallen. */ + linear-gradient(118deg, + transparent 18%, + rgba(255, 255, 255, .055) 38%, + color-mix(in srgb, var(--ton) 14%, transparent) 50%, + rgba(255, 255, 255, .045) 62%, + transparent 82%), + + /* ---- DIE NEBEL -------------------------------------------------- + Zwei Wolken im Ton der Kachel, diagonal versetzt, dazu ein + kuehler Gegenpol. Achtundzwanzig Kacheln sind damit + achtundzwanzig verschiedene Nebel -- dieselbe Arbeit, die die + Farbpalette leistet, nur in der Flaeche. */ + radial-gradient(62% 70% at 14% 8%, color-mix(in srgb, var(--ton) 55%, transparent), transparent 62%), + radial-gradient(52% 58% at 30% 86%, color-mix(in srgb, var(--ton) 34%, transparent), transparent 66%), + radial-gradient(80% 66% at 92% 96%, color-mix(in srgb, var(--ton) 26%, transparent), transparent 68%), + /* EIN KUEHLER GEGENPOL. Ein Nebel aus einer Farbe ist ein + Farbfleck; erst ein zweiter, kaelterer Ton macht daraus einen + Raum mit Ferne. Dieselbe Ueberlegung wie bei der + Willkommenskachel, wo Rot und Babyblau sich gegenueberstehen. */ + radial-gradient(56% 64% at 76% 18%, rgba(96, 158, 232, .16), transparent 64%), + /* DIE VIGNETTE. Aussen dunkler als innen -- ohne sie ist die + Kachel eine gleichmaessig helle Flaeche, mit ihr ein Ausschnitt + aus etwas Groesserem. Das ist der billigste Tiefeneindruck, den + es gibt, und der wirksamste. */ + radial-gradient(120% 120% at 50% 45%, transparent 38%, rgba(3, 5, 12, .55) 100%), + + /* ---- DER GRUND -------------------------------------------------- + Dunkler als frueher: Das Universum braucht Schwarz, sonst + leuchten die Sterne gegen ein Foto an und verlieren. Die Kachel + bleibt durchscheinend -- nur eben weniger. */ linear-gradient(155deg, - color-mix(in srgb, var(--ton) 26%, var(--flaeche)), - var(--flaeche) 62%); + color-mix(in srgb, var(--ton) 24%, var(--flaeche)), + color-mix(in srgb, #05070e 55%, var(--flaeche)) 58%); + background-size: + 317px 317px, 223px 223px, 271px 271px, + 137px 137px, 191px 191px, 233px 233px, 163px 163px, + 89px 89px, 137px 137px, 223px 223px, 107px 107px, 179px 179px, + 100% 100%, + 100% 100%, 100% 100%, 100% 100%, 100% 100%, 100% 100%, + 100% 100%; border: 1px solid var(--rand); /* Die feine helle Linie an der Oberkante. Ein Pixel, kaum sichtbar -- aber die Kachel wirkt dadurch von oben beleuchtet statt aufgeklebt. diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 6c922bfc..54c4b7df 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 @@ - - - - - + + + + +