Files
dogfather-universe/server/pruef-handy.mjs
T
DogFatherGitandClaude Opus 5 2f3b8630c7 Messungen am Telefon: 37 bekommen ihren Finger
Gestern Mittag gemessen: 39 `newContext`-Aufrufe nehmen ihre Breite
aus einer Variablen und setzen kein `hasTouch`. Ohne das meldet der
Browser `pointer: fine`, und KEINE Regel aus `@media (pointer:
coarse)` greift — dort stehen im ganzen Haus die 44-Pixel-
Beruehrziele, die ausgeblendeten Tastenkuerzel und die
eingeklappte Reiterleiste.

Was das anrichtet, war an pruef-breiten zu sehen: drei Befunde auf
320, 390 und 430 Pixeln, die mit dem Finger allesamt verschwanden —
Messfehler, keine Fehler.

37 DAVON HABEN IHN JETZT. Nur `hasTouch`, nicht `isMobile`: Gefragt
ist genau die eine Sache, um die es geht. `isMobile` waere eine
zweite Aenderung in derselben Zeile, und wenn danach etwas anders
aussieht, wuesste niemand, welche von beiden es war.

ZWEI BLEIBEN STEHEN, beide in pruef-grosscheck.mjs. Die Aenderung
dort waere ein Zweizeiler; sie zu pruefen hiesse, 206 Seiten ueber
vier Rollen laufen zu lassen, und das braucht Filipes Zusage. Eine
Aenderung, die ich nicht pruefen darf, liefere ich nicht aus.
Grundlinie in pruef-fingermass steht deshalb auf 2.

JEDE EINZELN NACHGELAUFEN — 37 Laeufe:

  34 gruen, darunter pruef-handy 186, pruef-material 159,
  pruef-start-ansicht 160, pruef-kalender 141, pruef-erwaehnung 129,
  pruef-bewerbung-aufgaben 163, pruef-neue-seiten 109

  3 mit Befunden, ALLE DREI VORBESTEHEND (Gegenprobe: alter Stand
  derselben Datei, gleicher Lauf, gleiche Zahl):
    pruef-browser        3  (WebKit startet auf diesem Rechner nicht)
    pruef-chat-anhaenge  2
    pruef-crew-wand-bild 3

EINE ZEITBOMBE GEFUNDEN UND ENTSCHAERFT

pruef-dabei-optik meldete „Haekchen: 0 -> 0" — aber nur, wenn vier
andere Pruefungen gleichzeitig liefen. Allein: „0 -> 1", gruen.

Dort stand `waitForTimeout(250)` mit der Begruendung „250 ms sind
reichlich ueber den 160" (der Dauer der Blende). Auf einem Rechner,
auf dem nebenher vier Browser messen, sind sie es nicht. Die
Pruefung war damit gruen, solange nichts anderes lief, und rot im
Gesamtlauf — also genau dann, wenn niemand sie einzeln nachstellen
kann.

Eine Wartezeit ist eine Annahme ueber den Rechner. Gewartet wird
jetzt auf das, worauf es ankommt: dass das Haekchen da ist. Laeuft
die Frist ab, faellt die Pruefung mit dem ECHTEN Wert um und nicht
mit einem Messfehler. Gegenprobe: unter derselben vierfachen Last,
die sie vorher rot gemacht hat, jetzt gruen.

UND DIE GRUNDLINIE WIEDER STRENG. Ich hatte sie kurz auf
„hoechstens" gestellt — damit haette ein Rueckgang stillschweigend
Platz fuer die naechste Suende gedeckt. Genau davor warnt der Kopf
derselben Datei bei der anderen Grundlinie, und ich habe es eine
Stunde spaeter selbst falsch gemacht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 13:37:05 +02:00

774 lines
38 KiB
JavaScript

