diff --git a/server/pruef-dialog-sichtbar.mjs b/server/pruef-dialog-sichtbar.mjs
new file mode 100644
index 00000000..4b60546f
--- /dev/null
+++ b/server/pruef-dialog-sichtbar.mjs
@@ -0,0 +1,350 @@
+/* =====================================================================
+ 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);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index f50d832c..be00c96e 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+