Kacheln: 37 Farben neu gerechnet, und in jeder ein eigenes Universum

Filipe, zwei Wuensche: "ich will das jede kachel eine andere farbe hat
... keine die sich irgendwie aehnlich sind ... richtig geil und
speziell" und "der hintergrund ... soll noch mehr viel mehr nach
universum sein. nicht einfach so paar weisse punkte sondern richtig
geil hochwertiger geiler universum ... in den kacheln ueberall".

BEIDES WAR BERECHTIGT, und beides liess sich messen:

  kleinster Farbabstand    0,0529   (Rueckmeldung / Steckbrief)
  Paare unter 0,10         26 von 666
  schlechtester Kontrast   1,64:1   (Ton 35 -- praktisch unlesbar)
  sieben Toene unter 4,5:1
  Universum                auf GENAU EINER Kachel

WIE DAS PASSIEREN KONNTE, obwohl es ein Farbwerkzeug gab: Es kannte 21
Farben. Seit dem 08.09. waren SECHZEHN von Hand dazugekommen (Ton 22
bis 37) -- jede einzeln plausibel, keine gegen die anderen gerechnet.
Das Werkzeug meldete weiter "0,0973, alle Bedingungen erfuellt" und
meinte einen Stand, den es nicht mehr gab. Dieselbe Krankheit wie eine
abgeschriebene Spaltenliste: Die Zahl wird nicht falsch, sie wird
UNZUSTAENDIG. Es liest die Anzahl jetzt aus den Dateien.

NEU GERECHNET, alle 37 auf einmal:
  kleinster Abstand   0,0529 -> 0,1010   (fast doppelt)
  Paare unter 0,10        26 -> 0
  schlechtester Kontrast 1,64 -> 4,50:1
  Buntheit bis 0,300 statt 0,170, Helligkeit 0,58 bis 0,92

Drei Stellschrauben: Buntheitsgrenze hoch (augenschonend heisst dunkles
Schema und kein Flackern, NICHT blass), Raster von 2 Grad auf 1 Grad
und von fuenf auf zwanzig Helligkeitsstufen, und sechzehn Startpunkte
statt einem. Der Kontrast 4,5:1 bleibt hart -- eine Kachelfarbe traegt
Text.

DAS UNIVERSUM STEHT JETZT IM HINTERGRUND JEDER KACHEL, nicht in einem
Element: Beide Pseudo-Elemente sind vergeben (Leuchtschiene, Glanz),
und ein neues Element muesste an jeder Stelle nachgetragen werden, die
Kacheln baut. Eine Ebene, die man vergessen kann, wird vergessen.

Siebzehn Ebenen: drei nahe Sterne mit Hof, vier mittlere (zwei im
Kachelton), fuenf Staubkoerner, eine Milchstrasse als Schraege, drei
Nebel im Kachelton, ein kuehler Gegenpol, eine Vignette. Die Nebel
tragen `var(--ton)` -- 37 Kacheln sind damit 37 verschiedene Nebel.
Keine Bewegung: 28 driftende Felder waeren 28 Dauerlaeufer auf der
Grafikkarte.

Nach dem ersten Bildschirmfoto nachgeschaerft -- die Sterne lagen bei
6 bis 14 Prozent und waren aus der Naehe nicht zu sehen, genau die
"paar weissen punkte". Der Grund steht in der Kachel selbst: Sie ist
halbdurchsichtig, darunter liegt das Buehnenbild. Ein Sternenfeld muss
sich hier gegen ein FOTO durchsetzen, nicht gegen Schwarz.

NEU: server/helfer-png.mjs -- ein PNG-Leser (zlib, 90 Zeilen). Er
beantwortet die Frage, die aus dem CSS nicht mehr zu beantworten war:
Zwischen Textfarbe und Flaeche liegen jetzt sieben Ebenen plus das
durchscheinende Buehnenbild. Gemessen am Bildschirmfoto: schlechtester
Textkontrast 8,02:1 (Grenze 4,5).

NEU: server/pruef-kachel-universum.mjs, 12 Pruefungen.
DREI ANLAEUFE FUER DIE STERNPRUEFUNG, und die ersten zwei waren gruen
und wertlos:
  1. Ebenen im CSS zaehlen -- `0px` ist auch eine Zahl. Alle Sterne
     auf null: blieb gruen.
  2. Helle Punkte im Bildschirmfoto zaehlen -- die Kachel ist
     halbdurchsichtig, das Foto darunter hat selbst Punktstruktur.
     Gemessen: 459 Punkte mit Sternen, 441 ohne. Vier Prozent sind
     kein Nachweis, sondern Rauschen.
  3. Jetzt die GROESSEN aus dem CSS (>= 0,7 px). Schwaecher, aber
     ehrlich -- und die Gegenprobe greift: 12 -> 0.
Dabei fiel ein eigener Messfehler auf: Der Bereich wurde aus der
Textposition geschaetzt und lag OBERHALB der Kachel. Verraten hat es
die Gegenprobe, die zweimal exakt 153 lieferte -- eine Messung, deren
Ergebnis sich nicht aendert, wenn man das Gemessene entfernt, misst
etwas anderes.

Gegenproben, alle zielgenau:
  zwei gleiche Farben   -> 3 rot      Sterne auf 0px  -> 1 rot
  Nebel ohne Kachelton  -> 1 rot (nach dem Schaerfen: vorher blieb es
                            gruen, weil `--ton` auch im Grundverlauf steht)

Gruen: kachel-universum, css-klassen, start-ansicht, namen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-18 02:55:23 +02:00
co-authored by Claude Opus 5
parent 3d9de349ad
commit 438493c7dc
37 changed files with 1229 additions and 1281 deletions
+103
View File
@@ -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);
}
+313 -628
View File
@@ -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);