Files
dogfather-universe/server/pruef-dialog-sichtbar.mjs
T
DogFatherGitandClaude Opus 5 4e67f8a6c2 Der schwarze Bildschirm: der Dialog stand ausserhalb des Fensters
Patrick ueber den Support: „Sophie kann Abschnitt 3 und 4 nicht
bestaetigen, beim druecken kommt ein schwarzer Bildschirm."

DER DIALOG WAR DIE GANZE ZEIT DA -- nur nicht im Bild. Nachgebaut auf
einer KOPIE der echten Datenbank, mit einem echten Browser, und dann
gemessen statt vermutet:

    position         absolute    statt fixed
    oben im Fenster   -143 px    also oberhalb des Sichtbaren

`module.css` setzte `.dialog { position: relative }`. Damit war die
Zentrierung des Browsers ueberschrieben: Ein Dialog aus `showModal()`
gehoert mit `position: fixed` in die Mitte des SICHTBAREN FENSTERS,
mit `relative` landet er im Dokumentfluss nahe dem SEITENANFANG. Wer
nach unten gescrollt hatte, sah nur noch den abdunkelnden Schleier --
einen schwarzen Bildschirm.

WARUM AUSGERECHNET „ABSCHNITT 3 UND 4"

Gar nicht wegen dieser beiden. Sophie hatte 1 und 2 schon bestaetigt,
dort gibt es keinen Knopf mehr -- 3 und 4 waren die einzigen, die sie
ueberhaupt noch druecken konnte, und sie stehen am weitesten unten.
Der Fehler hing nie an den Abschnitten, sondern an der Scrollhoehe.
Haette ich die Meldung woertlich genommen und in den Unterweisungen
gesucht, haette ich an der falschen Stelle gegraben.

ES BETRAF JEDEN DIALOG IM HAUS

22 Aufrufe von `showModal()` in 10 Dateien -- vom Loeschen einer Datei
ueber das Bearbeiten einer Aufgabe bis zum Eintragen eines
Monatsziels. Auf kurzen Seiten fiel es nie auf, weil dort niemand
scrollt. Keine einzige Pruefung im Haus hatte je einen Dialog
geoeffnet, NACHDEM sie gescrollt hat.

`relative` stand dort fuer die beiden Pseudo-Elemente (Leuchtschiene
und Lichtsaum) -- die brauchen einen positionierten Vorfahren, und
`fixed` ist ebenfalls einer. `inset: 0` und `margin: auto` schreiben
die Zentrierung jetzt ausdruecklich hin, statt sich auf eine Vorgabe
zu verlassen, die diese Datei selbst ueberschreibt.

WAS ICH FAST KAPUTT GEMACHT HAETTE

Mein erster Entwurf setzte zusaetzlich `max-height` und
`overflow: auto` auf den Dialog. Das waere ein Rueckschritt gewesen:
Das Rollen ist eine Ebene tiefer laengst geloest (`.dialog > form`),
gemessen am 25.09.2026 ueber vier Bildschirmgroessen, nachdem Miss
gemeldet hatte, dass sie aus einem Fenster nicht mehr herauskam. Dort
haengen auch die festen Kopf- und Fusszeilen -- und `position: sticky`
gilt immer zum naechsten rollenden Vorfahren. Ein zweiter Rollbereich
darueber haette genau die wieder geloest. Wieder entfernt;
`mess-dialog-ausgang` meldet unveraendert auf allen vier Groessen
„Kommt man heraus? JA".

NEU: pruef-dialog-sichtbar.mjs (18 Pruefungen, 0 Fehler)

Drei Ebenen, und die unterste allein waere zu wenig:

  * Im Quelltext: keine Regel darf `.dialog` wieder aus dem Fenster
    nehmen -- in ALLEN Stilvorlagen, nicht nur in module.css.
  * Am echten Bildschirm: zwei verschiedene Dialoge auf zwei
    verschiedenen Seiten, je auf Computer und Handy, jedes Mal ganz
    nach unten gescrollt (663 bis 1216 px).
  * Die Gegenprobe: Ein aus dem Fenster geschobener Dialog MUSS
    auffallen. Ohne sie waere „er steht im Bild" womoeglich eine
    Zeile, die immer wahr ist.

