diff --git a/server/pruef-chat-ausbau.mjs b/server/pruef-chat-ausbau.mjs
new file mode 100644
index 00000000..d3b5fd83
--- /dev/null
+++ b/server/pruef-chat-ausbau.mjs
@@ -0,0 +1,621 @@
+/* CHAT — DER AUSBAU VOM 09.09.2026
+
+ Filipe: "die kategorie chat, boah ich will dass du die wirklich
+ überall perfektionierst. wirklich alles was gratis ist und
+ hinzugefügt werden kann ohne problem will ich drin haben bitte."
+
+ Sechs Dinge sind dazugekommen. Diese Prüfung misst sie einzeln, an
+ einem echten Browser, mit zwei angemeldeten Personen:
+
+ 1. Adressen im Text sind anklickbar -- und zwar als echte Knoten,
+ nicht über innerHTML. Ein Chat ist die eine Stelle, an der
+ JEDER schreiben darf; hier wäre eine Einschleusung am teuersten.
+ 2. Jede Nachricht lässt sich kopieren.
+ 3. Der Verlauf reisst niemanden mehr weg, der weiter oben liest --
+ stattdessen sagt ein Knopf, wie viel unten wartet.
+ 4. Ein angefangener Satz überlebt den Wechsel des Gesprächs.
+ 5. Eine Emoji-Auswahl, die an der Cursorstelle einfügt.
+ 6. Ein Zeichenzähler, der erst erscheint, wenn es eng wird.
+
+ ZU JEDEM PUNKT EINE GEGENPROBE. Eine Prüfung, die immer bestätigt,
+ bestätigt nichts -- deshalb steht neben jedem "es passiert" auch ein
+ "und es passiert NICHT, wenn es nicht soll".
+
+ DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
+
+import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-chatausbau-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4319, "die Chat-Ausbaupruefung");
+
+process.env.PORT = "4319";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(480_000, "pruef-chat-ausbau");
+await new Promise((r) => setTimeout(r, 1000));
+const BASIS = "http://127.0.0.1:4319";
+
+const PROTOKOLL = join(process.cwd(), "pruef-chat-ausbau-lauf.txt");
+try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
+const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+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 jetzt = new Date().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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
+d.close();
+
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+async function alsRolle(rolle, code, breite = 1280) {
+ const ctx = await browser.newContext({
+ viewport: { width: breite, height: 900 },
+ permissions: ["clipboard-read", "clipboard-write"],
+ });
+ const seite = await ctx.newPage();
+ const konsole = [];
+ /* MIT HERKUNFT. Eine Meldung ohne Datei und Zeile kostet die halbe
+ Suche -- genau das ist mir beim ersten Lauf passiert. */
+ seite.on("pageerror", (f) => konsole.push("Absturz auf " + seite.url() + ": " + f.message
+ + " || " + String(f.stack || "").split(/\r?\n/).slice(1, 4).join(" / ")));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); });
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click(`.rolle[data-rolle="${rolle}"]`);
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+ return { ctx, seite, konsole };
+}
+
+/* Eine Nachricht auf dem kurzen Weg -- über die echte Schnittstelle der
+ Seite, damit Herkunftsprüfung und Sitzung mitlaufen. */
+async function schicke(s, raum, text) {
+ return s.evaluate(async ([r, t]) => {
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: t }),
+ });
+ return a.ok;
+ }, [raum, text]);
+}
+
+const luna = await alsRolle("creator", "CODE-CREA-0001");
+const pat = await alsRolle("scout", "CODE-SCOU-0001");
+
+/* Zwei Gespräche: eines mit Patrick, eines mit DogFather. Das zweite
+ braucht es für die Entwürfe -- ohne Wechsel kein Wechseltest. */
+async function direkt(s, mit) {
+ return s.evaluate(async (m) => {
+ const a = await fetch("/workspace/api/chat/direkt", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mit: m }) });
+ return (await a.json()).raum_id;
+ }, mit);
+}
+const raumId = await direkt(luna.seite, idPat);
+const raumDogi = await direkt(luna.seite, idDogi);
+ok(Number.isInteger(raumId) && raumId > 0, `das Gespräch mit Patrick steht (Raum ${raumId})`);
+ok(Number.isInteger(raumDogi) && raumDogi !== raumId, `und eines mit DogFather (Raum ${raumDogi})`);
+
+async function oeffne(s, id) {
+ await s.goto(`${BASIS}/workspace/chat.html?raum=${id}`, { waitUntil: "networkidle" });
+ await s.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
+ await s.waitForTimeout(500);
+}
+
+/* =======================================================================
+ 1. ADRESSEN IM TEXT
+ ======================================================================= */
+melde("\n=== 1. Adressen im Text ===");
+const MIT_LINKS = "Schau mal https://workspace.dogfather-universe.com/start.html "
+ + "und www.dogfather-universe.com, danke!";
+{
+ await schicke(luna.seite, raumId, MIT_LINKS);
+ await oeffne(luna.seite, raumId);
+
+ const links = await luna.seite.$$eval(".chat-nachricht:last-of-type .chat-nachricht__link",
+ (a) => a.map((x) => ({ text: x.textContent, href: x.href, ziel: x.target, rel: x.rel })));
+ ok(links.length === 2, `beide Adressen sind Links (${links.length} gefunden)`);
+ ok(links[0]?.href === "https://workspace.dogfather-universe.com/start.html",
+ `die erste zeigt dorthin, wo sie steht (${links[0]?.href})`);
+ ok(links[1]?.href === "https://www.dogfather-universe.com/",
+ `bei "www." wird https ergänzt (${links[1]?.href})`);
+ ok(links[1]?.text === "www.dogfather-universe.com",
+ `das Komma dahinter gehört zum Satz, nicht zur Adresse ("${links[1]?.text}")`);
+ ok(links.every((l) => l.ziel === "_blank"), "sie gehen in einen neuen Tab");
+ ok(links.every((l) => /noopener/.test(l.rel) && /noreferrer/.test(l.rel)),
+ `rel="noopener noreferrer" steht dran (${links[0]?.rel})`);
+
+ /* NICHTS DARF VERLOREN GEHEN. Beim Zerlegen eines Textes in Stücke ist
+ genau das der Fehler, den man nicht sieht: ein Zeichen zu viel oder
+ zu wenig an einer Nahtstelle. */
+ const ganz = await luna.seite.$eval(".chat-nachricht:last-of-type .chat-nachricht__text",
+ (p) => p.textContent);
+ ok(ganz === MIT_LINKS, "der Text ist Zeichen für Zeichen derselbe geblieben");
+
+ /* GEGENPROBE A: ohne Adresse kein Link. */
+ await schicke(luna.seite, raumId, "Hier steht z.b. gar keine Adresse, drei.punkte inklusive.");
+ await oeffne(luna.seite, raumId);
+ const ohne = await luna.seite.$$eval(".chat-nachricht:last-of-type .chat-nachricht__link", (a) => a.length);
+ ok(ohne === 0, `ein Satz ohne Adresse bekommt keinen Link (${ohne})`);
+
+ /* GEGENPROBE B: Auszeichnung wird NICHT ausgeführt. Das ist der
+ eigentliche Grund, warum hier nicht innerHTML steht. */
+ const BOESE = 'fett & "Zitat"';
+ await schicke(luna.seite, raumId, BOESE);
+ await oeffne(luna.seite, raumId);
+ const roh = await luna.seite.$eval(".chat-nachricht:last-of-type .chat-nachricht__text",
+ (p) => ({ text: p.textContent, kinder: p.querySelectorAll("b, img, script").length }));
+ ok(roh.kinder === 0, `keine eingeschleuste Auszeichnung (${roh.kinder} Fremdelemente)`);
+ ok(roh.text === BOESE, "der Text steht als Text da");
+}
+
+/* =======================================================================
+ 2. KOPIEREN
+ ======================================================================= */
+melde("\n=== 2. Kopieren ===");
+{
+ await oeffne(luna.seite, raumId);
+ const anzahl = await luna.seite.$$eval(".chat-nachricht__kopie-knopf", (b) => b.length);
+ const wieviel = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
+ ok(anzahl === wieviel && anzahl >= 3,
+ `jede der ${wieviel} Nachrichten hat einen Kopier-Knopf (${anzahl})`);
+
+ await luna.seite.click(".chat-nachricht:first-of-type .chat-nachricht__kopie-knopf");
+ await luna.seite.waitForTimeout(300);
+ const inZwischenablage = await luna.seite.evaluate(() => navigator.clipboard.readText());
+ ok(inZwischenablage === MIT_LINKS,
+ `der Text liegt in der Zwischenablage (${inZwischenablage.length} Zeichen)`);
+ const beschriftung = await luna.seite.$eval(
+ ".chat-nachricht:first-of-type .chat-nachricht__kopie-knopf", (b) => b.textContent);
+ ok(beschriftung === "kopiert", `der Knopf bestätigt sich selbst ("${beschriftung}")`);
+ await luna.seite.waitForTimeout(1800);
+ const zurueck = await luna.seite.$eval(
+ ".chat-nachricht:first-of-type .chat-nachricht__kopie-knopf", (b) => b.textContent);
+ ok(zurueck === "kopieren", `und fällt danach zurück ("${zurueck}")`);
+
+ /* GEGENPROBE: An einer zurückgenommenen Nachricht gibt es nichts zu
+ kopieren -- dort darf der Knopf nicht stehen. */
+ luna.seite.once("dialog", (dlg) => dlg.accept());
+ await luna.seite.click(".chat-nachricht:last-of-type .chat-nachricht__weg-knopf");
+ await luna.seite.waitForTimeout(900);
+ const anWeg = await luna.seite.$$eval('.chat-nachricht[data-weg="ja"] .chat-nachricht__kopie-knopf',
+ (b) => b.length);
+ const wegDa = await luna.seite.$$eval('.chat-nachricht[data-weg="ja"]', (b) => b.length);
+ ok(wegDa === 1 && anWeg === 0,
+ `an der zurückgenommenen Nachricht steht kein Kopier-Knopf (${wegDa} weg, ${anWeg} Knöpfe)`);
+}
+
+/* =======================================================================
+ 3. DER VERLAUF REISST NIEMANDEN MEHR WEG
+ ======================================================================= */
+melde("\n=== 3. Zum neuesten ===");
+{
+ for (let i = 1; i <= 40; i++) await schicke(pat.seite, raumId, `Zeile ${i} zum Vollmachen des Verlaufs.`);
+ await oeffne(luna.seite, raumId);
+ await oeffne(pat.seite, raumId);
+
+ const lang = await luna.seite.$eval("#verlauf", (e) => e.scrollHeight > e.clientHeight + 200);
+ ok(lang, "der Verlauf ist lang genug, um überhaupt scrollen zu können");
+
+ const amEnde = await luna.seite.$eval("#verlauf", (e) => e.scrollHeight - e.scrollTop - e.clientHeight < 40);
+ const knopfZu = await luna.seite.$eval("#zum-neuesten", (e) => e.hidden);
+ ok(amEnde && knopfZu, "beim Öffnen steht er unten, und der Knopf ist weg");
+
+ /* Luna liest weiter oben. */
+ await luna.seite.$eval("#verlauf", (e) => { e.scrollTop = 0; });
+ await luna.seite.waitForTimeout(400);
+ const vorher = await luna.seite.$eval("#verlauf", (e) => e.scrollTop);
+
+ await schicke(pat.seite, raumId, "Und noch etwas Neues dazu.");
+ await luna.seite.waitForFunction(
+ () => !document.getElementById("zum-neuesten").hidden, { timeout: 8000 })
+ .then(() => true).catch(() => false);
+ const nachher = await luna.seite.$eval("#verlauf", (e) => e.scrollTop);
+ ok(Math.abs(nachher - vorher) < 3, `die Stelle bleibt stehen (${vorher} -> ${nachher})`);
+ const text1 = await luna.seite.$eval("#zum-neuesten", (e) => e.textContent);
+ ok(text1 === "1 neue Nachricht", `der Knopf sagt, wie viel wartet ("${text1}")`);
+
+ await schicke(pat.seite, raumId, "Und noch eine.");
+ await luna.seite.waitForFunction(
+ () => document.getElementById("zum-neuesten").textContent === "2 neue Nachrichten",
+ { timeout: 8000 }).then(() => true).catch(() => false);
+ const text2 = await luna.seite.$eval("#zum-neuesten", (e) => e.textContent);
+ ok(text2 === "2 neue Nachrichten", `er zählt mit ("${text2}")`);
+
+ await luna.seite.click("#zum-neuesten");
+ await luna.seite.waitForTimeout(400);
+ const jetztUnten = await luna.seite.$eval("#verlauf", (e) => e.scrollHeight - e.scrollTop - e.clientHeight < 40);
+ const wiederZu = await luna.seite.$eval("#zum-neuesten", (e) => e.hidden);
+ ok(jetztUnten && wiederZu, "ein Klick bringt ans Ende, und der Knopf verschwindet");
+
+ /* GEGENPROBE: Wer schon unten steht, wird weiterhin mitgenommen --
+ sonst hätte ich die eine Unart gegen die andere getauscht. */
+ await schicke(pat.seite, raumId, "Eine letzte, während Luna unten steht.");
+ await luna.seite.waitForFunction(
+ () => [...document.querySelectorAll(".chat-nachricht__text")]
+ .some((t) => /während Luna unten steht/.test(t.textContent)), { timeout: 8000 })
+ .then(() => true).catch(() => false);
+ await luna.seite.waitForTimeout(400);
+ const bleibtUnten = await luna.seite.$eval("#verlauf", (e) => e.scrollHeight - e.scrollTop - e.clientHeight < 40);
+ const bleibtZu = await luna.seite.$eval("#zum-neuesten", (e) => e.hidden);
+ ok(bleibtUnten && bleibtZu, "wer unten steht, wird weiterhin mitgenommen — ohne Knopf");
+}
+
+/* =======================================================================
+ 4. DER ANGEFANGENE SATZ
+ ======================================================================= */
+melde("\n=== 4. Entwürfe ===");
+{
+ await oeffne(luna.seite, raumId);
+ await luna.seite.fill("#text", "Halber Satz an Patrick");
+ await luna.seite.waitForTimeout(250);
+ await oeffne(luna.seite, raumDogi);
+ const leerDrueben = await luna.seite.$eval("#text", (e) => e.value);
+ ok(leerDrueben === "", `im anderen Gespräch ist das Feld leer ("${leerDrueben}")`);
+
+ await luna.seite.fill("#text", "Und hier etwas ganz anderes");
+ await luna.seite.waitForTimeout(250);
+ await oeffne(luna.seite, raumId);
+ const wieder = await luna.seite.$eval("#text", (e) => e.value);
+ ok(wieder === "Halber Satz an Patrick", `der Satz ist wieder da ("${wieder}")`);
+ await oeffne(luna.seite, raumDogi);
+ const wieder2 = await luna.seite.$eval("#text", (e) => e.value);
+ ok(wieder2 === "Und hier etwas ganz anderes", "und jedes Gespräch behält seinen eigenen");
+
+ /* GEGENPROBE: Abgeschickt heisst weg. Eine Ablage, die nur wächst,
+ wäre ein Leck -- und beim nächsten Öffnen stünde der Satz doppelt. */
+ await oeffne(luna.seite, raumId);
+ await luna.seite.click("#senden");
+ await luna.seite.waitForTimeout(700);
+ const feldLeer = await luna.seite.$eval("#text", (e) => e.value);
+ const gespeichert = await luna.seite.evaluate((r) => localStorage.getItem(`dfw-chat-entwurf-${r}`), raumId);
+ ok(feldLeer === "" && gespeichert === null,
+ `nach dem Abschicken ist der Entwurf weg (Ablage: ${gespeichert})`);
+ await oeffne(luna.seite, raumId);
+ const nachSenden = await luna.seite.$eval("#text", (e) => e.value);
+ ok(nachSenden === "", "und er kommt beim nächsten Öffnen nicht zurück");
+
+ /* Und ein geleertes Feld löscht ihn auch. */
+ await oeffne(luna.seite, raumDogi);
+ await luna.seite.fill("#text", "");
+ await luna.seite.waitForTimeout(250);
+ const weg = await luna.seite.evaluate((r) => localStorage.getItem(`dfw-chat-entwurf-${r}`), raumDogi);
+ ok(weg === null, `wer sein Feld leert, hat auch keinen Entwurf mehr (${weg})`);
+}
+
+/* =======================================================================
+ 5. EMOJI
+ ======================================================================= */
+melde("\n=== 5. Emoji ===");
+{
+ await oeffne(luna.seite, raumId);
+ const zuAmAnfang = await luna.seite.$eval("#emoji-tafel", (e) => e.hidden);
+ const auf1 = await luna.seite.$eval("#emoji-auf", (e) => e.getAttribute("aria-expanded"));
+ ok(zuAmAnfang && auf1 === "false", "die Tafel ist zu, bis jemand sie aufmacht");
+
+ await luna.seite.click("#emoji-auf");
+ await luna.seite.waitForTimeout(250);
+ const offenJetzt = await luna.seite.$eval("#emoji-tafel", (e) => !e.hidden);
+ const auf2 = await luna.seite.$eval("#emoji-auf", (e) => e.getAttribute("aria-expanded"));
+ const wieviele = await luna.seite.$$eval(".emoji-tafel__knopf", (b) => b.length);
+ ok(offenJetzt && auf2 === "true", "ein Klick macht sie auf, und die Vorlesehilfe weiss es");
+ ok(wieviele === 30, `dreissig Zeichen stehen zur Auswahl (${wieviele})`);
+ const beschriftet = await luna.seite.$$eval(".emoji-tafel__knopf",
+ (b) => b.every((x) => (x.getAttribute("aria-label") || "").length > 6));
+ ok(beschriftet, "jedes hat eine Beschriftung für das Vorleseprogramm");
+
+ /* GEGENPROBE: Der Knopf, mit dem sie aufgeht, darf sie nicht sofort
+ wieder zumachen -- genau das wäre der naheliegende Fehler bei einem
+ Zuhörer auf dem ganzen Dokument. */
+ ok(offenJetzt, "der eigene Knopf schliesst sie nicht versehentlich gleich wieder");
+
+ /* AN DER CURSORSTELLE, nicht am Ende. */
+ await luna.seite.fill("#text", "Hallo Welt");
+ await luna.seite.$eval("#text", (e) => { e.focus(); e.selectionStart = e.selectionEnd = 5; });
+ const zeichen = await luna.seite.$eval(".emoji-tafel__knopf:nth-child(11)", (b) => b.textContent);
+ await luna.seite.click(".emoji-tafel__knopf:nth-child(11)");
+ await luna.seite.waitForTimeout(250);
+ const danach = await luna.seite.$eval("#text", (e) => ({ wert: e.value, pos: e.selectionStart }));
+ ok(danach.wert === "Hallo" + zeichen + " Welt", `es landet an der Cursorstelle ("${danach.wert}")`);
+ ok(danach.pos === 5 + zeichen.length, `und der Cursor steht dahinter (${danach.pos})`);
+
+ /* Sie steht ÜBER dem Feld und ragt nicht seitlich hinaus. */
+ const lage = await luna.seite.evaluate(() => {
+ const t = document.getElementById("emoji-tafel").getBoundingClientRect();
+ const f = document.getElementById("text").getBoundingClientRect();
+ return { l: t.left, r: t.right, u: t.bottom, o: f.top, breite: innerWidth };
+ });
+ ok(lage.u <= lage.o + 2, `sie liegt über dem Schreibfeld (${lage.u.toFixed(0)} <= ${lage.o.toFixed(0)})`);
+ ok(lage.l >= 0 && lage.r <= lage.breite,
+ `und ragt nicht seitlich hinaus (${lage.l.toFixed(0)}–${lage.r.toFixed(0)} in ${lage.breite})`);
+
+ /* Escape und ein Klick daneben schliessen sie. */
+ /* Escape dort, wo der Finger gerade ist -- nach dem Aufmachen steht
+ die Schreibmarke auf dem ersten Zeichen der Tafel. */
+ await luna.seite.keyboard.press("Escape");
+ await luna.seite.waitForTimeout(250);
+ ok(await luna.seite.$eval("#emoji-tafel", (e) => e.hidden), "Escape schliesst sie");
+ await luna.seite.click("#emoji-auf");
+ await luna.seite.waitForTimeout(200);
+ await luna.seite.click("#verlauf-titel");
+ await luna.seite.waitForTimeout(250);
+ const zuNachKlick = await luna.seite.$eval("#emoji-tafel", (e) => e.hidden);
+ const auf3 = await luna.seite.$eval("#emoji-auf", (e) => e.getAttribute("aria-expanded"));
+ ok(zuNachKlick && auf3 === "false", "ein Klick daneben ebenfalls");
+ await luna.seite.fill("#text", "");
+}
+
+/* =======================================================================
+ 6. ZEICHENZÄHLER
+ ======================================================================= */
+melde("\n=== 6. Zeichenzähler ===");
+{
+ const grenze = await luna.seite.$eval("#text", (e) => Number(e.getAttribute("maxlength")));
+ ok(grenze === 4000, `die Grenze steht am Feld selbst (${grenze})`);
+
+ await luna.seite.fill("#text", "x".repeat(100));
+ await luna.seite.waitForTimeout(200);
+ ok(await luna.seite.$eval("#zeichen", (e) => e.hidden), "bei 100 Zeichen sagt er nichts");
+
+ await luna.seite.fill("#text", "x".repeat(grenze - 300));
+ await luna.seite.waitForTimeout(250);
+ const nah = await luna.seite.$eval("#zeichen", (e) => ({ zu: e.hidden, text: e.textContent, eng: e.dataset.eng }));
+ ok(!nah.zu && nah.text === "noch 300", `ab 400 Rest meldet er sich ("${nah.text}")`);
+ ok(nah.eng === "nein", "und ist dabei noch nicht rot");
+
+ await luna.seite.fill("#text", "x".repeat(grenze - 40));
+ await luna.seite.waitForTimeout(250);
+ const eng = await luna.seite.$eval("#zeichen", (e) => ({ text: e.textContent, eng: e.dataset.eng }));
+ ok(eng.text === "noch 40" && eng.eng === "ja", `zum Schluss wird er deutlich ("${eng.text}")`);
+
+ /* GEGENPROBE: Er verschwindet auch wieder. Ein Hinweis, der einmal
+ erschienen ist und dann stehen bleibt, ist ein Fehler. */
+ await luna.seite.fill("#text", "kurz");
+ await luna.seite.waitForTimeout(250);
+ ok(await luna.seite.$eval("#zeichen", (e) => e.hidden), "und geht wieder weg, wenn wieder Platz ist");
+ await luna.seite.fill("#text", "");
+ await luna.seite.waitForTimeout(200);
+}
+
+/* =======================================================================
+ 7. AM HANDY — und nichts ragt hinaus
+ ======================================================================= */
+melde("\n=== 7. Am Handy (390 px) ===");
+{
+ const handy = await alsRolle("creator", "CODE-CREA-0001", 390);
+ await handy.seite.goto(`${BASIS}/workspace/chat.html?raum=${raumId}`, { waitUntil: "networkidle" });
+ await handy.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
+ await handy.seite.waitForTimeout(700);
+
+ await handy.seite.click("#emoji-auf");
+ await handy.seite.waitForTimeout(300);
+ const m = await handy.seite.evaluate(() => {
+ const t = document.getElementById("emoji-tafel").getBoundingClientRect();
+ return { l: t.left, r: t.right, doku: document.documentElement.scrollWidth, sicht: innerWidth };
+ });
+ ok(m.l >= 0 && m.r <= 390, `die Tafel bleibt im Bild (${m.l.toFixed(0)}–${m.r.toFixed(0)})`);
+ ok(m.doku <= m.sicht + 1, `die Seite hat keinen seitlichen Überstand (${m.doku} zu ${m.sicht})`);
+
+ /* Und die Knöpfe sind gross genug zum Treffen. Unter 38 px tippt man
+ daneben -- das ist keine Zierde, das ist Bedienbarkeit. */
+ const klein = await handy.seite.$$eval("#emoji-auf, .chat__senden",
+ (b) => b.map((x) => ({ n: x.id || x.className,
+ h: Math.round(x.getBoundingClientRect().height),
+ w: Math.round(x.getBoundingClientRect().width) }))
+ .filter((x) => x.h < 38 || x.w < 38));
+ ok(klein.length === 0, `Emoji- und Senden-Knopf sind gross genug (${JSON.stringify(klein)})`);
+ await handy.ctx.close();
+}
+
+/* =======================================================================
+ 8. ZWISCHEN GESPRÄCHEN WECHSELN, OHNE NEU ZU LADEN
+
+ Diese Prüfung gab es nicht, und deshalb war der Fehler drin: Der
+ Absatz "Links ein Gespräch auswählen" liegt IM Verlaufskasten, und
+ der wurde beim Zeichnen komplett geleert. Ab dem zweiten Öffnen war
+ `#verlauf-leer` weg -- `raumOeffnen` stürzte dort ab und ließ den
+ ganzen Rest liegen: Adresszeile, Lesemeldung, Liste.
+
+ Von aussen sah das normal aus. Nur der Ereignisstrom holte nach
+ einem Verbindungsabriss nichts mehr nach, und die ungelesen-Zahl
+ blieb beim Wechseln stehen.
+ ======================================================================= */
+melde("\n=== 8. Wechseln ohne Neuladen ===");
+{
+ await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
+ await luna.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 });
+ await luna.seite.waitForTimeout(500);
+ const vorher = luna.konsole.length;
+
+ const zuPatrick = luna.seite.locator(".chat-raum__knopf", { hasText: "Patrick" }).first();
+ const zuDogi = luna.seite.locator(".chat-raum__knopf", { hasText: "Filipe" }).first();
+
+ await zuPatrick.click();
+ await luna.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
+ await luna.seite.waitForTimeout(400);
+ ok(/raum=/.test(luna.seite.url()), `die Adresszeile merkt sich das Gespräch (${luna.seite.url().split("/").pop()})`);
+
+ await zuDogi.click();
+ await luna.seite.waitForTimeout(700);
+ const titel2 = await luna.seite.$eval("#verlauf-titel", (e) => e.textContent);
+ ok(titel2 === "Filipe", `der zweite Wechsel kommt an ("${titel2}")`);
+
+ await zuPatrick.click();
+ await luna.seite.waitForTimeout(700);
+ const titel3 = await luna.seite.$eval("#verlauf-titel", (e) => e.textContent);
+ const zahl = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
+ ok(titel3 === "Patrick" && zahl > 5,
+ `und der dritte auch, samt Verlauf ("${titel3}", ${zahl} Nachrichten)`);
+ ok(/raum=/.test(luna.seite.url()), "die Adresszeile geht beim Wechseln mit");
+ ok(luna.konsole.length === vorher,
+ `dabei kein einziger Absturz (${luna.konsole.length - vorher} neue Meldungen)`);
+
+ /* GEGENPROBE: Der Absatz, über den es gestolpert ist, muss noch da
+ sein -- und zwar versteckt, nicht gelöscht. Wäre er weg, ginge es
+ beim nächsten Umbau wieder los. */
+ const leer = await luna.seite.evaluate(() => {
+ const e = document.getElementById("verlauf-leer");
+ return e ? { da: true, zu: e.hidden, drin: e.parentElement.id } : { da: false };
+ });
+ ok(leer.da && leer.zu && leer.drin === "verlauf",
+ `"#verlauf-leer" steht noch im Kasten und ist versteckt (${JSON.stringify(leer)})`);
+}
+
+/* =======================================================================
+ 9. AUGENSCHONEND — gemessen, nicht behauptet
+
+ Vier neue Textstellen sind dazugekommen. Jede davon ist klein
+ (0,72 rem) und liegt auf einem durchscheinenden Grund, hinter dem ein
+ Bild steht -- die Sorte Stelle, an der Kontrast gern verlorengeht,
+ ohne dass es beim Hinsehen auffällt.
+
+ Gemessen wird gegen die WIRKLICHE Fläche dahinter: Der Text wird kurz
+ unsichtbar gemacht und die Fläche fotografiert. Ein Blick in die
+ CSS-Datei würde nur die oberste Schicht sehen.
+ ======================================================================= */
+melde("\n=== 9. Augenschonend ===");
+{
+ const leuchtkraft = (r, g, b) => {
+ const f = (w) => { const v = w / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+ };
+ const kontrast = (a, b) => {
+ const l1 = leuchtkraft(...a), l2 = leuchtkraft(...b);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+ };
+
+ /* Der Text wird unsichtbar gemacht, NICHT entfernt -- ein entferntes
+ Element ändert das Layout, und dann wird an der falschen Stelle
+ gemessen. Und die Farbe wird ZUERST abgeschrieben: getComputedStyle
+ liefert ein lebendes Objekt, das sonst gleich mit auf
+ "transparent" umspringt. */
+ async function messen(waehler) {
+ const stelle = await luna.seite.evaluate((w) => {
+ const e = document.querySelector(w);
+ if (!e) return null;
+ e.scrollIntoView({ block: "center" });
+ const r = e.getBoundingClientRect();
+ if (r.width < 8 || r.height < 6) return null;
+ const farbe = getComputedStyle(e).color.match(/\d+/g).slice(0, 3).map(Number);
+ e.dataset.messen = "ja";
+ e.style.color = "transparent";
+ return { x: Math.round(r.x), y: Math.round(r.y),
+ w: Math.round(r.width), h: Math.round(Math.min(r.height, 40)), farbe };
+ }, waehler);
+ if (!stelle) return null;
+ const bild = await luna.seite.screenshot({
+ clip: { x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h } });
+ await luna.seite.evaluate(() => {
+ const e = document.querySelector('[data-messen="ja"]');
+ if (e) { e.style.color = ""; delete e.dataset.messen; }
+ });
+ const hinten = await luna.seite.evaluate(async (b64) => {
+ const bild = new Image();
+ await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; });
+ const c = document.createElement("canvas");
+ c.width = bild.width; c.height = bild.height;
+ const g = c.getContext("2d", { willReadFrequently: true });
+ g.drawImage(bild, 0, 0);
+ const dd = g.getImageData(0, 0, c.width, c.height).data;
+ let r = 0, gr = 0, bl = 0, n = 0;
+ for (let i = 0; i < dd.length; i += 4) { r += dd[i]; gr += dd[i + 1]; bl += dd[i + 2]; n++; }
+ return n ? [Math.round(r / n), Math.round(gr / n), Math.round(bl / n)] : null;
+ }, bild.toString("base64"));
+ if (!hinten) return null;
+ return { farbe: stelle.farbe, hinten, wert: kontrast(stelle.farbe, hinten) };
+ }
+
+ /* Alle vier gleichzeitig sichtbar machen: den Zähler über die Länge,
+ den Sprungknopf über den Bildlauf. */
+ await luna.seite.fill("#text", "x".repeat(3800));
+ await luna.seite.$eval("#verlauf", (e) => { e.scrollTop = 0; });
+ await luna.seite.waitForTimeout(400);
+ await luna.seite.evaluate(() => { document.getElementById("zum-neuesten").hidden = false; });
+ await luna.seite.waitForTimeout(200);
+
+ for (const [name, waehler] of [
+ ["Zeichenzähler", "#zeichen"],
+ ["Kopier-Knopf", ".chat-nachricht__kopie-knopf"],
+ ["Antwort-Knopf (Vergleich)", ".chat-nachricht__antwort-knopf"],
+ ["Zum-neuesten-Knopf", "#zum-neuesten"],
+ ["Link in einer Nachricht", ".chat-nachricht__link"],
+ ]) {
+ const m = await messen(waehler);
+ ok(m !== null && m.wert >= 4.5,
+ `${name}: ${m ? m.wert.toFixed(2) + ":1 (" + m.farbe.join(",") + " auf " + m.hinten.join(",") + ")" : "nicht gefunden"}`);
+ }
+
+ /* GEGENPROBE: Ein absichtlich zu dunkler Text MUSS durchfallen --
+ sonst misst der Block oben nichts. */
+ await luna.seite.evaluate(() => {
+ const e = document.getElementById("zeichen");
+ e.dataset.probe = "ja"; e.style.color = "#20262e";
+ });
+ const schlecht = await messen("#zeichen");
+ ok(schlecht !== null && schlecht.wert < 4.5,
+ `absichtlich zu dunkler Text fällt durch (${schlecht?.wert.toFixed(2)}:1)`);
+ await luna.seite.evaluate(() => { document.getElementById("zeichen").style.color = ""; });
+ await luna.seite.fill("#text", "");
+}
+
+melde("\n=== 10. Stille Fehler ===");
+{
+ const alle = [...luna.konsole, ...pat.konsole];
+ ok(alle.length === 0, alle.length ? "Fehler in der Konsole: " + alle.join(" | ")
+ : "keine Skript- oder Konsolenfehler in beiden Browsern");
+
+ /* GEGENPROBE: Der Lauscher greift überhaupt. Ohne diesen Nachweis
+ wäre der Haken darüber wertlos -- er wäre auch grün, wenn niemand
+ zuhört. */
+ await luna.seite.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
+ await luna.seite.waitForTimeout(500);
+ ok(luna.konsole.some((z) => /Absicht/.test(z)),
+ `ein absichtlicher Fehler wird bemerkt (${luna.konsole.length} Meldung(en))`);
+}
+
+await luna.ctx.close();
+await pat.ctx.close();
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+melde(`\n${geprueft} Prüfungen`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
+process.exit(fehler ? 1 : 0);
diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css
index e84682cb..a799d4bb 100644
--- a/workspace/assets/css/chat.css
+++ b/workspace/assets/css/chat.css
@@ -321,12 +321,28 @@
border-style: dashed;
}
+/* EINE FARBE FÜR DIE GANZE FUSSZEILE — und zwar hier, an einer Stelle.
+ ---------------------------------------------------------------------
+ Uhrzeit, "antworten", "zurücknehmen" und "kopieren" standen alle auf
+ var(--text-still) (#7f8fa6). Gemessen (pruef-chat-ausbau, Abschnitt 9)
+ ergibt das auf der eigenen Blase 3,68:1 -- unter den 4,5:1, die für
+ Text dieser Größe gelten. Es ist nie aufgefallen, weil niemand
+ nachgemessen hat; die Blase ist heller als der übrige Grund, und
+ genau dort wird eine leise Farbe zu leise.
+
+ var(--text-leise) (#94a5bb) bringt dieselbe Stelle auf 4,80:1 und
+ bleibt trotzdem deutlich ruhiger als der Nachrichtentext.
+
+ Die Kinder erben sie (color: inherit). Vier eigene Farbangaben wären
+ vier Stellen, die beim nächsten Mal auseinanderlaufen -- der Fehler,
+ der in diesem Haus am häufigsten vorkommt. */
.chat-nachricht__fuss {
display: flex; align-items: center; gap: 9px;
margin-top: 4px;
+ color: var(--text-leise);
}
.chat-nachricht__zeit {
- font-size: .75rem; color: var(--text-still);
+ font-size: .75rem; color: inherit;
font-variant-numeric: tabular-nums;
}
/* Der Zurücknehmen-Knopf erscheint erst beim Darüberfahren -- er ist
@@ -335,7 +351,7 @@
.chat-nachricht__weg-knopf {
padding: 0; border: 0; background: none;
font: inherit; font-size: .75rem;
- color: var(--text-still); cursor: pointer;
+ color: inherit; cursor: pointer;
opacity: 0; transition: opacity .16s ease, color .16s ease;
}
.chat-nachricht:hover .chat-nachricht__weg-knopf,
@@ -790,7 +806,124 @@
.chat-nachricht__antwort-knopf {
border: 0; background: none; padding: 0;
font: inherit; font-size: .72rem;
- color: var(--text-still); cursor: pointer;
+ color: inherit; cursor: pointer;
text-decoration: underline; text-underline-offset: 2px;
}
-.chat-nachricht__antwort-knopf:hover { color: var(--text-leise); }
+.chat-nachricht__antwort-knopf:hover { color: var(--text); }
+
+/* =====================================================================
+ CHAT: SECHS ERGÄNZUNGEN (09.09.2026)
+
+ Filipe: "die kategorie chat, boah ich will dass du die wirklich
+ überall perfektionierst. wirklich alles was gratis ist und
+ hinzugefügt werden kann ohne problem will ich drin haben."
+
+ Alle sechs kommen ohne fremde Bibliothek aus und laden nichts nach:
+ Links, Kopieren, Emoji-Auswahl, Zeichenzähler, ein Bildlauf der nicht
+ mehr wegreisst, und Entwürfe je Gespräch (dafür braucht es kein CSS).
+ ===================================================================== */
+
+/* Ein Link in einer Nachricht ist unterstrichen, nicht farbig -- eine
+ Blase hat schon eine Farbe, und zwei Farben in einer Blase lesen sich
+ wie zwei Sachen. */
+.chat-nachricht__link {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-decoration-color: color-mix(in srgb, currentColor 55%, transparent);
+ word-break: break-word;
+}
+.chat-nachricht__link:hover { text-decoration-color: currentColor; }
+.chat-nachricht__link:focus-visible {
+ outline: 2px solid var(--akzent); outline-offset: 2px; border-radius: 3px;
+}
+
+/* Der Kopier-Knopf sieht aus wie "antworten" -- es ist dieselbe Sorte
+ Handgriff an derselben Stelle. Ein anders gestalteter Knopf daneben
+ waere eine zweite Sprache in derselben Zeile. */
+.chat-nachricht__kopie-knopf {
+ appearance: none; border: 0; background: none; padding: 0;
+ font: inherit; font-size: .72rem; font-weight: 600;
+ color: inherit; cursor: pointer;
+ text-decoration: underline; text-underline-offset: 2px;
+}
+.chat-nachricht__kopie-knopf:hover { color: var(--text); }
+.chat-nachricht__kopie-knopf:focus-visible {
+ outline: 2px solid var(--akzent); outline-offset: 2px; border-radius: 4px;
+}
+
+/* ---- Emoji ---------------------------------------------------------- */
+.chat__emoji {
+ flex: none;
+ width: 40px; height: 40px;
+ display: flex; align-items: center; justify-content: center;
+ appearance: none; border: 1px solid rgba(150, 186, 220, .18);
+ border-radius: 11px;
+ background: rgba(255, 255, 255, .04);
+ color: var(--text-leise);
+ font-size: 1.15rem; line-height: 1; cursor: pointer;
+ transition: color var(--tempo), border-color var(--tempo);
+}
+.chat__emoji:hover { color: var(--text); border-color: rgba(150, 186, 220, .34); }
+.chat__emoji:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+
+/* Die Tafel steht ÜBER der Eingabe, nicht darunter: Darunter läge sie
+ auf dem Handy hinter der Bildschirmtastatur. */
+.emoji-tafel {
+ position: absolute;
+ right: 14px; bottom: calc(100% - 6px);
+ z-index: 5;
+ display: grid; grid-template-columns: repeat(10, 1fr); gap: 2px;
+ padding: 10px;
+ border-radius: 14px;
+ background: rgba(8, 12, 20, .97);
+ border: 1px solid rgba(150, 186, 220, .2);
+ box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .9);
+}
+.emoji-tafel__knopf {
+ appearance: none; border: 0; background: none;
+ width: 34px; height: 34px; border-radius: 8px;
+ font-size: 1.15rem; line-height: 1; cursor: pointer;
+ transition: background var(--tempo);
+}
+.emoji-tafel__knopf:hover { background: rgba(255, 255, 255, .1); }
+.emoji-tafel__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; }
+
+@media (max-width: 620px) {
+ .emoji-tafel { left: 14px; right: 14px; grid-template-columns: repeat(8, 1fr); }
+}
+
+/* ---- Zeichenzähler --------------------------------------------------- */
+.chat__zeichen {
+ flex: none; align-self: center;
+ font-size: .72rem; font-weight: 700; letter-spacing: .04em;
+ color: var(--text-still);
+ font-variant-numeric: tabular-nums;
+}
+/* Eng heisst rot -- aber erst dann. Ein dauerhaft roter Zähler ist eine
+ Warnung, die niemand mehr liest. */
+.chat__zeichen[data-eng="ja"] { color: var(--warn, #ff9aa2); }
+
+/* ---- Zum neuesten ---------------------------------------------------- */
+.chat__zum-neuesten {
+ position: absolute; left: 50%; bottom: 96px;
+ transform: translateX(-50%);
+ z-index: 4;
+ padding: 9px 18px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--akzent) 40%, transparent);
+ background: rgba(8, 12, 20, .95);
+ color: var(--text);
+ font: inherit; font-size: .8rem; font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 10px 26px -12px rgba(0, 0, 0, .9);
+}
+.chat__zum-neuesten:hover { border-color: var(--akzent); }
+.chat__zum-neuesten:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+
+@media (prefers-reduced-motion: reduce) {
+ .chat__emoji, .emoji-tafel__knopf { transition: none; }
+}
+@media (forced-colors: active) {
+ .emoji-tafel, .chat__zum-neuesten { border: 1px solid CanvasText; background: Canvas; }
+}
diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js
index 8be96aab..c6605739 100644
--- a/workspace/assets/js/chat.js
+++ b/workspace/assets/js/chat.js
@@ -278,7 +278,14 @@
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
+ /* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
+ das Letzte sehen, nicht das Erste. */
+ unten = true; neuUnten = 0;
verlaufZeichnen();
+ /* Der Entwurf von vorhin, falls einer da ist. */
+ $('text').value = entwurfHolen(id);
+ hoeheAnpassen();
+ zaehlerZeigen();
if (!sanft) $('text').focus();
/* Die Adresszeile merkt sich das Gespräch -- so führt die
Benachrichtigung direkt hierher, und ein Neuladen bleibt am
@@ -290,7 +297,28 @@
function verlaufZeichnen() {
const ziel = $('verlauf');
- ziel.textContent = '';
+ /* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN.
+ ------------------------------------------------------------------
+ Hier stand `ziel.textContent = ''`. Das loeschte auch
+ `#verlauf-leer`, den Absatz "Links ein Gespraech auswaehlen", denn
+ der liegt IN diesem Kasten. Nach dem ersten Zeichnen gab es ihn
+ also nicht mehr — und `raumOeffnen` faellt sechs Zeilen weiter
+ genau darueber:
+
+ $('verlauf-leer').hidden = true; -> null
+
+ Folge, und die ist die eigentliche: `raumOeffnen` bricht an dieser
+ Stelle ab. Damit laufen `history.replaceState`, `gelesenMelden()`
+ und `raeumeZeichnen()` beim ZWEITEN Aufruf nie. Der zweite Aufruf
+ ist aber der Normalfall — der Ereignisstrom ruft nach jedem
+ Verbindungsaufbau genau das (`{ sanft: true }`), um nachzuholen,
+ was waehrend der Pause geschrieben wurde. Dieses Nachholen hat
+ nie funktioniert; der Absturz stand still in der Konsole.
+
+ Gefunden hat das nicht das Auge, sondern der Lauscher auf
+ `pageerror` in pruef-chat-ausbau.mjs. Ohne ihn waere es geblieben:
+ Die Seite sieht danach voellig normal aus. */
+ for (const alt of [...ziel.children]) if (alt.id !== 'verlauf-leer') alt.remove();
if (!nachrichten.length) {
ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.'));
return;
@@ -386,7 +414,7 @@
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
(white-space: pre-wrap). */
- blase.append(el('p', 'chat-nachricht__text', n.text));
+ blase.append(mitLinks(n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
@@ -410,11 +438,95 @@
weg.addEventListener('click', () => zuruecknehmen(n.id));
fuss.append(weg);
}
+ /* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie
+ weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst
+ und faellt danach zurueck; eine Meldung anderswo waere ein
+ zweiter Ort, an dem man nachsehen muesste. */
+ if (!n.zurueckgenommen && n.text) {
+ const kopie = el('button', 'chat-nachricht__kopie-knopf', 'kopieren');
+ kopie.type = 'button';
+ kopie.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(n.text);
+ kopie.textContent = 'kopiert';
+ } catch {
+ /* Ohne Zwischenablage-Recht (aeltere Browser, kein HTTPS)
+ den Text wenigstens auswaehlen -- dann geht Strg+C. */
+ const p = kopie.closest('.chat-nachricht')?.querySelector('.chat-nachricht__text');
+ if (p) { const r = document.createRange(); r.selectNodeContents(p);
+ const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); }
+ kopie.textContent = 'markiert';
+ }
+ setTimeout(() => { kopie.textContent = 'kopieren'; }, 1600);
+ });
+ fuss.append(kopie);
+ }
blase.append(fuss);
zeile.append(blase);
ziel.append(zeile);
}
- ziel.scrollTop = ziel.scrollHeight;
+
+ /* NUR NACH UNTEN SPRINGEN, WENN MAN SCHON UNTEN WAR.
+
+ Vorher sprang der Verlauf bei JEDEM Neuzeichnen ans Ende. Wer
+ weiter oben etwas nachlas und dabei eine neue Nachricht bekam,
+ wurde mitten im Satz weggerissen -- und das passiert im Live-
+ Strom staendig. Jetzt gilt: Wer unten steht, bleibt unten
+ (das ist der Normalfall). Wer liest, bleibt stehen und bekommt
+ einen Knopf, der sagt, wie viel unten wartet. */
+ if (unten) {
+ ziel.scrollTop = ziel.scrollHeight;
+ neuUnten = 0;
+ }
+ springKnopfZeigen();
+ }
+
+ /* Steht der Verlauf am Ende? 40 Pixel Nachsicht -- ein Bildlauf trifft
+ die letzte Zeile selten auf den Punkt, und ein Knopf, der bei einem
+ Pixel Abstand erscheint, ist ein Flackern. */
+ let unten = true;
+ let neuUnten = 0;
+ function amEnde(e) { return e.scrollHeight - e.scrollTop - e.clientHeight < 40; }
+
+ function springKnopfZeigen() {
+ const k = $('zum-neuesten');
+ if (!k) return;
+ k.hidden = unten || !nachrichten.length;
+ k.textContent = neuUnten
+ ? (neuUnten === 1 ? '1 neue Nachricht' : `${neuUnten} neue Nachrichten`)
+ : 'Zum neuesten';
+ }
+
+ /* ---------- Links in Nachrichten (09.09.2026) ---------------------------
+ Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
+ ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
+ Auszeichnung einschleusen, und in einem Chat schreibt jeder.
+
+ Erkannt wird bewusst wenig -- http, https und www. Wer mehr
+ erkennt (Mailadressen, nackte Domains), erwischt irgendwann
+ "3.stelle" oder "z.b." und macht daraus einen Link ins Nichts.
+
+ `rel="noopener noreferrer"` ist Pflicht: Ohne `noopener` kann die
+ geoeffnete Seite auf das Fenster zurueckgreifen, aus dem sie kam. */
+ const LINKMUSTER = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/g;
+ function mitLinks(text) {
+ const p = el('p', 'chat-nachricht__text');
+ let letzte = 0;
+ for (const treffer of String(text).matchAll(LINKMUSTER)) {
+ const roh = treffer[0];
+ /* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
+ const sauber = roh.replace(/[.,;:!?)\]]+$/, '');
+ if (treffer.index > letzte) p.append(document.createTextNode(text.slice(letzte, treffer.index)));
+ const a = el('a', 'chat-nachricht__link', sauber);
+ a.href = sauber.startsWith('www.') ? 'https://' + sauber : sauber;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ p.append(a);
+ if (sauber.length < roh.length) p.append(document.createTextNode(roh.slice(sauber.length)));
+ letzte = treffer.index + roh.length;
+ }
+ if (letzte < text.length) p.append(document.createTextNode(text.slice(letzte)));
+ return p;
}
/* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */
@@ -482,8 +594,13 @@
: null,
};
nachrichten.push(vorlaeufig);
+ /* Wer selbst schreibt, will das Ergebnis sehen -- auch wenn er
+ vorher weiter oben gelesen hat. */
+ unten = true; neuUnten = 0;
verlaufZeichnen();
feld.value = '';
+ entwurfWeg(offen.id);
+ zaehlerZeigen();
antwortLoeschen();
hoeheAnpassen();
melde('');
@@ -608,6 +725,9 @@
}
nachrichten.push({ ...n, selbst: n.von_id === ich.id });
+ /* Wer gerade weiter oben liest, soll sehen, WIE VIEL unten
+ dazugekommen ist -- nicht nur, DASS etwas da ist. */
+ if (!unten && n.von_id !== ich.id) neuUnten++;
verlaufZeichnen();
gelesenMelden();
}
@@ -789,7 +909,138 @@
$('schreiben').addEventListener('submit', abschicken);
- $('text').addEventListener('input', hoeheAnpassen);
+ $('text').addEventListener('input', () => {
+ hoeheAnpassen();
+ entwurfMerken();
+ zaehlerZeigen();
+ });
+
+ /* ---------- Entwurf je Gespraech (09.09.2026) ----------------------------
+ Wer mitten im Satz das Gespraech wechselt, findet den Satz beim
+ Zurueckkommen wieder. Ohne das ist jeder Wechsel eine Entscheidung:
+ "wegwerfen oder erst fertig schreiben?"
+
+ Im localStorage und nicht auf dem Server: Ein Entwurf ist nichts,
+ was jemand anders sehen soll, und er gehoert an dieses Geraet.
+ Abgeschickte oder geleerte Entwuerfe werden geloescht -- eine
+ Ablage, die nur waechst, ist ein Leck. */
+ const ENTWURF = (id) => `dfw-chat-entwurf-${id}`;
+ function entwurfMerken() {
+ if (!offen) return;
+ const t = $('text').value;
+ try {
+ if (t.trim()) localStorage.setItem(ENTWURF(offen.id), t);
+ else localStorage.removeItem(ENTWURF(offen.id));
+ } catch { /* privater Modus: dann eben nicht */ }
+ }
+ function entwurfHolen(raumId) {
+ try { return localStorage.getItem(ENTWURF(raumId)) || ''; } catch { return ''; }
+ }
+ function entwurfWeg(raumId) {
+ try { localStorage.removeItem(ENTWURF(raumId)); } catch { /* egal */ }
+ }
+
+ /* ---------- Zeichenzaehler ------------------------------------------------
+ Er erscheint erst, wenn es eng wird. Ein Zaehler, der von Anfang an
+ mitlaeuft, zaehlt die meiste Zeit etwas, das niemanden interessiert --
+ und wird dann auch dann uebersehen, wenn er zaehlt. */
+ /* Die Zahl steht NICHT hier. Sie steht im maxlength des Feldes, und
+ dort steht sie, weil der Server sie so haben will (TEXT_MAX = 4000
+ in workspace-chat.js). Zwei Stellen fuer dieselbe Zahl laufen
+ auseinander -- dann zaehlt der Zaehler bis 4000 herunter, waehrend
+ das Feld schon bei 2000 dichtmacht, und niemand versteht warum. */
+ const GRENZE = Number($('text').getAttribute('maxlength')) || 4000;
+ function zaehlerZeigen() {
+ const z = $('zeichen');
+ if (!z) return;
+ const n = $('text').value.length;
+ const rest = GRENZE - n;
+ z.hidden = rest > 400;
+ z.textContent = `noch ${rest}`;
+ z.dataset.eng = rest <= 100 ? 'ja' : 'nein';
+ }
+
+ /* ---------- Emoji-Auswahl -------------------------------------------------
+ Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese
+ dreissig decken den Alltag ab, und sie laden nichts nach. Eine
+ Emoji-Bibliothek waere ein Megabyte fuer etwas, das die
+ Bildschirmtastatur ohnehin kann -- am Rechner aber nicht.
+
+ Eingefuegt wird an der CURSORSTELLE, nicht am Ende: Wer mitten im
+ Satz eines setzen will, soll nicht erst umsortieren. */
+ const EMOJIS = ['😀','😂','🥹','😍','😎','🤔','😅','🙃','😴','🤝',
+ '👍','👏','🙌','💪','🙏','🔥','✨','⭐','💯','🎉',
+ '❤️','💙','💚','🖤','👀','🎮','🎧','📈','⏰','✅'];
+ function emojiTafelBauen() {
+ const tafel = $('emoji-tafel');
+ if (!tafel || tafel.childElementCount) return;
+ for (const e of EMOJIS) {
+ const b = el('button', 'emoji-tafel__knopf', e);
+ b.type = 'button';
+ b.setAttribute('aria-label', `Emoji ${e} einfügen`);
+ b.addEventListener('click', () => {
+ const f = $('text');
+ const a = f.selectionStart ?? f.value.length;
+ const b2 = f.selectionEnd ?? a;
+ f.value = f.value.slice(0, a) + e + f.value.slice(b2);
+ f.focus();
+ f.selectionStart = f.selectionEnd = a + e.length;
+ hoeheAnpassen(); entwurfMerken(); zaehlerZeigen();
+ });
+ tafel.append(b);
+ }
+ }
+ $('emoji-auf')?.addEventListener('click', () => {
+ const tafel = $('emoji-tafel');
+ emojiTafelBauen();
+ const auf = tafel.hidden;
+ tafel.hidden = !auf;
+ $('emoji-auf').setAttribute('aria-expanded', String(auf));
+ if (auf) tafel.querySelector('button')?.focus();
+ });
+ /* Ein Klick daneben schliesst die Tafel -- sonst steht sie im Weg,
+ und man sucht den Knopf, mit dem man sie aufgemacht hat. */
+ document.addEventListener('click', (e) => {
+ const tafel = $('emoji-tafel');
+ if (!tafel || tafel.hidden) return;
+ if (tafel.contains(e.target) || e.target.closest('#emoji-auf')) return;
+ tafel.hidden = true;
+ $('emoji-auf')?.setAttribute('aria-expanded', 'false');
+ });
+ /* Escape schliesst sie -- und zwar von ueberall her, nicht nur wenn
+ die Schreibmarke zufaellig noch in der Tafel steht.
+
+ Erster Anlauf hing der Zuhoerer an der Tafel selbst. Das geht gut,
+ solange man sie gerade aufgemacht hat (dann liegt der Fokus auf dem
+ ersten Zeichen). Wer danach ins Schreibfeld zurueckklickt, drueckt
+ Escape aber ins Leere -- und die Tafel steht weiter im Weg. Am
+ Dokument gilt es immer; ist die Tafel zu, tut die Zeile nichts. */
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape') return;
+ const tafel = $('emoji-tafel');
+ if (!tafel || tafel.hidden) return;
+ tafel.hidden = true;
+ $('emoji-auf')?.setAttribute('aria-expanded', 'false');
+ $('text').focus();
+ });
+
+ /* ---------- Zum neuesten springen ---------------------------------------- */
+ $('verlauf')?.addEventListener('scroll', () => {
+ const v = $('verlauf');
+ const jetztUnten = amEnde(v);
+ if (jetztUnten !== unten) {
+ unten = jetztUnten;
+ if (unten) neuUnten = 0;
+ springKnopfZeigen();
+ }
+ });
+ $('zum-neuesten')?.addEventListener('click', () => {
+ const v = $('verlauf');
+ v.scrollTop = v.scrollHeight;
+ unten = true; neuUnten = 0;
+ springKnopfZeigen();
+ });
+
/* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
und wer eine zweite Zeile will, käme sonst nie dazu. */
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index 5c1f739a..ede67924 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -14,10 +14,10 @@
-
-
-
-
+
+
+
+