diff --git a/server/mess-chat-optik.mjs b/server/mess-chat-optik.mjs
new file mode 100644
index 00000000..7a655a71
--- /dev/null
+++ b/server/mess-chat-optik.mjs
@@ -0,0 +1,203 @@
+/* =====================================================================
+ WIE DER CHAT AUSSIEHT (25.09.2026)
+
+ Filipe: „ich will dass du die komplette seite viel geiler und
+ moderner machst. die komplette kachel. die chat liste und die chats
+ selber. … es soll komplett aus der rolle fahren und was was wir noch
+ nie hatten."
+
+ SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
+ auf denen man vergleichen kann. Deshalb `mess-` und nicht `pruef-`.
+
+ VIER BILDER, weil die Seite zwei Zustaende und zwei Breiten hat:
+ Liste ohne Gespraech / Liste mit offenem Gespraech, jeweils am
+ Rechner und am Handy. Wer nur eines ansieht, baut fuer eines.
+
+ EIGENE WEGWERF-DATENBANK: Es entstehen echte Gespraeche und
+ Nachrichten. In einem laufenden System waeren das Testdaten, und die
+ sind verboten. Hier liegt alles in einem `mktemp`-Ordner.
+
+ Aufrufen mit: node server/mess-chat-optik.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { execFileSync } from "node:child_process";
+import { request as httpAnfrage } from "node:http";
+import { createServer as httpsServer } from "node:https";
+
+const PORT = 5403, HP = 5404;
+const CREW = "crew.dogfather-universe.com";
+const ordner = mkdtempSync(join(tmpdir(), "ws-chatoptik-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) {
+ return ec.call(this, n, w, { ...(o || {}), secure: false });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(260_000, "mess-chat-optik");
+await new Promise((r) => setTimeout(r, 900));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const vorStunden = (h) => new Date(Date.now() - h * 3600_000).toISOString();
+const sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
+const idVan = anlegen("VanVan", "hand", "CODE-HAND-0001");
+const idFunny = anlegen("Funny", "linke", "CODE-LINK-0001");
+const idDiene = anlegen("Diene", "modi", "CODE-MODI-0001");
+const idMarina = anlegen("Marina", "modi", "CODE-MODI-0002");
+const idGhost = anlegen("Ghost", "modi", "CODE-MODI-0003");
+const idMiss = anlegen("Miss", "modi", "CODE-MODI-0004");
+
+/* ---- Gespraeche und Nachrichten direkt in die Datenbank -------------
+ Ueber die Schnittstelle waeren es fuenfzehn Aufrufe fuer einen
+ Zustand, den es nur fuer das Bild braucht. */
+const raum = (art, name, kategorie, haus = "crew") => d.prepare(
+ "INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von, haus)"
+ + " VALUES (?,?,?,?,?,?) RETURNING id").get(art, name, kategorie, jetzt, idDogi, haus).id;
+const rein = (r, p) => d.prepare(
+ "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)")
+ .run(r, p, jetzt);
+const sagen = (r, p, text, wann) => d.prepare(
+ "INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
+ .run(r, p, text, wann);
+
+const rudel = raum("kanal", "Das Rudel", "treff");
+for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost, idMiss]) rein(rudel, p);
+sagen(rudel, idFunny, "@rudel das mache ich heute Abend fertig, versprochen 🙌", vorStunden(20));
+
+const modkanal = raum("kanal", "Chat-Moderation", "chat");
+for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost]) rein(modkanal, p);
+sagen(modkanal, idMarina, "Ach man 😢😢", vorStunden(30));
+
+const gruppe = raum("gruppe", "Abmeldungen 🏥", null);
+for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost, idMiss]) rein(gruppe, p);
+sagen(gruppe, idDiene, "Guten Morgen ihr Lieben – ich bin heute leider raus, Hals tut weh.", vorStunden(31));
+
+const mitVan = raum("direkt", null, null);
+rein(mitVan, idDogi); rein(mitVan, idVan);
+sagen(mitVan, idVan, "Da hat Miss was falsch eingegeben glaube ich", vorStunden(29));
+sagen(mitVan, idDogi, "Hab's gesehen. Ich schreib ihr gleich, kein Ding.", vorStunden(28));
+sagen(mitVan, idVan, "Top. Und die Abrechnung vom Wochenende liegt bei dir im Ordner.", vorStunden(27));
+sagen(mitVan, idDogi, "Perfekt, danke dir ❤️", vorStunden(2));
+
+const mitGhost = raum("direkt", null, null);
+rein(mitGhost, idDogi); rein(mitGhost, idGhost);
+sagen(mitGhost, idGhost, "Sorry hab's gefunden ist gelöscht 👍", vorStunden(29));
+
+d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(20), rudel);
+d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(2), mitVan);
+d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(29), mitGhost);
+d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(30), modkanal);
+d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(31), gruppe);
+d.close();
+
+const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: art,
+ headers: { Host: CREW, "Content-Type": "application/json" },
+ }, (r) => {
+ let t = ""; r.on("data", (x) => { t += x; });
+ r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+});
+const an = await anfrage("/workspace/api/anmelden", "POST",
+ { rolle: "admin", code: "CODE-DOGI-0001" });
+if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${an.code}`); process.exit(2); }
+const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
+
+const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl,
+ "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
+{ stdio: "ignore" });
+const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
+ (von, nach) => {
+ const w = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HP}` },
+ }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
+ von.pipe(w);
+ });
+await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
+
+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"],
+});
+const MARKE = process.env.MARKE || "jetzt";
+try {
+ for (const [wie, breite, hoehe] of [["computer", 1440, 980], ["handy", 412, 915]]) {
+ const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => {
+ if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR|410/i.test(e.text())) {
+ konsole.push(e.text().slice(0, 140));
+ }
+ });
+ await seite.goto(`https://${CREW}:${HP}/workspace/chat.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1600);
+
+ const liste = await seite.evaluate(() => ({
+ raeume: document.querySelectorAll("#raeume li").length,
+ namen: [...document.querySelectorAll(".chat-raum__name")].map((x) => x.textContent),
+ }));
+ console.log(`\n=== Chat, ${breite} x ${hoehe} (${wie}) ===`);
+ console.log(" Gespraeche:", liste.raeume, "|", liste.namen.join(" · "));
+ await seite.screenshot({ path: `server/mess-chat-${MARKE}-${wie}-liste.png` });
+
+ /* Und jetzt mit offenem Gespraech -- das ist die Haelfte der
+ Seite, die man am laengsten ansieht. */
+ await seite.evaluate(() => {
+ const zeilen = [...document.querySelectorAll("#raeume .chat-raum__knopf")];
+ (zeilen.find((z) => /VanVan/.test(z.textContent)) || zeilen[0])?.click();
+ });
+ await seite.waitForTimeout(1500);
+ const verlauf = await seite.evaluate(() => ({
+ nachrichten: document.querySelectorAll(".chat-nachricht").length,
+ titel: document.getElementById("verlauf-titel")?.textContent,
+ }));
+ console.log(" Verlauf:", verlauf.nachrichten, "Nachrichten in", verlauf.titel);
+ await seite.screenshot({ path: `server/mess-chat-${MARKE}-${wie}-verlauf.png` });
+ if (konsole.length) console.log(" FEHLER IN DER KONSOLE:", konsole);
+ console.log(` Bilder: server/mess-chat-${MARKE}-${wie}-{liste,verlauf}.png`);
+ await kontext.close();
+ }
+} finally {
+ await browser.close();
+ vorbau.close();
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(0);
+}
diff --git a/server/pruef-chat-optik.mjs b/server/pruef-chat-optik.mjs
index 95e4249f..717be9e7 100644
--- a/server/pruef-chat-optik.mjs
+++ b/server/pruef-chat-optik.mjs
@@ -769,8 +769,36 @@ melde("\n=== Die Schreibzeile auf dem Handy ===");
`das Schreibfeld ist ${zeile.feld?.b} px breit (mindestens 220)`);
/* SENDEN STEHT BEIM FELD. Vorher lag es eine Zeile tiefer, unten
links -- weiter vom Text entfernt als die Bueroklammer. */
- ok(Math.abs((zeile.senden?.y || 0) - (zeile.feld?.y || 0)) <= 6,
- `und "Senden" steht daneben, nicht darunter (y ${zeile.senden?.y} zu ${zeile.feld?.y})`);
+ /* ==== SIE VERGLICH DIE OBERKANTEN (behoben 25.09.2026) =========
+
+ Hier stand `Math.abs(senden.y - feld.y) <= 6`. Die Absicht ist
+ richtig, die Messung war es nicht: Die Zeile ist `align-items:
+ flex-end` -- beide stehen UNTEN buendig. Das Schreibfeld ist
+ zwei Zeilen hoch (66 px), der Knopf 42. Die Oberkanten liegen
+ damit zwangslaeufig 24 px auseinander, OBWOHL beide in
+ derselben Zeile stehen.
+
+ Die Pruefung war dadurch dauerhaft rot, ohne dass etwas kaputt
+ war -- und eine Warnung, die immer kommt, wird ueberlesen.
+ Gefunden habe ich es erst, als ich zweimal an der falschen
+ Stelle repariert hatte (Knopfbreite, Abstand): Beide Male
+ aenderte sich nichts an der Zahl. Eine Zahl, die sich durch die
+ Reparatur nicht bewegt, misst etwas anderes, als man denkt.
+
+ GEFRAGT WIRD JETZT, WAS GEMEINT IST: Teilen sich die beiden
+ eine Zeile? Das heisst, ihre senkrechten Bereiche UEBERLAPPEN
+ sich -- und zwar deutlich, nicht nur um einen Pixel. Rutscht
+ "Senden" wirklich eine Zeile tiefer, ist die Ueberlappung null
+ und die Zeile wird rot. Sie prueft damit mehr als vorher, nicht
+ weniger: Die alte Fassung haette zwei gleich hohe Elemente auch
+ dann durchgelassen, wenn sie in verschiedenen Spalten stehen. */
+ const fO = zeile.feld?.y || 0, fU = fO + (zeile.feld?.h || 0);
+ const sO = zeile.senden?.y || 0, sU = sO + (zeile.senden?.h || 0);
+ const gemeinsam = Math.min(fU, sU) - Math.max(fO, sO);
+ const kleinste = Math.min(zeile.feld?.h || 0, zeile.senden?.h || 0);
+ ok(kleinste > 0 && gemeinsam >= kleinste * 0.8,
+ `und "Senden" steht daneben, nicht darunter `
+ + `(${gemeinsam} von ${kleinste} px gemeinsame Hoehe)`);
ok(zeile.drueber.length === 0,
`nichts liegt uebereinander (${zeile.drueber.join(", ") || "nichts"})`);
ok(zeile.klein.length === 0,
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index f76d2f83..75b645a3 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+