diff --git a/server/pruef-push-hinweis.mjs b/server/pruef-push-hinweis.mjs
new file mode 100644
index 00000000..e08a3bc3
--- /dev/null
+++ b/server/pruef-push-hinweis.mjs
@@ -0,0 +1,296 @@
+/* =====================================================================
+ "DU BEKOMMST GERADE NICHTS" -- der Hinweis auf der Startseite.
+
+ WARUM ES IHN GIBT (03.10.2026).
+
+ Am 03.10. am laufenden System nachgemessen: 20 aktive Personen, 8
+ mit einer Push-Anmeldung. Zwoelf bekamen keine einzige
+ Benachrichtigung -- darunter die linke Hand und ein Modi. Keiner
+ von ihnen wusste es: Die Glocke sagt es nur dem, der sie anschaut.
+
+ WAS HIER WIRKLICH AUF DEM SPIEL STEHT, ist nicht "erscheint der
+ Hinweis". Das ist der leichte Teil. Die Gefahr liegt auf der
+ anderen Seite: Ein Hinweis, der auch denen erscheint, die laengst
+ alles angeschaltet haben, oder der nach dem Wegklicken
+ wiederkommt, ist eine Warnung, die immer kommt -- und die liest
+ nach dem zweiten Mal niemand mehr. Dann haette ich die Glocke
+ entwertet, die heute funktioniert.
+
+ Deshalb misst diese Pruefung VIER Abwesenheiten und nur eine
+ Anwesenheit:
+
+ * weg, sobald jemand "Nicht jetzt" tippt
+ * weg geblieben nach dem Neuladen (sonst ist es kein "einmalig")
+ * gar nicht erst da auf einer anderen Seite (er haengt an 39
+ Seiten mit, wenn man es falsch baut)
+ * gar nicht erst da, wenn die Benachrichtigungen AN sind
+
+ Die letzte ist die teuerste -- sie braucht ein echtes Fenster und
+ eine echte Anmeldung -- und sie ist die wichtigste.
+
+ MIT FENSTER, UND ZWAR NOTWENDIG: Ein kopfloser Chromium verweigert
+ Benachrichtigungen, egal wie man die Erlaubnis erteilt (am
+ 02.10.2026 in fuenf Aufbauten gemessen, siehe pruef-push-zu). Ohne
+ erteilte Erlaubnis kommt `lage()` nie auf "an", und die
+ entscheidende Gegenprobe waere nicht messbar.
+
+ DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen.
+ ===================================================================== */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-hinweis-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "pruef-push-hinweis");
+process.env.PORT = `${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(300_000, "pruef-push-hinweis");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }),
+}).catch(() => {});
+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 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 { chromium } = await import("playwright");
+const browser = await chromium.launch({ headless: false });
+
+const abbruch = async (grund) => {
+ console.log(`\n KONNTE NICHT NACHSEHEN: ${grund}`);
+ try { await browser.close(); } catch { /* egal */ }
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(2);
+};
+
+/** Meldet sich an und landet auf der Startseite. */
+async function anmelden(kontext) {
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 20000 });
+ await seite.waitForTimeout(700);
+ return seite;
+}
+
+/** Was steht gerade im Hinweis -- und wie gross ist es? */
+const hinweisLesen = (seite) => seite.evaluate(() => {
+ const k = document.querySelector("#push-hinweis .push-hinweis");
+ if (!k) return { da: false };
+ const t = k.querySelector(".push-hinweis__text");
+ const knoepfe = [...k.querySelectorAll(".push-hinweis__knopf")].map((b) => ({
+ text: b.textContent.trim(),
+ hoch: Math.round(b.getBoundingClientRect().height),
+ breit: Math.round(b.getBoundingClientRect().width),
+ }));
+ const stil = getComputedStyle(t);
+ return {
+ da: true,
+ text: t.textContent.replace(/\s+/g, " ").trim(),
+ schrift: parseFloat(stil.fontSize),
+ farbe: stil.color,
+ knoepfe,
+ };
+});
+
+/* =====================================================================
+ 1. Ohne Anmeldung: der Hinweis steht da
+ ===================================================================== */
+console.log("\n=== Wer nichts bekommt, erfaehrt es ===");
+const kontext = await browser.newContext({
+ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
+});
+const seite = await anmelden(kontext);
+
+{
+ const erlaubnis = await seite.evaluate(() => Notification.permission);
+ if (erlaubnis === "granted") {
+ await abbruch("der Browser hat Benachrichtigungen schon erlaubt -- dann "
+ + "laesst sich der Fall \"noch nicht angeschaltet\" hier nicht messen");
+ }
+ const h = await hinweisLesen(seite);
+ if (!h.da) {
+ await abbruch("auf der Startseite steht gar kein Hinweis -- ohne ihn "
+ + "ist nichts von dem unten Messbares messbar");
+ }
+ ok(/keine Benachrichtigungen/.test(h.text),
+ `er sagt, worum es geht ("${h.text.slice(0, 64)}…")`);
+ ok(h.knoepfe.length === 2, `zwei Knoepfe: ${h.knoepfe.map((b) => b.text).join(" / ")}`);
+ ok(h.knoepfe.some((b) => /Anschalten/.test(b.text)),
+ "einer davon schaltet es an -- der Hinweis ist nicht nur eine Mitteilung");
+
+ /* Lesbarkeit: im Haus gilt keine Schrift unter 11,5 px, und ein
+ Ziel, das man mit dem Daumen trifft, ist mindestens 44 px hoch. */
+ ok(h.schrift >= 11.5, `Schriftgroesse ${h.schrift} px (mindestens 11,5)`);
+ const klein = h.knoepfe.filter((b) => b.hoch < 44);
+ ok(klein.length === 0,
+ klein.length ? `zu flache Knoepfe: ${klein.map((b) => b.text + " " + b.hoch + "px")}`
+ : `beide Knoepfe mindestens 44 px hoch (${h.knoepfe.map((b) => b.hoch).join(", ")})`);
+ /* Auf 390 px darf nichts ueber den Rand stehen. */
+ const ueber = await seite.evaluate(() => {
+ const k = document.querySelector("#push-hinweis .push-hinweis");
+ return Math.round(k.getBoundingClientRect().right - document.documentElement.clientWidth);
+ });
+ ok(ueber <= 0, `nichts steht ueber (${ueber} px)`);
+}
+
+/* =====================================================================
+ 2. Auf anderen Seiten NICHT -- er haengt an 39 Seiten mit
+ ===================================================================== */
+console.log("\n=== Nur auf der Startseite ===");
+{
+ await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(600);
+ const h = await hinweisLesen(seite);
+ ok(!h.da, "auf der Aufgabenseite steht er nicht");
+ /* Gegenprobe zur Gegenprobe: Die Glocke ist dort sehr wohl da --
+ sonst wuerde oben nur gemessen, dass glocke.js gar nicht laeuft. */
+ const glocke = await seite.evaluate(() => !!document.getElementById("glocke"));
+ ok(glocke, "die Glocke selbst schon -- glocke.js laeuft dort also");
+ await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(600);
+ ok((await hinweisLesen(seite)).da, "und auf der Startseite wieder");
+}
+
+/* =====================================================================
+ 3. "Nicht jetzt" heisst nicht jetzt und auch nicht morgen
+ ===================================================================== */
+console.log("\n=== Einmal weggetippt, bleibt weg ===");
+{
+ await seite.click('.push-hinweis__knopf--spaeter');
+ await seite.waitForTimeout(300);
+ ok(!(await hinweisLesen(seite)).da, "nach dem Tippen ist er weg");
+
+ await seite.reload({ waitUntil: "networkidle" });
+ await seite.waitForTimeout(800);
+ ok(!(await hinweisLesen(seite)).da,
+ "und nach dem Neuladen immer noch -- sonst waere es keine einmalige Sache");
+}
+
+/* =====================================================================
+ 4. DIE WICHTIGSTE: Wer sie AN hat, wird nicht behelligt
+ ===================================================================== */
+console.log("\n=== Wer sie an hat, sieht nichts davon ===");
+{
+ /* ==== EIN ECHTES PROFIL, KEIN INKOGNITO-FENSTER =================
+
+ Erst gemessen, dann verstanden (03.10.2026): `browser.newContext()`
+ gibt ein Inkognito-Fenster, und dort meldete die Glocke hartnaeckig
+ "aus". Der Browser hat es im Klartext gesagt, sobald die Pruefung
+ ihn gefragt hat:
+
+ "Chrome currently does not support the Push API in incognito
+ mode. There is deliberately no way to feature-detect this,
+ since incognito mode needs to be undetectable by websites."
+
+ Also: kein Befund am Haus, sondern eine Eigenschaft des Fensters
+ -- und einer, den man NICHT abfragen kann. Ohne das Mitschreiben
+ der Browsermeldungen haette hier eine Stunde Suche im eigenen
+ Code gestanden.
+
+ `launchPersistentContext` mit einem eigenen Ordner ist ein
+ richtiges Profil. Der Ordner wird am Ende weggeraeumt. */
+ const profil = mkdtempSync(join(tmpdir(), "ws-hinweis-profil-"));
+ const k2 = await chromium.launchPersistentContext(profil, {
+ headless: false,
+ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
+ permissions: ["notifications"],
+ });
+ await k2.grantPermissions(["notifications"], { origin: BASIS });
+ const s2 = await anmelden(k2);
+
+ const erlaubnis = await s2.evaluate(() => Notification.permission);
+ if (erlaubnis !== "granted") {
+ await abbruch("der Browser erlaubt keine Benachrichtigungen "
+ + `(Notification.permission = "${erlaubnis}") -- ein kopfloser Chromium `
+ + "sagt immer „denied“. Diese Pruefung braucht ein Fenster.");
+ }
+
+ /* Vor dem Anschalten muss er da sein -- sonst beweist sein Fehlen
+ danach gar nichts (er koennte aus einem ganz anderen Grund
+ fehlen, etwa weil der neue Kontext nie fertig geladen hat). */
+ ok((await hinweisLesen(s2)).da,
+ "im frischen Browser steht er zuerst da (sonst beweist sein Fehlen nichts)");
+
+ /* Mitschreiben, was der Browser sagt -- scheitert das Anmelden,
+ steht der Grund hier und nicht in einer Vermutung. */
+ const klagen = [];
+ s2.on("console", (m) => { if (m.type() === "error") klagen.push(m.text()); });
+ s2.on("pageerror", (f) => klagen.push(String(f?.message || f)));
+
+ await s2.click(".push-hinweis__knopf--ja");
+
+ /* AUF DEN ZUSTAND WARTEN, NICHT AUF DIE UHR. Ein fester Schlaf ist
+ entweder zu kurz (dann ist es ein Fehlalarm) oder zu lang (dann
+ wartet jeder Lauf umsonst). `pushManager.subscribe` spricht mit
+ einem echten Push-Dienst im Netz -- wie lange das dauert, weiss
+ nur er. */
+ let lage = null;
+ for (let i = 0; i < 60; i++) {
+ lage = await s2.evaluate(() => document.getElementById("glocke")?.dataset.lage);
+ if (lage === "an") break;
+ await s2.waitForTimeout(500);
+ }
+ if (lage !== "an") {
+ await abbruch(`das Anschalten hat nicht geklappt (Glocke steht auf "${lage}") `
+ + "-- ohne eine echte Anmeldung ist die Gegenprobe nicht messbar."
+ + (klagen.length ? `\n Der Browser sagt dazu: ${klagen.slice(0, 3).join(" | ")}`
+ : "\n Der Browser hat sich dazu nicht geaeussert. Moeglich ist, dass "
+ + "pushManager.subscribe() keinen echten Push-Dienst erreicht "
+ + "(dafuer braucht dieser Rechner eine Verbindung ins Netz)."));
+ }
+ ok(lage === "an", "nach dem Antippen steht die Glocke auf „an“");
+ ok(!(await hinweisLesen(s2)).da,
+ "und der Hinweis ist weg -- ohne dass jemand ihn wegklicken musste");
+
+ await s2.reload({ waitUntil: "networkidle" });
+ await s2.waitForTimeout(1200);
+ ok(!(await hinweisLesen(s2)).da, "auch nach dem Neuladen bleibt er weg");
+
+ /* Und das Angemeldetsein ist echt: Es steht in der Datenbank. */
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const n = d.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
+ d.close();
+ ok(n === 1, `die Anmeldung steht wirklich in der Datenbank (${n})`);
+ await k2.close();
+ try { rmSync(profil, { recursive: true, force: true }); } catch { /* egal */ }
+}
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 36dc6236..7c498df1 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`. -->
-
-
+
+
-
+