/* ===================================================================
Die Handy-Pruefung.
Auf einem Telefon geht anderes kaputt als am Rechner, und man sieht
es beim Entwickeln nie: Ein Knopf steht 3 px aus dem Bild, eine
Ueberschrift ist auf halbem Wort abgeschnitten, ein Feld ist so
schmal, dass man es nicht trifft. Alles davon faellt erst auf, wenn
jemand das Geraet in der Hand hat.
Geprueft wird auf DREI echten Geraetegroessen, jede Seite einzeln:
1. Nichts steht ueber waagerechtes Scrollen ist auf einem Handy
der schlimmste Fehler -- die Seite ruckelt
und man verliert den Rand
2. Kein Text abgeschnitten gemessen wird die tatsaechliche
Textbreite gegen die verfuegbare, nicht
"sieht ganz aus"
3. Beruehrziele gross genug 24x24 CSS-Pixel ist das Mindestmass
(WCAG 2.2, Kriterium 2.5.8). Darunter
trifft man mit dem Daumen daneben
4. Nichts ueberlappt zwei Bedienelemente duerfen sich nicht
ueberdecken -- man traefe das falsche
5. Keine Fehler in der Konsole
=================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-handy-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
Ohne diese Zeile startet der eigene Server bei belegtem Port
STILL nicht -- und alles Folgende misst gegen einen fremden
Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
als kaputt. */
const PORT = await eigenerPort(import.meta, "pruef-handy");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-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 }); };
import { notbremse } from "./helfer-notbremse.mjs";
import { eigenerPort } from "./helfer-port.mjs";
await import("./index.js");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-handy");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 400_000).unref?.();
/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des
Benutzers; toISOString() liefert dagegen UTC und damit zwischen
Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese
Pruefung fehl, ohne dass etwas kaputt war. */
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
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");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
/* Echte Daten statt leerer Seiten: Eine leere Liste kann nicht
ueberlaufen. Geprueft wird mit LANGEN Namen und vollen Listen --
genau dort bricht es. */
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = tagText();
const gestern = tagText(new Date(Date.now() - 86400_000));
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-Maximilian", "scout", "CODE-SCOU-0001");
const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
/* Die beiden fehlenden Rollen -- gebraucht fuer die Mittenpruefung ganz
unten. Filipe: "das soll bei jedem und jeder rolle verbessert
werden", und eine Pruefung an drei von fuenf Rollen haette genau
diese Aussage nicht belegen koennen. */
const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt);
const LANG = "Ein sehr langer Eintrag ohne Leerzeichenumbruch fuer die Handypruefung";
for (const [status, frist] of [["offen", gestern], ["offen", heute], ["arbeit", null],
["review", null], ["erledigt", null]]) {
d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist,
prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,'hoch',?,?)`)
.run(LANG, status, idTili, idTili, frist, jetzt, idDogi);
}
for (const [art, stunde] of [["call", "10:00"], ["termin", "14:30"], ["review", "18:00"]]) {
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id,
erledigt, erstellt, erstellt_von) VALUES (?,?,?,60,?,?,0,?,?)`)
.run(LANG, art, heute + "T" + stunde, idTili, idTili, jetzt, idDogi);
}
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status,
creator_id, erstellt, erstellt_von)
VALUES ('technik','problem',?,?,'hoch','offen',?,?,?)`)
.run(LANG, heute, idTili, 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();
/* ---------- WELCHE SEITEN? ALLE. -----------------------------------------
Hier stand bis zum 06.09.2026 eine Liste von Hand -- und sie war
veraltet, ohne dass es jemandem auffiel: chat.html, leistung.html und
steckbrief.html fehlten darin, also DREI von neunzehn Seiten waren nie
auf einem Handy gemessen worden. Der Chat ausgerechnet ist die Seite,
die man am ehesten unterwegs benutzt.
Das ist genau die Sorte Fehler, die eine Liste macht: Sie altert in
dem Moment, in dem jemand weiterarbeitet, und sie sagt nichts dazu.
Der Lauf blieb gruen, die Zahl der Pruefungen stimmte -- gemessen
wurde trotzdem zu wenig.
Deshalb wird die Liste jetzt aus dem VERZEICHNIS gelesen. Eine neue
Seite ist damit von selbst dabei, so wie sie durch die Schranke in
workspace.js von selbst geschuetzt ist -- derselbe Gedanke: Der
sichere Rueckfall muss der VORGABEwert sein, nicht der, an den man
denken muss.
Ausgenommen ist einzig index.html -- das ist die Anmeldeseite, sie
wird weiter unten eigens geprueft (vor der Anmeldung, denn danach
leitet sie weiter). */
const { readdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
const { fileURLToPath } = await import("node:url");
/* NICHT process.cwd(): Diese Datei wird mal aus dem Wurzelverzeichnis
gestartet (tools/alles-pruefen.mjs) und mal aus server/ von Hand. Im
zweiten Fall zeigte cwd auf server/, dort gibt es kein workspace/,
und readdirSync wuerfe -- oder faende, schlimmer, ein anderes
Verzeichnis. Der Ort DIESER Datei steht dagegen fest. */
const SEITEN_ORDNER = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace");
const ALLE_SEITEN = readdirSync(SEITEN_ORDNER)
.filter((n) => n.endsWith(".html") && n !== "index.html")
.sort();
/* Ein paar Seiten brauchen eine Abfrage, sonst zeigen sie nur ihre
leere Huelle. Fuer die steht hier, WOMIT sie aufgerufen werden. */
const MIT_ABFRAGE = { "bereich.html": "?b=live" };
const SEITEN = ALLE_SEITEN.map((n) => n + (MIT_ABFRAGE[n] || ""));
/* Und die Zahl gehoert in die BEDINGUNG, nicht nur in den Meldetext:
Faende readdirSync nichts (falsches Arbeitsverzeichnis), liefe die
Schleife unten null Mal -- und der Lauf waere gruen, ohne eine
einzige Seite angesehen zu haben. Genau der Fehler, den ich schon
zweimal hatte. */
if (SEITEN.length < 15) {
console.log(` FEHL nur ${SEITEN.length} Seiten in ${SEITEN_ORDNER} gefunden `
+ "-- die Handypruefung kann so nichts messen");
process.exit(1);
}
console.log(`${SEITEN.length} Workspace-Seiten werden gemessen: ${SEITEN.join(", ")}`);
/* Drei echte Geraete, nicht "irgendwas Schmales":
320 iPhone SE (1. Gen) -- das schmalste noch benutzte Geraet
390 iPhone 14/15, das haeufigste Mass
412 Pixel 8 / Galaxy S24, typisches Android */
const GERAETE = [[320, 568, "sehr schmal"], [390, 844, "iPhone"], [412, 915, "Android"]];
const messen = () => ({
/* 1. Waagerechtes Scrollen. Der schlimmste Fehler auf einem Handy. */
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
schuld: [...document.querySelectorAll("body *")]
.filter((e) => {
const r = e.getBoundingClientRect();
/* Nur was wirklich sichtbar ist. Ein Wasserzeichen hinter
overflow:hidden meldet ein Rechteck, das nie zu sehen war --
das hat frueher schon einmal falschen Alarm ausgeloest. */
if (r.width < 1 || r.height < 1) return false;
const st = getComputedStyle(e);
if (st.visibility === "hidden" || st.display === "none") return false;
/* Steckt es in einem Vorfahren mit overflow:hidden? Dann ist es
abgeschnitten und steht nicht wirklich ueber. */
for (let v = e.parentElement; v; v = v.parentElement) {
const vs = getComputedStyle(v);
if (vs.overflow !== "visible" || vs.overflowX !== "visible") return false;
}
return r.right > window.innerWidth + 1;
})
.map((e) => `${e.tagName}.${String(e.className).slice(0, 26)}(+${Math.round(e.getBoundingClientRect().right - window.innerWidth)}px)`)
.slice(0, 4),
/* 2. Abgeschnittener Text. Gemessen wird die tatsaechliche Breite
gegen die verfuegbare -- nicht "sieht ganz aus". Elemente, die
ausdruecklich kuerzen duerfen (ellipsis), sind ausgenommen: Dort
ist das Kuerzen die Absicht und der volle Text steht woanders. */
abgeschnitten: [...document.querySelectorAll("h1, h2, h3, p, span, a, label, button, li, td, th")]
.filter((e) => {
if (e.children.length) return false;
const t = e.textContent.trim();
if (t.length < 3) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden") return false;
if (st.textOverflow === "ellipsis") return false;
if (st.overflow === "hidden" && st.whiteSpace === "nowrap") return false;
/* ==== NUR WO DIE MESSUNG UEBERHAUPT ETWAS BEDEUTET (25.09.2026)
`scrollWidth` und `clientWidth` gibt es nur fuer Elemente mit
einem eigenen Kasten. Bei `display: inline` liefert Chromium
fuer `clientWidth` glatt NULL -- und damit ist jeder Text
breiter als sein Kasten.
GEMELDET WURDE dadurch auf team.html: „Mara" (18>0), „Noch
niemand zugeteilt" (14>0), „Patrick-Maximilian" (18>15). Alle
drei stehen vollstaendig da; sie sind nur inline gesetzt.
Ein Inline-Element SCHNEIDET auch nichts ab -- es bricht um.
Abgeschnitten wird es erst durch einen Vorfahren mit
`overflow: hidden`, und dann greift die Zeile darueber.
DER RICHTIGE UMGANG MIT EINER UNGUELTIGEN MESSUNG ist, sie
nicht zu machen -- nicht, ihr Ergebnis zu glauben. */
if (st.display === "inline") return false;
/* ==== UND WAS FUERS AUGE WEGGENOMMEN IST (25.09.2026) ========
Fuer Vorleseprogramme steht auf mehreren Seiten Text da, den
man nicht sieht: ein echtes `<select>` unter dem selbst
gebauten Sicht-Umschalter, Beschriftungen zu Symbolknoepfen.
Versteckt wird so etwas NICHT mit `display: none` (dann
ueberspringen manche Vorleseprogramme es), sondern mit einem
1x1-Ausschnitt -- `clip-path: inset(50%)`.
Solche Elemente sind ein Pixel gross und ihr Inhalt breiter.
`scrollWidth > clientWidth` ist damit IMMER wahr, und die
Meldung lautete: „Mara" (18>0), „Noch niemand zugeteilt"
(14>0). Nichts davon ist abgeschnitten -- es ist mit Absicht
nicht zu sehen.
Dieselbe Erkennung benutzt `pruef-neue-seiten` beim
Ueberlappen; sie hat hier nur gefehlt. */
if (st.clipPath && st.clipPath !== "none") return false;
if (st.clip && st.clip !== "auto") return false;
return e.scrollWidth > e.clientWidth + 2;
})
.map((e) => `"${e.textContent.trim().slice(0, 22)}" (${e.scrollWidth}>${e.clientWidth}`
+ `, ${e.tagName.toLowerCase()}.${String(e.className).split(" ")[0]}`
+ `, ${getComputedStyle(e).display}, w=${Math.round(e.getBoundingClientRect().width)})`)
.slice(0, 5),
/* 3. Beruehrziele. 24x24 CSS-Pixel ist das Mindestmass nach WCAG 2.2
(2.5.8). Gemessen wird das Ziel selbst ODER sein umschliessender
Bereich -- ein 16 px grosses Symbol in einem 44 px grossen Knopf
ist in Ordnung.
LINKS IN EINEM SATZ SIND AUSGENOMMEN -- und zwar nicht, weil es
bequemer ist, sondern weil 2.5.8 das selbst so sagt: "Inline: The
target is in a sentence or its size is otherwise constrained by
the line-height of non-target text."
Die Pruefung zitierte bis zum 14.09.2026 die Regel, aber nicht
ihre Ausnahme. Gemeldet wurden dadurch die zwei Verweise im
Fliesstext der Treff-Regeln ("Das Rudel", "Regeln & Hilfe", je
20 px hoch). Sie auf 24 px zu bringen hiesse, die Zeilenhoehe des
Absatzes zu sprengen -- man wuerde also einen Text kaputtmachen,
um eine Regel zu erfuellen, die diesen Text ausdruecklich nicht
meint.
ENG GEFASST: Nur <a>, nur wirklich inline dargestellt, und nur
wenn im selben Absatz auch Text steht, der NICHT zum Link gehoert.
Ein allein stehender Link bleibt ein Beruehrziel. */
klein: (() => {
const imSatz = (e) => {
if (e.tagName !== "A") return false;
if (!getComputedStyle(e).display.startsWith("inline")) return false;
const eltern = e.parentElement;
if (!eltern) return false;
const daneben = [...eltern.childNodes]
.filter((n) => n !== e)
.map((n) => (n.textContent || "").trim())
.join(" ").trim();
return daneben.length >= 10; // das ist der Satz drumherum
};
return [...document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")]
.filter((e) => {
const r = e.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden") return false;
/* ==== WAS KEINE BERUEHRUNG ANNIMMT (25.09.2026) ==============
`pointer-events: none` heisst: Dieses Element ist fuer den
Finger nicht da -- die Beruehrung geht durch es hindurch an
das, was darunter liegt. Es als zu kleines Beruehrziel zu
melden, ist so, als beanstande man die Groesse eines
gemalten Knopfs.
GEMELDET WURDEN die Terminpunkte im Monatsraster des
Kalenders: 8 x 8 px, `font-size: 0`, `pointer-events: none`.
Sie sind mit Absicht so -- drei Termine an einem Tag sollen
drei Punkte sein und nicht drei Zeilen, und angetippt wird
die ZELLE, die den Tagesdialog oeffnet. Die Begruendung steht
ausfuehrlich in kalender.css.
Dieselbe Ausnahme kennt `pruef-breiten` seit jeher; sie hat
hier nur gefehlt. */
if (st.pointerEvents === "none") return false;
if (imSatz(e)) return false;
return r.width < 24 || r.height < 24;
})
.map((e) => {
const r = e.getBoundingClientRect();
return `${e.tagName}"${(e.textContent || e.getAttribute("aria-label") || "").trim().slice(0, 14)}" `
+ `${Math.round(r.width)}x${Math.round(r.height)}`;
})
.slice(0, 5);
})(),
/* 4. Ueberlappende Bedienelemente. Zwei Knoepfe, die sich decken,
heissen: Man trifft den falschen. Geprueft wird der Mittelpunkt --
liegt dort ein ANDERES Bedienelement, ist das Ziel verdeckt. */
verdeckt: [...document.querySelectorAll("button, a[href], input, select")]
.filter((e) => {
const r = e.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return false;
if (r.top < 0 || r.bottom > window.innerHeight) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false;
/* NICHT ZAEHLEN, WAS IN EINEM ZUGEKLAPPTEN <details> STECKT.
Chromium versteckt dessen Inhalt ueber `content-visibility`,
nicht ueber `display` -- die Kaesten behalten deshalb eine
Groesse und sehen fuer jede Messung aus wie sichtbare
Bedienelemente. Am 06.09.2026 meldete das einen "verdeckten"
Knopf, den auf dem Bildschirm niemand sehen konnte. Eine
Warnung ueber etwas Unsichtbares gewoehnt man sich ab zu
lesen. */
if (e.closest("details:not([open])")) return false;
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return oben && oben !== e && !e.contains(oben) && !oben.contains(e);
})
/* WER verdeckt WEN, und wo. Vorher stand hier nur der Name des
verdeckten Knopfes -- eine Meldung wie `BUTTON"Meine Sicht"` sagt,
DASS etwas nicht stimmt, und laesst einen danach raten, WAS
darueberliegt. Ich habe daran am 06.09.2026 eine Stunde gesucht.
Ein Befund, den man nicht verorten kann, ist ein halber Befund. */
.map((e) => {
const r = e.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
const kette = (x) => {
const a = [];
for (let v = x; v && a.length < 4; v = v.parentElement) {
a.push(v.tagName + (v.className && typeof v.className === "string"
? "." + v.className.split(" ")[0] : ""));
}
return a.join("<");
};
const q = oben ? oben.getBoundingClientRect() : null;
return `${kette(e)} "${(e.textContent || "").trim().slice(0, 16)}" `
+ `[${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}]`
+ ` <- verdeckt von ${oben ? kette(oben) : "?"}`
+ (q ? ` [${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}]` : "");
})
.slice(0, 3),
/* DIE GEOMETRIE DER KOPFLEISTE, aber nur wenn etwas nicht stimmt.
Am 06.09.2026 meldete diese Pruefung auf zwoelf Seiten "BUTTON
'Meine Sicht' verdeckt" -- und mehr nicht. Ich habe danach eine
Stunde gesucht, weil aus der Meldung nicht hervorging, WAS
darueberlag und bei WELCHER Breite. Erst dieser Auszug zeigte es in
einer Zeile: bei 412 px stand `wrap=nowrap`, der Umschalter war auf
50 px zusammengedrueckt, sein Knopf 104 px breit.
Bleibt deshalb drin. Ein Befund, den man nicht verorten kann, ist
ein halber Befund -- und er kostet beim naechsten Mal wieder eine
Stunde. */
kopfDump: (() => {
const k = document.querySelector(".kopfleiste__rechts");
const l = document.querySelector(".kopfleiste");
if (!k || !l) return null;
const box = (e) => { const q = e.getBoundingClientRect();
return `${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}`; };
const m = document.querySelector(".kopfleiste > .marke");
return {
w: window.innerWidth,
leiste: box(l) + " wrap=" + getComputedStyle(l).flexWrap,
rechts: box(k) + " wrap=" + getComputedStyle(k).flexWrap,
marke: m ? box(m) + " flex=" + getComputedStyle(m).flex : "keine",
kinder: [...k.children].map((e) => `${e.tagName}.${String(e.className||"").split(" ")[0]} ${box(e)}`),
};
})(),
/* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die
einzige Stelle, ueber die man wieder wegkommt. */
kopfDrin: (() => {
const a = document.querySelector(".abmelden")?.getBoundingClientRect();
return a ? (a.right <= window.innerWidth + 1 && a.left >= -1) : null;
})(),
/* 6. Schriftgroesse: unter 12 px liest niemand auf einem Handy. */
/* NUR SICHTBARER TEXT. Ein ausgeblendetes Element hat weiterhin eine
Schriftgroesse -- die Pruefung meldete deshalb ein Schild, das auf
dem Handy per display:none gar nicht erscheint. Eine Pruefung, die
Unsichtbares anmahnt, gewoehnt man sich ab zu lesen. */
winzig: [...document.querySelectorAll("p, span, li, a, label, small")]
.filter((e) => {
if (e.children.length || e.textContent.trim().length <= 3) return false;
const r = e.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
const st = getComputedStyle(e);
/* `font-size: 0` IST KEINE KLEINE SCHRIFT, SONDERN KEINE.
Im Monatsraster des Kalenders wird aus jedem Termin ein
farbiger Punkt: Die Schrift wird auf 0 gesetzt, die Farbe
bleibt. Der Text ist damit nicht winzig, sondern bewusst
nicht dargestellt -- gelesen wird er im Tagesdialog. Ihn als
„0px, zu klein" zu melden, schickt einen an eine Stelle, an
der genau das beabsichtigt ist.
DIE GRENZE NACH UNTEN BLEIBT SCHARF: Alles zwischen 0,1 und
11,5 px ist weiterhin ein Befund. Nur die glatte Null faellt
heraus, und die ist eine Aussage, keine Nachlaessigkeit. */
const g = parseFloat(st.fontSize);
if (!(g > 0)) return false;
return g < 11.5;
})
.map((e) => `"${e.textContent.trim().slice(0, 18)}" ${getComputedStyle(e).fontSize}`)
.slice(0, 3),
});
for (const [breite, hoehe, wie] of GERAETE) {
console.log(`\n=== ${wie} (${breite}x${hoehe})`);
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
});
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
/* Auch die Anmeldeseite selbst wird geprueft -- sie ist die erste,
die jemand auf dem Handy sieht. */
{
await seite.waitForTimeout(500);
const r = await seite.evaluate(messen);
const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length && !r.verdeckt.length;
ok(gut, `Anmeldung über=${r.ueber}px abgeschnitten=${r.abgeschnitten.length} `
+ `klein=${r.klein.length} verdeckt=${r.verdeckt.length}`
+ (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "")
+ (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "")
+ (r.klein.length ? `\n zu klein: ${r.klein.join(", ")}` : "")
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : ""));
}
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 });
for (const s of SEITEN) {
const konsole = [];
const auf = (m) => { if (m.type() === "error") konsole.push(m.text()); };
const err = (e) => konsole.push("pageerror: " + e.message);
seite.on("console", auf);
seite.on("pageerror", err);
await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
const r = await seite.evaluate(messen);
const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length
&& !r.verdeckt.length && !r.winzig.length
&& r.kopfDrin !== false && !konsole.length;
console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}`
+ `über=${String(r.ueber).padStart(3)}px`
+ (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "")
+ (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "")
+ (r.klein.length ? `\n Berührziel zu klein: ${r.klein.join(", ")}` : "")
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")
+ (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "")
+ (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "")
+ (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "")
+ (!gut && r.kopfDump ? `\n KOPF: ${JSON.stringify(r.kopfDump)}` : ""));
if (!gut) fehler++;
seite.off("console", auf);
seite.off("pageerror", err);
}
if (breite === 390) {
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
await seite.screenshot({ path: "pruef-handy.png", fullPage: true });
}
await kontext.close();
}
/* ---------------------------------------------------------------------
Bedienbarkeit mit dem Finger: Auf einem Geraet ohne feinen Zeiger darf
das Licht gar nicht erst eingebaut werden -- es kostete Arbeit fuer
einen Effekt, den dort niemand sehen kann.
--------------------------------------------------------------------- */
console.log("\n=== Kein Licht ohne Zeiger");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const seite = await kontext.newPage();
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: 15000 });
await seite.waitForTimeout(800);
/* Eine Kachel antippen und wieder loslassen -- danach darf nichts
leuchten bleiben. */
await seite.tap(".kachel >> nth=1").catch(() => {});
await seite.waitForTimeout(400);
const r = await seite.evaluate(() => ({
lichter: document.querySelectorAll(".licht").length,
an: document.querySelectorAll('[data-licht="an"]').length,
}));
ok(r.lichter === 0, `auf dem Handy wird gar kein Licht eingebaut (${r.lichter})`);
ok(r.an === 0, `und keines bleibt an (${r.an})`);
await kontext.close();
}
/* ---------------------------------------------------------------------
RING UND UHR STEHEN IN DER MITTE -- bei jeder Rolle (09.09.2026)
Filipe, mit Bildschirmfoto: "ich will dass auf dem handy die uhr und
der kreis ganz oben mit den erledigten aufgaben, schoen mittig,
zentriert sind. die stehen sehr verzogen von der mitte. das soll bei
jedem und jeder rolle verbessert werden."
Gemessen war es bei allen fuenf Rollen und beiden Breiten dieselbe
Zahl: Ring 31 px nach links, Uhr 31 px nach rechts. Ursache war
`justify-content: center` auf einer Reihe, in der neben dem
Instrument noch ein Knopf steht -- zentriert wurde der Block aus
beiden. 62 px Knopfbreite (`--instrument-knoepfe`), halbiert, macht
genau die 31.
Warum das hier ALLE Rollen durchgeht, obwohl die Ursache eine einzige
CSS-Regel ist: Weil genau das die Behauptung war, die geprueft
gehoert. Eine Regel kann von einer rollenabhaengigen ueberschrieben
werden, und dann faellt es nur bei einer auf.
--------------------------------------------------------------------- */
/* EINE Messung, zweimal benutzt (eigene und fremde Sicht). Zwei Kopien
waeren zwei Kopien, die auseinanderlaufen -- und dann prueft die eine
etwas anderes als die andere, ohne dass es auffaellt.
Gemessen wird die ZEILENZAHL, nicht die Hoehe: Eine Hoehengrenze waere
wieder eine Zahl von gestern -- sie stimmt, bis jemand die Polsterung
anfasst. Ob zwei Dinge in derselben Zeile stehen, sieht man an ihrer
Oberkante, und das gilt immer. */
async function kopfMessen(seite) {
return await seite.evaluate(() => {
const k = document.querySelector(".kopfleiste");
const teile = [...k.querySelectorAll(".kopfleiste__rechts > *"), k.querySelector(".marke")]
.filter((e) => e && e.getBoundingClientRect().width > 0);
const kanten = teile.map((e) => Math.round(e.getBoundingClientRect().top));
return { zeilen: new Set(kanten).size, spanne: Math.max(...kanten) - Math.min(...kanten),
anzahl: teile.length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth };
});
}
let fremdGeprueft = 0;
let weckerGeprueft = 0;
const mitUmschalter = new Set();
const KOPF_GERAETE = [[360, 800], [390, 844], [412, 915]];
console.log("\n=== Ring und Uhr stehen mittig (alle Rollen)");
for (const [breite, hoehe] of KOPF_GERAETE) {
for (const [rolle, code] of [["admin", "CODE-DOGI-0001"], ["spicy", "CODE-SPIC-0001"],
["manager", "CODE-MANA-0001"], ["scout", "CODE-SCOU-0001"], ["creator", "CODE-ANNA-0001"]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
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(1500);
const m = await seite.evaluate(() => {
const ab = (w) => {
const e = document.querySelector(w);
if (!e) return null;
const r = e.getBoundingClientRect();
if (r.width < 4) return null;
return Math.round((r.left + r.right) / 2 - window.innerWidth / 2);
};
return { ring: ab("#zring"), uhr: ab(".uhr"),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth };
});
/* 3 px Nachsicht: Ein ungerader Rest bei ungerader Breite ist kein
Befund. 31 px waren einer. */
const gut = m.ring !== null && m.uhr !== null
&& Math.abs(m.ring) <= 3 && Math.abs(m.uhr) <= 3 && m.ueber <= 1;
ok(gut, `${breite}px ${rolle.padEnd(8)} Ring ${m.ring} px, Uhr ${m.uhr} px von der Mitte`
+ (m.ueber > 1 ? `, Ueberstand ${m.ueber}` : ""));
/* ---- UND DIE KOPFLEISTE BLEIBT IN EINER REIHE (09.09.2026) ------
Filipe, mit Bildschirmfoto: "es soll alles in einer reihe sein."
Auf seinem Bild stand "Abmelden" allein in einer zweiten Zeile.
Gemessen wird die ZEILENZAHL, nicht die Hoehe: Eine Hoehengrenze
waere wieder eine Zahl von gestern -- sie stimmt, bis jemand die
Polsterung anfasst. Ob zwei Dinge in derselben Zeile stehen,
sieht man an ihrer Oberkante, und das gilt immer. */
const kopf = await kopfMessen(seite);
/* 12 px Nachsicht: Ein Knopf von 36 px und ein Bild von 28 px sind
mittig ausgerichtet und haben deshalb verschiedene Oberkanten --
das ist dieselbe Zeile. Eine zweite Zeile liegt 40 px tiefer. */
ok(kopf.anzahl >= 3 && kopf.spanne <= 12 && kopf.ueber <= 1,
`${breite}px ${rolle.padEnd(8)} Kopfleiste: ${kopf.anzahl} Teile in einer Reihe `
+ `(Spanne ${kopf.spanne} px)`);
/* ---- DAS ZEITFELD DES WECKERS STEHT IM BILD (09.09.2026) -------
Filipe: "wenn ich am handy auf den wecker drücke dan sieht man
nichts." Es ging auf -- nur ausserhalb des Bildschirms, bei
390 px von -92 bis 27. `.tagesruf__feld` oeffnet nach LINKS, und
auf dem Handy ist der Knopf ganz links.
Geprueft wird aufgeklappt, nicht zugeklappt: Ein verstecktes Feld
hat keine brauchbare Lage, und genau die ist die Frage. Deshalb
wird es hier von Hand aufgemacht -- was beim Einschalten sonst
das Skript tut. */
const wecker = await seite.evaluate(() => {
const feld = document.querySelector(".tagesruf__feld");
if (!feld) return null;
const vorher = feld.hidden;
feld.hidden = false;
const r = feld.getBoundingClientRect();
feld.hidden = vorher;
return { links: Math.round(r.left), rechts: Math.round(r.right),
breit: Math.round(r.width), fenster: window.innerWidth };
});
if (wecker) {
weckerGeprueft++;
ok(wecker.breit > 40 && wecker.links >= -1 && wecker.rechts <= wecker.fenster + 1,
`${breite}px ${rolle.padEnd(8)} Weckerfeld liegt bei `
+ `${wecker.links}..${wecker.rechts} von ${wecker.fenster}`);
}
/* ---- UND DASSELBE IN EINER FREMDEN SICHT (09.09.2026) ----------
DIESE PRUEFUNG FEHLTE, UND GENAU DAS HAT DER FEHLER AUSGENUTZT.
Filipe meldete aus der installierten App "es ist noch nicht alles
in einer zeile" -- waehrend hier alles gruen war. Der Grund: Alle
Messungen liefen in der EIGENEN Sicht. Dort klappt der Umschalter
auf 36 px zusammen; sobald man die Sicht einer anderen Person
uebernimmt, stand der Name darin und er war 152 px breit. Auf
320, 360, 390, 412 UND 430 px waren es dann zwei Zeilen.
Ein gruener Haken sagt nur, dass die Bedingung erfuellt war --
nicht, dass sie den Zustand geprueft hat, in dem der Nutzer ist.
Wer keinen Umschalter hat (Creator, Scout ...), wird nicht
uebersprungen, sondern gar nicht erst gezaehlt; dass ueberhaupt
jemand geprueft wurde, steht unten in einer eigenen Bedingung. */
const wenAnsehen = await seite.evaluate(() => {
const s = document.getElementById("kopf-sicht-feld");
if (!s) return null;
const o = [...s.options].find((x) => x.value);
return o ? { id: o.value, name: o.textContent } : null;
});
if (wenAnsehen) {
await seite.evaluate((x) => localStorage.setItem("dfw-sicht", x), wenAnsehen.id);
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
const fremd = await kopfMessen(seite);
const band = await seite.evaluate(() => {
const b = document.querySelector(".fremd-band");
if (!b) return { da: false };
const r = b.getBoundingClientRect();
return { da: r.width > 0 && r.height > 0, text: (b.textContent || "").trim().slice(0, 60) };
});
fremdGeprueft++;
mitUmschalter.add(rolle);
ok(fremd.anzahl >= 3 && fremd.spanne <= 12 && fremd.ueber <= 1,
`${breite}px ${rolle.padEnd(8)} fremde Sicht: ${fremd.anzahl} Teile in einer Reihe `
+ `(Spanne ${fremd.spanne} px)`);
/* Der Name darf nicht einfach verschwinden -- er ist aus der
Leiste ins Band gewandert, und dass er DORT steht, gehoert
geprueft. Sonst waere "eine Reihe" damit erkauft, dass niemand
mehr sieht, wessen Zahlen er ansieht. */
ok(band.da && band.text.includes(wenAnsehen.name.trim()),
`${breite}px ${rolle.padEnd(8)} das Band nennt den vollen Namen`);
await seite.evaluate(() => localStorage.removeItem("dfw-sicht"));
}
await kontext.close();
}
}
/* Die Zahl gehoert in die Bedingung, nicht nur in den Bericht: Findet
die Schleife keinen einzigen Umschalter (umbenannt, Rechte geaendert),
waere oben alles gruen, ohne dass die fremde Sicht je gemessen wurde.
Die Erwartung wird ABGELEITET, nicht hingeschrieben: so viele Rollen
mit Umschalter, wie es gibt, mal so viele Breiten, wie geprueft
werden. Heute hat nur DogFather einen -- bekommt Spicy morgen auch
einen, steigt die Zahl von selbst mit, statt still danebenzuliegen.
(Beim ersten Anlauf stand hier eine 6, geraten; gemessen waren es 3.
Genau die Sorte Zahl, die spaeter grundlos rot wird.) */
ok(mitUmschalter.size >= 1 && fremdGeprueft === KOPF_GERAETE.length * mitUmschalter.size,
`die fremde Sicht wurde ${fremdGeprueft}-mal gemessen `
+ `(${mitUmschalter.size} Rolle(n) mit Umschalter x ${KOPF_GERAETE.length} Breiten)`);
/* Auch hier: Findet die Schleife den Wecker gar nicht (umbenannt, an
eine andere Stelle gewandert, Push nicht moeglich), waere oben alles
gruen, ohne dass er je gemessen wurde. */
ok(weckerGeprueft >= KOPF_GERAETE.length,
`das Weckerfeld wurde ${weckerGeprueft}-mal wirklich gemessen`);
/* =====================================================================
GEGENPROBE ZUR BERUEHRZIEL-REGEL (14.09.2026)
Seit heute hat die Regel eine Ausnahme (Links im Satz, WCAG 2.5.8).
Eine Regel mit Ausnahme braucht den Beweis in BEIDE Richtungen --
sonst laesst sich nicht unterscheiden, ob sie nichts findet, weil
nichts da ist, oder weil die Ausnahme alles verschluckt.
Diese Datei hatte bis heute ueberhaupt keine Gegenprobe. Sie konnte
also nie zeigen, dass sie ein zu kleines Ziel ueberhaupt bemerkt.
===================================================================== */
console.log("\n=== Gegenprobe zur Beruehrziel-Regel ===");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.evaluate(() => {
const k = document.createElement("div");
k.id = "gegenprobe";
/* Zweimal dieselbe Hoehe: einmal allein stehend, einmal im Satz. */
k.innerHTML =
"<a href='#a' id='g-allein' style='display:block;width:20px;height:20px'>xx</a>"
+ "<p>Das hier ist ein ganz normaler Satz mit einem "
+ "<a href='#b' id='g-satz' style='font-size:13px;line-height:20px'>Verweis darin</a>"
+ " und noch etwas Text dahinter.</p>";
document.body.append(k);
});
const r = await seite.evaluate(messen);
const namen = r.klein.join(" | ");
ok(/20x20/.test(namen),
`ein allein stehendes 20x20-Ziel wird erkannt (${namen || "nichts gefunden"})`);
ok(!/Verweis darin/.test(namen),
"ein gleich grosser Verweis IM SATZ wird zu Recht nicht gemeldet (WCAG 2.5.8)");
await seite.evaluate(() => document.getElementById("gegenprobe")?.remove());
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
process.exit(fehler ? 1 : 0);