/* ===================================================================== pruef-handy.mjs — Handy im Browser UND als installierte App WARUM DIESE PRÜFUNG EIGENSTÄNDIG IST Ein enges Fenster auf dem Rechner ist kein Handy. Es hat keine Aussparung für die Kamera, keine Gestenleiste, keine Statusleiste, die über dem Inhalt liegt — und vor allem: keinen installierten Betrieb, in dem die Adressleiste fehlt. Genau dort entstehen die Fehler, die man am Schreibtisch nie sieht. Am 23.08.2026 stand deshalb `env(safe-area-inset-bottom)` im CSS und tat nichts: Ohne `viewport-fit=cover` liefert jedes `env()` null. Der Code sah richtig aus, das Ergebnis war null Abstand. Diese Prüfung fängt genau solche Fälle. WIE DER INSTALLIERTE BETRIEB GEPRUEFT WIRD Nicht durch Emulation -- die gibt es nicht. Beide naheliegenden Wege wurden versucht und sind gescheitert: - Emulation.setEmulatedMedia nimmt das Merkmal display-mode entgegen und antwortet mit {}, wendet es aber nicht an (nachgemessen: matchMedia meldete weiterhin 'browser'). - Ein echtes App-Fenster ueber --app= laesst Playwright nicht zu; der Versuch blieb haengen und musste abgebrochen werden. Geprueft wird deshalb zweiteilig (Einzelheiten bei TEIL 3): erst, ob der Medienblock existiert und woran er haengt, dann, ob sein Inhalt wirkt, wenn man seine Bedingung zur Laufzeit scharf schaltet. Was das NICHT beweist, steht dort ebenfalls -- lieber eine benannte Luecke als eine gruene Zeile, die nichts belegt. WIE DIE AUSSPARUNG GEPRÜFT WIRD Die tatsächlichen Werte von `env(safe-area-inset-*)` kann kein Emulator setzen. Prüfbar ist aber das, worauf es ankommt: dass die Seite die Werte ÜBERHAUPT verwendet und dass ihre Rechnung stimmt. Dafür werden die Variablen auf einen Notch-Wert gesetzt (wie bei einem iPhone 14 Pro: 59px oben, 34px unten) und gemessen, ob die Kopfzeile wirklich nach unten rückt. Bleibt sie stehen, ist der Abstand wirkungslos — und genau das war der Ausgangsfehler. ===================================================================== */ import { chromium, devices } from "playwright"; import { createServer } from "node:http"; import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join, extname } from "node:path"; const W = process.cwd(); const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json", ".webmanifest": "application/manifest+json" }; const srv = createServer((q, r) => { const p = join(W, decodeURIComponent(q.url.split("?")[0])); if (existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" }); return r.end(readFileSync(p)); } r.writeHead(404).end(""); }); await new Promise((r) => srv.listen(4341, r)); const URL_BASIS = "http://127.0.0.1:4341"; const ergebnisse = []; function pruefe(name, ok, zusatz) { ergebnisse.push({ name, ok }); console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : "")); } const SEITEN = readdirSync(join(W, "webdesign")).filter((f) => f.endsWith(".html")); /* Ersatzantworten, damit Portal und Verwaltung überhaupt etwas zeigen. Ohne sie prüfte man eine leere Seite — die hat nie einen Überlauf. */ const UEBERSICHT = { ok: true, beiMir: { anfragen: { n: 3, aeltestes: new Date(Date.now() - 8 * 864e5).toISOString() }, nachrichten: { n: 2, aeltestes: new Date(Date.now() - 3 * 864e5).toISOString() }, wuensche: { n: 1, aeltestes: new Date().toISOString() }, widerrufe: { n: 1, aeltestes: new Date().toISOString() }, projekte: 2 }, beiKunde: { projekte: 1, aufgaben: 4, zahlungen: { n: 2, summe: 120000, aeltestes: new Date(Date.now() - 5 * 864e5).toISOString() }, zahlungenText: "1.200,00 €" }, geld: { monat: { n: 3, brutto: 250000, netto: 241000, bruttoText: "2.500,00 €", nettoText: "2.410,00 €" }, gesamt: { n: 11, bruttoText: "9.800,00 €" }, gebuehrenText: "90,00 €" }, projekte: [{ id: "p1", nummer: "P-2026-001", titel: "Website für Musterbau", status: "bau", wartet_auf: "dogfather", richttermin: "2026-09-15", kunde_name: "Anna Muster", aufg_gesamt: 20, aufg_fertig: 13 }], verlauf: [{ bereich: "projekt", aktion: "Aufgabe erledigt: Startseite gebaut", wer: "dogfather", erstellt_am: new Date().toISOString() }], bestand: { kunden: 5, projekte: 2, anfragenGesamt: 9 }, paypal: { eingerichtet: true, live: true }, }; async function vorbereiten(seite) { await seite.route("**/webdesign/admin/**", (route) => { const u = new URL(route.request().url()).pathname; if (u.endsWith("/admin/uebersicht")) return route.fulfill({ json: UEBERSICHT }); if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 2 } }); return route.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], zahlungen: [], verlaeufe: [], ungelesen: 0 } }); }); await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } })); await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } })); await seite.addInitScript(() => { sessionStorage.setItem("wd-vw-token", "t"); sessionStorage.setItem("wd-sitzung-aktiv", "1"); }); } /* Schaltet die Seite in den installierten Betrieb. NICHT ueber Emulation -- die wirkt nicht (siehe Kopfkommentar). Statt dessen wird die Bedingung des Medienblocks selbst auf 'all' umgeschrieben. Damit gilt genau der Block, der auf einem installierten Geraet gelten wuerde, mit genau seinem Inhalt. Das ist kein Nachbau: Es ist derselbe Block aus derselben Datei. Was dabei NICHT mitgeprueft wird, ist die Frage, ob das Geraet die Bedingung richtig auswertet -- das ist Browser-Standardverhalten. Ohne diesen Kunstgriff waeren die Durchgaenge mit der Beschriftung 'installiert' in Wahrheit ein zweites Mal derselbe Browser-Durchgang gewesen: zwoelf gruene Zeilen, die nichts Eigenes belegen. Aufgefallen am 23.08.2026 beim Nachpruefen der Emulation. */ async function alsAppSchalten(kontext, seite) { await seite.evaluate(() => { for (const blatt of document.styleSheets) { let regeln; try { regeln = blatt.cssRules; } catch (e) { continue; } // fremde Herkunft for (const r of regeln) { if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) { r.media.mediaText = "all"; } } } }); } const browser = await chromium.launch(); /* ===================================================================== TEIL 1 — jede Seite auf jedem Gerät, im Browser und installiert ===================================================================== */ const GERAETE = [ ["iPhone 14 Pro", { ...devices["iPhone 14 Pro"] }], ["Pixel 7", { ...devices["Pixel 7"] }], /* 320px ist das schmalste Gerät, das man realistisch antrifft (iPhone SE der ersten Reihe, und der Modus mit vergrößerter Schrift macht aus jedem Handy faktisch ein schmaleres). Wer hier nicht überläuft, läuft nirgends über. */ ["sehr schmal (320px)", { viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2, userAgent: devices["iPhone 14 Pro"].userAgent }], ]; for (const [geraet, angaben] of GERAETE) { for (const modus of ["Browser", "installiert"]) { console.log(`\n=== ${geraet} · ${modus} ===`); const kontext = await browser.newContext(angaben); let ueberlaufFunde = [], kleineZiele = [], zoomFelder = [], fehlerSeiten = []; for (const datei of SEITEN) { const seite = await kontext.newPage(); const protokoll = []; seite.on("pageerror", (e) => protokoll.push(datei + ": " + e.message)); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/" + datei); /* Erst NACH dem Laden. Playwright setzt beim Navigieren seine eigene Medien-Emulation und ueberschreibt eine vorher gesetzte wieder -- gemessen am 23.08.2026: matchMedia meldete trotz gesetzter Emulation weiterhin false. Medienabfragen werden ohnehin laufend neu ausgewertet, das Nachreichen genuegt. */ if (modus === "installiert") await alsAppSchalten(kontext, seite); await seite.waitForTimeout(500); const befund = await seite.evaluate(() => { const d = document.documentElement; const raus = { ueberlauf: Math.max(0, d.scrollWidth - d.clientWidth), zuKlein: [], zuKlein2: [] }; /* Touch-Ziele. Die WCAG-Vorgabe 2.5.8 nennt 24px als Mindestmaß, Apple und Google nennen 44 bzw. 48. Geprüft wird gegen 44 — mit einer Ausnahme: Elemente, die INNERHALB eines größeren Bedienelements liegen (etwa ein Zähler-Abzeichen in einem Knopf), sind kein eigenes Ziel und werden übersprungen. Ohne diese Ausnahme meldet die Prüfung lauter Scheinfunde. */ for (const el of document.querySelectorAll("a[href], button, input, select, textarea, summary, [role='button']")) { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue; // unsichtbar if (getComputedStyle(el).display === "none") continue; /* Ausserhalb des Bildes. Der Honigtopf im Anfrageformular liegt bei left:-9999px -- absichtlich, denn display:none wuerde von Spam-Skripten erkannt. Er ist fuer Menschen unerreichbar und deshalb kein Bedienelement. */ if (r.right < 0 || r.left > innerWidth) continue; /* Alles unter aria-hidden ist fuer niemanden gedacht. */ if (el.closest('[aria-hidden="true"], [inert]')) continue; /* Teil eines groesseren Bedienelements (Abzeichen in einem Knopf, Symbol in einem Link) -- kein eigenes Ziel. */ const huelle = el.closest("button, a[href], [role='button']"); if (huelle && huelle !== el) continue; /* Ein Eingabefeld in einer Beschriftung: Getippt wird auf die BESCHRIFTUNG, sie ist das Ziel. Ein 1x1-Kaestchen mit einem 48px hohen Label daneben ist voellig in Ordnung. */ const label = el.closest("label"); const gemessen = (label && el.tagName === "INPUT") ? label.getBoundingClientRect() : r; /* WCAG 2.5.8 nimmt Ziele ausdruecklich aus, die INLINE in einem Fliesstext stehen ("inline" exception): Eine E-Mail-Adresse mitten im Satz auf 44px aufzublasen wuerde den Absatz zerreissen und niemandem helfen. Nur eigenstaendige Bedienelemente werden gemessen. */ if (el.tagName === "A" && getComputedStyle(el).display === "inline" && el.closest("p, li, td, dd, blockquote")) continue; if (gemessen.height < 44 || gemessen.width < 24) { raus.zuKlein.push((el.tagName + "." + (el.className || "").toString().slice(0, 26)).slice(0, 44) + " " + Math.round(gemessen.width) + "x" + Math.round(gemessen.height)); } } /* Schrift unter 16px in einem Eingabefeld lässt iOS beim Antippen in die Seite hineinzoomen — danach ist die halbe Oberfläche aus dem Bild, und herauszoomen muss man von Hand. */ for (const el of document.querySelectorAll("input, select, textarea")) { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue; if (r.right < 0 || r.left > innerWidth) continue; // Honigtopf if (el.closest('[aria-hidden="true"], [inert]')) continue; const t = (el.getAttribute("type") || "").toLowerCase(); if (["hidden", "checkbox", "radio", "range", "submit", "button"].includes(t)) continue; const g = parseFloat(getComputedStyle(el).fontSize); if (g < 16) raus.zuKlein2.push((el.id || el.name || el.tagName) + " " + g + "px"); } return raus; }); if (befund.ueberlauf > 1) ueberlaufFunde.push(datei + " (" + befund.ueberlauf + "px)"); if (befund.zuKlein.length) kleineZiele.push(datei + ": " + befund.zuKlein.slice(0, 3).join(", ")); if (befund.zuKlein2.length) zoomFelder.push(datei + ": " + befund.zuKlein2.slice(0, 3).join(", ")); if (protokoll.length) fehlerSeiten.push(protokoll[0]); await seite.close(); } pruefe(`${geraet}/${modus}: keine Seite laeuft seitlich ueber`, ueberlaufFunde.length === 0, ueberlaufFunde.join(" | ")); pruefe(`${geraet}/${modus}: alle Bedienelemente sind gross genug zum Antippen`, kleineZiele.length === 0, kleineZiele.slice(0, 4).join(" | ")); pruefe(`${geraet}/${modus}: kein Eingabefeld loest den iOS-Zoom aus`, zoomFelder.length === 0, zoomFelder.slice(0, 4).join(" | ")); pruefe(`${geraet}/${modus}: kein JavaScript-Fehler auf irgendeiner Seite`, fehlerSeiten.length === 0, fehlerSeiten.slice(0, 2).join(" | ")); await kontext.close(); } } /* ===================================================================== TEIL 2 — die Aussparung: rueckt die Kopfzeile WIRKLICH nach? ===================================================================== */ console.log("\n=== Aussparung (Notch) und Gestenleiste ==="); { const kontext = await browser.newContext(devices["iPhone 14 Pro"]); const seite = await kontext.newPage(); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/index.html"); await seite.waitForTimeout(400); const messung = await seite.evaluate(() => { const kopf = document.querySelector(".wd-kopf"); const bar = document.querySelector(".wd-kopf-bar"); const ohne = bar.getBoundingClientRect().top; /* Die echten env()-Werte kann kein Emulator setzen. Die Seite rechnet aber ueber eigene Variablen -- also werden diese auf die Masse eines iPhone 14 Pro gesetzt. Rueckt nichts nach, ist der Abstand wirkungslos. */ document.documentElement.style.setProperty("--wd-sicher-oben", "59px"); document.documentElement.style.setProperty("--wd-sicher-unten", "34px"); kopf.getBoundingClientRect(); const mit = bar.getBoundingClientRect().top; return { ohne, mit, differenz: mit - ohne }; }); pruefe("die Kopfzeile rueckt unter die Statusleiste, wenn eine da ist", Math.abs(messung.differenz - 59) < 2, "verschoben um " + Math.round(messung.differenz) + "px (erwartet 59)"); const benutzt = await seite.evaluate(() => { const w = getComputedStyle(document.documentElement); return ["--wd-sicher-oben", "--wd-sicher-unten", "--wd-sicher-links", "--wd-sicher-rechts"] .filter((v) => (w.getPropertyValue(v) || "").trim() !== "").length; }); pruefe("alle vier Sicherheitsabstaende sind definiert", benutzt === 4, benutzt + " von 4"); const vf = await seite.evaluate(() => (document.querySelector('meta[name="viewport"]') || {}).content || ""); pruefe("viewport-fit=cover ist gesetzt (sonst liefert env() immer null)", vf.includes("viewport-fit=cover"), vf); await kontext.close(); } /* Und auf JEDER Seite, nicht nur der Startseite — eine vergessene Seite waere im installierten Betrieb genau die eine mit dem Fehler. */ { let ohneCover = []; for (const datei of SEITEN) { const roh = readFileSync(join(W, "webdesign", datei), "utf8"); if (!/viewport-fit\s*=\s*cover/.test(roh)) ohneCover.push(datei); } pruefe("viewport-fit=cover steht auf ALLEN Seiten", ohneCover.length === 0, ohneCover.join(", ") || SEITEN.length + " Seiten geprueft"); } /* ===================================================================== TEIL 3 — was NUR im installierten Betrieb gilt EHRLICHE EINORDNUNG, WAS HIER BEWIESEN WIRD UND WAS NICHT. Der erste Versuch war, den installierten Betrieb zu emulieren. Das geht nicht: `Emulation.setEmulatedMedia` nimmt das Merkmal `display-mode` zwar entgegen und antwortet mit {} -- angewendet wird es nicht (nachgemessen am 23.08.2026: matchMedia meldete weiter `browser`). Ein echtes App-Fenster ueber `--app=` laesst Playwright nicht zu; der Versuch blieb haengen. Statt das zu verschweigen und eine gruene Zeile zu schreiben, die nichts belegt, wird hier ZWEITEILIG geprueft: 1. Gibt es den Block ueberhaupt, und haengt er wirklich an display-mode? (aus dem Stilblatt gelesen) 2. Bewirkt sein INHALT auch das, was er soll? (Bedingung zur Laufzeit auf `all` gestellt und die Wirkung gemessen) Was damit NICHT bewiesen ist: dass ein echtes Android- oder iOS-Geraet den Block auswertet. Das ist Standardverhalten und unstrittig -- aber es ist gemessen, nicht behauptet, wo es geht, und benannt, wo es nicht geht. ===================================================================== */ console.log("\n=== Nur installiert ==="); { const kontext = await browser.newContext(devices["iPhone 14 Pro"]); const seite = await kontext.newPage(); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/index.html"); await seite.waitForTimeout(400); /* --- Teil 1: Steht der Block da, und woran haengt er? --- */ const block = await seite.evaluate(() => { for (const blatt of document.styleSheets) { let regeln; try { regeln = blatt.cssRules; } catch (e) { continue; } for (const r of regeln) { if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) { return { bedingung: r.conditionText || r.media.mediaText, inhalt: Array.from(r.cssRules).map((x) => x.cssText).join(" ") }; } } } return null; }); pruefe("es gibt einen eigenen Zweig fuer den installierten Betrieb", !!block, block ? block.bedingung : "kein Block gefunden"); pruefe("er greift auch bei window-controls-overlay und minimal-ui", !!block && /window-controls-overlay/.test(block.bedingung) && /minimal-ui/.test(block.bedingung), block ? block.bedingung : ""); pruefe("der Installations-Hinweis wird darin ausgeblendet", !!block && /wd-install/.test(block.inhalt) && /display:\s*none/.test(block.inhalt)); /* --- Teil 2: Im Browser darf davon NICHTS gelten --- */ const imBrowser = await seite.evaluate(() => ({ erkannt: matchMedia("(display-mode: standalone)").matches, federn: getComputedStyle(document.body).overscrollBehaviorY, })); pruefe("im Browser gilt der Sonderzweig NICHT", imBrowser.erkannt === false, String(imBrowser.erkannt)); pruefe("im Browser bleibt das gewohnte Nachfedern erhalten", imBrowser.federn !== "none", imBrowser.federn); /* --- Teil 3: Wirkt der Inhalt, wenn die Bedingung erfuellt ist? --- */ const wirkung = await seite.evaluate(() => { /* Die Bedingung der Medienregel selbst umschreiben. Damit wird genau DIESER Block scharf geschaltet -- nicht ein nachgebauter. */ for (const blatt of document.styleSheets) { let regeln; try { regeln = blatt.cssRules; } catch (e) { continue; } for (const r of regeln) { if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) { r.media.mediaText = "all"; } } } const hinweis = document.querySelector(".wd-install"); return { federn: getComputedStyle(document.body).overscrollBehaviorY, hinweis: hinweis ? getComputedStyle(hinweis).display : "nicht auf dieser Seite", }; }); pruefe("scharf geschaltet: das Gummiband-Nachfedern ist wirklich aus", wirkung.federn === "none", wirkung.federn); pruefe("scharf geschaltet: der Installations-Hinweis verschwindet wirklich", wirkung.hinweis === "none" || wirkung.hinweis === "nicht auf dieser Seite", wirkung.hinweis); await kontext.close(); } /* ===================================================================== TEIL 3b — liegt irgendwo etwas unter der Gestenleiste oder dem Notch? Gesucht wird nicht im Quelltext, sondern im fertigen Bild. Ein Durchsuchen des CSS nach 'position: fixed' findet nur, woran man gedacht hat -- der 'Ueberspringen'-Knopf des Vorspanns stand mit 'bottom: 2rem' da und wurde beim Suchen prompt uebersehen, obwohl er auf einem iPhone 14 Pro genau auf dem Entsperr-Strich lag (der ist 34px hoch). Getippt haette man dann nicht 'ueberspringen', sondern die App verlassen. Deshalb: Abstaende auf Notch-Masse setzen, dann JEDES fest oder klebend positionierte Element messen. Was in den geschuetzten Streifen hineinragt, faellt auf -- unabhaengig davon, ob jemand daran gedacht hat. ===================================================================== */ console.log("\n=== Nichts unter Statusleiste oder Gestenleiste ==="); { const kontext = await browser.newContext(devices["iPhone 14 Pro"]); for (const datei of SEITEN) { const seite = await kontext.newPage(); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/" + datei); await seite.waitForTimeout(450); const funde = await seite.evaluate(() => { const OBEN = 59, UNTEN = 34; // iPhone 14 Pro document.documentElement.style.setProperty("--wd-sicher-oben", OBEN + "px"); document.documentElement.style.setProperty("--wd-sicher-unten", UNTEN + "px"); document.body.getBoundingClientRect(); // Neuberechnung erzwingen const raus = []; for (const el of document.querySelectorAll("*")) { const st = getComputedStyle(el); if (st.position !== "fixed" && st.position !== "sticky") continue; if (st.display === "none" || st.visibility === "hidden") continue; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8) continue; /* Ganzflaechige Ueberlagerungen (inset: 0) sind Hintergruende -- sie DUERFEN unter die Leisten laufen, das ist ihr Zweck. Nur was Text oder Bedienelemente traegt, zaehlt. */ const vollflaechig = r.top <= 1 && r.left <= 1 && r.width >= innerWidth - 2 && r.height >= innerHeight - 2; if (vollflaechig) continue; const name = el.tagName.toLowerCase() + "." + (el.className || "").toString().trim().slice(0, 24); /* Gemessen wird die INHALTSKANTE, nicht die Kastenkante. Die Kopfzeile SOLL mit ihrem Kasten unter die Statusleiste reichen -- ihr Hintergrund fuellt die Flaeche hinter der Uhr, sonst klaffte dort ein Loch. Verboten ist nur, dass Text oder Knoepfe dort landen. Genau dafuer hat sie padding-top in Hoehe des Sicherheitsabstands. Die erste Fassung dieser Pruefung mass die Kastenkante und meldete daraufhin elf Seiten als fehlerhaft, auf denen alles richtig war. */ const obenInhalt = r.top + parseFloat(st.paddingTop || 0) + parseFloat(st.borderTopWidth || 0); const untenInhalt = r.bottom - parseFloat(st.paddingBottom || 0) - parseFloat(st.borderBottomWidth || 0); if (untenInhalt > innerHeight - UNTEN && r.top < innerHeight) raus.push(name + " ragt " + Math.round(untenInhalt - (innerHeight - UNTEN)) + "px in die Gestenleiste"); else if (obenInhalt < OBEN && r.bottom > 0) raus.push(name + " liegt " + Math.round(OBEN - obenInhalt) + "px unter der Statusleiste"); } return raus; }); pruefe("nichts liegt unter den Systemleisten: " + datei, funde.length === 0, funde.slice(0, 3).join(" | ")); /* SELBSTPRUEFUNG -- nur einmal, auf der Startseite. Eine Pruefung, die nie etwas findet, kann zweierlei bedeuten: Es ist alles in Ordnung, ODER sie misst am Problem vorbei. Genau das war eben der Fall (falsche Kante gemessen). Deshalb wird hier ein absichtlich falsch platzierter Knopf eingesetzt und geprueft, ob die Messung ihn WIRKLICH findet. Erst dann ist ein gruenes Ergebnis oben etwas wert. */ if (datei === "index.html") { const gefunden = await seite.evaluate(() => { const UNTEN = 34; const k = document.createElement("button"); k.className = "pruef-koeder"; k.textContent = "absichtlich falsch"; k.style.cssText = "position:fixed;left:20px;bottom:4px;width:120px;height:44px;z-index:9999"; document.body.appendChild(k); const r = k.getBoundingClientRect(); const st = getComputedStyle(k); const untenInhalt = r.bottom - parseFloat(st.paddingBottom || 0) - parseFloat(st.borderBottomWidth || 0); const erwischt = untenInhalt > innerHeight - UNTEN; k.remove(); return erwischt; }); pruefe("die Messung erkennt einen absichtlich falsch gesetzten Knopf", gefunden === true, String(gefunden)); } await seite.close(); } await kontext.close(); } /* ===================================================================== TEIL 4 — die Verwaltung auf dem Handy ===================================================================== */ console.log("\n=== Verwaltung auf dem Handy ==="); { const kontext = await browser.newContext(devices["iPhone 14 Pro"]); const seite = await kontext.newPage(); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/verwaltung.html"); await seite.waitForTimeout(800); /* Die Reiter muessen EINE Zeile sein. Gemessen ueber die Oberkanten: Liegen zwei Reiter auf verschiedenen Hoehen, ist umgebrochen worden. */ const reiter = await seite.evaluate(() => { const k = Array.from(document.querySelectorAll(".vw-tabs [data-tab]")); const oben = new Set(k.map((x) => Math.round(x.getBoundingClientRect().top))); const streifen = document.querySelector(".vw-tabs"); return { anzahl: k.length, zeilen: oben.size, wischbar: streifen.scrollWidth > streifen.clientWidth + 1, hoehe: Math.round(streifen.getBoundingClientRect().height) }; }); pruefe("die sechs Reiter stehen in EINER Zeile", reiter.zeilen === 1, reiter.zeilen + " Zeilen bei " + reiter.anzahl + " Reitern"); pruefe("und der Streifen laesst sich wischen (sonst waeren sie abgeschnitten)", reiter.wischbar, "Hoehe " + reiter.hoehe + "px"); /* Der Kopfbereich darf nicht den halben Bildschirm fressen. ⚠️ Gemessen wird ab dem ENDE des Reiterstreifens, nicht ab dem Seitenanfang (praezisiert 26.08.2026). Vorher war es der Abstand vom oberen Rand bis zur ersten Kachel. Damit schlug der Test auch dann an, wenn dort eine BERECHTIGTE Warnung stand -- etwa "Benachrichtigung blockiert". Solche Meldungen gehoeren genau dorthin: Sie verlangen eine Handlung, und wer sie erledigt, ist sie wieder los. Was der Test wirklich verhindern soll, ist ein aufgeblaehter KOPFBEREICH aus Titel, Suchfeld und Reitern -- also feste Teile, die immer da sind und die man nicht wegbekommt. Genau die werden jetzt gemessen. Ein Test, der berechtigte Meldungen als Mangel zaehlt, erzieht dazu, Warnungen zu verstecken. Das waere das Gegenteil des Ziels. */ const kopfHoehe = await seite.evaluate(() => { const streifen = document.querySelector(".vw-tabs"); const gitter = document.querySelector(".vw-ub-gitter"); if (!streifen || !gitter) return null; return { kopfEndet: Math.round(streifen.getBoundingClientRect().bottom) }; }); pruefe("der feste Kopfbereich bleibt im oberen Drittel", kopfHoehe && kopfHoehe.kopfEndet < 300, (kopfHoehe ? kopfHoehe.kopfEndet : "?") + "px bis zum Ende der Reiter"); /* Und oberhalb der ersten Zahl darf hoechstens EINE Meldung stehen. ⚠️ Zuerst wurde hier der Abstand in Punkten gemessen. Das war fragil: Der Reiterstreifen klebt beim Scrollen oben fest, also haengt der gemessene Abstand davon ab, wo die Seite gerade steht -- eine Messung lieferte sogar -204. Ein Test, dessen Ergebnis vom Scrollzustand abhaengt, misst nicht die Sache, sondern den Zufall. Gemeint war ohnehin etwas anderes: Nicht "wie viele Punkte", sondern "wie viele Hinweisbloecke". Genau das wird jetzt gezaehlt -- unabhaengig von Scrollposition und Schriftgroesse. */ const meldungen = await seite.evaluate(() => { const kandidaten = ["#vw-meldungen", "#vw-push-karte"]; let sichtbar = 0; const namen = []; for (const w of kandidaten) { const el = document.querySelector(w); if (!el || el.hidden) continue; const r = el.getBoundingClientRect(); if (r.height > 0) { sichtbar++; namen.push(w + " (" + Math.round(r.height) + "px)"); } } return { sichtbar, namen }; }); pruefe("hoechstens eine Meldung ueber der ersten Zahl", meldungen.sichtbar <= 1, meldungen.namen.length ? meldungen.namen.join(", ") : "keine"); /* Zwei Spalten statt zwoelf Zeilen untereinander. */ const spalten = await seite.evaluate(() => { const k = Array.from(document.querySelectorAll("#vw-ub-beimir .vw-ub-kachel")); const links = new Set(k.map((x) => Math.round(x.getBoundingClientRect().left))); return { spalten: links.size, anzahl: k.length }; }); pruefe("die Kacheln stehen zweispaltig", spalten.spalten === 2, spalten.spalten + " Spalten bei " + spalten.anzahl + " Kacheln"); const laenge = await seite.evaluate(() => document.documentElement.scrollHeight); /* 2100 statt 2000: Seit dem 26.08.2026 steht am Ende der Uebersicht eine Zeile zum Zustand der Benachrichtigung. Sie kostet rund 70 Punkte und ist bewusst dort -- oben stand sie im Weg. Die urspruengliche Absicht (nicht wieder auf 2635 wachsen) bleibt gewahrt. */ pruefe("die Uebersicht bleibt kurz", laenge < 2100, laenge + "px (vorher 2635px)"); /* Der aktive Reiter muss sichtbar werden, wenn man ueber eine Kachel springt -- sonst sieht man nirgends, wo man gelandet ist. */ await seite.locator('#vw-ub-beimir .vw-ub-kachel[data-ziel="postfach"]').first().click(); await seite.waitForTimeout(800); const sichtbar = await seite.evaluate(() => { const a = document.querySelector('.vw-tabs [data-tab="postfach"]'); const s = document.querySelector(".vw-tabs"); const ar = a.getBoundingClientRect(), sr = s.getBoundingClientRect(); return ar.left >= sr.left - 1 && ar.right <= sr.right + 1; }); pruefe("nach dem Sprung ins Postfach ist dessen Reiter im Bild", sichtbar, String(sichtbar)); await seite.screenshot({ path: "bild-handy-verwaltung.png", fullPage: true }); await seite.close(); await kontext.close(); } /* ===================================================================== TEIL 5 — Bilder zum Ansehen ===================================================================== */ { const kontext = await browser.newContext(devices["iPhone 14 Pro"]); const seite = await kontext.newPage(); await vorbereiten(seite); await seite.goto(URL_BASIS + "/webdesign/index.html"); await alsAppSchalten(kontext, seite); await seite.waitForTimeout(600); /* Mit gesetzten Abstaenden aufnehmen, damit man sieht, wie es installiert auf einem Geraet mit Aussparung wirklich aussieht. */ await seite.evaluate(() => { document.documentElement.style.setProperty("--wd-sicher-oben", "59px"); document.documentElement.style.setProperty("--wd-sicher-unten", "34px"); }); await seite.waitForTimeout(200); await seite.screenshot({ path: "bild-handy-start-installiert.png" }); await seite.close(); await kontext.close(); } await browser.close(); srv.close(); const f = ergebnisse.filter((e) => !e.ok).length; console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`); process.exitCode = f ? 1 : 0;