From 31437ac0dc8ca4b124e3507ff2fc523176135e78 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 2 Oct 2026 12:29:54 +0200 Subject: [PATCH] Die Reaction auf dem kleinen Handy: gemessen, bewacht -- und ein Befund MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEINE EIGENE MERKLISTE WAR FALSCH. Dort stand seit dem 01.10.: „320x568 zeigt noch ein 320x75 grosses Videofeld und eine 1 px schmale Chatleiste." Nachgemessen stimmte davon keine einzige Zahl. Eine Bestandsliste ist ein Wegweiser, keine Wahrheit -- auch meine eigene. WAS WIRKLICH DASTEHT (320x568, laufende Sendung): Regie ZU Bild 127 px · Transportleiste endet bei 568 von 568 Regie AUF Bild 0 px · Transportleiste endet bei 1585 von 568 Regie AUF bei 390/430: Bild unveraendert, Transport am Fensterrand Die Chatleiste ist auf JEDER Handybreite `display: none` -- sie liegt dort im Registerstreifen. Absicht, keine 1-px-Leiste. Dass bei OFFENER Regie auf 320 px das Bild verschwindet, ist der in reaktion.css dokumentierte Mangel samt sechs gescheiterten Versuchen. Er wird hier NICHT repariert und auch nicht gruen abgehakt -- sonst wuerde eine spaetere Reparatur rot. DIE ENTSCHEIDENDE FRAGE WAR EINE ANDERE: Kommt man wieder heraus? Gemessen in jedem Zustand und auf jeder Breite: Der Knopf, der die Regie zumacht, steht im Fenster UND liegt obenauf (`elementFromPoint`, nicht nur „sichtbar"). Es ist also ein Schoenheitsfehler und keine Falle. Das war vorher niemandem bekannt, weil es niemand gemessen hat. NEU: pruef-reaktion-schmal.mjs (24 Pruefungen) Die Reaction-Seite war im Browser so gut wie unbewacht -- von fuenf Pruefungen, die reaktion.html erwaehnen, oeffnet sie nur eine ueberhaupt in einem Browser, und keine auf 320 px. Bewacht wird jetzt die Grenze: 1. Mit geschlossener Regie muss das Bild auf jeder Handybreite mindestens 100 px hoch sein (heute 127 / 219 / 242). 2. Ab 360 px -- genau dort verlaeuft `@media (pointer: coarse) and (min-width: 360px)` -- muss das Bild seine Hoehe auch bei OFFENER Regie behalten. 3. In JEDEM Zustand muss der Zu-Knopf im Fenster stehen und anklickbar sein. Mit Gegenproben, die beweisen, dass sie rot werden kann: ein auf 0 gedruecktes Bild faellt durch, und ein zugedeckter Knopf gilt nicht als anklickbar, obwohl er im Fenster steht. ------------------------------------------------------------------- EIN BEFUND, DER FILIPE GEHOERT UND NICHT MIR Beim Messen kam etwas heraus, das nicht auf der Liste stand. Die Seite zeigt ein Hinweisfeld „Kamera oder Mikrofon sind nicht freigegeben. Im Browser oben in der Adresszeile laesst sich …". Gemessen, zweimal reproduziert: 320 px Das Feld kostet das Bild 71 px: 56 -> 127 (es landet in einer impliziten FUENFTEN Rasterzeile; der Saal deklariert vier) 390 px Das Feld kostet 0 px -- weil es dort 0 px HOCH ist Also: Auf dem kleinen Handy frisst der Hinweis mehr als die Haelfte des Bildes. Auf dem normalen ist er ueberhaupt nicht zu lesen. Eine Meldung, die erklaert, wie man die Kamera freigibt, und die man dabei nicht sehen kann, ist keine. Wo dieser Hinweis hingehoert, ist eine Gestaltungsfrage und damit Filipes. Deshalb steht die Messung im Protokoll der Pruefung, aber nicht als Behauptung im Code. WIE ES GEMESSEN WIRD, nachdem der erste Anlauf wackelte: Nicht die Hoehe des Feldes (die hing davon ab, WANN gelesen wurde -- in einem Lauf 62 px, im naechsten 0), sondern der Unterschied vorher/nachher im selben Lauf: Bild messen, Meldung leeren, Bild noch einmal messen. Zweimal hintereinander identisch. GEPRUEFT: pruef-reaktion-schmal 24 ok · pruef-ports 10 ok · pruef-portnummern 41 ok · pruef-pruefzaehler 7 ok · pruef-struktur 99 ok. Die Portnummern leiten sich aus der alphabetischen Stelle ab, eine neue Pruefdatei verschiebt sie -- deshalb stehen die beiden Port-Pruefungen mit dabei. Der Gesamtlaeufer liest das Verzeichnis (`readdirSync`) und findet die neue Datei von selbst; es gibt keine Liste, die veralten koennte. AM PRODUKT IST NICHTS GEAENDERT. Co-Authored-By: Claude Opus 5 --- server/pruef-reaktion-schmal.mjs | 306 +++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 server/pruef-reaktion-schmal.mjs diff --git a/server/pruef-reaktion-schmal.mjs b/server/pruef-reaktion-schmal.mjs new file mode 100644 index 00000000..82360333 --- /dev/null +++ b/server/pruef-reaktion-schmal.mjs @@ -0,0 +1,306 @@ +/* DIE REACTION AUF EINEM KLEINEN HANDY (02.10.2026) + ===================================================================== + + WARUM ES DIESE DATEI GIBT + + Auf meiner eigenen Merkliste stand seit dem 01.10.: „320x568 zeigt + noch ein 320x75 grosses Videofeld und eine 1 px schmale + Chatleiste." Nachgemessen stimmte davon keine einzige Zahl: + + Regie ZU bei 320x568: Video 127 px, Transport-Unterkante 568 + bei Fensterhoehe 568 -- alles erreichbar + Regie AUF bei 320x568: Video 0 px, Transport-Unterkante 1585 + Regie AUF bei 390/430: Video unveraendert, Transport am Rand + + Und die Chatleiste ist auf JEDER Handybreite `display: none` -- + sie liegt dort im Registerstreifen, das ist Absicht und keine + 1-px-Leiste. + + Eine Bestandsliste ist ein Wegweiser, keine Wahrheit. Auch meine + eigene. + + WAS HIER BEWACHT WIRD UND WAS NICHT + + Dass bei OFFENER Regie auf 320 px das Bild verschwindet, ist ein + BEKANNTER Mangel. In reaktion.css steht die Begruendung samt sechs + gescheiterten Versuchen, und dort steht auch, dass es ein eigenes + Nachdenken braucht (etwa: die Regie wird dort ein eigener + Bildschirm statt einer Schublade). Diese Pruefung macht daraus + KEINEN gruenen Haken -- sonst wuerde eine spaetere Reparatur hier + rot, und das waere die Welt verkehrt herum. + + Bewacht wird die GRENZE darum herum, und zwar dreierlei: + + 1. Mit GESCHLOSSENER Regie muss das Bild auch auf 320 px eine + brauchbare Hoehe haben. Faellt es dort je auf 0, ist das + etwas Neues und kein Bestand. + 2. Ab 360 px -- der Breite, ab der die Regie aus dem Fluss + genommen wird -- muss das Bild seine Hoehe auch bei OFFENER + Regie behalten. Genau dort verlaeuft die Zeile + `@media (pointer: coarse) and (min-width: 360px)`. + 3. IN JEDEM ZUSTAND muss der Knopf, der die Regie wieder + zumacht, im Fenster stehen UND obenauf liegen. Das ist der + Unterschied zwischen „haesslich" und „Falle": Solange er da + ist, kommt man immer wieder zum Bild zurueck. Gemessen ist + er es heute ueberall -- diese Zeile haelt das fest. + + DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-rschmal-")); +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, "die schmale Reaction"); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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-reaktion-schmal"); +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(); + +/* Die Grenze, ab der reaktion.css die Regie aus dem Fluss nimmt. + Sie steht dort als `and (min-width: 360px)` -- hier als Zahl, weil + diese Pruefung genau an ihr entlangmisst. */ +const AUS_DEM_FLUSS_AB = 360; +/* So hoch muss ein Bild mindestens sein, um ein Bild zu sein. 127 px + sind heute auf 320 gemessen; 100 laesst Luft, ohne 0 zu decken. */ +const BILD_MIN = 100; + +const GERAETE = [[320, 568, "Handy klein"], [390, 844, "Handy"], [430, 932, "Handy gross"]]; + +/** Was am Bildschirm wirklich steht -- in einem Zustand der Regie. */ +const messen = (seite) => seite.evaluate(() => { + const bild = document.getElementById("leinwand").getBoundingClientRect(); + const transport = document.querySelector(".transport")?.getBoundingClientRect(); + const zu = document.getElementById("reiter-auf"); + const zr = zu?.getBoundingClientRect(); + /* IM FENSTER heisst nicht ANKLICKBAR. `elementFromPoint` fragt, was + an dieser Stelle wirklich obenauf liegt -- ein Knopf unter einer + Schublade ist sichtbar und trotzdem nicht zu treffen. */ + const obenauf = zr + ? document.elementFromPoint(zr.left + zr.width / 2, zr.top + zr.height / 2) + : null; + return { + bild: Math.round(bild.height), + transportUnten: transport ? Math.round(transport.bottom) : null, + fenster: window.innerHeight, + zuImFenster: !!zr && zr.height > 0 && zr.top >= 0 && zr.bottom <= window.innerHeight + && zr.left >= 0 && zr.right <= window.innerWidth, + zuObenauf: !!obenauf && (obenauf.id === "reiter-auf" || !!obenauf.closest("#reiter-auf")), + }; +}); + +for (const [breite, hoehe, wie] of GERAETE) { + console.log(`\n=== ${wie} (${breite}x${hoehe}) ===`); + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + hasTouch: true, isMobile: true }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 90))); + + 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 }); + + /* DIE SENDUNG MUSS LAUFEN. Im Zustand „zu" sind Kino und Chat + `display: none` -- wer das misst, misst den geschlossenen Saal + und haelt das Video fuer kaputt. Genau dieser Fehler ist mir + heute beim ersten Anlauf passiert. + + Gestellt wird der Zustand ueber die SCHNITTSTELLE, nicht ueber + `dataset.stand` im Browser: Sonst pruefte die Messung einen + Zustand, den das Haus so nie herstellt. */ + await seite.evaluate(async () => { + await fetch("/workspace/api/reaktion", { + method: "PUT", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ titel: "Schmale Probe", video: "dQw4w9WgXcQ" }), + }); + await fetch("/workspace/api/reaktion/stand", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "live" }), + }); + }); + await seite.goto(BASIS + "/workspace/reaktion.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + + const stand = await seite.evaluate(() => document.getElementById("saal")?.dataset.stand); + ok(stand === "live", `die Sendung laeuft wirklich (data-stand="${stand}") — sonst misst alles darunter nichts`); + + /* ==== DIE KAMERAMELDUNG WIRD WEGGERAEUMT, UND ZWAR MIT GRUND ==== + + Ein kopfloser Browser hat keine Kamera. `getUserMedia` + scheitert, und die Seite schreibt zu Recht „Kamera oder Mikrofon + sind nicht freigegeben…" in `#fehler`. Gemessen werden soll hier + aber die Aufteilung einer LAUFENDEN SENDUNG, nicht die Warnung + eines Rechners ohne Kamera -- sonst misst diese Pruefung eine + Eigenschaft der Messumgebung und nennt sie einen Befund. + + WAS DIE MELDUNG KOSTET, IST TROTZDEM GEMESSEN und steht unten im + Protokoll. Am 02.10.2026: + + 320x568 Bild mit Meldung 56 px · ohne Meldung 127 px + 390x844 Bild 601 px -- die Meldung ist dort 0 px HOCH + + Beides ist auffaellig und beides gehoert Filipe vorgelegt, nicht + hier stillschweigend repariert: Auf dem kleinen Handy frisst die + Meldung 71 Pixel aus einer impliziten FUENFTEN Rasterzeile (der + Saal deklariert vier), auf dem normalen ist sie ueberhaupt nicht + zu lesen. Eine Meldung, die erklaert, wie man die Kamera + freigibt, und die man dabei nicht sehen kann, ist keine. */ + /* GEMESSEN WIRD, WAS SIE KOSTET -- nicht, wie hoch sie gerade ist. + + Erster Anlauf las die Hoehe des Feldes selbst. Die hing davon ab, + WANN gelesen wurde: in einem Lauf 62 px, im naechsten 0 px, weil + die Meldung erst eintrifft, wenn `getUserMedia` aufgegeben hat. + Eine Protokollzeile, die mal so und mal so ausfaellt, ist + wertlos. + + Der Unterschied VORHER/NACHHER im selben Lauf ist dagegen + stabil: Bild messen, Meldung leeren, Bild noch einmal messen. */ + /* ZUERST DIE REGIE ZUMACHEN. Steht sie offen, ist das Bild auf + 320 px ohnehin 0 -- dann misst die Kostenrechnung „0 -> 0" und + sagt nichts. Genau das war mein erster Anlauf. */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "nein"; }); + await seite.waitForTimeout(350); + + const kosten = await (async () => { + const vorher = await seite.evaluate(() => Math.round( + document.getElementById("raum").getBoundingClientRect().height)); + const text = await seite.evaluate(() => { + const f = document.getElementById("fehler"); + const t = (f?.textContent || "").trim(); + if (f) f.textContent = ""; + return t; + }); + await seite.waitForTimeout(350); + const nachher = await seite.evaluate(() => Math.round( + document.getElementById("raum").getBoundingClientRect().height)); + return { vorher, nachher, text }; + })(); + console.log(` Hinweisfeld: ${kosten.text ? `"${kosten.text.slice(0, 44)}…"` : "leer"}` + + ` · kostet das Bild ${kosten.nachher - kosten.vorher} px` + + ` (${kosten.vorher} -> ${kosten.nachher})`); + + /* ---- Regie zu ---------------------------------------------------- */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "nein"; }); + await seite.waitForTimeout(350); + const zu = await messen(seite); + console.log(` Regie zu : Bild ${zu.bild} px · Transport endet bei ` + + `${zu.transportUnten} von ${zu.fenster}`); + ok(zu.bild >= BILD_MIN, + `mit geschlossener Regie ist das Bild ${zu.bild} px hoch (mindestens ${BILD_MIN})`); + ok(zu.transportUnten !== null && zu.transportUnten <= zu.fenster + 1, + ` und die Transportleiste endet im Fenster (${zu.transportUnten} von ${zu.fenster})`); + ok(zu.zuImFenster && zu.zuObenauf, + " der Knopf zum Auf- und Zumachen steht im Fenster und liegt obenauf"); + + /* ---- Regie auf --------------------------------------------------- */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "ja"; }); + await seite.waitForTimeout(350); + const auf = await messen(seite); + console.log(` Regie auf: Bild ${auf.bild} px · Transport endet bei ` + + `${auf.transportUnten} von ${auf.fenster}`); + + if (breite >= AUS_DEM_FLUSS_AB) { + /* AB 360 px wird die Regie aus dem Fluss genommen und liegt ueber + dem Bild. Dann darf das Bild nichts verlieren. */ + ok(auf.bild >= BILD_MIN, + `auch mit offener Regie bleibt das Bild ${auf.bild} px hoch`); + ok(auf.transportUnten !== null && auf.transportUnten <= auf.fenster + 1, + ` und die Transportleiste bleibt im Fenster (${auf.transportUnten})`); + } else { + /* UNTER 360 px GILT DIESE ZUSAGE NICHT -- und das wird hier NICHT + als „in Ordnung" abgehakt. Behauptet wird nur, was auch nach + einer Reparatur noch wahr ist: dass man wieder herauskommt. + Ein gruener Haken auf „das Bild ist 0" wuerde bei der + Reparatur rot. */ + console.log(` (unter ${AUS_DEM_FLUSS_AB} px bleibt die Regie im Fluss — ` + + "bekannter Mangel, siehe reaktion.css. Hier wird nur geprueft, " + + "dass man wieder herauskommt.)"); + } + + ok(auf.zuImFenster && auf.zuObenauf, + " und der Knopf zum Zumachen ist auch jetzt da und anklickbar — " + + "das trennt einen Schoenheitsfehler von einer Falle"); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole.join(" | ") : "kein Absturz im Browser"); + + /* ---- GEGENPROBE: kann diese Messung ueberhaupt rot werden? -------- + Nur einmal, am schmalsten Geraet. Beide Behauptungen werden + absichtlich gebrochen und muessen dann durchfallen. */ + if (breite === 320) { + await seite.evaluate(() => { + document.getElementById("pult").dataset.auf = "nein"; + document.getElementById("leinwand").style.height = "0px"; + }); + await seite.waitForTimeout(250); + const kaputt = await messen(seite); + ok(kaputt.bild < BILD_MIN, + `Gegenprobe: ein auf 0 gedruecktes Bild faellt durch (${kaputt.bild} px)`); + + await seite.evaluate(() => { + document.getElementById("leinwand").style.height = ""; + const deckel = document.createElement("div"); + deckel.id = "deckel-probe"; + deckel.style.cssText = "position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.01)"; + document.body.append(deckel); + }); + await seite.waitForTimeout(250); + const verdeckt = await messen(seite); + ok(verdeckt.zuImFenster && !verdeckt.zuObenauf, + " und ein zugedeckter Knopf gilt nicht als anklickbar, obwohl er im Fenster steht"); + await seite.evaluate(() => document.getElementById("deckel-probe")?.remove()); + } + + await kontext.close(); +} + +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 === 0 ? 0 : 1);