DREI MEINER EIGENEN MESSUNGEN WAREN ZUERST FALSCH, nicht der Code:
Die Textsuche fand Kommentare und `.dialog > *` (die Kinder DUERFEN
relativ sein); die Schulungstabellen entstehen erst beim ersten
Zugriff; und die Auswahl fuer den zweiten Dialog war seit dem
Schnell-Eintrag veraltet -- der erste Knopf oeffnet dort gar keinen
Dialog mehr. Alle drei berichtigt, bevor sie als Befund durchgingen.

NICHT VON MIR, aber beim Nachmessen aufgefallen: pruef-css-klassen
meldet 44 statt 42 Schriftgroessen unter 11,5 px (uebersicht.css,
wissen.css). Meine Aenderung fasst keine einzige Schriftgroesse an --
module.css hat vorher wie nachher denselben Wert. Der Befund ist
aelter und gehoert nicht hierher.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 19:32:53 +02:00

351 lines
16 KiB
JavaScript

/* =====================================================================
EIN DIALOG MUSS IM BILD STEHEN (05.10.2026)
=====================================================================
Patrick über den Support: „Sophie kann Abschnitt 3 und 4 nicht
bestätigen, beim drücken kommt ein schwarzer Bildschirm."
Der Dialog war die ganze Zeit da — nur nicht zu sehen. `module.css`
setzte `.dialog { position: relative }`, und damit war die
Zentrierung des Browsers überschrieben: Ein Dialog aus
`showModal()` gehört mit `position: fixed` in die Mitte des
SICHTBAREN FENSTERS, mit `relative` landet er im Dokumentfluss nahe
dem SEITENANFANG. Wer nach unten gescrollt hatte, sah nur noch den
abdunkelnden Schleier.
---------------------------------------------------------------------
WARUM ES NIEMANDEM AUFGEFALLEN IST
Auf einer kurzen Seite scrollt niemand, und dann stimmt es zufällig.
Es traf 22 Aufrufe von `showModal()` in 10 Dateien — vom Löschen
einer Datei bis zum Eintragen eines Monatsziels. Keine einzige
Prüfung im Haus hat je einen Dialog geöffnet, NACHDEM sie gescrollt
hat. Genau das tut diese hier.
---------------------------------------------------------------------
DREI EBENEN, UND DIE UNTERSTE ALLEIN WÄRE ZU WENIG
1. Im Quelltext: Keine Regel darf `.dialog` wieder aus dem Fenster
nehmen. Das findet den Rückfall sofort und ohne Browser.
2. Am echten Bildschirm: zwei verschiedene Dialoge auf zwei
verschiedenen Seiten, jeweils ganz nach unten gescrollt.
3. Die Gegenprobe: Mit wieder eingesetztem `position: relative`
MUSS die Messung anschlagen. Ohne sie wüsste niemand, ob hier
überhaupt etwas gemessen wird — „ist im Bild" ist auf einer
ungescrollten Seite immer wahr.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request as httpAnfrage } from "node:http";
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Dialogpruefung");
const HIER = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "ws-dlg-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
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";
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");
notbremse(240_000, "pruef-dialog-sichtbar");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function braucht(bedingung, was) {
if (bedingung) return;
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
/* =====================================================================
1 · IM QUELLTEXT
=====================================================================
Billig, schnell und ohne Browser -- und findet den Rueckfall, bevor
jemand ihn sieht. Gesucht wird in ALLEN Stilvorlagen, nicht nur in
module.css: Die naechste Regel koennte woanders stehen.
===================================================================== */
melde("\n=== Im Quelltext ===");
const CSS_ORDNER = join(HIER, "..", "workspace", "assets", "css");
const dateien = readdirSync(CSS_ORDNER).filter((f) => f.endsWith(".css"));
braucht(dateien.length > 5, `nur ${dateien.length} Stilvorlagen gefunden`);
const schlecht = [];
let regeln = 0;
/** Trifft diese Auswahl den Dialog SELBST?
*
* Beim ersten Lauf hat die Suche drei Fehlalarme gemeldet, und alle
* drei waren meine Schuld, nicht die des Hauses:
*
* * ein KOMMENTAR, in dem das Wort .dialog vorkam
* * `.dialog > *` -- das sind die KINDER, die duerfen relativ sein
* * `.dialog__titel` -- ein ganz anderes Element
*
* Eine Suche, die bei jedem Lauf meckert, wird nach dem zweiten Mal
* weggeklickt, zusammen mit dem echten Befund darin. Deshalb zaehlt
* nur, was wirklich auf dem Dialog endet. */
const trifftDialog = (auswahl) => auswahl.split(",").some((teil) => {
const t = teil.trim();
if (!t) return false;
/* Das letzte Glied der Auswahl muss der Dialog sein -- nach einem
Leerzeichen oder `>` kommen die Kinder. */
const letztes = t.split(/[\s>+~]+/).filter(Boolean).pop() || "";
if (!/(^|\.)dialog(?![\w-])/.test(letztes)) return false;
return !/::/.test(letztes); // ::backdrop und Co. duerfen
});
for (const name of dateien) {
/* KOMMENTARE ZUERST WEG. Dieses Haus schreibt viel in Kommentare --
darunter das Wort `.dialog`. Ohne diesen Schritt misst die
Pruefung Prosa. */
const text = readFileSync(join(CSS_ORDNER, name), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const bloecke = text.matchAll(/([^{}]*)\{([^{}]*)\}/g);
for (const b of bloecke) {
const auswahl = b[1].trim();
if (!trifftDialog(auswahl)) continue;
const stellung = b[2].match(/(^|[;\s])position\s*:\s*([a-z-]+)/);
if (!stellung) continue;
regeln++;
if (/^(relative|static|absolute)$/.test(stellung[2])) {
schlecht.push(`${name}: ${auswahl.slice(0, 48)} → position: ${stellung[2]}`);
}
}
}
ok(regeln > 0, `${regeln} Regel(n) setzen die Stellung eines Dialogs — es gibt etwas zu prüfen`);
ok(schlecht.length === 0, schlecht.length
? `${schlecht.length} Regel(n) nehmen den Dialog aus dem Fenster: ${schlecht.join(" · ")}`
: "keine Regel nimmt den Dialog aus dem Fenster");
/* GEGENPROBE fuer die Textsuche: Sie muss so eine Regel auch WIRKLICH
finden. Eine Suche, die nie etwas findet, meldet immer „in Ordnung". */
ok(trifftDialog(".dialog") && trifftDialog(".haus .dialog") && trifftDialog("a, .dialog"),
"Gegenprobe: der Dialog selbst wird erkannt");
ok(!trifftDialog(".dialog > *") && !trifftDialog(".dialog__titel")
&& !trifftDialog(".dialog .mz-feld"),
"Gegenprobe: Kinder und gleichnamige Nachbarn werden NICHT erkannt");
/* =====================================================================
2 · AM ECHTEN BILDSCHIRM
===================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
const jetzt = new Date().toISOString();
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, jetzt);
const CODES = {};
function anlegen(name, rolle, code) {
const salz = randomBytes(16).toString("hex");
const h = scryptSync(code, salz, 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,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, h, salz, 32768,
createHmac("sha256", kenn).update(code).digest("hex"), "2025-10-01T08:00:00Z");
CODES[name] = code;
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idCreator = anlegen("Creatorin", "creator", "CODE-DLG-CREA-1");
anlegen("Kevin", "scout", "CODE-DLG-SCOUT-1");
/* DIE TABELLEN ENTSTEHEN ERST BEIM ERSTEN ZUGRIFF
(`tabelle()` in workspace-schulung.js). Ohne diesen Aufruf gibt es
`schulungen` noch gar nicht -- beim ersten Lauf stand hier „no such
table". Das ist kein Fehler im Haus, sondern die Reihenfolge. */
d.close();
await anfrageVorab();
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
/* Vier Unterweisungen -- genau Sophies Lage: die ersten beiden
abgeschlossen, die unteren beiden offen. Dadurch stehen die
Knoepfe weit unten, und genau darum ging es. */
for (let i = 1; i <= 4; i++) {
d2.prepare(`INSERT INTO schulungen (titel, beschreibung, pflicht, aktiv, erstellt)
VALUES (?,?,1,1,?)`).run(`Abschnitt ${i}`, `Regeln, Teil ${i}`, jetzt);
}
for (const sid of [1, 2]) {
d2.prepare(`INSERT INTO schulung_bestaetigt
(schulung_id, creator_id, betreuer_id, creator_am, betreuer_am,
abgeschlossen, abgeschlossen_am) VALUES (?,?,1,?,?,1,?)`)
.run(sid, idCreator, jetzt, jetzt, jetzt);
}
d2.close();
/* Ein Aufruf, der die Schulungstabellen anlegt -- angemeldet, denn
die Schnittstelle will eine Sitzung sehen. */
async function anfrageVorab() {
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "creator", code: "CODE-DLG-CREA-1" });
braucht(an.code === 200, `Vorab-Anmeldung ergab ${an.code}`);
const k = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
await new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/schulung", method: "GET",
headers: { Cookie: k, Origin: `http://127.0.0.1:${PORT}` },
}, (r) => { r.on("data", () => {}); r.on("end", fertig); });
a.on("error", schief);
a.end();
});
}
/* Als FUNKTION und nicht als `const`: Sie wird von `anfrageVorab`
weiter oben gebraucht, und eine `const` ist dort noch nicht da
(ReferenceError beim ersten Lauf). Eine Funktion wird vorgezogen. */
function anfrage(pfad, art, koerper) { return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
}); }
async function keksFuer(rolle, code) {
const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
braucht(an.code === 200, `Anmeldung ${rolle} ergab ${an.code}`);
return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
}
const keks = {
creator: await keksFuer("creator", "CODE-DLG-CREA-1"),
scout: await keksFuer("scout", "CODE-DLG-SCOUT-1"),
};
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
/** Oeffnet einen Dialog NACH dem Scrollen und sagt, wo er landet.
* `kaputtMachen` setzt die alte, fehlerhafte Regel wieder ein --
* damit laesst sich beweisen, dass die Messung auch Nein sagen kann. */
async function dialogMessen(wer, pfad, knopfWahl, breite, hoehe, kaputtMachen = false) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
});
await kontext.addCookies(keks[wer].split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`http://127.0.0.1:${PORT}${pfad}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
if (kaputtMachen) {
/* WAS DIESE GEGENPROBE BEWEIST -- und was nicht.
Sie beweist, dass die MESSUNG anschlagen kann: Ein Dialog, der
nicht im sichtbaren Fenster steht, wird als solcher erkannt.
Ohne sie waere „er steht im Bild" womoeglich eine Zeile, die
immer wahr ist.
Sie baut NICHT die historische Regel nach. Das habe ich zuerst
versucht und bin zweimal danebengelandet -- ein Element in der
obersten Ebene rechnet `relative` zu `absolute` um, und die
Reparatur schreibt `inset: 0` und `margin: auto` so
ausdruecklich hin, dass der Dialog selbst mit verstellter
Stellung noch mittig bleibt. Das ist eine gute Nachricht ueber
die Reparatur und eine schlechte ueber jenen Nachbau.
Also wird hier schlicht ein Dialog aus dem Fenster geschoben.
Die Frage lautet: Merkt die Messung es? */
await seite.addStyleTag({
content: ".dialog { position: absolute !important; inset: auto !important;"
+ " top: -9999px !important; margin: 0 !important; }",
});
}
/* GANZ NACH UNTEN -- das ist der Unterschied zu allem, was im Haus
bisher gemessen wurde. */
await seite.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await seite.waitForTimeout(350);
const gescrollt = await seite.evaluate(() => Math.round(window.scrollY));
const knopf = await seite.$(knopfWahl);
if (!knopf) { await kontext.close(); return { fehltKnopf: true, gescrollt }; }
await knopf.click();
await seite.waitForTimeout(700);
const lage = await seite.evaluate(() => {
const dlg = [...document.querySelectorAll("dialog")].find((x) => x.open);
if (!dlg) return null;
const k = dlg.getBoundingClientRect();
return {
oben: Math.round(k.top), unten: Math.round(k.bottom),
hoehe: Math.round(k.height), fenster: window.innerHeight,
stellung: getComputedStyle(dlg).position,
/* GANZ im Bild, nicht „irgendwie angeschnitten". Ein Dialog,
dessen Knopf unter dem Fensterrand liegt, ist so unbrauchbar
wie einer, den man gar nicht sieht. */
ganzImBild: k.top >= -1 && k.bottom <= window.innerHeight + 1,
};
});
await kontext.close();
return { ...(lage || { fehlt: true }), gescrollt };
}
melde("\n=== Am echten Bildschirm ===");
const FAELLE = [
["der gemeldete Fall: Unterweisung bestätigen", "creator",
"/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein"],
/* NICHT `.mz-zeile__fuss .knopf` -- das ist seit dem Schnell-Eintrag
der Ein-Klick-Knopf, der gar keinen Dialog oeffnet. Beim ersten
Lauf stand hier deshalb „es ging kein Dialog auf", und das war
meine veraltete Auswahl, kein Befund. */
["ein zweiter, ganz anderer Dialog: Monatsziel eintragen", "scout",
"/workspace/manager-ziele.html", '.mz-schnell button:text-is("Mehr Angaben")'],
];
const gemessen = [];
for (const [was, wer, pfad, wahl] of FAELLE) {
for (const [wie, b, h] of [["Computer", 1280, 800], ["Handy", 412, 780]]) {
const r = await dialogMessen(wer, pfad, wahl, b, h);
if (r.fehltKnopf) { ok(false, `${was} (${wie}): der Knopf ist gar nicht da`); continue; }
if (r.fehlt) { ok(false, `${was} (${wie}): es ging kein Dialog auf`); continue; }
gemessen.push(r);
ok(r.gescrollt > 0,
`${was} (${wie}): die Seite liess sich überhaupt scrollen (${r.gescrollt} px)`);
ok(r.stellung === "fixed",
`${was} (${wie}): der Dialog hängt am Fenster (${r.stellung})`);
ok(r.ganzImBild,
`${was} (${wie}): er steht GANZ im Bild (oben ${r.oben}, unten ${r.unten} von ${r.fenster})`);
}
}
braucht(gemessen.length === 4, `nur ${gemessen.length} von 4 Fällen gemessen`);
/* =====================================================================
3 · DIE GEGENPROBE
=====================================================================
Mit der alten Regel MUSS dieselbe Messung anschlagen. Sonst hiesse
„er steht im Bild" moeglicherweise nur „diese Zeile ist immer wahr".
===================================================================== */
melde("\n=== Gegenprobe: mit der alten Regel muss es auffallen ===");
const kaputt = await dialogMessen("creator",
"/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein", 1280, 800, true);
/* NICHT `=== "relative"`: Ein Element in der obersten Ebene rechnet
`relative` zu `absolute` um -- gemessen, nicht nachgelesen. Wichtig
ist nur, dass es nicht mehr am Fenster haengt. */
ok(kaputt.stellung !== "fixed",
`die alte Regel ist wieder eingesetzt (${kaputt.stellung} statt fixed)`);
ok(kaputt.ganzImBild === false,
`und die Messung schlägt an: oben ${kaputt.oben}, Fenster ${kaputt.fenster}`
+ " — ein Dialog ausserhalb des Fensters faellt auf");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);