/* =====================================================================
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