Files
dogfather-universe/server/pruef-start-ansicht.mjs
T
DogFatherGitandClaude Opus 5 7673c12136 Startseite: 17 eigene Farben -- gerechnet, nicht gewaehlt
Filipe: "jede kiste soll seine eigene farbe haben. und die kacheln sollen
viel spezieller, viel spezieller sein."

BEIM ERSTEN MAL HATTE ICH ABGELEHNT, und das war zu bequem. Mein Einwand
stimmte zwar -- ein Versuch mit frei gewaehlten Farben ergab Paare mit
1,4 Grad Abstand, also praktisch dieselbe Farbe -- aber daraus "geht
nicht" zu machen, war falsch. Es geht, man muss nur rechnen.

Der Denkfehler war die Annahme, alle 17 muessten sich voneinander
unterscheiden. Das Auge vergleicht aber nur, was NEBENEINANDER liegt.
Also:

1. 17 Toene, gleichmaessig um den Farbkreis (je 21 Grad), gerechnet in
   OKLCH -- dort sind gleiche Abstaende auch fuer das Auge gleich.
   Helligkeit und Farbstaerke konstant, damit keine sich vordraengt. Wo
   die Farbstaerke den darstellbaren Bereich verliesse (Gelb und Gruen
   frueher als der Rest), wird sie gesenkt, bis sie hineinpasst.

2. Die ZUORDNUNG ist eine Suche ueber die tatsaechlichen Nachbarschaften
   im Raster (nebeneinander UND untereinander). Gesucht: die Anordnung
   mit dem groesstmoeglichen kleinsten Nachbarabstand. Ergebnis:
   mindestens 105,9 Grad zwischen allen Nachbarn.

3. Unter allen Anordnungen, die eine harte Untergrenze schaffen, gewinnt
   die passendste: LIVE rot, Technik gelb, Reports gruen, Personen
   rot-gold, Schutz stahlblau.

Steht als tools/kachel-farben.mjs im Repo, mit festem Zufallsstartwert --
derselbe Lauf ergibt dieselben Farben. Wer Kacheln umsortiert, aendert
die Nachbarschaften und muss es neu laufen lassen; das steht auch in
start.js.

Geprueft: Helligkeitsband, Farbstaerke und Kontrast bestehen fuer alle
siebzehn gegen genau diesen Hintergrund.

VIEL SPEZIELLER -- das WASSERZEICHEN:

Jede Kachel traegt ihr eigenes Zeichen noch einmal, riesig, angeschnitten
und fast unsichtbar (7 % Deckung) in der Ecke. Das ist der Grund, warum
siebzehn Kacheln nicht mehr wie siebzehn Kaesten aussehen: Jede bekommt
eine eigene grosse Form, ohne dass ein einziges zusaetzliches Bild
geladen wird -- es ist derselbe Pfad, nur groesser. Bewusst so schwach,
dass man es nicht liest, sondern nur spuert. Beim Ueberfahren wird es
etwas deutlicher und wandert zwei Pixel.

Dazu: Zeichenfeld 46 auf 52 px mit farbigem Schein darunter, Name auf
1,06 rem, mehr Polsterung. Alles mit prefers-reduced-motion abgesichert.

44 Pruefungen. Neu: dass jede Kachel eine EIGENE Farbe hat (17 Farben auf
17 Kacheln, gemessen an der berechneten Strichfarbe) und dass das
Wasserzeichen da und schwach genug ist.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-31 23:32:22 +02:00

284 lines
15 KiB
JavaScript

