/* ===================================================================== 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);