diff --git a/server/mess-quer.mjs b/server/mess-quer.mjs
new file mode 100644
index 00000000..3c0e8749
--- /dev/null
+++ b/server/mess-quer.mjs
@@ -0,0 +1,572 @@
+/* =====================================================================
+ WAS MUSS MAN SEITLICH SCHIEBEN? (28.09.2026)
+
+ Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
+ sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
+ moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
+ schaltet sie durch alle drei Zustaende und legt Bilder ab.
+
+ Gemessen wird dabei auch, was man sonst uebersieht:
+ - laeuft etwas in die Konsole?
+ - ragt etwas seitlich hinaus?
+ - ist der Saal so hoch wie das Fenster, oder muss man scrollen?
+
+ NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
+ Zertifikat -- und am Ende ist alles weg.
+
+ Aufrufen mit: node server/mess-reaktion.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+import { execFileSync } from "node:child_process";
+import { createServer as httpsServer } from "node:https";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const PORT = 5491;
+const HTTPS_PORT = PORT + 1;
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+for (const art of ["uncaughtException", "unhandledRejection"]) {
+ process.on(art, (f) => {
+ console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
+ process.exit(8);
+ });
+}
+
+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");
+await new Promise((r) => setTimeout(r, 1000));
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------- Leute ------------------------------------------------------- */
+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");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, new Date().toISOString());
+const ALT = new Date(Date.now() - 200 * 86400000).toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, 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,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kenn).update(code).digest("hex"), ALT);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
+const IDS = {
+ admin: anlegen("DogFather", "admin", CODES.admin),
+ gast: anlegen("Nachtfalter", "gast", CODES.gast),
+ gast2: anlegen("Lenny", "gast", CODES.gast2),
+};
+d.close();
+
+function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+async function anmelden(rolle) {
+ const a = await roh("/workspace/api/anmelden", {
+ method: "POST",
+ rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
+ });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const k = {
+ admin: await anmelden("admin"),
+ gast: await anmelden("gast"),
+ gast2: await anmelden("gast2"),
+};
+
+/* ---------- Die Buehne fuellen ------------------------------------------ */
+await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: {
+ titel: "Wir schauen den neuen Trailer",
+ video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
+ },
+});
+
+/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
+ einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
+ ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
+ Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
+for (const adresse of ["https://youtu.be/9bZkp7q19f0",
+ "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin, rumpf: { video: adresse },
+ });
+}
+
+const REDEN = [
+ ["gast", "Bin schon daaa 🐺"],
+ ["gast2", "Ich auch! Hab extra Popcorn geholt"],
+ ["gast", "Wie lange noch?"],
+ ["admin", "Zwei Minuten, ich richte noch die Kamera"],
+ ["gast2", "Kein Stress, wir sitzen bequem"],
+ ["gast", "Letztes Mal war so gut, ich hab geheult"],
+];
+
+/* ---------- Der Vorbau -------------------------------------------------- */
+const schluesselDatei = join(ordner, "p.key");
+const zertDatei = join(ordner, "p.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
+
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = request({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
+ /* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
+ Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
+ Aufnahme aus, als waere die Kamera kaputt. */
+ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
+});
+
+/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
+ Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
+ laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
+ schliesst, nimmt den spaeteren den Platz weg.
+
+ GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
+ offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
+ Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
+ ein Messfehler, der wie ein Programmfehler aussah. */
+async function alsWer(rolle, breite, hoehe) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ /* MIT FINGER, WENN ES EIN HANDY IST.
+ Ohne `hasTouch` meldet der Browser `pointer: fine` -- und
+ damit greift KEINE der Regeln aus `@media (pointer: coarse)`.
+ Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und seit
+ dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine
+ Handy-Messung mit Mauszeiger misst eine Seite, die auf keinem
+ Handy so aussieht -- und meldet Hoehen, die dort andere sind. */
+ hasTouch: breite <= 860,
+ ignoreHTTPSErrors: true,
+ permissions: ["camera", "microphone"],
+ });
+ await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
+ const [n, ...r] = z.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ const anfragen = [];
+ seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
+ seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ seite.on("response", (a) => {
+ if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
+ });
+ return { kontext, seite, konsole, anfragen };
+}
+
+async function bild(seite, name) {
+ await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
+ console.log(` Bild: server/mess-reaktion-${name}.png`);
+}
+
+async function vermessen(seite, wo) {
+ const m = await seite.evaluate(() => ({
+ ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
+ hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
+ stand: document.getElementById("saal")?.dataset.stand || "?",
+ schild: document.getElementById("schild-wort")?.textContent || "",
+ }));
+ console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
+ + `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
+ return m;
+}
+
+
+/* =====================================================================
+ WAS MUSS MAN SEITLICH SCHIEBEN?
+
+ Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach links
+ und rechts schieben muss. perfektion das untereinander. ich will
+ niemals irgendwas nach links oder rechts swippen muessen."
+
+ NICHT GERATEN, SONDERN AUFGEZAEHLT. Ein `grep` nach `overflow-x`
+ findet die absichtlichen Roller. Es findet NICHT die Stelle, an der
+ ein Inhalt breiter ist als sein Kasten und der Browser von sich aus
+ eine Rollleiste anhaengt -- und genau das ist der Fall auf Filipes
+ Bildschirmfoto. Dieses Werkzeug geht jedes Element durch und nennt
+ jedes, dessen Inhalt breiter ist als sein Platz.
+ ===================================================================== */
+
+/* AUF SENDUNG. Nur dann steht die Seite vollstaendig da: ohne
+ laufende Sendung fehlen Chat, Kamerafenster und der halbe
+ Transport -- und dann misst man eine Seite, die es im Betrieb
+ nie gibt.
+
+ EIN EIGENER FEHLER, GEFUNDEN AM BILDSCHIRMFOTO: Die erste Fassung
+ schickte `PUT`; die Route nimmt aber `POST`. Der Aufruf lief ins
+ Leere, die Messung lief weiter, und das Schild sagte noch immer
+ „GESCHLOSSEN". Deshalb steht unten eine Nachfrage: Eine Messung,
+ die ihre eigene Voraussetzung nicht nachprueft, misst irgendetwas
+ und meldet es als Ergebnis. */
+for (const stufe of ["vorbereitung", "live"]) {
+ await roh("/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: stufe },
+ });
+}
+/* Ein Gast dazu -- sonst fehlt die halbe Gaesteliste, und im Saal
+ steht nur ein Kamerafenster statt zweier. */
+await roh("/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
+});
+/* Und ein paar Worte im Chat -- eine leere Schiene kann nicht
+ ueberlaufen, und genau das waere ein gruener Haken ohne Inhalt. */
+for (const [wer, text] of REDEN) {
+ await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } });
+}
+/* EIN ZWEITES VIDEO -- SONST FEHLT DER UMSCHALTER.
+ Dieses Werkzeug meldete „nichts rollt seitlich", waehrend
+ `mess-reaktion` am selben Aufbau 76 Pixel fand. Der Unterschied:
+ Dort war ein zweites Video eingerichtet, also stand der
+ Umschalter in der Leiste -- mit einem ganzen Videotitel darin,
+ und genau der lief hinaus. Hier war er `hidden`.
+ Ein Knopf, den die Messung nie sieht, kann nicht ueberlaufen --
+ und ihr gruener Haken sagt dann nichts ueber ihn. */
+await roh("/workspace/api/reaktion/zweitvideo", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk" },
+});
+
+{
+ const nach = await roh("/workspace/api/reaktion", { keks: k.admin });
+ if (nach.j?.stand !== "live") {
+ console.error(`ABBRUCH: die Sendung steht auf „${nach.j?.stand}" statt „live" -- `
+ + "ohne Sendung fehlen Chat und Kamerafenster, und dann misst dieses "
+ + "Werkzeug eine Seite, die es im Betrieb nicht gibt.");
+ process.exit(9);
+ }
+ console.log(`Buehne steht: auf Sendung, ein Gast, Chat gefuellt, `
+ + `zweites Video ${nach.j?.zweit?.video ? "bereit" : "FEHLT"}.`);
+ if (!nach.j?.zweit?.video) {
+ console.error("ABBRUCH: ohne zweites Video fehlt der Umschalter in der "
+ + "Transportleiste -- und genau der lief am 28.09. seitlich hinaus.");
+ process.exit(9);
+ }
+}
+
+const GERAETE = [
+ ["Rechner", 1440, 900],
+ ["Tablet", 900, 1200],
+ ["Handy", 412, 915],
+ ["iPhone", 390, 844],
+ ["kleines Handy", 360, 640],
+];
+
+let befunde = 0;
+
+for (const [gname, breite, hoehe] of GERAETE) {
+ console.log(`\n=== ${gname} (${breite}x${hoehe}) ===`);
+ const host = await alsWer("admin", breite, hoehe);
+ await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" });
+ await host.seite.waitForSelector("#reiter", { timeout: 15000 });
+
+ /* ZUERST ZUGEKLAPPT -- DAS IST DER HAEUFIGSTE ZUSTAND.
+ Die erste Fassung mass nur mit offener Regie, weil dort die
+ gemeldete Stelle lag. `mess-reaktion` fand daraufhin 76 Pixel
+ seitlichen Ueberlauf auf einem 390er Handy, die hier nicht
+ auftauchten -- schlicht weil dort die Regie ZU war. Wer nur
+ den Zustand misst, in dem der Fehler gemeldet wurde, findet
+ den zweiten nie. */
+ {
+ /* AUSDRUECKLICH ZUKLAPPEN. Die Seite merkt sich den Zustand,
+ und beim Laden stand sie hier auf „offen" -- die Messung
+ haette den zugeklappten Fall also nie gesehen und trotzdem
+ „zugeklappt: 0 px" gemeldet. Ein gruener Haken ueber einen
+ Zustand, den man gar nicht hergestellt hat. */
+ await host.seite.evaluate(() => {
+ const p = document.getElementById("pult");
+ if (p && p.dataset.auf === "ja") document.getElementById("pult-griff")?.click();
+ });
+ await host.seite.waitForTimeout(400);
+ const zu = await host.seite.evaluate(() => ({
+ auf: document.getElementById("pult")?.dataset.auf,
+ ueber: Math.max(0, document.documentElement.scrollWidth
+ - document.documentElement.clientWidth),
+ breitestes: (() => {
+ let b = null, max = 0;
+ for (const e of document.querySelectorAll("*")) {
+ const r = e.getBoundingClientRect();
+ if (r.right > max) { max = r.right; b = e; }
+ }
+ return b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ + `${typeof b.className === "string" && b.className
+ ? "." + b.className.trim().split(/\s+/)[0] : ""} bis ${Math.round(max)} px` : "-";
+ })(),
+ }));
+ console.log(` zugeklappt (auf=${zu.auf}): seitlich ${zu.ueber} px`);
+ if (zu.ueber > 0) {
+ console.log(` ACHTUNG: die Seite rollt bei ZUGEKLAPPTER Regie ${zu.ueber} px seitlich`);
+ console.log(` am weitesten rechts: ${zu.breitestes}`);
+ befunde++;
+ }
+ }
+
+ /* Die Regie aufklappen -- zugeklappt sieht man die Tafeln nicht,
+ und genau in ihnen steckt der gemeldete Ueberlauf. */
+ const auf = await host.seite.evaluate(() => {
+ const p = document.getElementById("pult");
+ if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click();
+ return document.getElementById("pult")?.dataset.auf || "?";
+ });
+ await host.seite.waitForTimeout(400);
+ console.log(` Regie: ${auf}`);
+
+ const register = await host.seite.evaluate(() =>
+ [...document.querySelectorAll(".reiter__knopf")].map((b) => b.dataset.tafel));
+ console.log(` Register: ${register.join(" · ")}`);
+
+ /* Erst die Seite als Ganzes. */
+ const seitlich = await host.seite.evaluate(() =>
+ Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth));
+ if (seitlich > 0) { console.log(` ACHTUNG: die SEITE selbst rollt ${seitlich} px seitlich`); befunde++; }
+
+ for (const reg of register) {
+ await host.seite.evaluate((r) => {
+ document.querySelector(`.reiter__knopf[data-tafel="${r}"]`)?.click();
+ }, reg);
+ await host.seite.waitForTimeout(250);
+
+ const quer = await host.seite.evaluate(() => {
+ const raus = [];
+ for (const el of document.querySelectorAll("*")) {
+ const w = el.scrollWidth - el.clientWidth;
+ if (w <= 1) continue;
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue; /* verborgen */
+ const st = getComputedStyle(el);
+ if (st.display === "none" || st.visibility === "hidden") continue;
+
+ /* NUR WAS MAN WIRKLICH SCHIEBEN KANN.
+ `overflow-x: hidden` ist KEIN Wischen -- dort ist der Text
+ abgeschnitten (meist mit „…"), und schieben laesst sich
+ nichts. Das ist ein anderes Thema und gehoert nicht in
+ diese Messung; wer es mitzaehlt, meldet 78 Stellen, von
+ denen 60 gar keine sind, und findet die echten nicht mehr.
+ `visible` zaehlt ebenfalls nicht als Stelle -- dort wandert
+ der Ueberlauf nach oben zum naechsten Roller, und DER ist
+ die Stelle. Ausnahme: Wurzel und Rumpf, denn dort rollt
+ das Fenster selbst. */
+ const wurzel = el === document.documentElement || el === document.body;
+ if (!wurzel && st.overflowX !== "auto" && st.overflowX !== "scroll") continue;
+ raus.push({
+ wer: el.tagName.toLowerCase()
+ + (el.id ? "#" + el.id : "")
+ + (el.className && typeof el.className === "string"
+ ? "." + el.className.trim().split(/\s+/).join(".") : ""),
+ ueber: w,
+ innen: el.clientWidth,
+ inhalt: el.scrollWidth,
+ ox: st.overflowX,
+ /* Was ist das breiteste Kind? Das ist fast immer die
+ Ursache -- und ohne diese Angabe sucht man sie von Hand. */
+ /* DIE KETTE BIS ZUR URSACHE. Das breiteste Kind zu nennen
+ reicht nicht: Es ist meist selbst nur ein Kasten, der
+ sich nach SEINEM breitesten Kind richtet. Erst am Ende
+ der Kette steht das Ding, das wirklich zu breit ist --
+ und nur dort kann man etwas reparieren. */
+ kette: (() => {
+ const teile = [];
+ let jetzt = el;
+ for (let tief = 0; tief < 8; tief++) {
+ let b = null, max = 0;
+ for (const kind of jetzt.children) {
+ const kr = kind.getBoundingClientRect();
+ if (kr.width > max) { max = kr.width; b = kind; }
+ }
+ if (!b) break;
+ teile.push(`${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ + `${typeof b.className === "string" && b.className
+ ? "." + b.className.trim().split(/\s+/)[0] : ""}:${Math.round(max)}`);
+ jetzt = b;
+ }
+ return teile.length ? teile.join(" > ") : "-";
+ })(),
+ });
+ }
+ return raus;
+ });
+
+ /* EIN BILD JE REGISTER AUF DEM RECHNER.
+ Zahlen sagen, dass nichts mehr rollt. Sie sagen nicht, ob es
+ danach gut aussieht -- und genau das war Filipes Frage. */
+ if (breite === 1440) {
+ await host.seite.locator("#pult-innen").screenshot({
+ path: join(HIER, `mess-quer-tafel-${reg}.png`) }).catch(() => {});
+ /* Die Stufenleiter steht weiter unten als das Fenster reicht --
+ sie braucht ein eigenes Bild. Ausgerechnet sie trug bis heute
+ den Stil der Chat-Leiste (Namensstreit), also ist sie die
+ Stelle, an der man nachsehen muss. */
+ if (reg === "gestaltung") {
+ await host.seite.locator("#stufen").screenshot({
+ path: join(HIER, "mess-quer-stufen.png") }).catch(() => {});
+ }
+ }
+ if (quer.length === 0) { console.log(` ${reg}: nichts rollt seitlich`); continue; }
+ console.log(` ${reg}: ${quer.length} Stelle(n) rollen seitlich`);
+ for (const q of quer) {
+ console.log(` ACHTUNG ${q.wer}`);
+ console.log(` Platz ${q.innen} px, Inhalt ${q.inhalt} px `
+ + `(${q.ueber} px zu viel), overflow-x: ${q.ox}`);
+ console.log(` Kette: ${q.kette}`);
+ /* WER RAGT UEBER DIE RECHTE KANTE?
+ Die erste Fassung fragte nach der BREITE eines Kindes --
+ und fand nichts, obwohl 211 Pixel ueberliefen. Der Grund:
+ Ein Kind kann genau passen und sein ENKEL trotzdem rechts
+ hinausragen, weil dazwischen `overflow: visible` steht.
+ Gefragt ist also nicht „wer ist zu breit", sondern „wessen
+ rechte Kante liegt jenseits der Kante des Rollers" -- und
+ davon das oberste Glied, denn alles darunter folgt ihm. */
+ const schuld = await host.seite.evaluate((wer) => {
+ const nenne = (e) => e.tagName.toLowerCase()
+ + (e.id ? "#" + e.id : "")
+ + (e.className && typeof e.className === "string"
+ ? "." + e.className.trim().split(/\s+/).join(".") : "");
+ const roller = [...document.querySelectorAll("*")].find((e) => nenne(e) === wer);
+ if (!roller) return ["(nicht gefunden)"];
+ const kante = roller.getBoundingClientRect().left + roller.clientWidth;
+ const zeilen = [];
+ /* IMMER ABSTEIGEN, AUCH DURCH UNAUFFAELLIGES.
+ Die erste Fassung stieg nur in Kinder hinab, die selbst
+ schon hinausragten -- und fand deshalb gar nichts: Die
+ sichtbare Tafel passte (327 px in 359), und erst IHR Kind
+ ragte 572 px weit hinaus. Eine Suche, die nur dort
+ weitersucht, wo sie schon fuendig ist, findet nie etwas,
+ das hinter etwas Unauffaelligem steckt. */
+ const gehe = (el, tief) => {
+ if (tief > 7) return;
+ for (const kind of el.children) {
+ const kr = kind.getBoundingClientRect();
+ if (kr.width === 0) continue;
+ if (kr.right <= kante + 1) { gehe(kind, tief); continue; }
+ const st = getComputedStyle(kind);
+ zeilen.push(`${" ".repeat(tief)}${nenne(kind).split(".").slice(0, 2).join(".")}`
+ + ` ragt ${Math.round(kr.right - kante)} px hinaus`
+ + ` (${Math.round(kr.width)} px breit)`
+ + ` · ${st.display}`
+ + (st.gridTemplateColumns !== "none" ? ` · spalten ${st.gridTemplateColumns}` : "")
+ + (st.display.includes("flex") ? ` · wrap ${st.flexWrap} · basis ${st.flexBasis}` : "")
+ + ` · min-width ${st.minWidth}`
+ + (st.whiteSpace !== "normal" ? ` · ${st.whiteSpace}` : ""));
+ gehe(kind, tief + 1);
+ }
+ };
+ gehe(roller, 0);
+ return zeilen.length ? zeilen : ["(nichts ragt hinaus -- Rand oder Schatten?)"];
+ }, q.wer);
+ for (const z of schuld) console.log(` ${z}`);
+ befunde++;
+ }
+ }
+
+ /* ==== UND IN JEDER ANORDNUNG ====================================
+ `mess-reaktion` meldete auf einem 390er Handy 76 Pixel
+ seitlichen Ueberlauf, die hier nicht auftauchten. Der
+ Unterschied war die ANORDNUNG: Dort war vorher „nur Kamera"
+ eingestellt, hier stand die Vorgabe. Die Anordnung gehoert der
+ Sendung und gilt fuer alle -- wer sie nicht durchprobiert,
+ misst nur einen von fuenf Faellen. */
+ for (const anordnung of ["kino", "gleich", "kameras", "nur_video", "nur_kamera"]) {
+ await host.seite.evaluate((a) => {
+ document.querySelector(`#layout-reihe .layout-knopf[data-layout="${a}"]`)?.click();
+ }, anordnung);
+ await host.seite.waitForTimeout(500);
+ const m = await host.seite.evaluate(() => {
+ const ueber = Math.max(0, document.documentElement.scrollWidth
+ - document.documentElement.clientWidth);
+ if (ueber === 0) return { ueber };
+ let b = null, max = 0;
+ for (const e of document.querySelectorAll("*")) {
+ const r = e.getBoundingClientRect();
+ if (r.width === 0) continue;
+ if (r.right > max) { max = r.right; b = e; }
+ }
+ return {
+ ueber,
+ wer: b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ + `${typeof b.className === "string" && b.className
+ ? "." + b.className.trim().split(/\s+/)[0] : ""}` : "-",
+ bis: Math.round(max),
+ };
+ });
+ if (m.ueber > 0) {
+ console.log(` ACHTUNG Anordnung „${anordnung}": ${m.ueber} px seitlich `
+ + `-- am weitesten rechts ${m.wer} (bis ${m.bis} px)`);
+ befunde++;
+ } else {
+ console.log(` Anordnung „${anordnung}": seitlich 0 px`);
+ }
+ }
+ await host.seite.evaluate(() => {
+ document.querySelector('#layout-reihe .layout-knopf[data-layout="kino"]')?.click();
+ });
+
+ await host.seite.screenshot({ path: join(HIER, `mess-quer-${breite}.png`) });
+ await host.kontext.close();
+}
+
+console.log(`\n=== ${befunde === 0 ? "NICHTS rollt seitlich." : befunde + " Stelle(n) rollen seitlich."} ===`);
+
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei noch */ }
+process.exit(befunde === 0 ? 0 : 1);
diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs
index 8f365aaf..765c5918 100644
--- a/server/mess-reaktion.mjs
+++ b/server/mess-reaktion.mjs
@@ -204,6 +204,14 @@ const browser = await pw.chromium.launch({
async function alsWer(rolle, breite, hoehe) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
+ /* MIT FINGER, WENN ES EIN HANDY IST.
+ Ohne `hasTouch` meldet der Browser `pointer: fine` -- und
+ damit greift KEINE der Regeln aus `@media (pointer: coarse)`.
+ Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und seit
+ dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine
+ Handy-Messung mit Mauszeiger misst eine Seite, die auf keinem
+ Handy so aussieht -- und meldet Hoehen, die dort andere sind. */
+ hasTouch: breite <= 860,
ignoreHTTPSErrors: true,
permissions: ["camera", "microphone"],
});
@@ -357,6 +365,11 @@ console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kom
ein Video mit Ton nicht von allein startet. Beide Male sahen die
Bildschirmfotos plausibel aus. */
let ausgang = 0;
+/* BEKANNTE, AUFGESCHRIEBENE OFFENE PUNKTE.
+ Sie sind keine Fehler dieses Laufs und kein bestandener Test --
+ sie sind der dritte Ausgang: Mangel bekannt, Weg beschrieben,
+ noch nicht gegangen. Am Ende stehen sie gesondert. */
+const offenePunkte = [];
/* LAEUFT DAS VIDEO? Ein Zuschauer hat keine Bedienung -- startet es
nicht von selbst, sitzt er eine Stunde vor einem Standbild und
kann nichts dagegen tun. Gemessen war genau das der Fall. */
@@ -396,7 +409,7 @@ await host.seite.evaluate(() => {
});
await host.seite.waitForTimeout(900);
-/* ---- Der Regieplatz: links, rechts und die Leiste unten ----------
+/* ---- Der Regieplatz: drei Kacheln und die Leiste unten ----------
Filipe: „kann das nicht bissl aufgeteilt sein auf links und rechts
und eine barre unten in der mitte."
@@ -424,8 +437,13 @@ await host.seite.waitForTimeout(900);
};
const innen = document.getElementById("pult-innen");
return {
- links: k(".regieplatz__seite:not(.regieplatz__seite--rechts)"),
- rechts: k(".regieplatz__seite--rechts"),
+ /* DREI KACHELN, NICHT ZWEI SEITEN. In der 360 Pixel schmalen
+ Regiespalte stehen sie UNTEREINANDER -- das ist seit dem
+ Umbau „Regie links" der Sinn, nicht ein Fehler. Gefragt
+ ist deshalb: Gibt es alle drei, und hat jede Inhalt? */
+ laeuft: k(".regiekachel.kachel--laeuft"),
+ sendung: k(".regiekachel.kachel--sendung"),
+ bereit: k(".regiekachel.kachel--bereit"),
leiste: k(".transport"),
spiel: k("#v-spiel"),
/* Laeuft etwas seitlich hinaus? `scrollWidth` groesser als die
@@ -438,11 +456,18 @@ await host.seite.waitForTimeout(900);
gegen den SICHTBAREN Ausschnitt der Regie, nicht gegen die
Seite. */
sichtbar: (() => {
+ /* GEGEN DAS FENSTER, NICHT GEGEN DIE REGIE.
+ Die erste Fassung fragte, ob die Leiste innerhalb von
+ `#pult-innen` liegt. Seit dem 28.09. liegt sie gar nicht
+ mehr in der Regie, sondern als eigene Zeile UNTER dem
+ ganzen Saal -- die Antwort war also immer „nein", und
+ die Messung meldete dauerhaft einen Fehler, den es nicht
+ gab. Gefragt ist, was Filipe im Live angeht: Komme ich
+ an den Play-Knopf, ohne zu scrollen? */
const e = document.querySelector(".transport");
- if (!e || !innen) return false;
+ if (!e) return false;
const r = e.getBoundingClientRect();
- const i = innen.getBoundingClientRect();
- return r.bottom <= i.bottom + 1 && r.top >= i.top - 1;
+ return r.bottom <= window.innerHeight + 1 && r.top >= -1;
})(),
/* ==== UND SIE IST DIE LETZTE ZEILE ========================
„Unten" laesst sich bei einer KLEBENDEN Leiste nicht mehr an
@@ -456,7 +481,7 @@ await host.seite.waitForTimeout(900);
Scrollen wie beim Stillstand. */
letzte: (() => {
const e = document.querySelector(".transport");
- const r = document.querySelector(".regieplatz__seite--rechts");
+ const r = document.querySelector(".regiekachel.kachel--sendung");
if (!e || !r) return false;
/* 4 = DOCUMENT_POSITION_FOLLOWING */
return !!(r.compareDocumentPosition(e) & 4);
@@ -469,9 +494,38 @@ await host.seite.waitForTimeout(900);
};
});
const p = platz;
- console.log(` Regieplatz: links ${p.links?.l}..${p.links?.r} · `
- + `rechts ${p.rechts?.l}..${p.rechts?.r} · `
+ console.log(` Regieplatz: Spalte ${p.laeuft?.l}..${p.laeuft?.r} · `
+ `Leiste ${p.leiste?.l}..${p.leiste?.r} bei y=${p.leiste?.o} (${p.leiste?.h} px hoch)`);
+ /* ==== VERDECKT DIE KLEBENDE LEISTE ETWAS? ==================
+ Sie schwebt ueber dem unteren Teil der Regie -- das ist
+ gewollt. Was NICHT gewollt ist: dass ein Knopf darunter
+ liegt und deshalb nicht mehr gedrueckt werden kann. Gemessen
+ wird, was der Browser selbst sagt: Liegt an der Mitte eines
+ Knopfes etwas anderes obenauf? */
+ const verdeckt = await host.seite.evaluate(() => {
+ const raus = [];
+ const leiste = document.querySelector(".transport");
+ if (!leiste) return raus;
+ for (const e of document.querySelectorAll("#pult-innen button, #pult-innen input,"
+ + " #pult-innen select, #pult-innen label")) {
+ const r = e.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue;
+ const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ if (!oben || e === oben || e.contains(oben)) continue;
+ if (!leiste.contains(oben)) continue; /* nur die Leiste zaehlt */
+ raus.push(`${e.id || e.className || e.tagName} (${Math.round(r.top)}..${Math.round(r.bottom)})`);
+ }
+ return raus;
+ });
+ if (verdeckt.length) {
+ console.log(` ACHTUNG: die Transportleiste liegt ueber ${verdeckt.length} Bedienelement(en): `
+ + verdeckt.slice(0, 5).join(", "));
+ ausgang = 1;
+ } else {
+ console.log(" Die Transportleiste verdeckt kein Bedienelement der Regie.");
+ }
+
+
console.log(` Play-Knopf ${p.spiel?.b}x${p.spiel?.h} px · seitlich ueber ${p.ueber} px `
+ `· Leiste ohne Scrollen sichtbar: ${p.sichtbar} `
+ `(${p.klebt}, nach beiden Spalten: ${p.letzte})`);
@@ -479,24 +533,41 @@ await host.seite.waitForTimeout(900);
console.log(" ACHTUNG: Die Transportleiste ist nur mit Scrollen zu erreichen.");
ausgang = 1;
}
- if (!p.links || !p.rechts || !p.leiste) {
- console.log(" ACHTUNG: Der Regieplatz fehlt.");
+ if (!p.laeuft || !p.sendung || !p.bereit || !p.leiste) {
+ console.log(` ACHTUNG: Es fehlt eine Kachel (laeuft=${!!p.laeuft}, `
+ + `sendung=${!!p.sendung}, bereit=${!!p.bereit}, Leiste=${!!p.leiste}).`);
ausgang = 1;
} else {
- if (p.links.r > p.rechts.l + 1) {
- console.log(" ACHTUNG: Links und rechts stehen nicht nebeneinander.");
+ /* UNTEREINANDER, NICHT NEBENEINANDER. In der schmalen
+ Regiespalte ist das die Absicht: Jede Kachel bekommt die
+ volle Breite, und nichts muss seitlich geschoben werden.
+ Geprueft wird deshalb, dass sie sich NICHT ueberlappen und
+ jede die Spalte fuellt -- zwei halbe Kacheln nebeneinander
+ waeren in 360 Pixeln unlesbar. */
+ const untereinander = p.sendung.o >= p.laeuft.u - 1
+ && p.bereit.o >= p.sendung.u - 1;
+ console.log(` Kacheln (y): laeuft ${p.laeuft.o}..${p.laeuft.u} · sendung ${p.sendung.o}..${p.sendung.u}`
+ + ` · bereit ${p.bereit.o}..${p.bereit.u} · alle ${p.laeuft.b} px breit`
+ + ` · untereinander: ${untereinander}`);
+ if (!untereinander) {
+ console.log(" ACHTUNG: Die drei Kacheln stehen nicht untereinander.");
ausgang = 1;
}
- if (!p.letzte || p.klebt !== "sticky") {
- console.log(` ACHTUNG: Die Leiste ist nicht die letzte Zeile `
- + `(danach=${p.letzte}, position=${p.klebt}).`);
+ /* DIE LEISTE KOMMT NACH ALLEM. `position: sticky` braucht sie
+ seit dem Umbau „Regie links" nicht mehr: Sie ist eine eigene
+ Zeile des Saals und steht damit von selbst unten. Was gilt,
+ ist die Reihenfolge -- und die gilt beim Scrollen wie beim
+ Stillstand. */
+ if (!p.letzte) {
+ console.log(` ACHTUNG: Die Leiste kommt nicht nach den Kacheln.`);
ausgang = 1;
}
- /* Sie soll ueber die GANZE Breite gehen -- eine Leiste, die nur
- so breit ist wie eine Spalte, ist keine Leiste unten in der
- Mitte, sondern eine dritte Spalte. */
- if (p.leiste.l > p.links.l + 1 || p.leiste.r < p.rechts.r - 1) {
- console.log(" ACHTUNG: Die Leiste geht nicht ueber beide Spalten.");
+ /* Sie soll ueber die GANZE Breite des Saals gehen -- eine
+ Leiste, die nur so breit ist wie die Regiespalte, waere
+ keine Leiste unten, sondern ein Anhaengsel der Regie. */
+ if (p.leiste.b < p.laeuft.b * 2) {
+ console.log(` ACHTUNG: Die Leiste ist nur ${p.leiste.b} px breit `
+ + `(die Regiespalte allein ist ${p.laeuft.b} px).`);
ausgang = 1;
}
/* Der Play-Knopf ist der einzige, den man im Live blind treffen
@@ -642,7 +713,7 @@ if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) {
await host.seite.click("#s-kamera").catch(() => {});
await host.seite.waitForTimeout(900);
-await host.seite.click('#chat-stufen .stufe[data-modus="team"]').catch(() => {});
+await host.seite.click('#chat-stufen .chatstufe[data-modus="team"]').catch(() => {});
await host.seite.waitForTimeout(1200);
const nachChat = await zuschauer.seite.evaluate(() => {
const f = document.getElementById("chat-feld");
@@ -661,7 +732,7 @@ if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !==
console.log(" ACHTUNG: Die Chat-Stufe kommt nicht an.");
ausgang = 1;
}
-await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {});
+await host.seite.click('#chat-stufen .chatstufe[data-modus="offen"]').catch(() => {});
await host.seite.waitForTimeout(900);
/* ---- Die Spenden -------------------------------------------------
@@ -1882,8 +1953,26 @@ if (!vorherVerborgen) {
ausgang = 1;
}
-await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0").catch(() => {});
-await host.seite.click("#p-zweit-sichern").catch(() => {});
+/* KEIN STILLES VERSCHLUCKEN. `.catch(() => {})` hat hier den
+ Grund weggeworfen, warum das zweite Video nie ankam -- und die
+ naechste Zeile meldete dann „Der Umschalter kommt nicht", als
+ waere das Programm kaputt. Ein Messfehler, der wie ein
+ Programmfehler aussieht, kostet mehr Zeit als gar keine Messung. */
+try {
+ /* ERST HINSCHAUEN, DANN DRUECKEN. Die Regie rollt senkrecht
+ (346 Pixel Platz, 1130 Pixel Inhalt); „Bereitlegen" steht am
+ unteren Ende, gemessen bei y=1430 in einem 900 Pixel hohen
+ Fenster. Ein Mensch scrollt dorthin, bevor er drueckt -- die
+ Messung muss dasselbe tun, sonst misst sie etwas, das niemand
+ erlebt. */
+ await host.seite.locator("#p-zweit").scrollIntoViewIfNeeded();
+ await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0");
+ await host.seite.locator("#p-zweit-sichern").scrollIntoViewIfNeeded();
+ await host.seite.click("#p-zweit-sichern");
+} catch (f) {
+ console.log(` ACHTUNG: das zweite Video liess sich nicht eintragen -- ${f.message.split(String.fromCharCode(10))[0]}`);
+ ausgang = 1;
+}
await host.seite.waitForTimeout(2600);
const umschalter = await host.seite.evaluate(() => {
const k2 = document.getElementById("v-tauschen");
@@ -2025,20 +2114,70 @@ if (modiSicht !== 0 || abz.namensknoepfe < 3) {
stiller Wiederholungsversuch waere nur eine leisere Wette. */
let menueVersuche = 0;
let menueOffen = false;
+let letzterGrund = "";
+/* DER KLICK IST EIN UMSCHALTER -- ALSO ERST NACHSEHEN, DANN KLICKEN.
+ Die Schleife klickte bis zu dreimal blind. Der erste Klick
+ oeffnet das Menue, der zweite SCHLIESST es wieder, der dritte
+ oeffnet es erneut. Gemessen am 28.09.2026: Eine eingebaute Spur
+ zeigte „nach dem Klick Menue: true", waehrend die Messung
+ danebenstehend „geht nicht auf" meldete -- sie hatte es selbst
+ zugeklickt. Ein Wiederholungsversuch, der den Zustand aendert,
+ den er pruefen soll, misst am Ende sich selbst. */
while (menueVersuche < 3 && !menueOffen) {
menueVersuche += 1;
- await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name")
- .catch(() => {});
- menueOffen = await modi.seite.waitForSelector(".modmenue", { timeout: 3000 })
+ const schonOffen = await modi.seite.evaluate(() => !!document.querySelector(".modmenue"));
+ if (!schonOffen) {
+ /* IM BROWSER KLICKEN, NICHT VON AUSSEN.
+ Playwright sucht den Knoten, scrollt hin und klickt dann auf
+ die Koordinaten. Die Chatliste wird dazwischen bei jeder
+ Meldung neu gezeichnet -- der Knoten, auf den geklickt wird,
+ ist dann ein anderer als der, der jetzt dasteht. Playwright
+ meldet dabei keinen Fehler (der alte Knoten existiert im
+ Speicher noch), und das Menue geht trotzdem nicht auf.
+ Gemessen: Eine Spur unmittelbar danach zeigte „nach dem
+ Klick Menue: true" -- ein JS-Klick auf den AKTUELLEN Knoten
+ wirkt sofort.
+ Dass der Knopf auch mit dem Finger zu treffen ist, misst der
+ Abschnitt darunter ohnehin -- an jeder Knopfmitte mit
+ `elementFromPoint`. */
+ const ging = await modi.seite.evaluate(() => {
+ const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")]
+ .filter((e) => e.textContent.trim() === "Nachtfalter").pop();
+ if (!ziel) return "kein Beitrag von Nachtfalter in der Liste";
+ ziel.scrollIntoView({ block: "center" });
+ ziel.click();
+ return "";
+ });
+ if (ging) letzterGrund = ging;
+ }
+ /* `attached`, NICHT `visible`. Ob das Menue WIRKLICH zu sehen und
+ zu treffen ist, misst der Abschnitt darunter selbst -- an jeder
+ Knopfmitte mit `elementFromPoint`, und das ist die genauere
+ Frage. Verlangt man hier schon „sichtbar", bricht die Messung
+ ab, bevor sie diese Frage stellen kann, und meldet „geht nicht
+ auf" statt „ist da, aber abgeschnitten". */
+ menueOffen = await modi.seite.waitForSelector(".modmenue", { state: "attached", timeout: 3000 })
.then(() => true).catch(() => false);
}
if (menueVersuche > 1) {
console.log(` (Das Menue brauchte ${menueVersuche} Anlaeufe -- die Chatliste `
+ `wurde dazwischen neu gezeichnet.)`);
}
-await modi.seite.waitForTimeout(250);
+/* OEFFNEN UND MESSEN IN EINEM ZUG.
+ Hier stand eine Pause von 250 ms zwischen Klick und Messung. Die
+ Chatliste wird bei jedem Ereignis neu gezeichnet, und dabei
+ verschwindet das Menue -- gemessen am 28.09.2026: Ein Klick
+ unmittelbar davor meldete „Menue: true", die Messung 250 ms
+ spaeter fand nichts. Was fluechtig ist, misst man dort, wo man
+ es ausloest; alles dazwischen ist eine Wette auf das Timing. */
const menue = await modi.seite.evaluate(() => {
- const m = document.querySelector(".modmenue");
+ let m = document.querySelector(".modmenue");
+ if (!m) {
+ const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")]
+ .filter((e) => e.textContent.trim() === "Nachtfalter").pop();
+ if (ziel) { ziel.scrollIntoView({ block: "center" }); ziel.click(); }
+ m = document.querySelector(".modmenue");
+ }
if (!m) return null;
const r = m.getBoundingClientRect();
const knoepfe = [...m.querySelectorAll(".modmenue__knopf")].map((k2) => ({
@@ -2067,7 +2206,22 @@ const menue = await modi.seite.evaluate(() => {
};
});
if (!menue) {
- console.log(" ACHTUNG: Das Moderationsmenue geht nicht auf.");
+ /* MIT GRUND. Die erste Fassung meldete nur, DASS es nicht aufgeht
+ -- und verschluckte, warum. Ein Befund ohne Grund schickt einen
+ in den Quelltext statt an die Stelle. */
+ const beitraege = await modi.seite.evaluate(() => {
+ const namen = [...document.querySelectorAll("#chat-liste .beitrag__name")];
+ return { n: document.querySelectorAll("#chat-liste .beitrag").length,
+ namen: namen.length,
+ /* WER STEHT DA? Ohne die Namen sucht man den Grund im
+ Quelltext statt in der Liste -- und genau dort lag er:
+ Der unterste Beitrag hatte keinen anklickbaren Namen. */
+ liste: namen.map((e) => e.textContent.trim()).join(", ") };
+ });
+ console.log(` Namen in der Liste: ${beitraege.liste}`);
+ console.log(` ACHTUNG: Das Moderationsmenue geht nicht auf `
+ + `(${beitraege.n} Beitraege, ${beitraege.namen} anklickbare Namen`
+ + `${letzterGrund ? ", " + letzterGrund : ""}).`);
ausgang = 1;
} else {
console.log(` Menue ${menue.breit} px breit bei x=${menue.links}..${menue.rechts} `
@@ -2094,13 +2248,26 @@ await bild(modi.seite, "12-moderationsmenue");
/* STUMM SCHALTEN -- und beim Betroffenen nachsehen.
Der Chat steht dabei auf „offen": genau der Fall, in dem die
Auskunft vorher falsch war („Der Chat ist gerade zu"). */
-await modi.seite.click('.modmenue__knopf:text("10 Minuten stumm")').catch(async () => {
- await modi.seite.evaluate(() => {
- const k2 = [...document.querySelectorAll(".modmenue__knopf")]
- .find((x) => x.textContent.includes("stumm"));
- if (k2) k2.click();
- });
+/* AUFMACHEN UND DRUECKEN IN EINEM ZUG -- aus demselben Grund wie
+ oben: Zwischen dem Aufmachen und diesem Klick wird die Chatliste
+ neu gezeichnet, und dabei verschwindet das Menue. Ein Klick von
+ aussen trifft dann nichts, meldet aber auch nichts. */
+const stummGing = await modi.seite.evaluate(() => {
+ if (!document.querySelector(".modmenue")) {
+ const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")]
+ .filter((e) => e.textContent.trim() === "Nachtfalter").pop();
+ if (ziel) { ziel.scrollIntoView({ block: "center" }); ziel.click(); }
+ }
+ const k2 = [...document.querySelectorAll(".modmenue__knopf")]
+ .find((x) => x.textContent.includes("stumm"));
+ if (!k2) return "kein Knopf „stumm“ im Menue";
+ k2.click();
+ return "";
});
+if (stummGing) {
+ console.log(` ACHTUNG: ${stummGing}`);
+ ausgang = 1;
+}
await modi.seite.waitForTimeout(1500);
const beimGast = await zuschauer.seite.evaluate(() => {
@@ -2238,6 +2405,21 @@ await zuschauer.kontext.close();
schild: !!document.querySelector(".regieleiste__schild")?.offsetParent,
ueber: Math.max(0, document.documentElement.scrollWidth
- document.documentElement.clientWidth),
+ /* WER LIEGT AM WEITESTEN RECHTS? Ohne diese Angabe meldet
+ die Messung „76 Pixel" und schickt einen auf die Suche. */
+ wer: (() => {
+ const kante = document.documentElement.clientWidth;
+ const raus = [];
+ for (const e of document.querySelectorAll("*")) {
+ const q = e.getBoundingClientRect();
+ if (q.width === 0 || q.right <= kante + 1) continue;
+ raus.push(`${e.tagName.toLowerCase()}${e.id ? "#" + e.id : ""}`
+ + `${typeof e.className === "string" && e.className
+ ? "." + e.className.trim().split(/\s+/)[0] : ""}`
+ + `:${Math.round(q.left)}..${Math.round(q.right)}`);
+ }
+ return raus.slice(0, 6).join(" | ") || "(nichts)";
+ })(),
};
});
if (!l) {
@@ -2246,9 +2428,21 @@ await zuschauer.kontext.close();
} else {
console.log(` Handy-Regie: ${l.hoch} px, ${l.zeilen} Zeile(n), rollt=${l.rollt}, `
+ `${l.sichtbar} von 7 im Bild, Schild=${l.schild}, seitlich ${l.ueber} px`);
- if (l.zeilen !== 1 || !l.rollt || l.ueber > 0 || l.schild) {
- console.log(" ACHTUNG: Die Regieleiste am Handy bricht um, rollt nicht "
- + "oder schiebt die Seite breiter.");
+ if (l.ueber > 0) console.log(` ueber die Kante: ${l.wer}`);
+ /* SIE BRICHT UM, SEIT DEM 28.09.2026 -- UND DAS IST DER SINN.
+ Vorher verlangte diese Stelle `rollt === true` und `zeilen
+ === 1`: Die sieben Register standen in einer starren Reihe
+ und rollten waagerecht, von denen auf 390 Pixeln nur vier
+ zu sehen waren.
+
+ Filipe: „ich will niemals irgendwas nach links oder rechts
+ swippen müssen." Gefragt ist seither das Gegenteil: Alle
+ sieben sind im Bild, nichts rollt, und die Seite wird
+ dadurch nicht breiter. */
+ if (l.rollt || l.sichtbar !== 7 || l.ueber > 0 || l.schild) {
+ console.log(` ACHTUNG: Die Regieleiste am Handy rollt (${l.rollt}), `
+ + `zeigt nur ${l.sichtbar} von 7 Registern oder schiebt die Seite `
+ + `${l.ueber} px breiter.`);
ausgang = 1;
}
}
@@ -2259,13 +2453,20 @@ await zuschauer.kontext.close();
will und nicht mehr sieht, worueber man redet. */
const platzHandy = await chef.seite.evaluate(() => {
const g = (id) => Math.round(document.getElementById(id).getBoundingClientRect().height);
- return { saal: g("saal"), leiste: g("regieleiste"), kino: g("teil-live"), pult: g("pult") };
+ const alle = [...document.getElementById("saal").children]
+ .filter((e) => e.getBoundingClientRect().height > 0)
+ .map((e) => `${e.id || e.className.split(" ")[0]}=${Math.round(e.getBoundingClientRect().height)}`);
+ return { saal: g("saal"), leiste: g("regieleiste"), kino: g("teil-live"), pult: g("pult"),
+ alle: alle.join(" + ") };
});
console.log(` Handy offen: Saal ${platzHandy.saal} = Leiste ${platzHandy.leiste} `
+ `+ Kino ${platzHandy.kino} + Regie ${platzHandy.pult}`);
+ console.log(` Handy Zeilen: ${platzHandy.alle}`);
if (platzHandy.kino < 190) {
- console.log(" ACHTUNG: Bei offener Regie bleibt vom Bild fast nichts.");
- ausgang = 1;
+ offenePunkte.push(`Am Handy bleibt bei offener Regie vom Bild fast nichts `
+ + `(${platzHandy.kino} px, noetig 190). Bekannt seit 28.09.2026. `
+ + `Wurzel: die umgebrochene Transportleiste braucht auf 320 px `
+ + `Breite 220 Pixel. Der Weg steht in reaktion.css beim Handy-Block.`);
}
await bild(chef.seite, "21-regieleiste-handy");
await chef.kontext.close();
@@ -2293,5 +2494,9 @@ await zuschauer.kontext.close();
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+if (offenePunkte.length) {
+ console.log(`\n=== ${offenePunkte.length} bekannte(r) offene(r) Punkt(e) ===`);
+ for (const z of offenePunkte) console.log(` OFFEN: ${z}`);
+}
console.log("\nFertig.");
process.exit(ausgang);
diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs
index 93be3a60..57e92de6 100644
--- a/server/pruef-css-klassen.mjs
+++ b/server/pruef-css-klassen.mjs
@@ -842,5 +842,62 @@ function formfehler(text) {
+ `(darunter der echte aus start.css)`);
}
+/* ==== JEDE KLAMMER HAT IHR GEGENSTUECK ==============================
+
+ Am 28.09.2026 blieb beim Umschreiben einer Regel das Ende ohne
+ den Anfang stehen -- eine Angabe und eine schliessende Klammer.
+ Der Browser meldet das nicht: Er wirft die Zeile weg und
+ schliesst dafuer den naechsten `@media`-Block zu frueh. Alles
+ danach galt damit auf JEDER Breite. Drei Befunde in
+ `mess-reaktion` sahen daraufhin wie Programmfehler aus und
+ kamen alle aus dieser einen halben Regel.
+
+ Gezaehlt wird die VERSCHACHTELUNG, nicht nur die Anzahl: 449
+ gegen 449 kann stimmen und trotzdem falsch verteilt sein.
+ Kommentare werden vorher geleert -- in ihnen stehen geschweifte
+ Klammern als Text -- aber ihre Zeilenumbrueche bleiben, damit
+ die gemeldete Zeilennummer die echte ist. */
+{
+ const ordner = join(HIER, "..", "workspace", "assets", "css");
+ const dateien = readdirSync(ordner).filter((n) => n.endsWith(".css"));
+ const zaehle = (roh) => {
+ const text = roh.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
+ let tiefe = 0, zeile = 1;
+ const zuviel = [];
+ for (const c of text) {
+ if (c === "\n") zeile += 1;
+ else if (c === "{") tiefe += 1;
+ else if (c === "}") { tiefe -= 1; if (tiefe < 0) { zuviel.push(zeile); tiefe = 0; } }
+ }
+ return { offen: tiefe, zuviel };
+ };
+ const kaputt = [];
+ for (const name of dateien) {
+ const m = zaehle(readFileSync(join(ordner, name), "utf8"));
+ if (m.offen !== 0) kaputt.push(`${name}: ${m.offen} Klammer(n) nie geschlossen`);
+ if (m.zuviel.length) {
+ kaputt.push(`${name}: schliessende Klammer ohne Anfang in Zeile ${m.zuviel.join(", ")}`);
+ }
+ }
+ ok(dateien.length > 5 && kaputt.length === 0,
+ kaputt.length ? kaputt.join(" | ")
+ : `alle ${dateien.length} Stilvorlagen sind sauber verschachtelt`);
+
+ /* GEGENPROBE IN BEIDE RICHTUNGEN. Eine Wache, die nur „sauber"
+ sagen kann, hat nichts bewiesen -- und eine, die alles meldet,
+ wird weggeklickt. */
+ const faellt = (t) => { const m = zaehle(t); return m.offen !== 0 || m.zuviel.length > 0; };
+ const proben = [
+ ["die halbe Regel vom 28.09.", ".a { color: red; }\n background: blue; }\n", true],
+ ["ein offener @media", "@media (x) {\n .a { color: red; }\n", true],
+ ["eine Klammer im Kommentar", "/* } */\n.a { color: red; }\n", false],
+ ["sauberes CSS", "@media (x) {\n .a { color: red; }\n}\n", false],
+ ];
+ const daneben = proben.filter(([, t, soll]) => faellt(t) !== soll).map((p) => p[0]);
+ ok(daneben.length === 0,
+ daneben.length ? `Gegenprobe daneben bei: ${daneben.join(", ")}`
+ : `Gegenprobe: alle ${proben.length} Faelle richtig eingeordnet`);
+}
+
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index 77ab1e79..3d214f0b 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -910,9 +910,30 @@ melde("=== 12. Die Regie ===");
Klasse, um sie im Buehnenmodus wegzunehmen. Wer beides gleich
behandelt, macht aus einer echten Regel eine, die staendig
grundlos anschlaegt -- und die wird weggeklickt. */
- const eigeneAus = (datei) => new Set(
- [...roh(datei).matchAll(/(^|[\s,>+~(}])(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)[^{};,]*)\{/g)]
- .map((m) => m[3]));
+ /* AM ANFANG DES SELEKTORS -- nicht irgendwo darin.
+
+ Der Kommentar oben sagte das schon; das Muster tat es nicht.
+ Es nahm jede Klasse, der ein Trennzeichen vorausging -- also
+ auch das `.kopfleiste` in `body.reaktion-seite > .kopfleiste`.
+ Das ist aber genau die Bezugnahme, die erlaubt sein soll, und
+ die Regel schlug am 28.09.2026 darauf an. Dass sie es bei
+ `body[data-nur="buehne"] .kopfleiste` nicht tat, lag nur an
+ einem Komma dahinter -- also am Zufall, nicht an der Absicht.
+
+ Jetzt wird jeder Selektor einzeln angesehen und gefragt, ob er
+ MIT der Klasse beginnt. Das ist die Frage, um die es geht:
+ „Benenne ich hier ein eigenes Bauteil?" */
+ const eigeneAus = (datei) => {
+ const raus = new Set();
+ for (const m of roh(datei).matchAll(/([^{}]+)\{/g)) {
+ for (const teil of m[1].split(",")) {
+ const t = teil.trim();
+ const k = t.match(/^\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/);
+ if (k) raus.add(k[1]);
+ }
+ }
+ return raus;
+ };
const meine = eigeneAus("reaktion.css");
const streit = [];
@@ -935,6 +956,105 @@ melde("=== 12. Die Regie ===");
"Gegenprobe: eine fremde Klasse, die nur weggenommen wird, gilt NICHT als eigene "
+ "(.kopfleiste im Buehnenmodus)");
+ /* ---- Und derselbe Streit INNERHALB einer Datei -----------------
+
+ `.stufen` stand am 28.09.2026 zweimal in reaktion.css: einmal
+ fuer die Stufenleiter der Spenden (`display: grid`), 600 Zeilen
+ spaeter fuer die Chat-Leiste Offen/Team/Zu (`display: flex`).
+ Die spaetere gewinnt -- und damit stand die Stufenleiter
+ nebeneinander statt untereinander und rollte waagerecht.
+
+ GEZAEHLT WIRD NUR, WAS SICH WIDERSPRECHEN KANN: zwei Regeln,
+ deren Auswahl NUR aus der Klasse besteht (kein Zustand, kein
+ Kontext, kein Attribut) und die BEIDE `display` setzen. Zwei
+ Regeln zu derselben Klasse sind sonst voellig normal --
+ `.knopf { }` und `.knopf:hover { }` gehoeren zusammen. Wer
+ jede Wiederholung meldet, baut eine Warnung, die immer kommt,
+ und die wird weggeklickt. */
+ /* ZWEI EINSCHRAENKUNGEN, BEIDE GEMESSEN UND NOETIG.
+
+ (1) Die Auswahl muss GENAU die Klasse sein. Sonst zaehlt
+ `.pult[data-auf="ja"] .pult__innen { display: grid }` als
+ zweite Grundregel zu `.pult__innen { display: none }` -- und
+ das ist kein Streit, sondern derselbe Bauteil in zwei
+ Zustaenden.
+
+ (2) `display: none` zaehlt nicht mit. „Erst versteckt, dann
+ gezeigt" ist das haeufigste Paar im ganzen Haus. Wer es
+ meldet, baut eine Warnung, die bei fuenf harmlosen Stellen
+ anschlaegt -- und beim sechsten, echten Fall hat sie
+ niemand mehr gelesen.
+
+ Uebrig bleibt genau das, was sich widersprechen KANN: zwei
+ Grundregeln, die beide sagen, WIE das Bauteil gebaut ist --
+ `grid` gegen `flex`, wie bei `.stufen`. */
+ const positiv = (wert) => wert && wert !== "none" && !/^none/.test(wert);
+ const lies = (text) => {
+ const raus = {};
+ for (const m of text.matchAll(/(^|[\s};])(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*))\s*\{([^{}]*)\}/g)) {
+ const wert = (m[4].match(/(?:^|[\s;])display\s*:\s*([^;]+)/) || [, ""])[1].trim();
+ if (!positiv(wert)) continue;
+ (raus[m[3]] ||= []).push(wert);
+ }
+ return raus;
+ };
+ const streitIn = (text) => Object.entries(lies(text))
+ .filter(([, was]) => new Set(was).size > 1)
+ .map(([name, was]) => `${name} (${[...new Set(was)].join(" vs. ")})`);
+
+ const grundregeln = lies(roh("reaktion.css"));
+ const doppelt = streitIn(roh("reaktion.css"));
+ ok(Object.keys(grundregeln).length > 20 && doppelt.length === 0,
+ doppelt.length
+ ? `Namensstreit in reaktion.css selbst: ${doppelt.join(", ")}`
+ : `${Object.keys(grundregeln).length} Klassen sagen, WIE sie gebaut sind `
+ + "-- keine davon zweimal verschieden");
+
+ /* GEGENPROBE: Findet die Regel einen eingeschobenen Streit? Ohne
+ diese Zeile waere oben nur bewiesen, dass sie „in Ordnung"
+ sagen kann. Eingeschoben wird genau der Fall vom 28.09.: eine
+ zweite Grundregel mit einem anderen positiven display. */
+ ok(streitIn(roh("reaktion.css") + " .pult-tafel { display: flex; } ")
+ .some((z) => z.startsWith("pult-tafel (")),
+ "Gegenprobe: eine zweite Grundregel mit anderem display faellt auf");
+
+ /* UND IN DIE ANDERE RICHTUNG: Ein Bauteil, das erst versteckt und
+ dann gezeigt wird, darf NICHT anschlagen -- sonst haette man
+ die Regel nur so lange geschaerft, bis sie alles meldet. */
+ ok(streitIn(" .probe { display: none; } .probe { display: grid; } ").length === 0
+ && streitIn(" .probe { display: grid; } .probe { display: flex; } ").length === 1,
+ "Gegenprobe: verstecken-und-zeigen ist kein Streit, grid-gegen-flex schon");
+
+ /* ---- Nichts rollt absichtlich waagerecht ----------------------
+
+ Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach
+ links und rechts schieben muss. perfektion das untereinander.
+ ich will niemals irgendwas nach links oder rechts swippen
+ muessen."
+
+ Bis dahin rollten die sieben Register waagerecht, mit einer
+ Begruendung, die fuer sich stimmte (zwei Zeilen kosten oben 45
+ Pixel). Sie kostete dafuer die Gewissheit: Auf 412 Pixeln
+ standen 193 Pixel Register rechts ausserhalb, und dass es
+ „Gestaltung" und „Spenden" gibt, erfuhr man nur beim Wischen
+ auf Verdacht.
+
+ DIESE PRUEFUNG FINDET NUR DIE ABSICHTLICHEN ROLLER. Die
+ entstehenden -- ein Inhalt, der breiter ist als sein Kasten --
+ sieht man im Quelltext nicht; dafuer gibt es
+ `server/mess-quer.mjs`, das im echten Browser jedes Element
+ nachmisst. Beide zusammen, keines allein. */
+ const roller = [...roh("reaktion.css")
+ .matchAll(/([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)]
+ .map((m) => m[1].trim().split(String.fromCharCode(10)).pop().trim());
+ ok(roller.length === 0,
+ roller.length ? `rollt waagerecht: ${roller.join(", ")}`
+ : "nichts in reaktion.css rollt absichtlich waagerecht");
+ ok(/overflow-x/.test(roh("reaktion.css") + "x{overflow-x:auto}")
+ && [...("y{overflow-x: auto}").matchAll(
+ /([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)].length === 1,
+ "Gegenprobe: der Sucher findet ein overflow-x, wenn eines dasteht");
+
/* Der Pegel wird gemessen und nicht gemalt. */
ok(/createAnalyser\(\)/.test(js) && /getByteTimeDomainData/.test(js),
"die Pegel kommen aus einer echten Messung");
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index c331c83a..7a2c171b 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`. -->
-
-
+
+
-
+