/* ===================================================================== chat.js — Gespräche im Workspace. Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht reinschreiben, die andere person bekommt eine benachrichtigung und kann dann antworten."* --------------------------------------------------------------------- DREI ENTSCHEIDUNGEN, die den Rest erklären 1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die es meistens nicht gibt. Reisst die Verbindung ab, verbindet der Browser sich von selbst neu -- und wir holen die Lücke über den normalen Verlauf nach. 2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet. Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit, wird sie sichtbar als "nicht angekommen" markiert -- nicht still entfernt. 3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat nichts gelesen, und eine Zahl, die von selbst verschwindet, ist schlimmer als keine. ===================================================================== */ (() => { '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); /* WELCHE SCHRIFT AUF WELCHER KACHEL -- vom Server gerechnet (schriftFuer in workspace-chat.js). Hier wird sie nur nachgeschlagen: Eine zweite Rechnung im Browser waere die Fassung, die beim naechsten Ton aelter ist als die andere. */ const KACHEL_SCHRIFT = Object.create(null); let kachelListe = []; /* Die 360 Farben des Rings, Index = Winkel. Kommt vom Server; hier wird nichts gerechnet (Begruendung steht an der Route). */ let kreisToene = []; /* Die sieben Helligkeiten und welche davon die Mitte ist. Beides kommt vom Server (siehe chat-kreis-stufen.js). */ let kreisStufen = [{ wert: 's4', misch: 0, name: 'normal' }]; let kreisMitte = 's4'; /** Einen Grundton heller oder dunkler mischen. * * `misch` ist negativ fuer dunkler (Richtung Schwarz) und positiv * fuer heller (Richtung Weiss) -- dieselbe Rechnung wie * `color-mix(in srgb, …)` und wie `tonFarbe()` im Server. Sie steht * hier ein zweites Mal, und das ist Absicht: Die ZAHLEN kommen vom * Server, nur das Mischen selbst passiert im Browser. Eine Farbe, * die 2520-mal ueber die Leitung geht, waere teurer als drei Zeilen * Rechnung. */ function tonMischen(grund, misch) { const s = String(grund).replace('#', ''); if (s.length !== 6) return grund; const teile = [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16)); const ziel = misch < 0 ? 0 : 255; const t = Math.abs(misch); return '#' + teile .map((v) => Math.round(v * (1 - t) + ziel * t).toString(16).padStart(2, '0')) .join(''); } let meineKachel = null; let autoKachel = 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; /* Was getragen wird, wenn man nichts waehlt. Der Server rechnet das aus der Person aus; die Oberflaeche rechnet es NICHT nach. Zwei Rechnungen fuer dieselbe Farbe waeren zwei Stellen, an denen sie stimmen muss. */ autoKachel = d.automatisch || null; for (const k of kachelListe) { KACHEL_FARBE[k.wert] = k.farbe; if (k.schrift) KACHEL_SCHRIFT[k.wert] = k.schrift; } /* DER KREIS KOMMT IN DIESELBE TABELLE. Das ist der ganze Trick an dieser Stelle: Ab hier ist "ton-214" ein Schluessel wie "veilchen" auch. blaseFaerben(), der Knopf in der Kopfzeile und jede andere Stelle, die eine Farbe nachschlaegt, funktionieren unveraendert weiter -- ohne dass irgendwo eine zweite Art Wahl behandelt werden muesste. Eine Sonderbehandlung an zehn Stellen waere neun Gelegenheiten gewesen, eine davon zu vergessen. */ kreisToene = Array.isArray(d.kreis) ? d.kreis : []; /* ==== DIE SIEBEN HELLIGKEITEN (25.09.2026) ================== Filipe: „ich will dass die leute auch heller und dunkler aussuchen koennen." NICHT 2520 FERTIGE FARBEN, sondern 360 Grundtoene und sieben Mischanteile -- die ganze Tabelle waere 20 kB fuer etwas, das eine Zeile Rechnung ist. DIE ZAHLEN KOMMEN VOM SERVER und stehen nicht hier: Sie sind aus der Kontrastrechnung hergeleitet (chat-kreis-stufen.js), und eine zweite Fassung im Browser waere ab der naechsten Messung falsch -- unbemerkt, denn sie lieferte ja weiter „irgendeine Farbe". Kommt nichts, bleibt es bei der einen Mitte: der Kreis von gestern. */ kreisStufen = Array.isArray(d.kreis_stufen) && d.kreis_stufen.length ? d.kreis_stufen : [{ wert: 's4', misch: 0, name: 'normal' }]; kreisMitte = d.kreis_mitte || 's4'; kreisToene.forEach((farbe, winkel) => { KACHEL_FARBE['ton-' + winkel] = farbe; if (d.kreis_schrift) KACHEL_SCHRIFT['ton-' + winkel] = d.kreis_schrift; /* Jede Stufe bekommt ihren eigenen Schluessel in derselben Tabelle. Damit funktionieren ALLE Stellen, die eine Farbe nachschlagen, unveraendert weiter -- dieselbe Ueberlegung wie am 23.09., als der Kreis dazukam. */ for (const s of kreisStufen) { if (!s.misch) continue; KACHEL_FARBE['ton-' + winkel + '-' + s.wert] = tonMischen(farbe, s.misch); } /* Und der Schluessel MIT der Mitte zeigt auf dieselbe Farbe wie der ohne -- sonst waere „ton-214" und „ton-214-s4" zweierlei, und beide kaemen vor. */ KACHEL_FARBE['ton-' + winkel + '-' + kreisMitte] = 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) { /* Der Knopf zeigt, was WIRKLICH getragen wird -- also die eigene Wahl, sonst die automatische. Zeigte er den neutralen Ton, waere er eine Auskunft ueber etwas, das man auf dem Bildschirm daneben anders sieht. */ probe.style.background = KACHEL_FARBE[meineKachel] || KACHEL_FARBE[autoKachel] || KACHEL_FARBE.grund || '#4f545c'; } } /* WELCHE KACHELN NEBEN DEM RING BLEIBEN -- abgeleitet, nicht aufgezaehlt. Auf dem Ring liegt alles, was einen Farbton hat und auf der gemeinsamen Leuchtdichte sitzt. Zwei Kacheln koennen das nicht: Grau hat keinen Farbton, und Babyblau ist die eine helle mit eigener, dunkler Schrift. Beide gehoeren weiter dazu. Eine Liste ["grund","babyblau"] waere die naheliegende Loesung und genau die Falle vom 11.09.: Kommt morgen eine dritte Sonderkachel dazu, fehlt sie stillschweigend im Waehler. Stattdessen wird gemessen -- liegt die Farbe nahe an einer Ringfarbe, ist sie auf dem Ring; sonst braucht sie einen eigenen Knopf. DIE SCHWELLE IST NICHT GERATEN, sondern nachgemessen (23.09.2026): Die elf bunten Kacheln liegen zwischen 0,0 und 2,4 von ihrer Ringfarbe entfernt, Grau bei 75,4 und Babyblau bei 192,9. Zwischen 2,4 und 75 ist so viel Luft, dass jede Zahl darin dieselbe Antwort gibt -- die Schwelle ist also keine knappe Entscheidung, die beim naechsten Nachrechnen kippt. */ const RINGNAH = 12; function abstand(a, b) { if (!a || !b || a.length !== 7 || b.length !== 7) return Infinity; let s = 0; for (const i of [1, 3, 5]) { const d2 = parseInt(a.slice(i, i + 2), 16) - parseInt(b.slice(i, i + 2), 16); s += d2 * d2; } return Math.sqrt(s); } /** Der Winkel, dessen Farbe dieser am naechsten kommt -- samt Abstand. */ function naechsterWinkel(farbe) { let best = Infinity, winkel = 0; kreisToene.forEach((t, i) => { const d2 = abstand(t, farbe); if (d2 < best) { best = d2; winkel = i; } }); return { winkel, abstand: best }; } function kachelwahlBauen() { radBauen(); const gitter = $('kachel-gitter'); if (!gitter) return; gitter.textContent = ''; for (const k of kachelListe) { /* Liegt sie auf dem Ring, hat sie dort schon ihren Platz. */ if (kreisToene.length && naechsterWinkel(k.farbe).abstand <= RINGNAH) continue; 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. */ /* DAS „Aa" TRAEGT DIE SCHRIFT DIESER KACHEL (22.09.2026). Sonst stuende in der Auswahl ein helles Aa auf Babyblau -- also genau das Bild, das es zu vermeiden gilt, und zwar an der Stelle, an der man sich entscheidet. */ const wort = el('span', 'chat-kachelwahl__wort', 'Aa'); if (k.schrift) wort.style.color = k.schrift.text; b.append(wort); 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; /* OHNE DEN GEDRUECKTEN KNOPF ZU KENNEN. Vorher wurde ueber knopf.parentElement markiert -- seit der Ring dazugekommen ist, gibt es Aufrufe ganz ohne Knopf, und die waeren daran abgestuerzt. Der Zustand steht in meineKachel; die Anzeige wird daraus abgeleitet. */ auswahlMarkieren(); 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. */ /** Flaeche UND Schrift in einem Griff. * * ZUSAMMEN UND NICHT NACHEINANDER (22.09.2026): Wer die Farbe an * einer Stelle setzt und die Schrift an einer anderen, hat * irgendwann eine helle Kachel mit heller Schrift -- und merkt es * erst, wenn jemand nichts mehr lesen kann. Es gibt deshalb genau * diesen einen Weg, eine Blase einzufaerben. */ function blaseFaerben(blase, wert) { const farbe = KACHEL_FARBE[wert] || null; const schrift = KACHEL_SCHRIFT[wert] || null; if (farbe) blase.style.setProperty('--kachel', farbe); else blase.style.removeProperty('--kachel'); /* ==== DIE SCHRIFTFARBE WIRD HIER NICHT MEHR GESETZT (25.09.2026) Sie wechselte bisher mit der Kachel: Auf einer hellen Flaeche dunkle Schrift, auf einer dunklen helle. Das war genau richtig, SOLANGE der Text auf der Kachel stand. Seit heute steht er das nicht mehr. Die Blase ist dunkles Glas fuer alle; die persoenliche Farbe ist die KANTE, der NAME und ein Schein darunter. Damit ist der Untergrund des Textes immer derselbe -- und eine Schriftfarbe, die mit der Kachel wechselt, waere ab jetzt eine Rechnung fuer einen Untergrund, den es nicht mehr gibt. Auf einer hellen Kachel haette sie dunkle Schrift auf dunkles Glas gesetzt: unlesbar. `schrift` bleibt trotzdem stehen und wird weiter geholt -- der Farbring in der Mitte zeigt damit weiter, wie die Farbe mit Schrift wirkt. Nur die Blase fragt nicht mehr danach. */ void schrift; } function eigeneKachelnFaerben() { for (const z of document.querySelectorAll('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase')) { blaseFaerben(z, meineKachel); } } /** Markiert im Waehler, was gerade gewaehlt ist. * * NUR DIE KNOEPFE, NICHT DER RING. Der Ring wird von radZeigen() * gestellt -- riefe er hier zurueck, riefen sich die beiden * gegenseitig auf. Es endete zwar nach zwei Schritten, aber wer * spaeter eine Zeile dazwischensetzt, baut daraus eine Schleife, * die den Browser anhaelt. Also gar nicht erst. */ function auswahlMarkieren() { const gitter = $('kachel-gitter'); if (!gitter) return; for (const b of gitter.querySelectorAll('.chat-kachelwahl__ton')) { b.setAttribute('aria-checked', String((meineKachel || 'grund') === b.dataset.wert)); } } /* ===================================================================== DER FARBRING (23.09.2026) Filipe: „kannst du nicht so eine art diagramm machen wo man mit einem kreis herum gehen kann und sie die farbe ganz genau selber auswaehlen kann?" --------------------------------------------------------------------- GESPEICHERT WIRD ERST, WENN MAN LOSLAESST Wer einmal um den Ring fuehrt, erzeugt sonst dreihundert Speicheranfragen -- auf einem Handy im Zug heisst das: der Ring haengt, und die Farbe, die am Ende ankommt, ist die aus der Mitte der Bewegung. Die Vorschau laeuft deshalb sofort und oertlich, die Anfrage geht einmal, wenn die Bewegung endet. Bei der Tastatur dasselbe mit einer halben Sekunde Ruhe. DER RING IST NICHT DIE WAHRHEIT, DIE TABELLE IST ES. Der Farbverlauf blendet zwischen benachbarten Winkeln weich ueber; was gewaehlt wird, kommt immer aus kreisToene[winkel]. Sonst haette man eine Farbe, die auf dem Bildschirm ausgerechnet zwischen zwei geprueften Werten liegt. ===================================================================== */ let radWinkel = 0; /* Welche der sieben Helligkeiten gerade gilt. Der Ring bestimmt den Farbton, die Leiter die Helligkeit -- zwei Achsen derselben Farbe. */ let radStufe = 's4'; let radUhr = null; /** Der Schluessel aus beidem. Die Mitte bekommt KEINEN Zusatz: * „ton-214" ist seit dem 23.09. der gueltige Schluessel, und wer * ihn traegt, soll nach diesem Umbau nicht ploetzlich einen anderen * in der Datenbank stehen haben. */ function radSchluessel() { return radStufe === kreisMitte ? 'ton-' + radWinkel : 'ton-' + radWinkel + '-' + radStufe; } function radBauen() { const ring = $('kachelrad-ring'); const rad = $('kachelrad'); if (!ring || !rad) return; /* OHNE KREIS KEIN RING. Kommt die Liste nicht (alter Server, Netzproblem), bleibt das Rad weg und die Sonderknoepfe darunter tun weiter ihren Dienst -- statt eines leeren, schwarzen Kreises, bei dem jeder den Fehler bei sich sucht. */ if (kreisToene.length < 2) { rad.hidden = true; return; } rad.hidden = false; ring.style.background = 'conic-gradient(' + kreisToene.join(',') + ',' + kreisToene[0] + ')'; /* Wo der Griff steht, wenn man aufmacht: bei der eigenen Farbe. Wer Babyblau traegt, hat keinen Winkel -- dann zeigt der Griff auf den naechstgelegenen, ohne dass etwas umgestellt wird. */ const jetzt = KACHEL_FARBE[meineKachel] || KACHEL_FARBE[autoKachel] || null; /* Der Schluessel darf seit dem 25.09.2026 eine Stufe tragen ("ton-214-s6"). Ohne Zusatz gilt die Mitte -- so wie der Server es auch liest. */ const t = /^ton-(\d{1,3})(?:-(s[1-7]))?$/.exec(String(meineKachel || '')); radWinkel = t ? Number(t[1]) : (jetzt ? naechsterWinkel(jetzt).winkel : 0); radStufe = (t && t[2]) || kreisMitte; leiterBauen(); radZeigen(radWinkel, { nurAnzeige: true }); } /** Griff, Vorschau und Vorlesewert auf diesen Winkel stellen. */ function radZeigen(winkel, { nurAnzeige = false } = {}) { const ring = $('kachelrad-ring'), arm = $('kachelrad-arm'), mitte = $('kachelrad-mitte'); if (!ring || !arm) return; const w = ((Math.round(winkel) % 360) + 360) % 360; radWinkel = w; arm.style.setProperty('--winkel', w + 'deg'); ring.setAttribute('aria-valuenow', String(w)); /* EINE ZAHL IN GRAD SAGT NIEMANDEM ETWAS. Vorgelesen wird der Farbname, den ein Mensch benutzt -- "Blaugruen, 190 Grad". */ /* DIE HELLIGKEIT GEHOERT IN DEN VORLESETEXT. Ohne sie saegte eine Sprachausgabe bei allen sieben Stufen dasselbe -- und man haette kein Mittel, die Wahl zu hoeren. */ ring.setAttribute('aria-valuetext', farbwort(w) + ', ' + w + ' Grad, ' + stufenName(radStufe)); if (mitte) { /* ==== DIE PROBE ZEIGT, WIE ES WIRKLICH AUSSIEHT (25.09.2026) Hier stand eine gefuellte Flaeche in der gewaehlten Farbe mit heller Schrift darauf. So sah die Blase bis zum 25.09. aus -- seither ist sie fuer alle dunkles Glas, und der gewaehlte Ton ist der NAME darauf. Die Probe zeigte also etwas, das es nicht mehr gibt. Jetzt zeigt sie genau die Rechnung aus chat.css: der Ton, mit Weiss aufgehellt (`--namen-anteil`), auf dem Blasengrund. Wer hier eine Farbe waehlt, sieht die Schrift, die die anderen im Chat sehen werden. NEBENWIRKUNG, UND SIE IST DER GRUND, WARUM DIE HELLIGKEITEN UEBERHAUPT GEHEN: Eine gefuellte Flaeche muesste Schrift tragen -- und bei sehr hellen Toenen schafft das keine der beiden Schriftfarben mehr (gemessen: 4,1 statt noetiger 7). Als NAME auf dunklem Grund ist derselbe helle Ton dagegen besonders gut lesbar (8,0). Die neue Probe ist damit nicht nur ehrlicher, sie macht die sieben Stufen erst moeglich. */ const farbe = KACHEL_FARBE[radSchluessel()] || kreisToene[w]; mitte.style.background = ''; mitte.style.setProperty('--probe-ton', farbe || ''); } if (!nurAnzeige) { /* Die eigenen Blasen im Verlauf faerben sich mit -- man entscheidet sich ja nicht fuer einen Fleck, sondern dafuer, wie die eigenen Nachrichten aussehen. */ meineKachel = radSchluessel(); /* DIE LEITER FAERBT MIT. Ohne diese Zeile zeigten die sieben Felder die Helligkeiten des Tons von vorhin -- man waehlt Gruen und sieht sieben Rottoene darunter. */ leiterZeigen(); kachelKnopfZeigen(); eigeneKachelnFaerben(); auswahlMarkieren(); } } /** Der Name einer Helligkeitsstufe -- fuer Sprachausgabe und Titel. */ function stufenName(wert) { return (kreisStufen.find((s) => s.wert === wert) || {}).name || 'normal'; } /* ==== DIE HELLIGKEITSLEITER (25.09.2026) ========================= Filipe: „ich will dass die leute auch heller und dunkler aussuchen koennen … so dass die leute viel krassere moeglichkeiten haben." SIEBEN FELDER, JEDES IN SEINER ECHTEN FARBE -- nicht sieben graue Kaestchen mit Beschriftung. Man waehlt eine Farbe; also soll man Farben sehen. Die Felder faerben sich beim Drehen am Ring mit: Wer den Farbton aendert, sieht sofort, wie die sieben Helligkeiten DIESES Tons aussehen. EINE LEISTE UND KEIN ZWEITER KREIS: Farbton laeuft rund (359 Grad grenzt an 0), Helligkeit nicht -- sie hat ein dunkles und ein helles Ende. Eine runde Form fuer etwas, das gerade verlaeuft, waere eine Form, die luegt. `role="slider"` AM GANZEN KASTEN und nicht sieben Knoepfe: Es ist EIN Wert mit sieben Stufen. Sieben Knoepfe waeren sieben Tabulatorschritte fuer eine Entscheidung. */ function leiterBauen() { const leiter = $('kachelhell-leiter'); if (!leiter) return; leiter.textContent = ''; kreisStufen.forEach((s, i) => { const feld = el('span', 'kachelhell__feld'); feld.dataset.stufe = s.wert; feld.title = s.name; /* aria-hidden: Der Wert steht am Kasten (`aria-valuenow`), die Felder sind seine Darstellung. Ohne das laese eine Sprachausgabe sieben leere Felder vor. */ feld.setAttribute('aria-hidden', 'true'); feld.addEventListener('click', () => stufeWaehlen(i, true)); leiter.append(feld); }); leiterZeigen(); } /** Die Felder auf den aktuellen Farbton bringen und die Wahl zeigen. */ function leiterZeigen() { const leiter = $('kachelhell-leiter'); if (!leiter) return; const nr = Math.max(0, kreisStufen.findIndex((s) => s.wert === radStufe)); leiter.setAttribute('aria-valuenow', String(nr + 1)); leiter.setAttribute('aria-valuetext', stufenName(radStufe)); for (const feld of leiter.children) { const s = kreisStufen.find((x) => x.wert === feld.dataset.stufe); const grund = kreisToene[radWinkel]; if (s && grund) feld.style.background = s.misch ? tonMischen(grund, s.misch) : grund; feld.dataset.an = feld.dataset.stufe === radStufe ? 'ja' : 'nein'; } } /** Eine Stufe waehlen -- ueber den Index, damit Pfeiltasten und * Klick denselben Weg gehen. */ function stufeWaehlen(nr, sichern) { const n = Math.max(0, Math.min(kreisStufen.length - 1, nr)); radStufe = kreisStufen[n].wert; radZeigen(radWinkel); leiterZeigen(); if (sichern) radSichern(0); } function leiterBedienen() { const leiter = $('kachelhell-leiter'); if (!leiter) return; leiter.addEventListener('keydown', (e) => { const nr = kreisStufen.findIndex((s) => s.wert === radStufe); let ziel = null; if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') ziel = nr - 1; else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') ziel = nr + 1; else if (e.key === 'Home') ziel = 0; else if (e.key === 'End') ziel = kreisStufen.length - 1; if (ziel === null) return; e.preventDefault(); /* Mit einer halben Sekunde Ruhe speichern -- wer mit der Taste durch die Stufen faehrt, soll nicht sieben Anfragen ausloesen. Dieselbe Ueberlegung wie am Ring. */ stufeWaehlen(ziel, false); radSichern(500); }); } /** Ein Wort fuer den Bereich, damit eine Sprachausgabe etwas sagen kann. */ function farbwort(w) { const namen = [ [0, 'Rot'], [20, 'Orangerot'], [45, 'Orange'], [70, 'Goldbraun'], [95, 'Oliv'], [120, 'Gruen'], [155, 'Tannengruen'], [180, 'Blaugruen'], [205, 'Tuerkis'], [230, 'Blau'], [265, 'Indigo'], [290, 'Violett'], [315, 'Magenta'], [340, 'Pink'], ]; let wort = namen[0][1]; for (const [ab, n] of namen) if (w >= ab) wort = n; return wort; } /** Nach der Bewegung einmal speichern. */ function radSichern(verzoegerung = 0) { clearTimeout(radUhr); radUhr = setTimeout(() => kachelSetzen(radSchluessel()), verzoegerung); } function radBedienen() { const ring = $('kachelrad-ring'); if (!ring) return; /* Aus einem Punkt auf dem Bildschirm wird ein Winkel. atan2(dx, -dy) zaehlt bei ZWOELF UHR los und laeuft im Uhrzeigersinn -- genau so, wie conic-gradient den Ring malt. Damit stimmt der Griff mit der Farbe darunter ueberein; mit dem ueblichen atan2(dy, dx) laege er um 90 Grad daneben. */ const winkelAus = (ev) => { const r = ring.getBoundingClientRect(); const dx = ev.clientX - (r.left + r.width / 2); const dy = ev.clientY - (r.top + r.height / 2); return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360; }; let zieht = false; ring.addEventListener('pointerdown', (ev) => { zieht = true; ring.setPointerCapture(ev.pointerId); radZeigen(winkelAus(ev)); ev.preventDefault(); }); ring.addEventListener('pointermove', (ev) => { if (zieht) radZeigen(winkelAus(ev)); }); const los = () => { if (zieht) { zieht = false; radSichern(0); } }; ring.addEventListener('pointerup', los); ring.addEventListener('pointercancel', los); /* MIT DER TASTATUR GEHT ES AUCH. Ein Ring, den man nur mit der Maus bedienen kann, ist fuer jeden ohne Maus eine Sackgasse -- und der Chat ist die Seite, auf der am meisten passiert. */ ring.addEventListener('keydown', (ev) => { const schritt = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1, PageUp: 15, PageDown: -15 }[ev.key]; if (schritt === undefined) { if (ev.key === 'Home') { radZeigen(0); radSichern(500); ev.preventDefault(); } return; } radZeigen(radWinkel + schritt * (ev.shiftKey ? 5 : 1)); radSichern(500); ev.preventDefault(); }); } function kachelwahlBedienen() { radBedienen(); leiterBedienen(); 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; if (text !== undefined) k.textContent = text; return k; }; /* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte 'spicy' nicht -- siehe bereiche.js. */ const ROLLENKURZ = window.Bereiche.ROLLEN_NAME; let ich = null; let raeume = []; let offen = null; // der gerade angesehene Raum let nachrichten = []; let strom = null; // die EventSource let gewaehlt = new Set(); // im Neu-Dialog /* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2). `darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus einem Rollenvergleich hier. Diese Datei bekommt jeder, der die Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */ let angeheftet = []; let darfAnheftenHier = false; let darfAllesLoeschen = false; /* DARF ICH DAS RUDEL RUFEN? (23.09.2026) Kommt wie die beiden darueber vom Server (`darf_rudel`) -- hier wird keine Rolle verglichen. Und das WORT kommt mit, damit es nicht an zwei Stellen steht: Der Vorschlag beim Tippen setzt genau das ein, was der Server hinterher erkennt. */ let darfRudel = false; let rudelWort = 'rudel'; /* WEN "@rudel" IN DIESEM RAUM MEINT -- vom Server, nicht geraten. 'raum' = alle Anwesenden (so im Rudel-Raum), 'team' = Team Dogi (ueberall sonst). Steht die Unterscheidung auch hier, gibt es zwei Wahrheiten, und die Liste verspricht irgendwann etwas anderes, als der Ruf hinterher tut. */ let rudelWen = 'team'; let darfKanal = false; /* Was der Server ueber die Nachtruhe sagt. `null` heisst: Diese Person hat mit dem Treff nichts zu tun -- dann gibt es auch nichts anzuzeigen. */ let treffStand = null; /* `null` heisst "der Server hat nichts gesagt" -- dann bleibt alles wie bisher. Erst ein ausdrueckliches `false` nimmt die Knoepfe weg. */ let darfAnrufen = null; let taktUhr = null; let kanalFrei = []; let neuArt = 'gespraech'; // 'gespraech' | 'kanal' let kanalUmbau = null; // Raumnummer, wenn die Besetzung geaendert wird /* ---------- Kleinkram ---------------------------------------------------- */ async function hole(weg, einst) { const a = await fetch(weg, { credentials: 'same-origin', ...einst }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); } return a; } /** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der * Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von * Hohenstein" sonst zu "PV" würde. */ function initialen(name) { const teile = String(name || '').trim().split(/\s+/).filter(Boolean); if (!teile.length) return '?'; const erst = [...teile[0]][0] || ''; const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : ''; return (erst + letzt).toLocaleUpperCase('de-DE'); } /** "14:32" heute, "Gestern", sonst "3. Sep". * * ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom * Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre * UTC und läge nachts einen Tag daneben. */ function wann(iso, lang = false) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`; const heute = new Date(); const gleich = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); if (gleich(d, heute)) return uhr; const gestern = new Date(heute.getTime() - 86400000); if (gleich(d, gestern)) return 'Gestern'; return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); } /* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt -- und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf. Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */ /* ---- ANRUFEN (18.09.2026) ------------------------------------------ Die zwei Knöpfe stehen im Kopf des Gesprächs, neben den anderen. Sie erscheinen NUR, wenn ein Gespräch offen ist -- ein Anrufknopf ohne Gegenüber wäre ein Angebot ohne Gegenstand, derselbe Gedanke wie beim Löschen-Knopf daneben. */ function anrufKnoepfeSetzen() { const ziel = document.querySelector('.chat__kopf-knoepfe'); if (!ziel || !window.anrufStarten) return; /* DIE COMMUNITY TELEFONIERT NICHT (19.09.2026). Filipe: "die sollen nicht anrufen koennen. nur schreiben." Der Riegel sitzt am Anruf-Router; hier faellt nur der Knopf weg. BEIDES ZUSAMMEN: Ohne den Riegel waere es kosmetisch, ohne den fehlenden Knopf staende ein Angebot da, das mit 404 antwortet. `=== false` und nicht `!`: Fehlt die Angabe (aeltere Antwort des Servers), bleibt alles wie bisher -- ein fehlendes Feld soll niemandem das Telefon wegnehmen. */ if (darfAnrufen === false) { document.getElementById('anruf-ton-start')?.remove(); document.getElementById('anruf-video-start')?.remove(); return; } let ton = document.getElementById('anruf-ton-start'); let video = document.getElementById('anruf-video-start'); if (!ton) { ton = document.createElement('button'); ton.type = 'button'; ton.id = 'anruf-ton-start'; ton.className = 'chat__weg chat__anruf'; ton.title = 'Anrufen'; ton.setAttribute('aria-label', 'Anrufen'); ton.innerHTML = ''; ton.addEventListener('click', () => { if (!offen) return; /* DEN NAMEN MITGEBEN (19.09.2026): Der Anrufkasten zeigte vorher nur „Es klingelt …" -- man sah nicht, wen man anruft. Der Name steht hier ohnehin schon. */ window.anrufStarten(offen.id, false, offen.name); }); video = document.createElement('button'); video.type = 'button'; video.id = 'anruf-video-start'; video.className = 'chat__weg chat__anruf'; video.title = 'Mit Video anrufen'; video.setAttribute('aria-label', 'Mit Video anrufen'); video.innerHTML = ''; video.addEventListener('click', () => { if (!offen) return; window.anrufStarten(offen.id, true, offen.name); }); ziel.prepend(video); ziel.prepend(ton); /* ---- DER WEG ZUR ANRUF-PROBE (19.09.2026) ------------------ Filipe: „geht es nicht auf der app?" Nein -- und das war mein Fehler. Ich hatte eine Seite gebaut, die sagt, WARUM es nicht klingelt, und sie war nur ueber die Adresszeile erreichbar. In der installierten App gibt es keine. Eine Hilfe, die man nicht aufrufen kann, ist keine. Sie steht deshalb hier, neben den Anrufknoepfen -- klein und ohne Farbe. Wer telefoniert, uebersieht sie; wer vor einem stillen Telefon sitzt, findet sie genau dort, wo er gerade hinschaut. */ const probe = document.createElement('a'); probe.id = 'anruf-probe-weg'; probe.className = 'chat__weg chat__probe'; probe.href = 'anruf-probe.html'; probe.title = 'Klingelt nichts? Hier nachsehen'; probe.setAttribute('aria-label', 'Klingelt nichts? Hier nachsehen'); probe.textContent = '?'; ziel.append(probe); } const da = !!offen; ton.hidden = !da; video.hidden = !da; const probeWeg = document.getElementById('anruf-probe-weg'); if (probeWeg) probeWeg.hidden = !da; } window.anrufMeldung = (text) => melde(text); const melde = (t, art = 'fehler') => { const z = $('fehler'); z.textContent = t || ''; z.dataset.art = t ? art : 'fehler'; }; /* ==== DIE NACHTRUHE DES TREFFS (19.09.2026) ========================== Filipe: "nach mitternacht bis morgens 6 uhr keiner schreiben kann damit auch die privatsphaere beruecksichtig wird ueber nacht." DREI DINGE, DIE DIESE FUNKTION TUT, UND EINES, DAS SIE NICHT TUT: 1. Sie sagt es, BEVOR jemand tippt -- ueber dem Feld, nicht darunter. 2. Sie warnt VORHER. Unter 15 Minuten steht da, wie lange noch. Ohne das waere die Nachtruhe ein Abbruch mitten im Satz. 3. Sie schaltet das Feld aus, wenn zu ist -- sichtbar, nicht heimlich. SIE ENTSCHEIDET NICHTS. Ob geschrieben werden darf, sagt der Server, und zwar auch dann noch einmal, wenn hier alles offen aussieht. Ein ausgegrautes Feld ist eine Hoeflichkeit, kein Schloss. NUR IM TREFF. In einem Teamraum passiert hier gar nichts -- dort gibt es keine Nachtruhe, dort arbeiten Leute nachts. */ const NACHTRUHE_VORWARNUNG = 15; // Minuten /* ==== DIE SCHRIFTLEISTE KLAPPT AUF UND ZU (25.09.2026) ========== Filipe: „damit es nicht zu viel platz nimmt." SIE IST ZU, BIS JEMAND SIE AUFMACHT. Die vier Zeichen braucht man in den seltensten Nachrichten; eine Zeile, die immer dasteht, um selten etwas zu koennen, ist die teuerste Art von Platz. DIE WAHL WIRD GEMERKT -- wer viel gestaltet, gestaltet meist weiter. `try` um den Speicher, weil localStorage in einem privaten Fenster wirft; ohne den Fang bliebe die Leiste dort fuer immer zu, und der Knopf sähe kaputt aus. DIE TASTENKUERZEL BLEIBEN IMMER AKTIV. Strg+B und Strg+I haengen am Schreibfeld, nicht an der Leiste (textform.js) -- wer sie kennt, braucht den Knopf gar nicht. Eine zugeklappte Leiste nimmt also nichts weg, sie zeigt nur weniger. */ let schriftAuf = (() => { try { return localStorage.getItem('chat-schrift') === 'auf'; } catch { return false; } })(); function schriftLeisteZeigen() { const form = $('schreiben'); const knopf = $('schrift-auf'); if (form) form.dataset.schrift = schriftAuf ? 'auf' : 'zu'; if (knopf) knopf.setAttribute('aria-expanded', String(schriftAuf)); } $('schrift-auf')?.addEventListener('click', () => { schriftAuf = !schriftAuf; try { localStorage.setItem('chat-schrift', schriftAuf ? 'auf' : 'zu'); } catch { /* egal */ } schriftLeisteZeigen(); /* ZURUECK INS FELD. Wer die Leiste aufmacht, will gleich darauf tippen oder schreiben -- ohne diese Zeile steht der Fokus auf einem Knopf, und die naechste Taste macht nichts. */ if (schriftAuf) $('text')?.focus(); }); function nachtruheZeigen() { const band = $('nachtruhe'); const feld = $('text'); const senden = $('senden'); if (!band) return; const imTreff = !!offen && offen.art === 'kanal' && offen.kategorie === 'treff'; if (!imTreff || !treffStand) { band.hidden = true; delete band.dataset.lage; if (feld) { feld.disabled = false; feld.placeholder = 'Nachricht'; } if (senden) senden.disabled = false; const k = $('anhang-auf'); if (k) { k.disabled = false; k.title = 'Bild oder PDF anhängen'; } return; } const zu = !!treffStand.zu; const gleich = !zu && treffStand.minuten <= NACHTRUHE_VORWARNUNG; band.hidden = !(zu || gleich); band.dataset.lage = zu ? 'zu' : 'gleich'; if (zu) { $('nachtruhe-text').textContent = treffStand.satz + ` (noch ${stundenSatz(treffStand.minuten)})`; } else if (gleich) { $('nachtruhe-text').textContent = `In ${stundenSatz(treffStand.minuten)} macht das Rudel zu. ` + `Ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr geht es weiter.`; } /* DIE BUEROKLAMMER GEHOERT DAZU (19.09.2026, beim Ansehen des Bildschirmfotos aufgefallen). Der Server lehnt Anhaenge nachts ebenso ab wie Nachrichten -- der Knopf stand aber offen da. Wer ihn drueckt, waehlt ein Bild aus, wartet auf den Upload und bekommt dann eine Absage. Das ist schlimmer als ein ausgegrauter Knopf: Die Arbeit ist schon gemacht. */ const klammer = $('anhang-auf'); if (klammer) { klammer.disabled = zu; klammer.title = zu ? `Nachts keine Anhänge – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder` : 'Bild oder PDF anhängen'; } if (feld) { feld.disabled = zu; /* DER PLATZHALTER SAGT DEN GRUND. Ein graues Feld ohne Text sieht aus wie ein Fehler -- und man tippt trotzdem hinein. */ feld.placeholder = zu ? `Das Rudel schläft – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder` : 'Nachricht'; } if (senden) senden.disabled = zu; } /* ==== DER TAKT: die Minuten laufen wirklich ab ====================== Ohne ihn stuende die Zahl vom Seitenaufruf da, bis jemand neu laedt. Ein Chat steht aber stundenlang offen -- "noch 12 Minuten" waere um drei Uhr nachts immer noch zu lesen. EINMAL JE MINUTE, und er zaehlt SELBST herunter, statt den Server zu fragen. Eine Abfrage je Minute je offenem Fenster waere Last ohne Gegenwert: Die Minuten vergehen hier genauso wie dort. BEIM WECHSEL WIRD GEFRAGT. Laeuft der Zaehler auf null, holt er den Stand EINMAL frisch -- das ist der Moment, in dem sich wirklich etwas aendert, und der einzige, in dem die eigene Rechnung danebenliegen koennte (Zeitumstellung, schlafender Rechner, eine geaenderte Einstellung). NUR SOLANGE ES ETWAS ZU ZAEHLEN GIBT. Wer mit dem Treff nichts zu tun hat, bekommt keinen Zeitgeber. */ function taktStarten() { if (taktUhr) { clearInterval(taktUhr); taktUhr = null; } if (!treffStand) return; taktUhr = setInterval(async () => { if (!treffStand) { clearInterval(taktUhr); taktUhr = null; return; } treffStand.minuten -= 1; if (treffStand.minuten > 0) { nachtruheZeigen(); return; } /* Der Wechsel. Einmal fragen, dann weiterzaehlen. */ try { const a = await hole('/workspace/api/chat/raeume'); if (a.ok) { const d = await a.json(); treffStand = d.treff || null; } } catch { /* kein Netz -- beim naechsten Mal wieder */ } /* Faellt die Antwort aus, wird selbst umgeschaltet: Lieber eine Minute ungenau als eine Stunde falsch. */ if (treffStand && treffStand.minuten <= 0) { treffStand.zu = !treffStand.zu; treffStand.minuten = (treffStand.zu ? (treffStand.bis - treffStand.ab + 24) % 24 : (treffStand.ab - treffStand.bis + 24) % 24) * 60; } nachtruheZeigen(); }, 60_000); } /* Ein schlafender Rechner haelt `setInterval` an. Wer sein Handy nach zwei Stunden wieder aufklappt, haette sonst eine Zahl von vor zwei Stunden vor sich -- und zwar eine, die nach oben zaehlt. Beim Zurueckkommen wird deshalb einmal gefragt. */ document.addEventListener('visibilitychange', () => { if (document.hidden || !treffStand) return; raeumeLaden().catch(() => {}); }); /** „3 Minuten" / „2 Stunden 10 Minuten" -- nie „130 Minuten". * * Wer „noch 347 Minuten" liest, rechnet im Kopf und liegt daneben. * Die Zahl soll eine Auskunft sein, keine Aufgabe. */ function stundenSatz(min) { const m = Math.max(0, Math.round(min)); if (m < 60) return `${m} Minute${m === 1 ? '' : 'n'}`; const st = Math.floor(m / 60), rest = m % 60; return `${st} Stunde${st === 1 ? '' : 'n'}` + (rest ? ` ${rest} Minute${rest === 1 ? '' : 'n'}` : ''); } /* ---------- Die Gesprächsliste ------------------------------------------- */ async function raeumeLaden() { try { const a = await hole('/workspace/api/chat/raeume'); if (!a.ok) return; const daten = await a.json(); raeume = daten.raeume || []; darfKanal = !!daten.darf_kanal; /* DER SERVER SAGT, OB DER TREFF SCHLAEFT -- hier wird nichts nachgerechnet. Eine zweite Rechnung im Browser waere die, die bei der naechsten Zeitumstellung anders herauskommt als die im Server; dann sagt die Seite "offen" und der Server lehnt ab. */ treffStand = daten.treff || null; if (typeof daten.darf_anrufen === 'boolean') darfAnrufen = daten.darf_anrufen; nachtruheZeigen(); taktStarten(); kanalFrei = daten.kanal_frei || []; /* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat. Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine Enttaeuschung mit Ansage. */ $('neu-art').hidden = !darfKanal; /* Abgeleitet, nicht zweimal entschieden: Die Erklaerung gehoert genau dann hin, wenn es auch etwas zu entscheiden gibt. */ $('neu-erklaerung').hidden = $('neu-art').hidden; if (!darfKanal) neuArt = 'gespraech'; } catch { return; } raeumeZeichnen(); ungelesenMelden(); } /* ==== DIE DREI FAECHER (25.09.2026) ============================== Filipe: „ich will 3 kategorien haben. chats mit einzelnen personen, gruppen chats und kanäle." DIE ART STEHT SCHON AN JEDEM GESPRAECH (`r.art`: direkt, gruppe, kanal) -- es gibt also nichts zu erfinden, nur zu sortieren. Genau deshalb ist diese Liste hier eine ABLEITUNG und keine zweite Wahrheit: Kaeme morgen eine vierte Art dazu, faellt sie hier auf (die Zeilen waeren in keinem Fach), statt still zu verschwinden. GEMERKT WIRD DIE WAHL, und zwar ueber das Neuladen hinaus: Wer im Kanal arbeitet, arbeitet meist den ganzen Tag im Kanal. Jedes Mal neu zu waehlen waere ein Griff, den niemand will. `try` UM DEN SPEICHER: In einem privaten Fenster wirft localStorage. Ohne den Fang waere die ganze Liste weg -- wegen einer Bequemlichkeit. */ const FAECHER = [ { wert: 'direkt', name: 'Personen', leer: 'Noch kein Gespräch mit einer einzelnen Person.' }, { wert: 'gruppe', name: 'Gruppen', leer: 'Noch keine Gruppe. Mit „Neu“ legst du eine an.' }, { wert: 'kanal', name: 'Kanäle', leer: 'Noch kein Kanal.' }, ]; let fach = (() => { try { return localStorage.getItem('chat-fach') || 'direkt'; } catch { return 'direkt'; } })(); if (!FAECHER.some((f) => f.wert === fach)) fach = 'direkt'; /* Einmal je Seitenaufruf wird geprueft, ob das gemerkte Fach ueberhaupt etwas enthaelt -- siehe raeumeZeichnen(). */ let fachGeprueft = false; function fachWaehlen(wert) { fach = wert; try { localStorage.setItem('chat-fach', wert); } catch { /* egal */ } raeumeZeichnen(); } /* Die Leiste wird bei jedem Zeichnen neu gebaut -- die Zahlen daran aendern sich mit jeder Nachricht. */ function faecherZeichnen() { const leiste = $('chat-arten'); if (!leiste) return; leiste.textContent = ''; for (const f of FAECHER) { const drin = raeume.filter((r) => (r.art || 'direkt') === f.wert); const neu = drin.reduce((n, r) => n + (Number(r.ungelesen) || 0), 0); /* WURDE ICH IN DIESEM FACH GERUFEN? (25.09.2026) Gefunden hat das nicht ein Blick, sondern pruef-erwaehnung: Die Erwaehnung lag in einer GRUPPE, das offene Fach war „Personen" -- und das @ an der Zeile war damit nirgends zu sehen. Ein Ruf, den ein geschlossenes Fach verschluckt, ist schlimmer als gar kein Ruf: Man glaubt, es wartet nichts. Dieselbe Antwort wie bei den Ungelesenen eine Zeile weiter oben, und aus demselben Grund. Der Unterschied: Das @ tritt NICHT zurueck, wenn daneben eine Zahl steht -- es ist die wichtigere Auskunft von beiden ("es ging an DICH"), genau wie an der Zeile selbst. */ const gerufen = drin.some((r) => r.erwaehnt); const k = el('button', 'chat-art'); k.type = 'button'; k.setAttribute('role', 'tab'); k.dataset.art = f.wert; k.setAttribute('aria-selected', String(f.wert === fach)); /* NICHT AUSWAEHLBAR HEISST NICHT UNSICHTBAR: Ein leeres Fach bleibt stehen und sagt "hier ist nichts". Waere es weg, wuesste niemand, dass es die Art ueberhaupt gibt -- und die Leiste haette mal zwei, mal drei Faecher. */ k.append(el('span', 'chat-art__name', f.name)); if (gerufen) { const at = el('span', 'chat-art__at', '@'); at.title = 'In diesem Fach wurdest du erwähnt'; /* Ein nacktes "@" wird je nach Vorleseprogramm als "at", "Klammeraffe" oder gar nicht gelesen -- derselbe Satz wie an der Zeile. */ at.setAttribute('aria-label', 'In diesem Fach wurdest du erwähnt'); k.append(at); } k.append(el('span', 'chat-art__zahl', String(drin.length))); if (neu) { /* DIE UNGELESENEN DES FACHS STEHEN AM FACH. Sonst waere ein geschlossenes Fach ein Ort, an dem Nachrichten liegen bleiben, ohne dass man es sieht -- der schlimmste Fehler, den eine Sortierung machen kann. */ const punkt = el('span', 'chat-art__neu', neu > 99 ? '99+' : String(neu)); punkt.setAttribute('aria-label', neu === 1 ? '1 ungelesene Nachricht' : neu + ' ungelesene Nachrichten'); k.append(punkt); } k.addEventListener('click', () => fachWaehlen(f.wert)); /* Mit den Pfeiltasten zwischen den Faechern -- so erwartet es, wer eine Registerleiste bedient. */ k.addEventListener('keydown', (e) => { const i = FAECHER.findIndex((x) => x.wert === f.wert); if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); const n = (i + (e.key === 'ArrowRight' ? 1 : FAECHER.length - 1)) % FAECHER.length; fachWaehlen(FAECHER[n].wert); $('chat-arten')?.querySelector(`[data-art="${FAECHER[n].wert}"]`)?.focus(); } }); leiste.append(k); } } /* ==== EIN ZEICHEN, EINE STELLE (25.09.2026) ===================== Das runde Zeichen – Raute für einen Kanal, Zahl für eine Gruppe, Gesicht oder Anfangsbuchstaben für einen Menschen – stand bisher nur in der Liste, als rund fünfzig Zeilen mitten im Zeichnen. Seit der Kopf des Gesprächs dasselbe Zeichen trägt, gäbe es das zweimal, und die zweite Abschrift ist immer die, in der beim nächsten Umbau eine Zeile fehlt (siehe 11.09.2026, die abgeschriebene Spaltenliste – sie hat Daten gekostet). SIE FÜLLT EIN VORHANDENES ELEMENT, statt eines zu bauen: Der Kopf hat sein Zeichen fest im HTML und tauscht nur den Inhalt. Deshalb wird hier ZUERST aufgeräumt – sonst trüge ein Zweiergespräch noch die Raute des Kanals von vorhin. */ function zeichenFuellen(ziel, r) { ziel.textContent = ''; delete ziel.dataset.kanal; delete ziel.dataset.gruppe; delete ziel.dataset.rolle; const art = r.art || 'direkt'; if (art === 'kanal') { /* Das Rautenzeichen ist die Schreibweise, die jeder aus anderen Messengern kennt -- es muss nicht erklaert werden. */ ziel.dataset.kanal = 'ja'; ziel.textContent = '#'; } else if (art === 'gruppe') { /* Eine Gruppe zeigt ihre Groesse statt einer Initiale -- der Name steht ohnehin daneben. */ ziel.dataset.gruppe = 'ja'; ziel.textContent = String(r.teilnehmer.length); } else { ziel.textContent = initialen(r.name); const gegen = r.teilnehmer.find((t) => t.id !== ich.id); if (gegen) { /* DAS GESICHT STATT DER INITIALEN (20.09.2026). Filipe: "da soll man im chat auch die profilfotos von den leuten sehen wenn die schon eins drin haben." DER BUCHSTABE BLEIBT ALS UNTERLAGE LIEGEN und wird nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. EINMAL, NICHT ZWEIMAL (behoben 24.09.2026): Dieser Block stand einmal vor und einmal nach der Rollenzeile. Jedes Gespraech mit Profilbild lud das Bild also zweimal -- zu sehen war davon nichts, gekostet hat es trotzdem, bei jedem Neuzeichnen der Liste. */ ziel.dataset.rolle = gegen.rolle; if (gegen.bild) { const b = document.createElement('img'); b.src = gegen.bild; b.alt = ''; b.addEventListener('error', () => b.remove()); ziel.append(b); } } } return ziel; } function raeumeZeichnen() { const ziel = $('raeume'); /* ==== EIN LEERES FACH MERKT MAN SICH NICHT (25.09.2026) ========= Gefunden hat das pruef-gifs, nicht ein Blick: In der Pruefung gibt es genau EIN Gespraech, den Treff -- einen Kanal. Das voreingestellte Fach ist „Personen", und dort stand: „Noch kein Gespräch mit einer einzelnen Person." Die Seite sah leer aus, obwohl ein voller Kanal danebenlag, nur eine Lasche weiter. Genau so geht es jedem Neuen im Haus: Der erste Raum, in dem er landet, ist der Treff -- ein Kanal. Er haette beim ersten Oeffnen eine leere Liste gesehen. DIE WAHL WIRD NICHT GESPEICHERT. Das hier ist geraten, nicht gewaehlt: Sobald jemand selbst auf ein Fach tippt, gilt seine Wahl und ueberlebt das Neuladen. Wuerde diese Notlage mit abgespeichert, waere sie beim naechsten Mal nicht mehr von einer echten Entscheidung zu unterscheiden. DIE REIHENFOLGE IST NICHT BELIEBIG: erst das Fach, in dem jemand GERUFEN wurde, dann das mit Ungelesenem, dann das erste, in dem ueberhaupt etwas steht. Dieselbe Rangfolge wie an jeder Zeile -- „es ging an dich" wiegt schwerer als „es ist neu", und das wiegt schwerer als „es ist da". EINMAL, NICHT BEI JEDEM ZEICHNEN: Die Liste wird bei jeder eintreffenden Nachricht neu gezeichnet. Ohne den Merker spraenge das Fach mitten im Lesen um, sobald irgendwo etwas hereinkommt -- eine Seite, die unter den Haenden wegwandert. */ if (!fachGeprueft && raeume.length) { fachGeprueft = true; const inFach = (w, pruefe) => raeume.some( (r) => (r.art || 'direkt') === w && (!pruefe || pruefe(r))); if (!inFach(fach)) { const wahl = FAECHER.find((f) => inFach(f.wert, (r) => r.erwaehnt)) || FAECHER.find((f) => inFach(f.wert, (r) => r.ungelesen)) || FAECHER.find((f) => inFach(f.wert)); if (wahl) fach = wahl.wert; } } const suchtext = ($('suche').value || '').trim().toLowerCase(); faecherZeichnen(); /* BEIM SUCHEN GILT DAS FACH NICHT. Wer einen Namen eintippt, will ihn finden -- und nicht erst raten, in welchem der drei Faecher er liegt. Ein Treffer, der wegen einer Voreinstellung nicht erscheint, ist schlimmer als kein Suchfeld. */ const zeigen = suchtext ? raeume.filter((r) => r.name.toLowerCase().includes(suchtext)) : raeume.filter((r) => (r.art || 'direkt') === fach); ziel.textContent = ''; /* DER LEER-SATZ GEHOERT ZUM FACH, nicht zur ganzen Liste. Vorher stand er nur da, wenn es GAR kein Gespraech gab -- ein leeres Fach neben zwei vollen zeigte einfach nichts an, und das sieht aus wie ein Fehler beim Laden. */ const leerSatz = $('liste-leer'); if (leerSatz) { leerSatz.hidden = zeigen.length > 0 || !!suchtext; if (!zeigen.length && !suchtext) { leerSatz.textContent = raeume.length ? (FAECHER.find((f) => f.wert === fach)?.leer || 'Hier ist noch nichts.') : 'Noch keine Gespräche. Mit „Neu“ schreibst du der ersten Person.'; } } /* WIE VIELE WARTEN AUF MICH (24.09.2026) -- die Summe, nicht die Zahl der Gespraeche. „3 Gespraeche mit Neuem" und „17 neue Nachrichten" sind zwei verschiedene Auskuenfte, und die zweite ist die, nach der man fragt, bevor man anfaengt. GERECHNET UEBER `raeume` UND NICHT UEBER `zeigen`: Wer im Suchfeld tippt, filtert die Liste -- die Summe darueber wuerde dann mitwandern und behaupten, es warte weniger. */ { const zaehler = $('liste-zaehler'); if (zaehler) { const summe = raeume.reduce((n, r) => n + (Number(r.ungelesen) || 0), 0); zaehler.textContent = summe > 99 ? '99+' : String(summe); zaehler.setAttribute('aria-label', summe === 1 ? '1 ungelesene Nachricht' : summe + ' ungelesene Nachrichten'); zaehler.hidden = summe === 0; } } /* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu suchen. Vorher ist es ein Feld ohne Zweck. */ /* IMMER SICHTBAR SEIT DEM 09.09.2026. Hier stand `raeume.length < 8` mit der Begruendung, ein Suchfeld ueber drei Eintraegen sei Zierde. Das stimmte, solange es nur NAMEN durchsuchte. Seit es auch die Nachrichten durchsucht, ist es das Gegenteil: Zwei Gespraeche koennen tausend Nachrichten haben, und genau dann braucht man es am dringendsten. */ $('suche-zeile').hidden = false; for (const r of zeigen) { const li = el('li', 'chat-raum'); li.dataset.id = String(r.id); /* DIE ART GEHOERT AN DIE ZEILE (24.09.2026). Kanal, Gruppe und Zweiergespraech sehen unterschiedlich aus; bisher stand die Art nur am runden Zeichen, und das Stilblatt konnte deshalb nur DAS einfaerben -- nicht die Zeile drumherum. */ li.dataset.art = r.art || 'direkt'; if (offen && r.id === offen.id) li.dataset.offen = 'ja'; if (r.ungelesen) li.dataset.neu = 'ja'; const knopf = el('button', 'chat-raum__knopf'); knopf.type = 'button'; const zeichen = el('span', 'chat-raum__zeichen'); zeichen.setAttribute('aria-hidden', 'true'); /* GEBAUT WIRD ES AN EINER STELLE (25.09.2026). Hier standen rund fuenfzig Zeilen, die seit heute auch der Kopf des Gespraechs braucht -- siehe zeichenFuellen() weiter oben. Die Begruendungen zum Profilbild sind dorthin mitgewandert und nicht verlorengegangen. */ zeichenFuellen(zeichen, r); knopf.append(zeichen); const mitte = el('span', 'chat-raum__mitte'); const oben = el('span', 'chat-raum__oben'); oben.append(el('span', 'chat-raum__name', r.name)); if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt))); mitte.append(oben); const vorschau = el('span', 'chat-raum__vorschau'); if (r.letzte) { /* Wer geschrieben hat, gehoert ueberall dorthin, wo mehr als zwei Leute schreiben koennen -- in einer Gruppe wie in einem Kanal. Hier stand `=== 'gruppe'`, und ein Kanal waere damit die einzige Stelle ohne Absender in der Vorschau gewesen. */ /* WER GESCHRIEBEN HAT, steht seit dem 24.09.2026 in einem eigenen Stueck Text statt mitten im Satz. Damit kann das Stilblatt den Namen leicht absetzen -- beim Ueberfliegen sucht man „wer", nicht „was", und ein durchgehend grauer Satz zwingt zum Lesen. */ const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art !== 'direkt' ? `${r.letzte.von}: ` : ''); /* KEIN „Nachricht zurueckgenommen" MEHR (22.09.2026). Hier stand genau dieser Satz -- und die Gespraechsliste ist die auffaelligste Stelle der ganzen Seite. Geloescht heisst geloescht: Der Server liefert eine geloeschte Nachricht gar nicht mehr als letzte, weil es die Zeile nicht mehr gibt. Sollte doch einmal eine alte, zurueckgenommene durchkommen, steht hier lieber nichts als ein Hinweis auf sie. */ if (r.letzte.zurueckgenommen) { vorschau.textContent = 'Noch nichts geschrieben'; vorschau.dataset.weg = 'ja'; } else { if (wer) vorschau.append(el('span', 'chat-raum__wer', wer)); vorschau.append(document.createTextNode(r.letzte.text)); } } else { vorschau.textContent = 'Noch nichts geschrieben'; vorschau.dataset.weg = 'ja'; } mitte.append(vorschau); knopf.append(mitte); /* WURDE ICH HIER ANGESPROCHEN? (20.09.2026) Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an DICH war. Wer morgens vier Gespraeche mit Zahlen sieht, macht das lauteste zuerst auf -- und dort steht selten das, was auf ihn wartet. Das Zeichen steht VOR der Zahl, weil es die wichtigere Auskunft ist, und es traegt einen eigenen Satz fuer Vorleseprogramme: Ein "@" allein wird je nach Programm als "at", "Klammeraffe" oder gar nicht vorgelesen. */ /* ==== DIE RECHTE SPALTE (24.09.2026) ========================= HIER LAG EIN ECHTER FEHLER, und man sah ihn auf Filipes Bildschirmfoto: Bei „Das Rudel" stand das „@" rechts oben und die orange „6" eine ZEILE TIEFER, links unter dem Zeichen. URSACHE: Der Knopf ist ein Raster mit DREI Spalten (Zeichen | Mitte | Rest). Mit „@" UND Zahl sind es aber VIER Kinder -- und das vierte faellt in die naechste Rasterzeile. Die Zeile wurde dadurch hoeher als alle anderen, und die wichtigste Auskunft der ganzen Liste („hier warten sechs ungelesene") stand an der unauffaelligsten Stelle. Beide sitzen jetzt in EINER Spalte nebeneinander. Damit bleibt das Raster dreispaltig, egal wie viele Marken dazukommen -- und eine vierte Marke morgen bricht es nicht wieder auf. */ const rail = el('span', 'chat-raum__rail'); if (r.erwaehnt) { const marke = el('span', 'chat-raum__erwaehnt', '@'); marke.title = 'Du wurdest hier erwähnt'; marke.setAttribute('aria-label', 'Du wurdest hier erwähnt'); rail.append(marke); } if (r.ungelesen) { const zahl = el('span', 'chat-raum__zahl', r.ungelesen > 99 ? '99+' : String(r.ungelesen)); zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`); rail.append(zahl); } /* Die Spalte steht IMMER da, auch leer. Ohne sie waere die Mitte bei Zeilen ohne Marke breiter als bei Zeilen mit -- und die Namen staenden nicht mehr untereinander. Eine Liste, in der die Kanten wandern, wirkt unruhig, ohne dass man sagen kann, warum. */ knopf.append(rail); knopf.addEventListener('click', () => raumOeffnen(r.id)); li.append(knopf); /* ==== OBEN ANHEFTEN (25.09.2026) ============================== Filipe: „ich will dass man auch individuel jeder fuer sich auch in der liste chats fixieren kann. auch mehrere nicht nur eins." EIN EIGENER KNOPF NEBEN DER ZEILE, nicht darin. Die Zeile ist selbst ein