diff --git a/server/pruef-installieren.mjs b/server/pruef-installieren.mjs
new file mode 100644
index 00000000..145d4378
--- /dev/null
+++ b/server/pruef-installieren.mjs
@@ -0,0 +1,206 @@
+/* =====================================================================
+ pruef-installieren.mjs — Die Seite als App auf dem Gerät
+
+ Filipe, 20.09.2026: „kannst du da dazwischen in der starseite oben,
+ ein button reinsetzen damit die leute die seite installieren koennen
+ auf dem handy oder pc. das soll auch verschwinden wenn die leute es
+ installiert haben."
+
+ ---------------------------------------------------------------------
+ WARUM DAS MEHR IST ALS EIN KNOPF
+
+ Ohne installierte App gibt es auf dem Handy keine verlässlichen
+ Benachrichtigungen — daran hing im September das Telefonieren. Das
+ Manifest war die ganze Zeit vollständig, die App ließ sich
+ installieren. Nur bot das ausschließlich der Browser an, versteckt
+ in seinem Dreipunktemenü.
+
+ DREI ZUSTÄNDE, DREI ANTWORTEN — und jede wird hier geprüft:
+
+ 1. schon installiert -> gar kein Knopf
+ 2. Browser bietet an -> Knopf fragt ihn
+ 3. Safari/iPhone -> Knopf erklärt den Weg (dort gibt es
+ `beforeinstallprompt` nicht und wird es
+ nicht geben)
+
+ Der gefährlichste Fall ist der dritte: Ein Knopf, der auf dem iPhone
+ nichts tut, ist schlimmer als keiner — man drückt ihn und sucht den
+ Fehler bei sich.
+
+ AUFRUF: node server/pruef-installieren.mjs
+ ===================================================================== */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { portMussFreiSein } from "./helfer-port.mjs";
+import { notbremse } from "./helfer-notbremse.mjs";
+
+const PORT = await portMussFreiSein(4435, "pruef-installieren");
+const ordner = mkdtempSync(join(tmpdir(), "ws-install-"));
+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(220_000, "pruef-installieren");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+console.log("\n=== Die Seite als App installieren ===\n");
+
+/* ---- 1. Das Manifest, ohne Browser -------------------------------- */
+{
+ const { readFileSync } = await import("node:fs");
+ const { fileURLToPath } = await import("node:url");
+ const { dirname } = await import("node:path");
+ const HIER = dirname(fileURLToPath(import.meta.url));
+ const m = JSON.parse(readFileSync(join(HIER, "..", "workspace", "crew.webmanifest"), "utf8"));
+ /* Ohne diese vier Angaben bietet kein Browser das Installieren an --
+ der schoenste Knopf haette dann nichts zu fragen. */
+ ok(!!m.name && !!m.short_name, `Name und Kurzname stehen (${m.short_name})`);
+ ok(m.display === "standalone", `display ist "${m.display}"`);
+ ok(!!m.start_url && m.start_url.startsWith("/workspace/"),
+ `Startadresse zeigt in den Workspace (${m.start_url})`);
+ const gross = (m.icons || []).filter((i) => /512|384/.test(i.sizes || ""));
+ ok(gross.length >= 1, `mindestens ein grosses Symbol (${gross.length})`);
+}
+
+/* ---- 2. Am echten Bildschirm -------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const salt = randomBytes(16).toString("hex");
+const hash = scryptSync("CODE-DOGI-0001", 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("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
+d.close();
+
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+try {
+ async function seiteAuf(extra = {}) {
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 }, ...extra });
+ const s = await ctx.newPage();
+ await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await s.click('.rolle[data-rolle="admin"]');
+ await s.fill("#code", "CODE-DOGI-0001");
+ await s.click("button[type=submit]");
+ await s.waitForURL("**/start.html", { timeout: 25000 });
+ await s.waitForTimeout(1300);
+ return { ctx, s };
+ }
+
+ /* ---- Zustand 2: der Browser bietet es an ----------------------- */
+ const a = await seiteAuf();
+ /* Chromium loest `beforeinstallprompt` in dieser Umgebung nicht von
+ selbst aus (kein echtes Profil, kein Nutzerverhalten). Das
+ Ereignis wird deshalb nachgestellt -- geprueft wird, was DIE SEITE
+ damit macht, und genau das ist hier die Frage. */
+ const vorher = await a.s.locator(".install-knopf").count();
+ const sichtbarVorher = await a.s.evaluate(() =>
+ !!document.querySelector(".install-knopf:not([hidden])"));
+ ok(vorher === 1, `der Knopf steht im Dokument (${vorher})`);
+ ok(sichtbarVorher === false,
+ "bleibt aber versteckt, solange der Browser nichts anbietet — kein Knopf ohne Wirkung");
+
+ await a.s.evaluate(() => {
+ const e = new Event("beforeinstallprompt");
+ e.prompt = () => {};
+ e.userChoice = Promise.resolve({ outcome: "accepted" });
+ window.dispatchEvent(e);
+ });
+ await a.s.waitForTimeout(300);
+ const nachher = await a.s.evaluate(() => {
+ const k = document.querySelector(".install-knopf");
+ if (!k) return null;
+ const r = k.getBoundingClientRect();
+ return { sichtbar: !k.hidden, text: k.textContent.trim(),
+ satz: k.getAttribute("aria-label"),
+ breit: Math.round(r.width), hoch: Math.round(r.height) };
+ });
+ ok(nachher?.sichtbar === true, `sobald der Browser anbietet, steht er da ("${nachher?.text}")`);
+ ok((nachher?.hoch || 0) >= 44, ` und ist am Finger treffbar (${nachher?.hoch} px)`);
+ ok(/installieren/i.test(nachher?.satz || ""),
+ ` mit einem ganzen Satz fuer Vorleseprogramme ("${nachher?.satz}")`);
+
+ /* Und ein Druck macht ihn fertig. */
+ await a.s.click(".install-knopf");
+ await a.s.waitForTimeout(500);
+ const weg = await a.s.locator(".install-knopf").count();
+ ok(weg === 0, `nach dem Installieren ist er weg (${weg})`);
+
+ /* ---- Zustand 1: schon installiert ------------------------------ */
+ const b = await browser.newContext({ viewport: { width: 1280, height: 860 } });
+ const bs = await b.newPage();
+ /* `display-mode: standalone` laesst sich nicht direkt setzen --
+ `matchMedia` wird deshalb vor dem Laden ueberschrieben. Damit
+ antwortet die Seite genau so, wie sie es in einer installierten
+ App taete. */
+ await bs.addInitScript(() => {
+ const echt = window.matchMedia.bind(window);
+ window.matchMedia = (q) => (q.includes("display-mode: standalone")
+ ? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }
+ : echt(q));
+ });
+ await bs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await bs.click('.rolle[data-rolle="admin"]');
+ await bs.fill("#code", "CODE-DOGI-0001");
+ await bs.click("button[type=submit]");
+ await bs.waitForURL("**/start.html", { timeout: 25000 });
+ await bs.waitForTimeout(1300);
+ const inApp = await bs.locator(".install-knopf").count();
+ ok(inApp === 0,
+ `Gegenprobe: in der installierten App gibt es den Knopf gar nicht erst (${inApp})`);
+
+ /* ---- Zustand 3: Safari auf dem iPhone -------------------------- */
+ const c = await browser.newContext({
+ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
+ + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ });
+ const cs = await c.newPage();
+ await cs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await cs.click('.rolle[data-rolle="admin"]');
+ await cs.fill("#code", "CODE-DOGI-0001");
+ await cs.click("button[type=submit]");
+ await cs.waitForURL("**/start.html", { timeout: 25000 });
+ await cs.waitForTimeout(1300);
+ const beiApple = await cs.evaluate(() => {
+ const k = document.querySelector(".install-knopf");
+ return k ? { sichtbar: !k.hidden, breit: Math.round(k.getBoundingClientRect().width) } : null;
+ });
+ /* DER WICHTIGSTE FALL: Dort gibt es `beforeinstallprompt` nicht --
+ der Knopf muss trotzdem dastehen und erklaeren. */
+ ok(beiApple?.sichtbar === true,
+ "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)");
+ ok((beiApple?.breit || 0) >= 44,
+ ` und ist auch dort treffbar (${beiApple?.breit} px breit)`);
+
+ await cs.click(".install-knopf");
+ await cs.waitForTimeout(700);
+ const erklaerung = await cs.evaluate(() => {
+ const d2 = document.querySelector("dialog.nachfrage[open]");
+ return d2 ? d2.textContent.replace(/\s+/g, " ").trim() : null;
+ });
+ ok(/Teilen|Home-Bildschirm/i.test(erklaerung || ""),
+ ` und erklaert den Weg statt es zu versuchen ("${(erklaerung || "").slice(0, 70)}…")`);
+
+ await a.ctx.close(); await b.close(); await c.close();
+} finally {
+ await browser.close();
+}
+
+console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 95e1a868..dd4df60a 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+