Files
dogfather-universe/server/pruef-handy.mjs
T
DogFatherGitandClaude Opus 5 1fe443e59c Handy durchgeprueft, Lichtfarbe je Kachel, haengendes Licht behoben
DAS LICHT BLIEB HAENGEN -- vier Ursachen, jede einzeln behoben:

1. EIN WETTLAUF. Zwischen dem Anmelden eines Bildaufbaus und seinem
   Ablauf konnte der Zeiger die Karte laengst verlassen haben. Dann hatte
   pointerout schon aufgeraeumt, und der Bildaufbau schaltete das Licht
   gleich wieder AN. Der haeufigste Fall und der unauffaelligste.
2. UEBER EINE LUECKE VERLASSEN. Wer eine Karte nicht ueber eine
   Nachbarkarte verliess, sondern ueber den Zwischenraum, loeste kein
   Ereignis aus, das die alte Karte kannte.
3. GESCROLLT, OHNE DIE MAUS ZU BEWEGEN. Die Karte wandert unter dem
   stehenden Zeiger weg -- es kommt gar kein Zeigerereignis. Jetzt wird
   beim Scrollen nachgesehen, ob die beleuchtete Karte noch unter dem
   Zeiger liegt.
4. FENSTER ODER TAB VERLASSEN. Auch dort kommt nichts mehr.

Es gibt jetzt genau EINE beleuchtete Karte -- mehr kann es nicht geben,
es gibt ja nur einen Zeiger. Beim Wechsel geht die alte aus, bevor die
neue angeht.

DIE FARBE GEHOERT ZUR KACHEL. Auf der Wissensseite leuchteten alle sechs
Welten violett, weil die Farbe dort --w heisst und der ganze uebrige
Workspace --ton benutzt. Das Licht griff auf --ton zu, fand nichts und
nahm den Farbton der SEITE. Dieselbe Luecke bei den Aufgaben-Spalten
(--sfarbe), den Kalenderkarten (--afarbe) und den Kalenderzeilen
(--zfarbe). Alle vier setzen jetzt --ton mit.

Nachgewiesen an echten Bildpunkten, nicht am Quelltext: Der Zeiger wird
auf die Aufgaben-Kachel gesetzt (dort muss Rot ueberwiegen: 85 zu 22)
und auf die Kalender-Kachel (dort Blau: 71 zu 13). Ein
Zeichenkettenvergleich haette das nicht gekonnt -- der Browser rechnet
color-mix() aus und schreibt je nach Fassung rgb(), color() oder oklab()
zurueck.

DAS HANDY, komplett durchgeprueft: neue pruef-handy.mjs faehrt alle
fuenfzehn Seiten auf DREI echten Geraetegroessen ab (320 px iPhone SE,
390 px iPhone, 412 px Android) und misst fuenf Dinge, die am Rechner
unsichtbar sind. Gefunden und behoben:

  * ZWEI ECHTE UEBERLAEUFE (startcheck +27 px, automation +33 px). Die
    Seite liess sich waagerecht schieben -- auf einem Telefon der
    schlimmste Fehler. Ursachen: zwoelf Raster mit festem Mindestmass
    (minmax(280px, 1fr) kann nicht schrumpfen -> min(280px, 100%)),
    zwoelf feste Mindestbreiten an Auswahlfeldern, und ein "flex: none"
    an den Stufen-Knoepfen, das jedes Schrumpfen verbot. "width: 100%"
    allein reichte dort nicht.
  * BERUEHRZIELE unter 24 px (WCAG 2.2, Kriterium 2.5.8). Auswahlfelder
    waren 18 bis 22 px hoch. Jetzt 44 px -- die Empfehlung von Apple und
    Google, und der Daumen ist nun einmal breiter als ein Mauszeiger.
    Nebenbei behebt die Schriftgroesse 16 px das Hineinzoomen von iOS.
  * SCHRIFT unter 11,7 px an 52 Stellen. Gesucht wurden sie nicht von
    Hand -- das waere ein Ratespiel gewesen und haette die Haelfte
    uebersehen -- sondern durch Durchsuchen der Stildateien nach
    font-size unter 0,73rem.

ZWEI FEHLER IN DEN EIGENEN PRUEFUNGEN gefunden und behoben: Die
Schriftregel griff zuerst gar nicht (start.css wird VOR den Seitenstilen
geladen, bei gleicher Staerke gewinnt die spaetere -- jetzt mit
body.start qualifiziert), und die Lichtpruefung scrollte 600 px und
stellte nicht zurueck, sodass die folgende Pruefung ins Leere zeigte.

GEGENPROBE gemacht: Mit absichtlich eingebauten Fehlern (8-px-Schrift,
500 px breiter Inhalt) meldet die Handypruefung sofort Rot. Eine
Pruefung, die immer bestaetigt, bestaetigt nichts.

Alle achtzehn Pruefungen laufen gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 14:48:48 +02:00

304 lines
15 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 }); };
await import("./index.js");
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?.();
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 = jetzt.slice(0, 10);
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick-Maximilian", "scout", "CODE-SCOU-0001");
const idTili = anlegen("Tili", "creator", "CODE-ANNA-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();
const SEITEN = [
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "personen.html",
"profil.html", "wissen.html", "dateien.html", "calls.html", "startcheck.html",
"report.html", "scouting.html", "automation.html", "content.html", "bereich.html?b=live",
];
/* 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. */
klein: [...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;
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;
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return oben && oben !== e && !e.contains(oben) && !oben.contains(e);
})
.map((e) => `${e.tagName}"${(e.textContent || "").trim().slice(0, 16)}"`)
.slice(0, 3),
/* 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. */
winzig: [...document.querySelectorAll("p, span, li, a, label, small")]
.filter((e) => e.children.length === 0 && e.textContent.trim().length > 3
&& 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(" | ")}` : ""));
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();
}
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);