/* Browserpruefung der Startseite: Gruppen, Zeichen, Farbtoene, Zahlen auf
den Kacheln -- und was jede Rolle davon zu sehen bekommt. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-start-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4186";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
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 }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4186";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = jetzt.slice(0, 10);
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt);
/* Arbeit an mehreren Stellen -- damit auf den Kacheln wirklich Zahlen
stehen und man sieht, ob sie an der richtigen landen. */
for (const [status, frist] of [["offen", gestern], ["offen", gestern], ["offen", heute],
["arbeit", null], ["review", null]]) {
d.prepare("INSERT INTO aufgaben (titel, status, creator_id, frist, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)")
.run(status, idAnna, frist, jetzt, idDogi);
}
d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Heute',?,?,0,?,?)")
.run(heute + "T18:00", idAnna, jetzt, idDogi);
d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem','Ton weg',?,'hoch','offen',?,?,?)")
.run(heute, idAnna, jetzt, idDogi);
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function anmelden(kontext, rolle, code) {
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.waitForTimeout(900);
return seite;
}
const lesen = (seite) => seite.evaluate(() => ({
gruss: document.getElementById("gruss")?.textContent,
gruppen: [...document.querySelectorAll(".gruppe")].map((g) => ({
name: g.querySelector(".gruppe__name")?.textContent,
kacheln: g.querySelectorAll(".kachel").length,
})),
kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({
name: k.querySelector(".kachel__name")?.textContent,
unter: k.querySelector(".kachel__unter")?.textContent,
ton: k.dataset.ton,
farbe: getComputedStyle(k.querySelector(".kachel__svg")).stroke,
pfade: k.querySelectorAll(".kachel__svg path").length,
zahl: k.querySelector(".kachel__zahl")?.textContent || null,
warn: k.querySelector(".kachel__zahl")?.dataset.warn === "ja",
ziel: k.querySelector(".kachel__link")?.getAttribute("href"),
})),
/* Abgeschnittener Text ist der haeufigste stille Fehler in einer
Kachel -- er sieht auf den ersten Blick nach Absicht aus. Gemessen
wird die tatsaechliche Textbreite gegen die verfuegbare. */
abgeschnitten: [...document.querySelectorAll(".kachel__name, .kachel__unter")]
.filter((e) => e.scrollWidth > e.clientWidth + 1)
.map((e) => e.textContent),
hinweise: [...document.querySelectorAll("#dran .dran__punkt")]
.filter((z) => z.offsetParent !== null).length,
hinweiseGesamt: document.querySelectorAll("#dran .dran__punkt").length,
zeichenGroesse: Math.round(document.querySelector(".kachel__zeichen")?.getBoundingClientRect().width || 0),
wasserzeichen: document.querySelectorAll(".kachel__wasserzeichen").length,
wasserzeichenDeckung: getComputedStyle(document.querySelector(".kachel__wasserzeichen") || document.body).opacity,
wasserzeichenSchwach: Number(getComputedStyle(
document.querySelector(".kachel__wasserzeichen") || document.body).opacity) <= 0.15,
namenGroesse: Math.round(parseFloat(getComputedStyle(document.querySelector(".kachel__name")).fontSize) || 0),
kachelHoehe: Math.round(document.querySelectorAll(".kachel")[1]?.getBoundingClientRect().height || 0),
zahlenLeer: !document.getElementById("zahlen-leer")?.hidden,
zahlen: [...document.querySelectorAll(".zahl")].length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
ueberstehend: [...document.querySelectorAll("main *")]
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
.map((e) => String(e.className).slice(0, 40)).slice(0, 4),
}));
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(`\n=== DogFather · ${wie}`);
const kontext = await browser.newContext({ viewport: { width: breite, height: 1200 } });
const konsole = [];
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const r = await lesen(seite);
console.log(" Gruß: ", r.gruss);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · "));
ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.length === 17, `siebzehn Kacheln (${r.kacheln.length})`);
ok(/^(Guten Morgen|Guten Tag|Guten Abend|Noch wach), Filipe$/.test(r.gruss || ""),
`Begrüßung nach Tageszeit ("${r.gruss}")`);
/* Jede Kachel muss ein Zeichen haben -- eine ohne waere ein leeres
Kaestchen und faellt sofort auf. */
/* Das Wasserzeichen: dasselbe Zeichen noch einmal, riesig und schwach
im Hintergrund. Es macht jede Kachel unverwechselbar -- fehlt es,
sehen alle wieder gleich aus. */
ok(r.wasserzeichen === r.kacheln.length,
`jede Kachel trägt ihr Wasserzeichen (${r.wasserzeichen} von ${r.kacheln.length})`);
ok(r.wasserzeichenSchwach, `und es ist schwach genug, um nicht zu stören (${r.wasserzeichenDeckung})`);
ok(r.kacheln.every((k) => k.pfade > 0),
`jede Kachel hat ein Zeichen (${r.kacheln.filter((k) => !k.pfade).map((k) => k.name).join(", ") || "alle"})`);
ok(r.kacheln.every((k) => k.unter && k.unter.length > 4), "jede Kachel sagt, was drin ist");
ok(r.kacheln.every((k) => k.ziel && k.ziel !== "#"), "jede Kachel führt irgendwohin");
/* Acht verschiedene Farben, und nirgends steht "ÖFFNEN" mehr. */
const toene = new Set(r.kacheln.map((k) => k.farbe));
/* JEDE Kachel ihre eigene Farbe -- siebzehn verschiedene, keine
doppelt. (Der Creator sieht "Mein Profil" statt "Creator-Profile";
das sind zwei Namen fuer denselben Bereich und teilen sich einen
Ton -- deshalb wird hier gegen die Zahl der KACHELN geprueft.) */
ok(toene.size === r.kacheln.length,
`jede Kachel hat ihre eigene Farbe (${toene.size} Farben auf ${r.kacheln.length} Kacheln)`);
const oeffnen = await seite.evaluate(() => document.body.textContent.includes("ÖFFNEN"));
ok(!oeffnen, "das Wort ÖFFNEN kommt nicht mehr vor");
/* Die Zahlen müssen auf der RICHTIGEN Kachel stehen. */
const auf = (n) => r.kacheln.find((k) => k.name === n);
ok(auf("Aufgaben")?.zahl === "4",
`Aufgaben trägt 4 (2 überfällig + 1 heute + 1 Review) (${auf("Aufgaben")?.zahl})`);
ok(auf("Aufgaben")?.warn === true, "und ist als Warnung markiert");
ok(auf("Kalender")?.zahl === "1", `Kalender trägt 1 (${auf("Kalender")?.zahl})`);
ok(auf("LIVE-Analyse")?.zahl === "1", `der dringende Bereichspunkt (${auf("LIVE-Analyse")?.zahl})`);
ok(!auf("Wissens-Bibliothek")?.zahl, "wo nichts liegt, steht keine Zahl");
/* Die Kacheln sollen spürbar größer sein als vorher (Zeichenfeld 38px,
Name .93rem). Gemessen statt geglaubt -- "sieht größer aus" ist kein
Nachweis. */
ok(r.zeichenGroesse >= 46, `Zeichenfeld mindestens 46px (${r.zeichenGroesse}px)`);
ok(r.namenGroesse >= 16, `Name mindestens 16px (${r.namenGroesse}px)`);
ok(r.kachelHoehe >= 80, `Kachel mindestens 80px hoch (${r.kachelHoehe}px)`);
ok(r.abgeschnitten.length === 0,
`kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`);
ok(r.hinweise <= 4, `höchstens vier Hinweise sichtbar (${r.hinweise} von ${r.hinweiseGesamt})`);
ok(r.zahlen === 6, `die Aufgabenzahlen stehen (${r.zahlen})`);
ok(!r.zahlenLeer, "und nicht der Leer-Satz");
ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`);
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
await seite.screenshot({ path: breite === 1440 ? "pruef-start-computer.png" : "pruef-start-handy.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Creator");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "creator", "CODE-ANNA-0001");
const r = await lesen(seite);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Mein Profil"), "sieht sein eigenes Profil");
ok(!namen.includes("Creator-Profile"), "und nicht die Liste aller Creator");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Scout-Pipeline"), "keine Scout-Pipeline");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(r.gruppen.length === 3, `trotzdem drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.every((k) => k.pfade > 0), "alle Zeichen da");
await seite.screenshot({ path: "pruef-start-creator.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Scout");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "scout", "CODE-SCOU-0001");
const r = await lesen(seite);
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Scout-Pipeline"), "sieht seine Pipeline");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(namen.includes("Creator-Profile"), "aber die Creator-Profile");
await kontext.close();
}
console.log("\n=== Weniger Bewegung: keine Animation");
{
const kontext = await browser.newContext({
viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const bewegung = await seite.evaluate(() => {
const k = document.querySelector(".kachel");
const s = getComputedStyle(k);
return { animation: s.animationName, sichtbar: s.opacity };
});
ok(bewegung.animation === "none", `keine Animation (${bewegung.animation})`);
ok(Number(bewegung.sichtbar) === 1, `und trotzdem voll sichtbar (${bewegung.sichtbar})`);
await kontext.close();
}
console.log("\n=== Das Licht folgt dem Zeiger");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const kachel = seite.locator(".kachel").nth(1);
const kasten = await kachel.boundingBox();
const ruhe = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
await seite.mouse.move(kasten.x + kasten.width * 0.2, kasten.y + kasten.height * 0.3);
await seite.waitForTimeout(250);
const links = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
await seite.mouse.move(kasten.x + kasten.width * 0.85, kasten.y + kasten.height * 0.6);
await seite.waitForTimeout(250);
const rechts = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
console.log(` links: ${links} · rechts: ${rechts}`);
ok(parseFloat(links) < 40, `links steht der Fleck links (${links})`);
ok(parseFloat(rechts) > 60, `rechts steht er rechts (${rechts})`);
/* Beim Verlassen zurück in die Ruhelage. Ohne das blieben die Kacheln
nach einer Weile alle unterschiedlich beleuchtet stehen, ohne dass
jemand wüsste, warum. */
await seite.mouse.move(kasten.x - 250, kasten.y - 250);
await seite.waitForTimeout(250);
const danach = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
ok(danach === "(Ruhelage)", `nach dem Verlassen wieder in Ruhe (${danach})`);
await kontext.close();
}
console.log("\n=== Ohne feinen Zeiger bleibt es aus");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
await seite.touchscreen.tap(200, 700).catch(() => {});
await seite.waitForTimeout(200);
const gesetzt = await seite.evaluate(() =>
[...document.querySelectorAll(".kachel")].some((k) => k.style.getPropertyValue("--mx")));
ok(!gesetzt, "auf dem Handy wird nichts gesetzt");
await kontext.close();
}
await browser.close();
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler === 0 ? 0 : 1);