diff --git a/server/pruef-chatkachel.mjs b/server/pruef-chatkachel.mjs new file mode 100644 index 00000000..7709c06f --- /dev/null +++ b/server/pruef-chatkachel.mjs @@ -0,0 +1,337 @@ +/* ===================================================================== + pruef-chatkachel.mjs — Bild hinter dem Chat, Kachel für jeden Account + + Filipe, 20.09.2026: „ich will auch ein krasses hintergrundbild für + den treff chat. und die texte sollen immer in einer kachel sein. und + jeder account soll seine kachel personalisieren können." + + --------------------------------------------------------------------- + DIE EINE FRAGE, AN DER SO ETWAS SCHEITERT: LESBARKEIT + + Ein Bild hinter einem Chat geht nur, WENN der Text auf einer + deckenden Kachel steht. Sonst liegt Schrift auf einem Foto — an der + dunklen Stelle lesbar, an der hellen nicht, und beim Scrollen + wechselt es. Deshalb wird hier nicht geprüft, ob ein Bild da ist, + sondern ob die Kachel DECKT: gemessen am Alphawert, nicht geschätzt. + + Und alle zwölf Töne müssen gleich gut tragen. Ein freier Farbwähler + hätte genau hier sein Problem: Mit ihm kann sich jemand unlesbar + machen, und sehen müssen es die anderen. + + AUFRUF: node server/pruef-chatkachel.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await portMussFreiSein(4442, "pruef-chatkachel"); +const ordner = mkdtempSync(join(tmpdir(), "ws-kachel-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(250_000, "pruef-chatkachel"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +console.log("\n=== Chat: Bild dahinter, Kachel davor ===\n"); + +/* ---- 1. Die Töne, ohne Browser ----------------------------------- */ +const { CHAT_KACHELN, kachelPruefen } = await import("./workspace-chat.js"); +ok(CHAT_KACHELN.length >= 8, `es gibt genug Töne zur Auswahl (${CHAT_KACHELN.length})`); + +/* ALLE GLEICH DUNKEL. Nur dann ist der Kontrast zur Schrift bei jedem + gleich, und niemand kann sich unlesbar machen. Gerechnet wird die + relative Helligkeit nach WCAG — nicht der Augenschein. */ +const helligkeit = (hex) => { + const z = hex.replace("#", ""); + const teil = [0, 2, 4].map((i) => parseInt(z.slice(i, i + 2), 16) / 255); + const lin = teil.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +}; +const kontrast = (a, b) => { + const [h, d] = [helligkeit(a), helligkeit(b)].sort((x, y) => y - x); + return (h + 0.05) / (d + 0.05); +}; +const SCHRIFT = "#e8f1fa"; /* die Textfarbe des Hauses */ + +let schlechtester = 99, schlechtesterName = ""; +for (const k of CHAT_KACHELN) { + const c = kontrast(SCHRIFT, k.farbe); + if (c < schlechtester) { schlechtester = c; schlechtesterName = k.name; } +} +/* 7:1 ist die strengere WCAG-Stufe (AAA für Fließtext). Ein Chat ist + die Seite, auf der am längsten gelesen wird — hier die niedrigere + Schwelle zu nehmen wäre am falschen Ende gespart. */ +ok(schlechtester >= 7, + `auch der hellste Ton trägt die Schrift noch (${schlechtesterName}: ${schlechtester.toFixed(1)}:1, nötig 7)`); + +const spanne = Math.max(...CHAT_KACHELN.map((k) => helligkeit(k.farbe))) + / Math.max(1e-6, Math.min(...CHAT_KACHELN.map((k) => helligkeit(k.farbe)))); +ok(spanne < 6, + `die Töne liegen nah beieinander (Faktor ${spanne.toFixed(1)}) — keiner sticht heraus`); + +/* KEINE DOPPELTE FARBE. Zwei gleiche Töne wären zwei Namen für + dieselbe Kachel — man wählt den zweiten und sieht keinen Unterschied. */ +const farben = new Set(CHAT_KACHELN.map((k) => k.farbe.toLowerCase())); +ok(farben.size === CHAT_KACHELN.length, + `kein Ton doppelt (${farben.size} von ${CHAT_KACHELN.length})`); + +/* Die Prüfung am Wert selbst — sie ist der Riegel gegen alles, was + nicht aus der Liste kommt. */ +ok(kachelPruefen("moos") === "moos", "ein bekannter Ton kommt durch"); +ok(kachelPruefen("rgb(255,0,0)") === null, + "Gegenprobe: ein erfundener Wert nicht — sonst stünde CSS aus der Datenbank in der Seite"); +ok(kachelPruefen(null) === null, "und 'nichts eingestellt' ist ein gültiger Zustand"); + +/* ---- 2. Am echten Bildschirm ------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const mach = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + return d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run(name, rolle, h, s, 32768, new Date().toISOString()).lastInsertRowid; +}; +mach("Filipe", "admin", "CODE-DOGI-0001"); +const idLuna = mach("Luna", "manager", "CODE-LUNA-0002"); +d.close(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ rolle, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +}; +const kFilipe = await anmelden("admin", "CODE-DOGI-0001"); +const kLuna = await anmelden("manager", "CODE-LUNA-0002"); +ok(!!kFilipe && !!kLuna, "beide angemeldet"); + +/* Ein Gespräch und zwei Nachrichten. */ +const raumA = await fetch(BASIS + "/workspace/api/chat/direkt", { + method: "POST", headers: { "Content-Type": "application/json", Cookie: kFilipe, Origin: BASIS }, + body: JSON.stringify({ mit: idLuna }), +}); +const raum = (await raumA.json()).raum_id; +ok(!!raum, `ein Gespräch steht (${raum})`); +const schreib = async (keks, text) => fetch(BASIS + `/workspace/api/chat/raeume/${raum}/nachrichten`, { + method: "POST", headers: { "Content-Type": "application/json", Cookie: keks, Origin: BASIS }, + body: JSON.stringify({ text }), +}); +await schreib(kFilipe, "Servus, hier ist Filipe."); +await schreib(kLuna, "Und hier Luna."); + +/* Luna stellt eine Farbe ein. */ +const setzen = await fetch(BASIS + "/workspace/api/chat/kachel", { + method: "PUT", headers: { "Content-Type": "application/json", Cookie: kLuna, Origin: BASIS }, + body: JSON.stringify({ kachel: "moos" }), +}); +ok(setzen.ok, `Luna stellt ihre Kachel ein (${setzen.status})`); + +const boese = await fetch(BASIS + "/workspace/api/chat/kachel", { + method: "PUT", headers: { "Content-Type": "application/json", Cookie: kLuna, Origin: BASIS }, + body: JSON.stringify({ kachel: "url(javascript:0)" }), +}); +ok(boese.status === 400, + `Gegenprobe: ein erfundener Wert wird abgelehnt (${boese.status}) — eine Regel, die nur im Formular steht, ist keine Regel`); + +/* FILIPE SIEHT LUNAS FARBE — das ist der Sinn der Sache. */ +const verlauf = await (await fetch(BASIS + `/workspace/api/chat/raeume/${raum}/nachrichten`, + { headers: { Cookie: kFilipe } })).json(); +const vonLuna = (verlauf.nachrichten || []).find((n) => n.von === "Luna"); +ok(vonLuna?.von_kachel === "moos", + `Filipe bekommt Lunas Kachel mitgeliefert (${vonLuna?.von_kachel})`); +const vonFilipe = (verlauf.nachrichten || []).find((n) => n.von === "Filipe"); +ok(!vonFilipe?.von_kachel, + "und seine eigene bleibt Standard, solange er nichts einstellt"); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +try { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addCookies(kFilipe.split("; ").filter(Boolean).map((t) => { + const [name, ...rest] = t.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const s = await ctx.newPage(); + const konsole = []; + s.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + s.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await s.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); + await s.waitForTimeout(1400); + await s.click(".chat-raum"); + await s.waitForTimeout(1200); + + /* ==== DIE EIGENTLICHE MESSUNG: SCHEINT DAS BILD DURCH? =========== + + Der erste Anlauf las `backgroundColor` und pruefte den Alphawert. + Das war falsch, und zwar auf eine Art, die fast durchgegangen + waere: Eine Kachel mit einem VERLAUF hat `backgroundColor: + rgba(0,0,0,0)` -- der Verlauf steht in `background-image`. Die + Pruefung meldete „durchsichtig" bei einer Kachel, die voellig + deckt, und haette umgekehrt eine halbtransparente Verlaufskachel + durchgelassen. + + GEMESSEN WIRD JETZT DIE SACHE SELBST: zweimal dasselbe Bildpunkt- + feld, einmal mit und einmal ohne Buehnenbild. Deckt die Kachel, + sind beide Aufnahmen an dieser Stelle identisch. Scheint das Bild + durch, unterscheiden sie sich. + + DIE GEGENPROBE IST EINGEBAUT und kostet nichts: An der Flaeche + ZWISCHEN den Kacheln MUSS sich etwas aendern. Tut sie das nicht, + misst der Vergleich gar nichts -- etwa weil das Bild in dieser + Umgebung nie geladen wurde. Genau die Sorte stiller Erfolg, vor + der die Hausregel zum dritten Ausgang warnt. */ + const punkte = await s.evaluate(() => { + const b = document.querySelector('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase') + || document.querySelector(".chat-nachricht__blase"); + const v = document.querySelector(".chat__verlauf"); + if (!b || !v) return null; + const rb = b.getBoundingClientRect(), rv = v.getBoundingClientRect(); + return { + /* Mitten in der Kachel -- nicht am Rand, dort liegen Kante und + abgerundete Ecke. */ + kachel: { x: Math.round(rb.left + rb.width / 2), y: Math.round(rb.top + rb.height / 2) }, + /* Und ein Fleck im Verlauf, auf dem sicher keine Kachel liegt: + ganz rechts aussen, wo die eigenen Nachrichten nicht hinreichen. */ + frei: { x: Math.round(rv.right - 6), y: Math.round(rv.top + rv.height / 2) }, + }; + }); + ok(!!punkte, "Kachel und freie Fläche gefunden"); + + /* Playwright kann einen Ausschnitt aufnehmen -- ein Bildpunkt reicht. */ + const punktFarbe = async (pkt) => { + const bild = await s.screenshot({ + clip: { x: pkt.x, y: pkt.y, width: 1, height: 1 }, type: "png", + }); + /* Der Farbwert steckt im PNG. Statt es zu entpacken, wird die + AUFNAHME selbst verglichen: Zwei Aufnahmen desselben Bildpunkts + sind byteweise gleich, wenn die Farbe gleich ist. Das ist genau + die Frage, und sie braucht keinen Decoder. */ + return bild.toString("base64"); + }; + + const mitBild = { kachel: await punktFarbe(punkte.kachel), frei: await punktFarbe(punkte.frei) }; + await s.addStyleTag({ content: "body.start::before { display: none !important; }" }); + await s.waitForTimeout(350); + const ohneBild = { kachel: await punktFarbe(punkte.kachel), frei: await punktFarbe(punkte.frei) }; + + /* ZUERST DIE GEGENPROBE. Ohne sie waere die Hauptmessung wertlos. */ + ok(mitBild.frei !== ohneBild.frei, + "Gegenprobe: auf der freien Fläche ändert sich etwas, wenn das Bild weg ist — es ist also wirklich da"); + ok(mitBild.kachel === ohneBild.kachel, + mitBild.kachel === ohneBild.kachel + ? "auf der Kachel ändert sich NICHTS — sie deckt, das Bild kommt nicht durch die Schrift" + : "das Bühnenbild scheint durch die Kachel — dort läge Schrift auf einem Foto"); + + /* Die Fläche darum MUSS durchlassen, sonst gäbe es gar kein Bild zu + sehen -- das ist die Hälfte des Wunsches. */ + const flaeche = await s.evaluate(() => { + const c = getComputedStyle(document.querySelector(".chat")).backgroundColor; + /* ZWEI SCHREIBWEISEN, NICHT EINE (20.09.2026). + + Der erste Anlauf suchte nur `rgba(...)`. Chrome gibt das + Ergebnis von `color-mix()` aber als `color(srgb r g b / a)` + zurueck -- die Suche fand nichts, der Rueckfall war „Alpha 1", + und die Pruefung meldete „deckt" bei einer Flaeche, die + durchscheint. Ein Messfehler, der genau das Gegenteil behauptet. */ + const alphaVon = (farbe) => { + const rgba = farbe.match(/rgba?\(([^)]+)\)/); + if (rgba) { + const t = rgba[1].split(",").map((x) => parseFloat(x)); + return t.length > 3 ? t[3] : 1; + } + const srgb = farbe.match(/color\([^/)]*\/\s*([\d.]+)\s*\)/); + if (srgb) return parseFloat(srgb[1]); + return farbe === "transparent" ? 0 : 1; + }; + return { alpha: alphaVon(c), roh: c, + buehne: getComputedStyle(document.body, "::before").backgroundImage }; + }); + ok(flaeche.alpha < 0.98, + `die Fläche um die Kacheln lässt durch (Alpha ${flaeche.alpha.toFixed(2)}, ${flaeche.roh})`); + ok(/\.webp|\.png|\.jpg/i.test(flaeche.buehne || ""), + `und dahinter steht wirklich ein Bild (…${(flaeche.buehne || "").slice(-30)}`); + + const lage = await s.evaluate(() => ({ + blasen: document.querySelectorAll(".chat-nachricht__blase").length, + knopf: !!document.querySelector(".chat__kachelknopf:not([hidden])"), + })); + ok(lage.blasen >= 2, `die Nachrichten stehen in Kacheln (${lage.blasen})`); + + ok(lage.knopf, "der Knopf für die eigene Kachel steht da"); + + /* Und er lässt sich bedienen. */ + await s.click(".chat__kachelknopf"); + await s.waitForTimeout(400); + const wahl = await s.evaluate(() => { + const dlg = document.querySelector("#kachel-wahl[open]"); + const toene = [...document.querySelectorAll(".chat-kachelwahl__ton")]; + return { + offen: !!dlg, + toene: toene.length, + mitNamen: toene.filter((t) => (t.getAttribute("aria-label") || "").length > 2).length, + hoch: toene.length ? Math.round(toene[0].getBoundingClientRect().height) : 0, + }; + }); + ok(wahl.offen, "die Auswahl geht auf"); + ok(wahl.toene === CHAT_KACHELN.length, + `mit allen ${CHAT_KACHELN.length} Tönen (${wahl.toene})`); + ok(wahl.mitNamen === wahl.toene, + `jeder trägt seinen Namen (${wahl.mitNamen}) — wer Farben schlecht unterscheidet, wählt sonst blind`); + ok(wahl.hoch >= 44, `und ist am Finger treffbar (${wahl.hoch} px)`); + + /* Eine andere wählen und nachsehen, ob die eigenen Kacheln sofort + umspringen -- ohne Neuladen. */ + /* DIE FARBE STECKT IM VERLAUF, nicht in `backgroundColor`. + + Auch das war erst falsch: Eine Kachel mit `background: + linear-gradient(...)` hat `backgroundColor: rgba(0,0,0,0)` -- der + Vergleich lieferte zweimal „durchsichtig" und meldete „springt + nicht um" bei einer Kachel, die sehr wohl umsprang. Beides wird + gelesen und aneinandergehaengt; dann ist es egal, welche der + beiden Stellen die Farbe traegt. */ + const kachelBild = () => s.evaluate(() => { + const b = document.querySelector('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase'); + const c = getComputedStyle(b); + return c.backgroundColor + " | " + c.backgroundImage; + }); + const vorher = await kachelBild(); + await s.click('.chat-kachelwahl__ton[data-wert="wein"]'); + await s.waitForTimeout(600); + const nachher = await kachelBild(); + ok(vorher !== nachher, + `die eigenen Kacheln springen sofort um (${vorher.slice(0, 46)} → ${nachher.slice(0, 46)})`); + + /* UND ES IST GESPEICHERT, nicht nur gemalt. */ + const nachSetzen = await (await fetch(BASIS + "/workspace/api/chat/kacheln", + { headers: { Cookie: kFilipe } })).json(); + ok(nachSetzen.meine === "wein", + `und die Wahl ist gespeichert, nicht nur gemalt (${nachSetzen.meine})`); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 80) : "keine Fehler im Browser"); + await ctx.close(); +} finally { + await browser.close(); +} + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 0f7abf9b..15c7ff39 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -174,6 +174,65 @@ function bildWegFuer(personId) { } catch { return null; } } +/* ===================================================================== + JEDER ACCOUNT SEINE EIGENE KACHEL (20.09.2026) + + Filipe: „jeder account soll seine kachel personalisieren koennen." + + --------------------------------------------------------------------- + EINE AUSWAHL, KEIN FARBWAEHLER + + Ein freier Farbwaehler klingt grosszuegiger und ist die schlechtere + Loesung: Mit ihm laesst sich Schwarz auf Schwarz einstellen, oder + Neongelb, das jedem anderen im Gespraech in die Augen sticht. Die + Kachel gehoert einem selbst, GESEHEN wird sie von allen anderen -- + deshalb ist sie kein Platz fuer etwas, das man nicht abschalten kann. + + Die zwoelf Toene hier sind alle gleich dunkel und gleich gesaettigt. + Sie unterscheiden sich im Farbton und in sonst nichts. Damit ist der + Kontrast zur Schrift bei jedem gleich -- er muss nicht bei jedem + einzeln geprueft werden, und niemand kann sich unlesbar machen. + Augenschonend ist hier keine Geschmacksfrage: Ein Chat ist die + Seite, auf der am laengsten gelesen wird. + + KEINE der zwoelf ist hell. Das ist Absicht und nicht Sparsamkeit -- + die Schrift ist hell, und eine helle Kachel waere fuer die Person, + die sie waehlt, hübsch und fuer alle anderen unlesbar. + ===================================================================== */ +export const CHAT_KACHELN = [ + { wert: "grund", name: "Standard", farbe: "#131a25" }, + { wert: "tinte", name: "Tinte", farbe: "#141c2e" }, + { wert: "veilchen", name: "Veilchen", farbe: "#1d1730" }, + { wert: "pflaume", name: "Pflaume", farbe: "#251729" }, + { wert: "wein", name: "Wein", farbe: "#2a1620" }, + { wert: "ziegel", name: "Ziegel", farbe: "#2a1a17" }, + { wert: "bernstein", name: "Bernstein", farbe: "#271f14" }, + { wert: "moos", name: "Moos", farbe: "#16251b" }, + { wert: "tanne", name: "Tanne", farbe: "#132622" }, + { wert: "lagune", name: "Lagune", farbe: "#122429" }, + { wert: "stahl", name: "Stahl", farbe: "#1b2128" }, + { wert: "schiefer", name: "Schiefer", farbe: "#20201f" }, +]; + +/** Nur ein Ton aus der Liste kommt durch -- sonst nichts. + * + * DIE PRUEFUNG STEHT HIER UND NICHT NUR IM FORMULAR. Was aus der + * Datenbank kommt, koennte aus einer aelteren Fassung stammen oder + * von einem Aufruf an der Oberflaeche vorbei; ein unbekannter Wert + * wanderte sonst als CSS-Farbe in die Seite. `null` heisst + * „Standard", und das ist ein gueltiger Zustand. */ +export function kachelPruefen(wert) { + if (!wert) return null; + return CHAT_KACHELN.some((k) => k.wert === String(wert)) ? String(wert) : null; +} + +function kachelFuer(personId) { + try { + return kachelPruefen(db().prepare( + "SELECT chat_kachel FROM personen WHERE id = ?").get(personId)?.chat_kachel); + } catch { return null; } +} + function teilnehmerVon(raumId) { return db().prepare(` SELECT t.person_id AS id, p.name, p.rolle, p.bild, t.leitung, t.gelesen_bis @@ -465,6 +524,54 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => { }); /** Meine Gespräche, das jüngste zuerst. */ +/* ===================================================================== + DIE EIGENE KACHEL EINSTELLEN (20.09.2026) + + Filipe: „jeder account soll seine kachel personalisieren koennen." + + ZWEI WEGE, WEIL ES ZWEI FRAGEN SIND: „welche gibt es" und „das will + ich". Die Liste kommt vom Server, damit im Browser keine zweite + Fassung der zwoelf Toene steht -- die waere beim dreizehnten die + vergessene. + + NUR FUER SICH SELBST. Es gibt keinen Weg, die Kachel eines anderen + zu setzen, und das ist keine Luecke: Die Kachel ist das Einzige auf + dieser Seite, das einem selbst gehoert. Ein Recht der Leitung darauf + waere ein Recht, jemandem seine Farbe wegzunehmen. + ===================================================================== */ +chatRouter.get("/workspace/api/chat/kacheln", (req, res) => { + try { + res.json({ + kacheln: CHAT_KACHELN, + meine: kachelFuer(req.person.id), + }); + } catch (f) { + console.error("[chat] Kacheln:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +chatRouter.put("/workspace/api/chat/kachel", express.json({ limit: "2kb" }), + gleicheHerkunft, (req, res) => { + try { + const roh = req.body?.kachel; + /* LEER IST GUELTIG und heisst "Standard". Wer sich umentscheidet, + braucht einen Weg zurueck -- eine Einstellung ohne Rueckweg ist + eine Falle, keine Einstellung. */ + const wert = (roh === null || roh === "" || roh === "grund") + ? null : kachelPruefen(roh); + if (roh && roh !== "grund" && wert === null) { + return res.status(400).json({ fehler: "unbekannte_kachel" }); + } + db().prepare("UPDATE personen SET chat_kachel = ? WHERE id = ?") + .run(wert, req.person.id); + res.json({ kachel: wert }); + } catch (f) { + console.error("[chat] Kachel setzen:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + chatRouter.get("/workspace/api/chat/raeume", (req, res) => { try { /* WEGGERÄUMTE GESPRÄCHE STEHEN NICHT IN DER LISTE. @@ -888,7 +995,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { const weg = geloeschtBis(raumId, req.person.id); const reihen = db().prepare(` SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle, - p.bild AS von_bild, + p.bild AS von_bild, p.chat_kachel AS von_kachel, n.antwort_auf, n.angeheftet_am, n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse, n.anhang_breite, n.anhang_hoehe, @@ -974,6 +1081,17 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { reaktionen: reakt.get(n.id) || [], id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null, + /* DIE EIGENE KACHELFARBE (20.09.2026). + + Filipe: "jeder account soll seine kachel personalisieren + koennen." + + Sie kommt aus `personen`, nicht aus der Nachricht: Wer seine + Farbe aendert, aendert sie fuer ALLE seine Nachrichten, + auch die von gestern. Die Farbe an jede Zeile zu schreiben + waere eine zweite Wahrheit -- und nach der ersten Aenderung + haette dieselbe Person zwei Farben im selben Gespraech. */ + von_kachel: kachelPruefen(n.von_kachel), text: n.weg_am ? null : n.text, zurueckgenommen: !!n.weg_am, erstellt: n.erstellt, @@ -1093,6 +1211,10 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, laedt, ein Gesicht. Derselbe Mensch, zwei Darstellungen, und der Unterschied haengt nur daran, wann man geschaut hat. */ von_bild: bildWegFuer(req.person.id), + /* Auch hier -- sonst kaeme die frisch gesendete Nachricht ohne + Farbe bei den anderen an und faerbte sich erst beim naechsten + Laden. Derselbe Fehler wie beim Bild eine Zeile darueber. */ + von_kachel: kachelFuer(req.person.id), rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false, antwort: zitat ? { id: antwortAuf, von: zitat.von || "Gelöscht", diff --git a/server/workspace.js b/server/workspace.js index f813f210..e4bf2722 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2491,6 +2491,13 @@ function umstellungen(d) { ["personen", "instagram", "TEXT"], ["personen", "youtube", "TEXT"], ["personen", "twitch", "TEXT"], + /* Die eigene Kachelfarbe im Chat (20.09.2026). Ein Schluessel aus + CHAT_KACHELN, nicht die Farbe selbst: Sonst stuende in der + Datenbank ein CSS-Wert, und eine Aenderung am Farbton hiesse, + jede Zeile anzufassen. NULL heisst "Standard" und ist der + Normalfall -- ein Pflichtwert waere eine Entscheidung, die + niemand treffen wollte. */ + ["personen", "chat_kachel", "TEXT"], /* ---- Wiederkehrende Termine (02.09.2026) ---- Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index 89bd6393..cff0b212 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -61,9 +61,33 @@ gap: 0; border: 1px solid var(--rand); border-radius: var(--radius); + /* ==== DAS BUEHNENBILD SCHEINT DURCH (20.09.2026) ================== + + Filipe: „ich will auch ein krasses hintergrundbild fuer den treff + chat. und die texte sollen immer in einer kachel sein." + + BEIDES ZUSAMMEN, und in dieser Reihenfolge -- sie bedingen + einander: Ein Bild hinter dem Chat geht nur, WENN der Text auf + einer deckenden Kachel steht. Sonst liegt Schrift auf einem Foto, + und das ist an der hellsten Stelle unlesbar. Genau deshalb ist der + zweite Satz kein Zusatzwunsch, sondern die Bedingung fuer den + ersten. + + WARUM KEIN EIGENES BILD, SONDERN DAS DER SEITE: Hinter jeder Seite + steht ohnehin eine Buehne (start.css, neun Szenen, fuer den Chat + die Lounge). Sie lag bisher hinter einer deckenden Flaeche -- + vorhanden, geladen, unsichtbar. Sie durchscheinen zu lassen kostet + KEINE einzige Datei mehr, und auf der Adresse von Team Dogi kommt + damit von selbst deren eigene Fassung (crew-buehne-lounge), ohne + dass hier eine zweite Liste von neun Bildern stuende. Eine solche + Liste waere die, die beim zehnten Motiv vergessen wird. + + DER SCHLEIER IST NICHT DEKORATION. Ohne ihn waere die Liste links + (Namen, Vorschauzeilen) Text direkt auf dem Foto. Er ist deshalb + kraeftig und wird gemessen, nicht geschaetzt. */ background: radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, .03), transparent 55%), - var(--flaeche); + color-mix(in srgb, var(--flaeche) 88%, transparent); overflow: hidden; } @@ -250,6 +274,16 @@ overflow-y: auto; padding: 18px 18px 8px; display: flex; flex-direction: column; gap: 3px; + /* IM VERLAUF DARF MEHR DURCHKOMMEN als in der Liste links: Hier steht + jede Zeile auf einer eigenen, deckenden Kachel -- dazwischen ist + Luft, und durch die Luft sieht man den Raum. Links stehen Namen + ohne Kachel, dort bleibt es dichter. + Ein eigener Schleier statt einer Aenderung am Grund darueber: So + hat jede der beiden Spalten ihren Wert, und eine Aenderung an der + einen verstellt die andere nicht. */ + background: linear-gradient(180deg, + color-mix(in srgb, var(--grund) 58%, transparent), + color-mix(in srgb, var(--grund) 34%, transparent)); /* Ein weicher Schleier am oberen Rand: Der Verlauf scrollt unter den Kopf, statt an einer harten Kante abgeschnitten zu wirken. */ mask-image: linear-gradient(180deg, transparent, #000 14px); @@ -323,7 +357,19 @@ padding: 10px 13px 7px; border: 1px solid var(--rand); border-radius: 14px 14px 14px 4px; - background: rgba(255, 255, 255, .045); + /* DECKEND, NICHT DURCHSCHEINEND (20.09.2026). + + Hier stand `rgba(255,255,255,.045)` -- praktisch durchsichtig. Das + war richtig, solange dahinter eine ruhige Flaeche lag. Seit das + Buehnenbild durchscheint, waere es Schrift auf einem Foto: an der + dunklen Stelle lesbar, an der hellen nicht, und beim Scrollen + wechselt es. + + `--kachel` statt einer festen Farbe: Darueber personalisiert jeder + Account seine eigene Kachel (siehe weiter unten). Der Rueckfall + ist die Hausfarbe -- wer nichts einstellt, bekommt das, was vorher + auch da war, nur deckend. */ + background: var(--kachel, #131a25); min-width: 0; } .chat-nachricht[data-folge="ja"] .chat-nachricht__blase { border-radius: 14px 14px 14px 8px; } @@ -333,7 +379,10 @@ .chat-nachricht[data-selbst="ja"] .chat-nachricht__blase { grid-column: 1; border-radius: 14px 14px 4px 14px; - background: color-mix(in srgb, var(--akzent) 16%, transparent); + /* Auch die eigene deckt jetzt. `color-mix` auf `transparent` liesse + das Foto durch -- gemischt wird deshalb auf die Hausflaeche. */ + background: var(--kachel, + color-mix(in srgb, var(--akzent) 16%, #131a25)); border-color: color-mix(in srgb, var(--akzent) 30%, transparent); } .chat-nachricht[data-selbst="ja"][data-folge="ja"] .chat-nachricht__blase { @@ -666,9 +715,25 @@ } .chat-nachricht[data-selbst="ja"] .chat-nachricht__blase { border-radius: 16px 16px 5px 16px; + /* DIESE REGEL IST DIE ZWEITE FÜR DIESELBE KACHEL (20.09.2026). + + Weiter oben steht schon eine -- und genau daran wäre die eigene + Kachel beinahe transparent geblieben: Dort wurde `--kachel` + eingebaut, hier gewinnt der Verlauf, und der kannte die Farbe + nicht. Gemessen am Bildschirm: rgba(0,0,0,0), also nichts. + + Dass es zwei Stellen gibt, ist gewachsen (Grundform oben, + Feinschliff hier). Sie zusammenzulegen wäre ein größerer Umbau an + einer Datei, die heute schon viel abbekommen hat -- deshalb + stattdessen: BEIDE kennen `--kachel`, und diese Zeile hier sagt + es dazu. + + .95 ist zu .99 geworden: Seit das Bühnenbild durchscheint, sind + fünf Prozent Foto in der Schriftfläche sichtbar -- und zwar + wandernd, weil das Bild fest steht und die Kachel scrollt. */ background: linear-gradient(160deg, - color-mix(in srgb, var(--akzent) 26%, rgba(17, 24, 37, .95)), - color-mix(in srgb, var(--akzent) 12%, rgba(12, 17, 27, .95))); + color-mix(in srgb, var(--akzent) 26%, var(--kachel, rgba(17, 24, 37, .99))), + color-mix(in srgb, var(--akzent) 12%, var(--kachel, rgba(12, 17, 27, .99)))); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .10), 0 2px 8px -3px color-mix(in srgb, var(--akzent) 45%, transparent); @@ -2208,3 +2273,80 @@ @media (forced-colors: active) { .anruf__stumm { border: 2px solid CanvasText; animation: none; } } + + +/* ===================================================================== + MEINE KACHEL (20.09.2026) + + Filipe: „jeder account soll seine kachel personalisieren können." + + Der Knopf ist ein FARBFLECK, kein Wort. Er zeigt, was eingestellt + ist — ein Knopf mit der Aufschrift „Kachel" müsste erst geöffnet + werden, damit man sieht, was gerade gilt. + ===================================================================== */ +.chat__kachelknopf { + flex: 0 0 auto; + margin-left: auto; + width: 30px; height: 30px; + padding: 3px; + border: 1px solid var(--rand-hell); + border-radius: 9px; + background: transparent; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; +} +.chat__kachelknopf:hover { border-color: var(--akzent); } +.chat__kachelknopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.chat__kachelknopf-probe { + display: block; width: 100%; height: 100%; + border-radius: 6px; + /* Eine feine Innenkante, damit ein sehr dunkler Ton nicht aussieht + wie ein Loch im Knopf. */ + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10); +} +/* „Neu" steht danach und schiebt sich nicht mehr selbst -- der + Farbfleck hat den auto-Rand übernommen. Zwei auto-Ränder + nebeneinander teilen sich den Platz und reißen die Knöpfe + auseinander; derselbe Fehler stand am 11.09. schon im Verlaufskopf. */ +.chat__kopf #neu-oeffnen { flex: 0 0 auto; margin-left: 8px; } + +.chat-kachelwahl__gitter { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(62px, 1fr)); + gap: 8px; + margin: 14px 0 4px; +} +.chat-kachelwahl__ton { + position: relative; + min-height: 48px; /* am Finger treffbar */ + border: 1px solid var(--rand); + border-radius: 12px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + color: var(--text); + font: inherit; font-size: .84rem; +} +.chat-kachelwahl__wort { opacity: .9; } +.chat-kachelwahl__ton:hover { border-color: var(--rand-hell); } +.chat-kachelwahl__ton:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* DIE WAHL WIRD MIT EINEM HAKEN ANGEZEIGT, nicht nur mit einem + helleren Rahmen: Auf zwölf dunklen Feldern ist ein Rahmen mehr der + Unterschied, den man sucht — ein Haken ist der, den man sieht. */ +.chat-kachelwahl__ton[aria-checked="true"] { + border-color: var(--akzent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--akzent) 45%, transparent); +} +.chat-kachelwahl__ton[aria-checked="true"]::after { + content: "¹3"; + position: absolute; top: 3px; right: 6px; + font-size: .8rem; font-weight: 700; + color: var(--akzent); +} + +@media (forced-colors: active) { + .chat-kachelwahl__ton[aria-checked="true"] { border: 3px solid Highlight; } + .chat__kachelknopf { border: 1px solid CanvasText; } + /* Im Kontrastmodus gibt es keine Farbflächen -- dann sind die zwölf + Töne ununterscheidbar, und nur der Name trägt. Er steht ohnehin + im aria-label und im title. */ +} diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index aedf4016..338be6b1 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -30,6 +30,124 @@ 'use strict'; const $ = (id) => document.getElementById(id); + + /* ===================================================================== + MEINE KACHEL (20.09.2026) + + Filipe: „jeder account soll seine kachel personalisieren koennen." + + DIE TABELLE IST LEER UND WIRD GEFUELLT. Sie steht nicht als feste + Liste hier: Die zwoelf Toene gehoeren dem Server (CHAT_KACHELN in + workspace-chat.js), und eine zweite Fassung im Browser waere beim + dreizehnten die vergessene -- dieselbe Krankheit wie bei KANAL_WORT + auf dem Highlight-Brett, die heute behoben wurde. + + WOZU SIE HIER GEBRAUCHT WIRD: Die Nachricht bringt einen + SCHLUESSEL mit („moos"), keine Farbe. Waere die Farbe selbst in der + Nachricht, stuende ein CSS-Wert aus der Datenbank in der Seite -- + und ein vertippter oder boeswilliger Eintrag koennte faerben, was + er will. So kommt nur durch, was in der Tabelle steht. */ + const KACHEL_FARBE = Object.create(null); + let kachelListe = []; + let meineKachel = null; + + async function kachelnLaden() { + try { + const a2 = await fetch('/workspace/api/chat/kacheln', { credentials: 'same-origin' }); + if (!a2.ok) return; + const d = await a2.json(); + kachelListe = Array.isArray(d.kacheln) ? d.kacheln : []; + meineKachel = d.meine || null; + for (const k of kachelListe) KACHEL_FARBE[k.wert] = k.farbe; + kachelKnopfZeigen(); + } catch { /* ohne die Liste bleibt alles beim Standard -- kein Grund + zu scheitern, der Chat funktioniert weiter */ } + } + + function kachelKnopfZeigen() { + const knopf = $('kachel-oeffnen'); + if (!knopf) return; + /* OHNE LISTE KEIN KNOPF. Ein Knopf, der eine leere Auswahl oeffnet, + ist schlimmer als keiner -- man drueckt ihn und sucht den Fehler + bei sich. */ + knopf.hidden = kachelListe.length === 0; + const probe = knopf.querySelector('.chat__kachelknopf-probe'); + if (probe) { + probe.style.background = KACHEL_FARBE[meineKachel] || KACHEL_FARBE.grund || '#131a25'; + } + } + + function kachelwahlBauen() { + const gitter = $('kachel-gitter'); + if (!gitter) return; + gitter.textContent = ''; + for (const k of kachelListe) { + const b = el('button', 'chat-kachelwahl__ton'); + b.type = 'button'; + b.dataset.wert = k.wert; + b.style.background = k.farbe; + b.setAttribute('role', 'radio'); + /* DER NAME GEHOERT DAZU, nicht nur die Farbe. Wer Farben schlecht + unterscheidet, waehlt sonst zwoelf Mal dasselbe Kaestchen -- + und ein Vorleseprogramm saehe zwoelf namenlose Knoepfe. */ + b.setAttribute('aria-label', k.name); + b.title = k.name; + b.setAttribute('aria-checked', String((meineKachel || 'grund') === k.wert)); + /* EINE VORSCHAU MIT ECHTEM TEXT, nicht nur ein Farbfleck: Gewaehlt + wird ja nicht die Farbe, sondern wie die eigene Nachricht + aussieht. */ + b.append(el('span', 'chat-kachelwahl__wort', 'Aa')); + b.addEventListener('click', () => kachelSetzen(k.wert, b)); + gitter.append(b); + } + } + + async function kachelSetzen(wert, knopf) { + const vorher = meineKachel; + /* SOFORT UMSTELLEN, dann speichern. Wer eine Farbe antippt und eine + halbe Sekunde nichts sieht, tippt noch einmal. Geht es schief, + wird es zurueckgenommen -- und zwar sichtbar. */ + meineKachel = (wert === 'grund') ? null : wert; + for (const b of knopf.parentElement.querySelectorAll('.chat-kachelwahl__ton')) { + b.setAttribute('aria-checked', String(b === knopf)); + } + kachelKnopfZeigen(); + eigeneKachelnFaerben(); + try { + const a2 = await hole('/workspace/api/chat/kachel', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kachel: wert }), + }); + if (!a2.ok) throw new Error('abgelehnt'); + } catch { + meineKachel = vorher; + kachelKnopfZeigen(); + eigeneKachelnFaerben(); + kachelwahlBauen(); + melde('Die Farbe konnte nicht gespeichert werden.'); + } + } + + /** Die eigenen Nachrichten im offenen Verlauf sofort umfaerben. + * + * OHNE NEU ZU LADEN: Der Verlauf neu zu holen hiesse, beim Faerben + * nach unten zu springen und die Stelle zu verlieren, an der man + * gerade liest. Die Farbe steht ohnehin nur an der Kachel. */ + function eigeneKachelnFaerben() { + const farbe = KACHEL_FARBE[meineKachel] || null; + for (const z of document.querySelectorAll('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase')) { + if (farbe) z.style.setProperty('--kachel', farbe); + else z.style.removeProperty('--kachel'); + } + } + + function kachelwahlBedienen() { + const knopf = $('kachel-oeffnen'), dlg = $('kachel-wahl'), zu = $('kachel-zu'); + if (!knopf || !dlg) return; + knopf.addEventListener('click', () => { kachelwahlBauen(); dlg.showModal(); }); + zu?.addEventListener('click', () => dlg.close()); + } + const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; @@ -1090,6 +1208,23 @@ if (n.angeheftet) zeile.dataset.angeheftet = 'ja'; const blase = el('div', 'chat-nachricht__blase'); + /* DIE EIGENE KACHELFARBE (20.09.2026). + + Filipe: „jeder account soll seine kachel personalisieren + koennen." + + Der Wert kommt vom Server als SCHLUESSEL, nicht als Farbe -- + hier wird er in der Tabelle nachgeschlagen, die ebenfalls vom + Server kam. Waere die Farbe selbst in der Nachricht, stuende + ein CSS-Wert aus der Datenbank in der Seite; ein vertippter + oder boeswilliger Eintrag koennte dann faerben, was er will. + + Kein Wert heisst Standard, und dafuer steht gar nichts da -- + der Rueckfall liegt im Stilblatt (`var(--kachel, …)`). Eine + Zeile, die die Standardfarbe noch einmal hinschreibt, waere + eine zweite Stelle, an der sie stimmen muss. */ + const kFarbe = KACHEL_FARBE[n.von_kachel]; + if (kFarbe) blase.style.setProperty('--kachel', kFarbe); if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') { blase.append(el('p', 'chat-nachricht__von', n.von)); } @@ -2811,6 +2946,11 @@ window.werZeigen?.(ich); await raeumeLaden(); + /* DIE KACHELN VOR DEM ERSTEN VERLAUF. Kaemen sie danach, stuenden + die ersten Nachrichten kurz in Standardfarbe und faerbten sich + dann um -- eine Seite, die sich unter den Fingern aendert. */ + await kachelnLaden(); + kachelwahlBedienen(); stromOeffnen(); unterbauBeobachten(); unterbauMessen(); diff --git a/workspace/chat.html b/workspace/chat.html index 864e69a3..3f712198 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -70,9 +70,39 @@

Gespräche

+ +
+ + +

Deine Kachel

+

+ So sehen deine Nachrichten für alle anderen aus. Alle Töne sind + gleich dunkel – damit bleibt die Schrift überall gleich gut + lesbar. +

+
+
+ +
+
+