Files
dogfather-universe/server/pruef-handy.mjs
T
DogFatherGitandClaude Opus 5 e3e015bca4 Kopfleiste: ein Name, der zweimal vergeben war
Filipe zu einem Bildschirmfoto: "was soll das jetzt, wieso sieht das in
gewissen seiten so scheisse aus?!"

Der Teilen-Knopf stand doppelt so hoch wie seine Nachbarn, mit dem
Zeichen UEBER dem Wort statt daneben. Es war kein Gestaltungsfehler,
sondern ein Name: `.teilen` gab es ZWEIMAL --

  gate.css      Knopf der Kopfleiste   inline-flex, waagerecht
  bereich.css   Layout der Teilen-Seite  flex column, 22px, 44rem

Auf den drei Seiten, die beide Dateien laden (bereich, bewerben,
teilen), gewann die spaeter geladene. Der Knopf bekam das Layout einer
ganzen Seite. "Gewisse Seiten" war exakt richtig beobachtet.

DERSELBE NAME KOSTETE AUSSERDEM DEN KNOPF: kopf.js baut ihn nur ein,
wenn `getElementById('teilen')` nichts findet -- ein Schutz gegen
doppelten Einbau. Auf teilen.html steht `<section id="teilen">` aber
schon im HTML. Dort fehlte der Knopf ganz, ohne Spur, ohne Meldung.

REPARIERT ALS REGEL, NICHT ALS EINZELFALL:
  - Was kopf.js in die Leiste haengt, traegt jetzt `kopf-` davor
    (kopf-teilen, kopf-sicht-wahl, kopf-sicht-feld, kopf-fremd-band).
    Eine Seite darf dann heissen wie sie will -- auch "teilen", "chat"
    oder "suchen", und genau diese Woerter braucht als naechstes eine
    Seite.
  - Die Teilen-Seite bekommt eigene Klassennamen (tl-*).

NEU: server/pruef-namen.mjs (12 Pruefungen, ohne Server, <1 s).
Es ist der ZWEITE Namenskonflikt an einem Tag -- der erste war
`.t-schritt` auf der Talente-Seite, gefunden nur durch Zaehlen im
Browser. Beide waren gruen in jeder bestehenden Prueflinie.
Gemessen, um den Schnitt zu finden, der etwas taugt:
  2872 Klasse in zwei geladenen Dateien erwaehnt      -> Rauschen
  1887 davon mit eigener Blockdefinition              -> Rauschen
    12 davon mit widersprechendem `display`
     6 davon ohne `display: none`                     -> geprueft
Mit Gegenprobe, die aus den Dateien ABGELEITET wird (die erste, fest
abgeschriebene Fassung fand nichts, weil die gewaehlte Klasse dort gar
kein `display` setzt -- eine Gegenprobe, die selbst danebenliegt,
beweist nichts).

pruef-kopf-messen: 3 -> 16 Messungen, und drei eigene Fehler behoben,
die alle dieselbe Form hatten -- zu frueh hingesehen:
  1. Sie mass NUR uebersicht.html. Die laedt bereich.css nicht und war
     fuer diesen Fehler blind.
  2. Sie mass nur 320/390/430px. Unter 720px versteckt gate.css das
     Wort "Teilen"; der Knopf bleibt klein, auch falsch angeordnet.
     Filipes Bild war 1366px breit.
  3. Sie wartete 300ms. kopf.js haengt Teile erst nach `/api/ich` ein
     und hat ein Netz bei 1200ms. Zweiter Versuch: warten, bis `#wer`
     Text hat -- SOFORT wahr, dort steht `…` als Platzhalter. Dritter:
     auf Stabilitaet warten -- nach 0ms und 500ms gleich gross, also
     "fertig". Erst die Mindestwartezeit (1200 aus kopf.js plus Luft)
     davor machte die Messung echt.
Bewiesen an der Gegenprobe: mit dem alten Fehler meldet sie jetzt
"kopf-teilen ist 77px -- die anderen im Schnitt 43px", vorher blieb sie
gruen.

Statt einer festen Hoehe wird gefragt, ob EIN Teil aus der Reihe faellt
(> 1,6x der Schnitt seiner Nachbarn). Das waechst mit, wenn die Leiste
groesser wird, und muss bei keinem neuen Knopf nachgezogen werden.

Gruen: namen, kopf-messen, sicht, css-klassen, struktur.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 18:59:19 +02:00

695 lines
34 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");
process.env.PORT = "4198";
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";
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:4198";
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;
return e.scrollWidth > e.clientWidth + 2;
})
.map((e) => `"${e.textContent.trim().slice(0, 22)}" (${e.scrollWidth}>${e.clientWidth})`)
.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 ("Der Treff", "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;
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;
return parseFloat(getComputedStyle(e).fontSize) < 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 } });
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);