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 @@ - - - - + + + + @@ -231,10 +231,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index cd4133b4..c9f0ea38 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -140,10 +140,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 4a2cf827..ba402b88 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -149,14 +149,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index c24975c8..b59d5692 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -82,11 +82,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 7589cd76..efae6096 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -14,17 +14,17 @@ - - + + - - - + + + @@ -116,6 +116,11 @@ + +

Links ein Gespräch auswählen – oder mit Neu eines anfangen. @@ -158,6 +163,17 @@ + + +

+ +
- + diff --git a/workspace/kalender.html b/workspace/kalender.html index c8676a02..2a5cc6bd 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -340,11 +340,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index d0da0fb8..94d3ecc0 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -217,10 +217,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index f6c8c2ff..01898bfa 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -335,10 +335,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index ba0d26f5..0b5d8c9d 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -207,11 +207,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index bdf3e6ba..4da829c0 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -99,11 +99,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 7130933d..a535941f 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -132,10 +132,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index a6d0eaf5..78e665e5 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -14,12 +14,12 @@ - - + + - - + + @@ -667,10 +667,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index e014095d..06e16ef6 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -78,11 +78,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 985df3c0..9a44a3ab 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -119,10 +119,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 4b39d6f6..0f6e68f4 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -14,13 +14,13 @@ - - + + - - - + + + @@ -142,11 +142,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 722f8bc9..80c5c66b 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -183,10 +183,10 @@ - - - - - + + + + +