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]>
695 lines
34 KiB
JavaScript
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);
|