Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."
Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.
confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.
Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).
DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
.dialog stand in aufgaben.css und leistung.css -> auf dateien.html
waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
per Muster geschnitten -- heute frueh hat ein nicht-gieriges
Muster schon einmal CSS zerrissen).
Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
nur in aufgaben.css. Gemessen: padding 0px, und die Felder
verloren ihre height:44px. Jetzt steht alles unter
.nachfrage__form in module.css; der Dialog borgt nichts mehr.
Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.
ZWEI FUNDE NEBENBEI:
hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
eingeschaltet (das loescht echte Daten und ist Filipes
Entscheidung), sondern der Kommentar richtiggestellt.
Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
Route, die ihn wieder oeffnet. Vorher stand darueber nur die
Frage nach einem Schlusswort. Jetzt sagt der Dialog es.
pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.
Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.
Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).
pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
681 lines
32 KiB
JavaScript
681 lines
32 KiB
JavaScript
/* =====================================================================
|
||
pruef-handy-teamdogi.mjs — Der Rundgang, den es für diese Rollen
|
||
noch nie gab. (19.09.2026)
|
||
|
||
Filipe: „mach einen kompletten check dass die app auf dem handy
|
||
perfekt funktioniert … weil die modis haben schon probleme."
|
||
Auf die Rückfrage, was genau: „einfach ALLES ABCHECKEN ALLES
|
||
MÖGLICHE."
|
||
|
||
---------------------------------------------------------------------
|
||
DER BEFUND, DER DIESE DATEI AUSGELÖST HAT
|
||
|
||
Es gibt seit dem 02.09.2026 einen großen Rundgang (`pruef-grosscheck`).
|
||
Er geht über jede Seite, zwei Bildschirmgrößen und vier Rollen:
|
||
|
||
admin · manager · scout · creator
|
||
|
||
Vier von acht. Es fehlen **modi, hand und gast** — also genau die drei
|
||
Rollen, die auf `crew.dogfather-universe.com` leben, und genau die,
|
||
von denen die Beschwerden kommen. Ihre Seiten sind nie im Ganzen auf
|
||
einem Handy durchgemessen worden.
|
||
|
||
Das ist kein Vorwurf an den Rundgang: Er wurde für das Agenturhaus
|
||
gebaut, und das Team-Dogi-Haus kam später dazu. Aber es erklärt,
|
||
warum Fehler dort überleben konnten.
|
||
|
||
---------------------------------------------------------------------
|
||
WARUM DIE PRÜFUNG WIRKLICH `crew.` AUFRUFT
|
||
|
||
Das Haus entscheidet am **Host**, wer was sieht:
|
||
`haus = istCrewAdresse(req.get("host")) ? "crew" : …`. Gegen
|
||
`127.0.0.1` gemessen bekäme man ein anderes Haus, andere Kacheln und
|
||
eine Adressregel, die alles durchlässt — und damit ein Ergebnis über
|
||
eine Seite, die es so nicht gibt.
|
||
|
||
Deshalb bekommt der Browser `--host-resolver-rules`, die
|
||
`crew.dogfather-universe.com` auf den lokalen Server zeigen lassen.
|
||
Die Anwendung sieht den echten Namen, die Prüfung trifft trotzdem
|
||
niemals den echten Server.
|
||
|
||
---------------------------------------------------------------------
|
||
ZWEI BREITEN, UND BEIDE HABEN EINEN GRUND
|
||
|
||
390 px iPhone 12/13/14/15 — die schmalste verbreitete Breite
|
||
412 px die häufigste Android-Breite überhaupt
|
||
|
||
412 ist der wichtigere Wert, und das ist gemessen, nicht geraten: Im
|
||
Haus sind schon zweimal Umbrüche für „das Handy" auf 380 px gerechnet
|
||
worden. Bei 390 ging es gut, bei 412 lag ein Knopf über dem anderen —
|
||
und beim Rundgang fiel es nicht auf, weil er nur 390 kannte.
|
||
|
||
---------------------------------------------------------------------
|
||
WAS GEMESSEN WIRD
|
||
|
||
1. KOMMT DIE SEITE AN? Statuscode, Inhalt, keine Umleitung ins
|
||
Leere.
|
||
2. STÜRZT ETWAS AB? Jede Fehlermeldung der Konsole und jeder
|
||
nicht abgefangene Fehler. Ein Skript,
|
||
das aussteigt, lässt alles Folgende
|
||
tot zurück — das sieht aus wie „die
|
||
Seite ist leer".
|
||
3. LÄUFT ETWAS ÜBER? Waagerechter Überstand, und WER ihn
|
||
verursacht.
|
||
4. IST TEXT ABGESCHNITTEN? Mit den Ausnahmen, die Absicht sind
|
||
(Wasserzeichen, Klassen nur für
|
||
Vorleseprogramme).
|
||
5. KANN MAN ES TREFFEN? Jedes Bedienelement unter 44 px — mit
|
||
der Ausnahme, die im Standard steht
|
||
(Links mitten im Fließtext).
|
||
6. LIEGT ETWAS ÜBEREINANDER? Zwei Bedienelemente, die sich
|
||
überdecken. Das ist der Fehler, bei dem
|
||
man auf A tippt und B bekommt — und
|
||
genau der, den Nutzer als „der Knopf
|
||
geht nicht" melden.
|
||
7. WEISS MAN, WAS ES TUT? Ein Bedienelement ohne lesbaren Namen
|
||
ist für ein Vorleseprogramm ein leerer
|
||
Knopf.
|
||
|
||
---------------------------------------------------------------------
|
||
UND EINE GEGENPROBE
|
||
|
||
Am Ende wird eine absichtlich zu breite Kachel eingebaut und ein
|
||
absichtlich zu kleiner Knopf. Beide MÜSSEN gemeldet werden. Eine
|
||
Prüfung, die immer bestätigt, bestätigt nichts — das ist hier seit
|
||
dem 01.09.2026 Hausregel.
|
||
|
||
---------------------------------------------------------------------
|
||
DIE SEITENLISTE WIRD GERECHNET, NICHT GEPFLEGT
|
||
|
||
Welche Seite zu welcher Rolle gehört, steht in `rechte.js` und
|
||
`workspace.js`. Diese Prüfung liest beides und leitet ihre Liste
|
||
daraus ab. Eine abgeschriebene Liste wäre die Stelle, an der in drei
|
||
Monaten eine neue Seite fehlt — und niemand würde es merken, weil
|
||
der Lauf ja grün bleibt.
|
||
|
||
AUFRUF: node server/pruef-handy-teamdogi.mjs
|
||
Dauer: rund drei Minuten (4 Rollen × 2 Breiten × ~20 Seiten).
|
||
===================================================================== */
|
||
|
||
import { tagLokal } from "./helfer-zeit.mjs";
|
||
import { mkdtempSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join, dirname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
import { portMussFreiSein } from "./helfer-port.mjs";
|
||
|
||
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-handy-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
|
||
const PORT = await portMussFreiSein(4231, "pruef-handy-teamdogi");
|
||
process.env.PORT = String(PORT);
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||
|
||
/* Der Server setzt seine Kekse mit `secure` -- über http bliebe die
|
||
Anmeldung damit wirkungslos, und ALLES weitere wäre eine Messung an
|
||
der Anmeldewand. */
|
||
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");
|
||
/* NACH index.js: Der Server hängt dort zwei Auffangnetze ein, die einen
|
||
Absturz nur protokollieren. Richtig für den Betrieb, fatal für eine
|
||
Prüfung -- ohne die Notbremse bliebe der Prozess ewig stehen. */
|
||
notbremse(600_000, "pruef-handy-teamdogi");
|
||
await new Promise((r) => setTimeout(r, 900));
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Die Leute, über die geprüft wird
|
||
------------------------------------------------------------------- */
|
||
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)); }
|
||
}
|
||
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const jetzt = new Date().toISOString();
|
||
|
||
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;
|
||
}
|
||
|
||
/* LANGE NAMEN MIT ABSICHT. Ein „Max" passt überall hin; ein Name, der
|
||
wirklich vorkommt, findet die Stellen, an denen zu wenig Platz ist.
|
||
Dasselbe macht der große Rundgang. */
|
||
const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
|
||
const idHand = anlegen("VanVan Schmidt-Hohenberg", "hand", "CODE-HAND-0001");
|
||
const idModi = anlegen("Diene Margarethe Oberländer", "modi", "CODE-MODI-0001");
|
||
const idGast = anlegen("Melli aus der Community", "gast", "CODE-GAST-0001");
|
||
|
||
/* Die Community muss ihr Alter bestätigt haben -- sonst hängt die
|
||
Anmeldung an der Altersfrage, und wir messen die Wand statt der App. */
|
||
try {
|
||
d.prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?").run(jetzt, idGast);
|
||
} catch { /* Spalte gibt es (noch) nicht -- dann klickt die Prüfung die Frage an. */ }
|
||
|
||
/* ---- Inhalt, damit die Seiten nicht leer sind ----------------------
|
||
Leere Seiten prüfen nichts: Ein Raster ohne Karten läuft nie über,
|
||
und ein Knopf, der nie gezeichnet wird, ist nie zu klein. */
|
||
const setze = (sql, ...w) => { try { d.prepare(sql).run(...w); } catch { /* Tabelle fehlt */ } };
|
||
|
||
for (const [titel, wer] of [
|
||
["Thumbnail für den Freitagsstream bauen und hochladen", idModi],
|
||
["Regelverstoß von gestern Abend nachbereiten", idModi],
|
||
["Community-Umfrage auswerten", idHand],
|
||
]) {
|
||
setze("INSERT INTO aufgaben (titel, beschreibung, status, verantwortlich_id, erstellt_von,"
|
||
+ " erstellt, frist) VALUES (?,?,?,?,?,?,?)",
|
||
titel, "Beschreibung mit genug Text, dass die Karte wirklich Höhe bekommt "
|
||
+ "und ein Umbruch entstehen kann.", "offen", wer, idDogi, jetzt,
|
||
/* Ortszeit, nicht UTC: Zwischen 00:00 und 02:00 deutscher Zeit
|
||
liegt UTC noch im Vortag -- die Frist waere dann um einen Tag
|
||
verschoben, und die Pruefung schluege nachts falsch an.
|
||
Gefunden hat das `pruef-struktur`, nicht ich. */
|
||
tagLokal(3));
|
||
}
|
||
|
||
for (const [bereich, titel] of [
|
||
["treff", "Hallo zusammen, ich bin neu hier und freu mich auf die Streams"],
|
||
["anschlag", "Ab Montag starten wir eine Stunde früher"],
|
||
["wunsch", "Zockernacht am Samstag mit der ganzen Community"],
|
||
["ansteht", "Stream am Freitag um 20 Uhr"],
|
||
["highlight", "Der Moment, als Dogi den Boss gelegt hat"],
|
||
["mitmachen", "Wie werde ich Modi?"],
|
||
["rueckmeldung", "Die neue Aufgabenübersicht ist viel klarer geworden"],
|
||
]) {
|
||
setze("INSERT INTO eintraege (bereich, titel, text, status, erstellt_von, erstellt)"
|
||
+ " VALUES (?,?,?,?,?,?)", bereich, titel,
|
||
"Ein Text, der lang genug ist, dass er über mehrere Zeilen läuft und damit "
|
||
+ "zeigt, ob die Karte auf einem schmalen Bildschirm sauber umbricht oder nicht.",
|
||
"offen", idDogi, jetzt);
|
||
}
|
||
|
||
setze("INSERT INTO wissen (titel, kategorie, text, erstellt_von, erstellt)"
|
||
+ " VALUES (?,?,?,?,?)", "Wie du einen Stream vorbereitest", "Live",
|
||
"Schritt für Schritt erklärt.", idDogi, jetzt);
|
||
|
||
d.close();
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Welche Seite gehört zu welcher Rolle? — gerechnet, nicht gepflegt
|
||
------------------------------------------------------------------- */
|
||
const rechte = await import("./rechte.js");
|
||
const ws = await import("./workspace.js");
|
||
|
||
const CREW = "crew.dogfather-universe.com";
|
||
|
||
/* =====================================================================
|
||
WARUM DIESE PRÜFUNG ÜBER https SPRICHT — 19.09.2026
|
||
|
||
Der erste Anlauf lief über `http://crew.dogfather-universe.com:PORT`.
|
||
Ergebnis: JEDE Stilvorlage, jedes Bild und jedes Skript schlug mit
|
||
`ERR_SSL_PROTOCOL_ERROR` fehl. Gemessen wurde also eine Seite ganz
|
||
ohne CSS — und die meldete brav ein 1647 px breites Bild auf einem
|
||
390 px breiten Schirm.
|
||
|
||
Das sah aus wie ein schwerer Fund und war einer in MEINER Prüfung:
|
||
`dogfather-universe.com` steht in der fest eingebauten HSTS-Liste von
|
||
Chromium. Der Browser schaltet dort von sich aus auf https um, und
|
||
zwar unabänderlich — auch `--disable-features=HttpsUpgrades` hilft
|
||
nicht, das ist nachgemessen.
|
||
|
||
Eine plausible Herleitung ersetzt eben keine Messung. Hätte ich den
|
||
Befund gemeldet statt nachzusehen, wäre ein halber Tag in eine
|
||
Reparatur geflossen, die nichts repariert.
|
||
|
||
Deshalb spricht die Prüfung jetzt selbst https: ein eigenes
|
||
Zertifikat für genau diesen Namen, ein winziger Vorbau davor, der
|
||
alles unverändert an den echten Server weiterreicht. Die Anwendung
|
||
merkt davon nichts — sie sieht denselben Host wie im Betrieb.
|
||
===================================================================== */
|
||
const { execFileSync } = await import("node:child_process");
|
||
const { createServer: httpsServer } = await import("node:https");
|
||
const { request: httpAnfrage } = await import("node:http");
|
||
|
||
const schluessel = join(ordner, "pruef.key");
|
||
const zertifikat = join(ordner, "pruef.crt");
|
||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||
"-keyout", schluessel, "-out", zertifikat, "-days", "2",
|
||
"-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
|
||
{ stdio: "ignore" });
|
||
|
||
const HTTPS_PORT = PORT + 1;
|
||
const vorbau = httpsServer({
|
||
key: (await import("node:fs")).readFileSync(schluessel),
|
||
cert: (await import("node:fs")).readFileSync(zertifikat),
|
||
}, (von, nach) => {
|
||
/* UNVERÄNDERT WEITERREICHEN -- besonders die Host-Kopfzeile. An ihr
|
||
entscheidet das Haus, wer was sieht; würde sie hier umgeschrieben,
|
||
misst die Prüfung wieder das falsche Haus. */
|
||
const weiter = httpAnfrage({
|
||
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
|
||
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
|
||
}, (antwort) => {
|
||
nach.writeHead(antwort.statusCode || 502, antwort.headers);
|
||
antwort.pipe(nach);
|
||
});
|
||
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
|
||
von.pipe(weiter);
|
||
});
|
||
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
|
||
|
||
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
|
||
|
||
function seitenFuer(rolle) {
|
||
const tafel = rechte.tafelJetzt();
|
||
const person = { rolle, haus: "crew" };
|
||
const seiten = Object.entries(tafel)
|
||
.filter(([, liste]) => Array.isArray(liste) && liste.includes(rolle))
|
||
.map(([pfad]) => pfad)
|
||
.filter((pfad) => ws.gehoertAufDieseAdresse(person, pfad));
|
||
|
||
/* Die Bretter hinter `bereich.html?b=…` sind eigene Seiten, auch wenn
|
||
sie sich eine Datei teilen. Wer nur `bereich.html` prüft, prüft
|
||
eines von vierzehn. Welche es gibt, steht in den Kacheln. */
|
||
const bretter = [...(ws.bereicheFuer(person) || []),
|
||
...(ws.zusatzBereicheFuer(person) || [])]
|
||
.map((k) => String(k?.ziel || ""))
|
||
.filter((z) => z.startsWith("bereich.html?b="))
|
||
.map((z) => "/workspace/" + z);
|
||
|
||
return [...new Set([...seiten.filter((p) => !p.endsWith("bereich.html")), ...bretter])];
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Die Messung — sie läuft IM Browser
|
||
------------------------------------------------------------------- */
|
||
const messen = () => {
|
||
const sichtbar = (e) => {
|
||
const r = e.getBoundingClientRect();
|
||
const st = getComputedStyle(e);
|
||
return r.width > 0 && r.height > 0 && st.visibility !== "hidden"
|
||
&& st.display !== "none" && Number(st.opacity) > 0.05;
|
||
};
|
||
const name = (e) => (e.tagName.toLowerCase()
|
||
+ (e.id ? "#" + e.id : "." + String(e.className || "").split(" ")[0])).slice(0, 36);
|
||
|
||
const BEDIENBAR = 'a[href], button, input, select, textarea, [role="button"],'
|
||
+ ' [role="tab"], [role="radio"], [tabindex]:not([tabindex="-1"])';
|
||
|
||
/* NUR-FUER-VORLESEPROGRAMME IST KEIN FEHLER. Solche Elemente sind
|
||
absichtlich 1 px groß und weggeschnitten -- sie als „zu klein" zu
|
||
melden wäre ein Fehlalarm, der die echten Funde zudeckt. */
|
||
const nurVorlesen = (e) => {
|
||
const st = getComputedStyle(e);
|
||
const r = e.getBoundingClientRect();
|
||
return (r.width <= 2 && r.height <= 2)
|
||
|| st.clipPath === "inset(50%)" || st.clip === "rect(0px, 0px, 0px, 0px)";
|
||
};
|
||
|
||
const alle = [...document.querySelectorAll("body *")];
|
||
const bedienbar = [...document.querySelectorAll(BEDIENBAR)]
|
||
.filter(sichtbar).filter((e) => !nurVorlesen(e));
|
||
|
||
/* ---- Überstand: wer ragt über den rechten Rand? ------------------ */
|
||
const breite = document.documentElement.clientWidth;
|
||
const ueberstand = alle.filter((e) => {
|
||
if (!sichtbar(e)) return false;
|
||
const r = e.getBoundingClientRect();
|
||
/* ZIERRAT DARF HINAUSRAGEN (geschärft 19.09.2026).
|
||
|
||
Der erste Lauf meldete 26 Überstände -- und beim Nachsehen waren
|
||
es fast durchweg Dinge, die ein Mensch weder sieht noch bedient:
|
||
`div.aussen__schein` (ein Lichtschein, `aria-hidden="true"`),
|
||
`span.logo-feld__stueck` (leere Spans, aus denen ein Logo
|
||
zusammengesetzt wird), dekorative SVG. Alle werden von
|
||
`overflow: hidden` sauber weggeschnitten.
|
||
|
||
Ein Überstand ist ein Fehler, wenn er etwas WEGSCHNEIDET, das
|
||
jemand lesen oder antippen wollte. Bei einem Lichtschein ist er
|
||
die Gestaltung. Gemeldet wird deshalb nur noch, was Text trägt
|
||
oder bedienbar ist -- sonst hätte die Liste 26 Einträge, von
|
||
denen keiner stimmt, und die echten gingen darin unter. */
|
||
if (e.closest(".kopf-wasserzeichen")) return false;
|
||
if (e.closest('[aria-hidden="true"]')) return false;
|
||
const hatText = (e.textContent || "").trim().length > 0;
|
||
const bedienbarHier = e.matches(BEDIENBAR) || !!e.closest(BEDIENBAR);
|
||
if (!hatText && !bedienbarHier) return false;
|
||
return r.right > breite + 2;
|
||
}).slice(0, 4).map((e) => `${name(e)} bis ${Math.round(e.getBoundingClientRect().right)}px`);
|
||
|
||
/* ---- Abgeschnittener Text ---------------------------------------- */
|
||
const abgeschnitten = alle.filter((e) => {
|
||
if (!sichtbar(e) || e.children.length > 0) return false;
|
||
/* NUR-FUER-VORLESEPROGRAMME IST KEIN ABGESCHNITTENER TEXT
|
||
(nachgetragen 19.09.2026).
|
||
|
||
Der erste Lauf meldete 192-mal `span.zurueck-knopf__text` und
|
||
154-mal `span.teilen__text` als abgeschnitten. Beide sind
|
||
ABSICHTLICH 1 px gross und weggeschnitten: Auf dem Handy sagt
|
||
der Pfeil allein „zurueck", das Wort daneben kostet 65 px --
|
||
und ein Vorleseprogramm liest es trotzdem.
|
||
|
||
Gemeldet als Fehler waeren das rund 350 Fehlalarme, die jeden
|
||
echten Fund zudecken. Eine Warnung, die immer kommt, ist keine
|
||
Warnung mehr -- diese Lehre steht seit dem 03.09.2026 im Haus,
|
||
und sie gilt fuer meine eigene Pruefung genauso. */
|
||
if (nurVorlesen(e)) return false;
|
||
const t = (e.textContent || "").trim();
|
||
if (t.length < 4) return false;
|
||
const st = getComputedStyle(e);
|
||
if (st.overflow === "visible" && st.textOverflow !== "ellipsis") return false;
|
||
/* `text-overflow: ellipsis` ist eine ABSICHT, keine Panne -- dort
|
||
ist das Kürzen gewollt und die volle Fassung steht im title. */
|
||
if (st.textOverflow === "ellipsis") return false;
|
||
/* `-webkit-line-clamp` IST AUCH ABSICHT (nachgetragen 19.09.2026).
|
||
Eine Beschreibung, die nach zwei Zeilen endet, ist gewollt --
|
||
die volle Fassung steht beim Aufklappen. Ohne diese Zeile meldete
|
||
die Pruefung `.kachel__beschreibung` 24-mal als abgeschnitten. */
|
||
if (st.webkitLineClamp && st.webkitLineClamp !== "none") return false;
|
||
return e.scrollWidth > e.clientWidth + 3 || e.scrollHeight > e.clientHeight + 3;
|
||
}).slice(0, 4).map((e) => `${name(e)} „${(e.textContent || "").trim().slice(0, 28)}…"`);
|
||
|
||
/* ---- Zu kleine Tippziele ----------------------------------------- */
|
||
const klein = bedienbar.filter((e) => {
|
||
const r = e.getBoundingClientRect();
|
||
/* 43,5 STATT 44: Ein Knopf von 43,98 px wird als „44x44"
|
||
angezeigt und waere trotzdem ein Fund -- ein halbes Pixel
|
||
aus einer Zeilenhoehe ist kein Bedienproblem, aber es kostet
|
||
Vertrauen in die Liste. */
|
||
if (r.width >= 43.5 && r.height >= 43.5) return false;
|
||
/* DIE AUSNAHME AUS DEM STANDARD (WCAG 2.5.8): Ein Link mitten in
|
||
einem Satz darf klein sein -- ihn zu vergrößern würde den Satz
|
||
zerreißen. Erkannt daran, dass der Elternteil deutlich mehr Text
|
||
trägt als der Link selbst. */
|
||
const p = e.parentElement;
|
||
const eigen = (e.textContent || "").trim().length;
|
||
const drumherum = ((p?.textContent || "").trim().length) - eigen;
|
||
if (e.tagName === "A" && drumherum > eigen + 20) return false;
|
||
return true;
|
||
}).slice(0, 5).map((e) => {
|
||
const r = e.getBoundingClientRect();
|
||
return `${name(e)} ${Math.round(r.width)}×${Math.round(r.height)}`;
|
||
});
|
||
|
||
/* ---- Überlappende Bedienelemente ---------------------------------
|
||
DER WICHTIGSTE PUNKT. Wer auf A tippt und B bekommt, meldet „der
|
||
Knopf geht nicht" -- und niemand findet etwas, weil beide Knöpfe
|
||
für sich genommen tadellos sind. Genau dieser Fehler ist im Haus
|
||
schon zweimal aufgetreten. */
|
||
const ueberdeckt = [];
|
||
for (let i = 0; i < bedienbar.length && ueberdeckt.length < 4; i++) {
|
||
for (let j = i + 1; j < bedienbar.length; j++) {
|
||
const a = bedienbar[i], b = bedienbar[j];
|
||
if (a.contains(b) || b.contains(a)) continue;
|
||
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
||
const breiteUeber = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
||
const hoeheUeber = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||
if (breiteUeber <= 6 || hoeheUeber <= 6) continue;
|
||
/* Ein Element in einem geöffneten Dialog darf den Rest
|
||
überdecken -- das ist der Zweck eines Dialogs. */
|
||
if (a.closest("dialog[open], .anruf, .anruf-klingelt")
|
||
!== b.closest("dialog[open], .anruf, .anruf-klingelt")) continue;
|
||
ueberdeckt.push(`${name(a)} ⨯ ${name(b)} (${Math.round(breiteUeber)}×${Math.round(hoeheUeber)}px)`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* ---- Bedienelemente ohne lesbaren Namen -------------------------- */
|
||
const namenlos = bedienbar.filter((e) => {
|
||
const t = (e.textContent || "").trim();
|
||
if (t.length > 0) return false;
|
||
if (e.getAttribute("aria-label")) return false;
|
||
if (e.getAttribute("aria-labelledby")) return false;
|
||
if (e.getAttribute("title")) return false;
|
||
/* `labels` GIBT ES AN JEDEM BESCHRIFTBAREN ELEMENT -- auch an
|
||
TEXTAREA (berichtigt 19.09.2026).
|
||
|
||
Vorher standen hier nur INPUT und SELECT. Ergebnis: `#sb-ueber`
|
||
wurde achtmal als „Bedienelement ohne Namen" gemeldet, obwohl
|
||
direkt darüber `<label for="sb-ueber">Ein Satz über dich</label>`
|
||
steht. Wieder ein Fehlalarm aus meiner eigenen Prüfung -- und
|
||
hätte ich ihn geglaubt, hätte ich eine Beschriftung ergänzt, die
|
||
längst da ist.
|
||
|
||
Jetzt wird `labels` gefragt, wo es das gibt, statt eine Liste von
|
||
Elementnamen zu pflegen, die irgendwann eines vergisst. */
|
||
if (e.labels && e.labels.length) return false;
|
||
return true;
|
||
}).slice(0, 4).map(name);
|
||
|
||
return {
|
||
inhalt: (document.querySelector("main")?.textContent || "").trim().length,
|
||
elemente: alle.length,
|
||
bedienbar: bedienbar.length,
|
||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
ueberstand, abgeschnitten, klein, ueberdeckt, namenlos,
|
||
};
|
||
};
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Der Rundgang
|
||
------------------------------------------------------------------- */
|
||
/* DERSELBE PFAD WIE IN DEN UEBRIGEN BROWSER-PRUEFUNGEN. Playwright
|
||
liegt nicht in diesem Repo, sondern einmal zentral daneben -- und
|
||
auf Windows braucht ein absoluter Pfad ein `file://` davor, sonst
|
||
haelt Node das "C:" fuer ein Protokoll. */
|
||
const { chromium } = await import(
|
||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
|
||
const browser = await chromium.launch({
|
||
/* DAMIT DER SERVER SEINEN ECHTEN NAMEN SIEHT. Ohne diese Zeile
|
||
misst die Prüfung gegen 127.0.0.1 -- ein anderes Haus, andere
|
||
Kacheln, eine Adressregel, die alles durchlässt.
|
||
|
||
`--ignore-certificate-errors` gehört dazu: Der Name steht in der
|
||
HSTS-Liste, und dort lässt Chromium sonst kein eigenes Zertifikat
|
||
durch -- auch nicht mit ignoreHTTPSErrors am Kontext. */
|
||
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`,
|
||
"--ignore-certificate-errors"],
|
||
ignoreHTTPSErrors: true,
|
||
});
|
||
|
||
const ROLLEN = [
|
||
["hand", "CODE-HAND-0001", "Rechte Hand"],
|
||
["modi", "CODE-MODI-0001", "Modi"],
|
||
["gast", "CODE-GAST-0001", "Community"],
|
||
["admin", "CODE-DOGI-0001", "DogFather"],
|
||
];
|
||
const BREITEN = [[390, 844, "iPhone"], [412, 915, "Android"]];
|
||
|
||
let fehler = 0, geprueft = 0, elementeGesamt = 0, bedienbarGesamt = 0;
|
||
const probleme = [];
|
||
const melde = (t) => console.log(t);
|
||
|
||
for (const [breite, hoehe, geraet] of BREITEN) {
|
||
console.log(`\n══════════ ${geraet} · ${breite}px ══════════`);
|
||
|
||
for (const [rolle, code, wie] of ROLLEN) {
|
||
const seiten = seitenFuer(rolle);
|
||
const kontext = await browser.newContext({
|
||
viewport: { width: breite, height: hoehe },
|
||
hasTouch: true, isMobile: true, deviceScaleFactor: 3,
|
||
ignoreHTTPSErrors: true,
|
||
});
|
||
const konsole = [];
|
||
const seite = await kontext.newPage();
|
||
seite.on("console", (m) => {
|
||
if (m.type() === "error") konsole.push(m.text().slice(0, 100));
|
||
});
|
||
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 100)));
|
||
|
||
/* ---- Anmelden, wie ein Mensch es tut ---------------------------- */
|
||
let drin = false;
|
||
try {
|
||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded", timeout: 20000 });
|
||
await seite.click(`.rolle[data-rolle="${rolle}"]`, { timeout: 8000 });
|
||
await seite.fill("#code", code);
|
||
/* Die Altersfrage erscheint nur für die Community. */
|
||
const frage = await seite.$("#alter-ok");
|
||
if (frage && await frage.isVisible().catch(() => false)) await frage.check();
|
||
await seite.click("button[type=submit]");
|
||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||
drin = true;
|
||
} catch (f) {
|
||
/* EINE GESCHEITERTE ANMELDUNG IST DER SCHLIMMSTE BEFUND -- danach
|
||
wird gar nichts mehr gemessen. Sie gehoert deshalb SOFORT auf
|
||
den Bildschirm und nicht nur in die Liste am Ende. */
|
||
const wo = seite.url();
|
||
const sichtbar = await seite.evaluate(() => ({
|
||
fehler: document.getElementById("fehler")?.textContent?.trim() || "",
|
||
rollen: [...document.querySelectorAll(".rolle")].map((r) => r.dataset.rolle).join(","),
|
||
})).catch(() => ({ fehler: "(nicht lesbar)", rollen: "?" }));
|
||
melde(` FEHL ${wie.padEnd(12)} ANMELDUNG SCHEITERT`);
|
||
melde(` · ${String(f.message).slice(0, 110)}`);
|
||
melde(` · steht auf: ${wo}`);
|
||
melde(` · Meldung der Seite: "${sichtbar.fehler}"`);
|
||
melde(` · Rollen auf der Wand: ${sichtbar.rollen}`);
|
||
probleme.push(`[${geraet} ${wie}] ANMELDUNG SCHEITERT: ${String(f.message).slice(0, 80)}`);
|
||
fehler++;
|
||
}
|
||
if (!drin) { await kontext.close(); continue; }
|
||
|
||
let schlecht = 0;
|
||
const details = [];
|
||
|
||
for (const pfad of seiten) {
|
||
geprueft++;
|
||
let antwort = null;
|
||
try {
|
||
antwort = await seite.goto(BASIS + pfad,
|
||
{ waitUntil: "domcontentloaded", timeout: 20000 });
|
||
/* Kurz warten: Diese Seiten holen ihren Inhalt nach. Ohne das
|
||
misst man ein leeres Gerüst und meldet es als tadellos. */
|
||
await seite.waitForTimeout(900);
|
||
} catch (f) {
|
||
details.push(`${pfad}: lädt nicht (${String(f.message).slice(0, 50)})`);
|
||
schlecht++; continue;
|
||
}
|
||
|
||
const lage = antwort?.status() ?? 0;
|
||
const wo = new URL(seite.url()).pathname;
|
||
if (lage >= 400) {
|
||
details.push(`${pfad}: Statuscode ${lage}`); schlecht++; continue;
|
||
}
|
||
/* Eine Umleitung auf die Startseite heißt: Die Seite gibt es für
|
||
diese Rolle auf dieser Adresse nicht -- obwohl die Rechtetafel
|
||
sie erlaubt. Das ist eine Sackgasse, kein Erfolg. */
|
||
if (wo !== pfad && wo.endsWith("/start.html") && !pfad.endsWith("/start.html")) {
|
||
details.push(`${pfad}: wird auf start.html umgeleitet (Sackgasse)`);
|
||
schlecht++; continue;
|
||
}
|
||
|
||
const m = await seite.evaluate(messen);
|
||
elementeGesamt += m.elemente; bedienbarGesamt += m.bedienbar;
|
||
|
||
const kurz = pfad.replace("/workspace/", "");
|
||
if (m.inhalt < 40) { details.push(`${kurz}: fast kein Inhalt (${m.inhalt} Zeichen)`); schlecht++; }
|
||
/* GEMESSEN WIRD AM ELEMENT, NICHT AM DOKUMENT (berichtigt 19.09.2026).
|
||
|
||
Vorher entschied `scrollWidth - clientWidth`. Die Gegenprobe hat
|
||
gezeigt, dass das nichts findet: `body { overflow-x: hidden }`
|
||
schneidet den Überhang weg, also sind beide Werte immer gleich.
|
||
Die Prüfung war blind und hätte trotzdem grün gemeldet -- die
|
||
schlimmste Sorte, weil man ihr glaubt.
|
||
|
||
Jetzt zählt, ob ein sichtbares Element über den rechten Rand
|
||
ragt. Das sieht man auch dann, wenn der Balken versteckt ist. */
|
||
if (m.ueberstand.length) {
|
||
details.push(`${kurz}: ragt über den Rand — ${m.ueberstand.join(", ")}`);
|
||
schlecht++;
|
||
}
|
||
if (m.abgeschnitten.length) {
|
||
details.push(`${kurz}: Text abgeschnitten — ${m.abgeschnitten.join(", ")}`); schlecht++;
|
||
}
|
||
if (m.klein.length) {
|
||
details.push(`${kurz}: zu kleine Tippziele — ${m.klein.join(", ")}`); schlecht++;
|
||
}
|
||
if (m.ueberdeckt.length) {
|
||
details.push(`${kurz}: LIEGT ÜBEREINANDER — ${m.ueberdeckt.join(", ")}`); schlecht++;
|
||
}
|
||
if (m.namenlos.length) {
|
||
details.push(`${kurz}: Bedienelement ohne Namen — ${m.namenlos.join(", ")}`); schlecht++;
|
||
}
|
||
}
|
||
|
||
if (konsole.length) {
|
||
details.push(`Konsole: ${[...new Set(konsole)].slice(0, 3).join(" | ")}`);
|
||
schlecht++;
|
||
}
|
||
|
||
const zeichen = schlecht === 0 ? "ok " : "FEHL";
|
||
melde(` ${zeichen} ${wie.padEnd(12)} ${String(seiten.length).padStart(2)} Seiten`
|
||
+ (schlecht ? ` — ${schlecht} Befund${schlecht === 1 ? "" : "e"}` : " — sauber"));
|
||
/* ALLE BEFUNDE, NICHT DIE ERSTEN ZWOELF. Eine Liste, die abschneidet,
|
||
versteckt genau die Fundstellen, die man noch nicht kennt -- und
|
||
man repariert dann dreimal dasselbe, weil das Neue nie
|
||
auftaucht. */
|
||
for (const t of details) melde(` · ${t}`);
|
||
if (schlecht) { fehler += schlecht; probleme.push(...details.map((t) => `[${geraet} ${wie}] ${t}`)); }
|
||
|
||
await kontext.close();
|
||
}
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE GEGENPROBE
|
||
|
||
Eine Prüfung, die immer „alles in Ordnung" sagt, sagt nichts. Hier
|
||
werden ein zu breites Element und ein zu kleiner Knopf eingebaut --
|
||
beide MÜSSEN gefunden werden.
|
||
===================================================================== */
|
||
melde("\n --- Gegenprobe (beide Fehler MÜSSEN gefunden werden) ---");
|
||
{
|
||
const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, ignoreHTTPSErrors: true });
|
||
const seite = await kontext.newPage();
|
||
await seite.goto(BASIS + "/workspace/", { waitUntil: "load" });
|
||
await seite.waitForTimeout(600);
|
||
await seite.evaluate(() => {
|
||
/* MIT TEXT, NICHT LEER (berichtigt 19.09.2026).
|
||
|
||
Der erste Entwurf hängte ein leeres <div> an -- und seit die
|
||
Messung Zierrat ausnimmt (nur Text oder Bedienbares zählt), fiel
|
||
genau dieses <div> durch den Filter. Die Gegenprobe meldete
|
||
„nichts" und bewies damit ihren eigenen Zweck: Sie hat gemerkt,
|
||
dass die Messung nichts mehr findet.
|
||
|
||
Jetzt trägt das Element Text -- also genau das, dessen
|
||
Abschneiden ein echter Fehler wäre. */
|
||
const breit = document.createElement("div");
|
||
breit.textContent = "Absichtlich zu breit, damit die Pruefung es finden muss";
|
||
breit.style.cssText = "width:900px;height:24px;background:#333;white-space:nowrap";
|
||
document.body.append(breit);
|
||
const winzig = document.createElement("button");
|
||
winzig.textContent = "x";
|
||
winzig.style.cssText = "width:18px;height:18px";
|
||
document.body.append(winzig);
|
||
});
|
||
const g = await seite.evaluate(messen);
|
||
const ok1 = g.ueberstand.length > 0;
|
||
const ok2 = g.klein.length > 0;
|
||
melde(` ${ok1 ? "ok " : "FEHL"} ein zu breites Element wird gemeldet (${g.ueberstand.join(", ") || "nichts"})`);
|
||
melde(` ${ok2 ? "ok " : "FEHL"} ein zu kleiner Knopf wird gemeldet (${g.klein.join(", ") || "nichts"})`);
|
||
if (!ok1 || !ok2) { fehler += 1; probleme.push("GEGENPROBE VERSAGT — die Messung findet nichts mehr"); }
|
||
await kontext.close();
|
||
}
|
||
|
||
await browser.close();
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
|
||
melde("");
|
||
melde(` ${geprueft} Seitenaufrufe, ${elementeGesamt} Elemente, ${bedienbarGesamt} Bedienelemente gemessen`);
|
||
melde(` ${fehler} Befund${fehler === 1 ? "" : "e"}`);
|
||
melde(fehler ? " NICHT IN ORDNUNG" : " ALLES IN ORDNUNG");
|
||
process.exit(fehler ? 1 : 0);
|