diff --git a/server/chat-kreis.js b/server/chat-kreis.js
new file mode 100644
index 00000000..13c01848
--- /dev/null
+++ b/server/chat-kreis.js
@@ -0,0 +1,77 @@
+/* ERZEUGT von tools/chat-kacheln-rechnen.mjs --kreis.
+ NICHT VON HAND AENDERN -- wer hier eine Farbe eintippt, umgeht
+ genau die Kontrastrechnung, die die Lesbarkeit garantiert.
+
+ 360 Farbtoene, Schritt 1 Grad, alle auf derselben Leuchtdichte
+ wie die zwoelf Kacheln. Der Index IST der Winkel: KREIS[214] ist
+ 214 Grad. */
+export const KREIS = [
+ "#a60156", "#a60054", "#a60252", "#a80051", "#a8004f", "#a7004c",
+ "#a8014a", "#a80148", "#a90046", "#a90044", "#a90042", "#a90040",
+ "#aa013e", "#a9023b", "#aa0039", "#aa0037", "#ab0035", "#ab0032",
+ "#ab0130", "#ab012d", "#ab002a", "#ab0027", "#ab0124", "#ab0221",
+ "#ac001e", "#ac011a", "#ac0015", "#ab0010", "#ad000a", "#ac0002",
+ "#ab0b01", "#a91400", "#a51a00", "#a41f00", "#a02300", "#9f2600",
+ "#9d2900", "#9b2b00", "#9b2e00", "#993000", "#973201", "#963400",
+ "#933500", "#933700", "#923800", "#8f3901", "#8e3a00", "#8d3b00",
+ "#8c3c00", "#8b3d01", "#893e00", "#883f01", "#874001", "#864102",
+ "#844102", "#844100", "#834302", "#834300", "#814400", "#804402",
+ "#7f4500", "#7e4501", "#7e4600", "#7d4702", "#7c4700", "#7c4800",
+ "#7a4800", "#7a4900", "#784902", "#784900", "#784a00", "#754a01",
+ "#754a00", "#744b03", "#734b01", "#734c01", "#724c00", "#724d00",
+ "#704d02", "#6f4d01", "#6f4d01", "#6e4e00", "#6d4e00", "#6d4f00",
+ "#6d4f00", "#6b4f03", "#6a5003", "#6a5003", "#685002", "#685002",
+ "#675102", "#665102", "#665102", "#655202", "#645202", "#635202",
+ "#635303", "#615303", "#625400", "#615400", "#5f5400", "#5e5400",
+ "#5e5401", "#5d5502", "#5c5503", "#5c5600", "#5a5601", "#595602",
+ "#585602", "#585700", "#575701", "#555701", "#555801", "#535800",
+ "#525802", "#525900", "#505800", "#4f5903", "#4d5900", "#4d5a00",
+ "#4b5a00", "#4a5a02", "#485a00", "#485b00", "#455b00", "#445c00",
+ "#435c03", "#415c01", "#3f5c00", "#3d5d00", "#3b5e00", "#395d01",
+ "#365e01", "#345e01", "#325e01", "#2f5f01", "#2b5f01", "#296002",
+ "#246100", "#1f6100", "#1a6102", "#126200", "#086201", "#006205",
+ "#006210", "#006216", "#00621c", "#016220", "#016123", "#006227",
+ "#016129", "#00612c", "#00622f", "#036131", "#026133", "#026135",
+ "#026137", "#036139", "#01603a", "#02603b", "#00613e", "#01603f",
+ "#026040", "#046041", "#006042", "#036044", "#006045", "#036046",
+ "#016048", "#036049", "#00604a", "#025f4a", "#005f4b", "#035f4c",
+ "#015f4d", "#01604f", "#045f4f", "#025f50", "#015f51", "#045f52",
+ "#025f52", "#015f53", "#005f55", "#045e55", "#035e56", "#005e56",
+ "#005e57", "#005f58", "#015f5a", "#045e59", "#035e5a", "#025e5b",
+ "#035e5c", "#025e5d", "#025e5d", "#015e5e", "#015e5f", "#015e5f",
+ "#015e60", "#005d61", "#005d61", "#005d62", "#015d63", "#015d63",
+ "#015d64", "#025d65", "#025d65", "#035d66", "#045d67", "#055d68",
+ "#055c68", "#005d6a", "#005c6a", "#015c6a", "#025c6b", "#035c6b",
+ "#065c6d", "#015c6e", "#025c6e", "#045c6f", "#005c71", "#015b71",
+ "#025b71", "#005c73", "#015b73", "#035b74", "#005b76", "#025b76",
+ "#055b77", "#025b78", "#005b7a", "#015a79", "#005b7c", "#025a7c",
+ "#005a7d", "#045a7e", "#02597f", "#005980", "#005982", "#035982",
+ "#035984", "#015885", "#015886", "#025888", "#02588a", "#01588d",
+ "#00578e", "#02578f", "#005792", "#015693", "#005696", "#025597",
+ "#00559c", "#00549e", "#0053a0", "#0053a4", "#0152a6", "#0151aa",
+ "#0150af", "#004fb5", "#004dbc", "#004ac0", "#0047ca", "#0042d6",
+ "#0035ee", "#0d27ff", "#1824ff", "#2024ff", "#2622ff", "#2c21fd",
+ "#3120fe", "#351bff", "#3a17ff", "#3d17fd", "#4112ff", "#450efe",
+ "#4808fe", "#4b05fe", "#4e00fe", "#5102fd", "#5402fb", "#5602f9",
+ "#5801f5", "#5b02f6", "#5d02f4", "#5f02f2", "#6202f0", "#6300ee",
+ "#6500ec", "#6701ea", "#6902e8", "#6b00e7", "#6d01e5", "#6e02e1",
+ "#7001e0", "#7202de", "#7300db", "#7401d9", "#7700d9", "#7802d7",
+ "#7900d4", "#7b00d3", "#7b01cf", "#7d00ce", "#7f01cc", "#7f01c9",
+ "#8100c8", "#8301c6", "#8301c3", "#8500c2", "#8601bf", "#8701bc",
+ "#8802ba", "#8900b9", "#8a01b6", "#8c01b5", "#8d00b2", "#8d00af",
+ "#8e02ad", "#9001ac", "#9001a9", "#9200a8", "#9200a5", "#9200a2",
+ "#9301a0", "#95019e", "#96019d", "#96019a", "#960097", "#980096",
+ "#980093", "#980090", "#9a008f", "#9a028d", "#9a028a", "#9b0088",
+ "#9b0086", "#9c0084", "#9e0083", "#9d0080", "#9f007f", "#9e007c",
+ "#a0007b", "#a00178", "#a10177", "#a00074", "#a10072", "#a10170",
+ "#a2016e", "#a3016d", "#a2006a", "#a40069", "#a30166", "#a30064",
+ "#a50163", "#a40260", "#a5015e", "#a5005c", "#a6005b", "#a50058",
+];
+
+/** Der Winkel zu einem Schluessel der Form "ton-214", sonst null. */
+export function winkelAus(wert) {
+ const t = /^ton-(\d{1,3})$/.exec(String(wert ?? ""));
+ if (!t) return null;
+ const n = Number(t[1]);
+ return n >= 0 && n < KREIS.length ? n : null;
+}
diff --git a/server/pruef-chat-neu.mjs b/server/pruef-chat-neu.mjs
index ec792729..d510f2e4 100644
--- a/server/pruef-chat-neu.mjs
+++ b/server/pruef-chat-neu.mjs
@@ -232,7 +232,7 @@ console.log("\n=== 3. Der Ruf ans Rudel ===");
});
ok(!!liste, "ein @ oeffnet die Liste");
ok(liste?.erster === "rudel", `der Ruf steht oben (${liste?.erster})`);
- ok(/alle im Team/i.test(liste?.zusatz || ""), `mit der Erklaerung daneben (${liste?.zusatz})`);
+ ok(/alle in diesem Chat/i.test(liste?.zusatz || ""), `mit der Erklaerung daneben (${liste?.zusatz})`);
ok((liste?.hoch || 0) >= 44 && liste?.imBild,
`am Daumen treffbar und im Bild (${liste?.hoch} px)`);
await frida.seite.keyboard.press("Enter");
@@ -264,6 +264,196 @@ console.log("\n=== 3. Der Ruf ans Rudel ===");
`gerufen wurden ${gemeint.length} -- die andere Modi und DogFather, nicht sie selbst`);
}
+/* =====================================================================
+ 4. DER FARBRING (23.09.2026)
+
+ Filipe: "anstatt sowas um die farben auszusuchen, 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?"
+
+ WAS HIER WIRKLICH AUF DEM SPIEL STEHT, ist nicht die Optik. Die
+ zwoelf Kacheln waren gerechnet, damit niemand sich unlesbar machen
+ kann -- alle auf derselben Leuchtdichte. Ein Ring mit freier Wahl
+ ist genau die Gelegenheit, dieses Versprechen zu verlieren, und
+ zwar unbemerkt: Die Farbe sieht huebsch aus, und erst die anderen
+ koennen den Text nicht mehr lesen.
+
+ Deshalb misst diese Probe zuerst den KONTRAST und dann die
+ Bedienung.
+ ===================================================================== */
+{
+ console.log("");
+ console.log("=== 4. Der Farbring ===");
+
+ const { KREIS } = await import("./chat-kreis.js");
+
+ /* ---- 4a. Das Versprechen, an allen 360 Toenen ------------------ */
+ const schriftFarben = await frida.seite.evaluate(() => {
+ /* AM ECHTEN ELEMENT MESSEN, nicht an einem selbstgebauten.
+ Erster Anlauf legte ein
an
+ und las dort -- und bekam zweimal nichts zurueck. Die beiden
+ Werte stehen naemlich eine Ebene hoeher, auf .chat-nachricht,
+ und werden von dort vererbt. Ergebnis war ein roter Haken an
+ einer Stelle, an der nichts kaputt war: die Messung war falsch,
+ nicht das Programm.
+
+ Erst die Blase im Verlauf; gibt es keine, ein Ersatzstueck mit
+ der RICHTIGEN Klasse. */
+ const echt = document.querySelector(".chat-nachricht");
+ const ziel = echt || document.body.appendChild(
+ Object.assign(document.createElement("div"), { className: "chat-nachricht" }));
+ const s2 = getComputedStyle(ziel);
+ const raus = { text: s2.getPropertyValue("--blase-text").trim(),
+ leise: s2.getPropertyValue("--blase-leise").trim(),
+ woher: echt ? "aus dem Verlauf" : "aus einem Ersatzstueck" };
+ if (!echt) ziel.remove();
+ return raus;
+ });
+
+ const zuZahl = (f) => {
+ const t = /^#?([0-9a-f]{6})$/i.exec(String(f).trim());
+ if (t) return [0, 2, 4].map((i) => parseInt(t[1].slice(i, i + 2), 16));
+ const r = /rgba?\(([^)]+)\)/i.exec(String(f));
+ return r ? r[1].split(",").slice(0, 3).map((x) => Math.round(parseFloat(x))) : null;
+ };
+ const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
+ const leucht = (rgb) => 0.2126 * linear(rgb[0] / 255) + 0.7152 * linear(rgb[1] / 255)
+ + 0.0722 * linear(rgb[2] / 255);
+ const kontrast = (a, b) => {
+ const x = leucht(a), y = leucht(b);
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
+ };
+
+ const textRgb = zuZahl(schriftFarben.text);
+ const leiseRgb = zuZahl(schriftFarben.leise);
+ ok(!!textRgb && !!leiseRgb,
+ `die Schriftfarben der Blase sind lesbar, ${schriftFarben.woher}`
+ + ` (${schriftFarben.text || "nichts"} / ${schriftFarben.leise || "nichts"})`);
+
+ if (textRgb && leiseRgb) {
+ let schlimmster = Infinity, schlimmsterWinkel = -1, gemessen = 0;
+ for (let w = 0; w < KREIS.length; w++) {
+ const f = zuZahl(KREIS[w]);
+ if (!f) continue;
+ gemessen++;
+ for (const [s3, noetig] of [[textRgb, 7], [leiseRgb, 4.8]]) {
+ const v = kontrast(f, s3) / noetig;
+ if (v < schlimmster) { schlimmster = v; schlimmsterWinkel = w; }
+ }
+ }
+ /* DIE ANZAHL STEHT IN DER BEDINGUNG. Waere die Tabelle leer,
+ bliebe schlimmster auf Infinity und die Zeile waere gruen,
+ ohne eine einzige Farbe angesehen zu haben. */
+ ok(gemessen === 360 && schlimmster >= 1,
+ `alle ${gemessen} Toene des Rings tragen die Schrift der Blase`
+ + ` (knappster: Winkel ${schlimmsterWinkel}, Faktor ${schlimmster.toFixed(3)})`);
+ }
+
+ /* ---- 4b. Steht der Ring da, und ist er zu treffen? ------------- */
+ /* ERST ZURUECK ZUR LISTE -- und das ist keine Umstaendlichkeit,
+ sondern der Weg, den ein Mensch am Handy geht. Der Farbknopf
+ steht in der Spalte mit den Gespraechen, und die weicht auf
+ einem schmalen Bildschirm zur Seite, sobald ein Gespraech offen
+ ist. Der erste Anlauf dieser Probe hat genau hier 30 Sekunden
+ lang auf einen Knopf gewartet, den es zwar gab, den aber niemand
+ sehen konnte -- ein gutes Beispiel dafuer, dass "ist im HTML"
+ und "ist erreichbar" zwei verschiedene Aussagen sind. */
+ const zurueck = frida.seite.locator("#zur-liste");
+ if (await zurueck.isVisible().catch(() => false)) {
+ await zurueck.click();
+ await frida.seite.waitForTimeout(600);
+ }
+ const knopfDa = await frida.seite.locator("#kachel-oeffnen").isVisible().catch(() => false);
+ ok(knopfDa, "der Farbknopf ist am Handy erreichbar (ueber die Gespraechsliste)");
+ if (!knopfDa) throw new Error("ohne den Knopf ist der Rest nicht messbar");
+ await frida.seite.click("#kachel-oeffnen");
+ await frida.seite.waitForTimeout(600);
+
+ const rad = await frida.seite.evaluate(() => {
+ const r = document.getElementById("kachelrad-ring");
+ if (!r) return null;
+ const k = r.getBoundingClientRect();
+ const g = getComputedStyle(r);
+ return {
+ breit: Math.round(k.width), hoch: Math.round(k.height),
+ links: Math.round(k.left), oben: Math.round(k.top),
+ imBild: k.left >= -1 && k.right <= innerWidth + 1
+ && k.top >= -1 && k.bottom <= innerHeight + 1,
+ farben: (g.backgroundImage.match(/rgb\(/g) || []).length,
+ fassen: g.touchAction,
+ rolle: r.getAttribute("role"),
+ };
+ });
+ ok(!!rad, "der Ring ist da");
+ /* 180 px sind nicht geraten: Ein Ring, den man mit dem Daumen im
+ Kreis fuehren soll, braucht sichtbar mehr als die 44 px einer
+ Schaltflaeche -- sonst rutscht man beim Drehen heraus. */
+ ok((rad?.breit || 0) >= 180 && rad?.imBild,
+ `er passt auf den Bildschirm und ist gross genug (${rad?.breit}x${rad?.hoch} px)`);
+ /* Der Verlauf muss aus VIELEN Farben bestehen. Waere die Liste
+ nicht angekommen, stuende dort ein einzelner Ton -- und der Ring
+ saehe aus wie eine Scheibe. */
+ ok((rad?.farben || 0) >= 300, `er ist aus ${rad?.farben} Farben gemalt`);
+ /* OHNE DAS SCHIEBT DAS HANDY DIE SEITE WEG, statt den Ring zu
+ drehen. Genau diese Zeile ist es, die auf einem Beruehrgeraet
+ ueber brauchbar und unbrauchbar entscheidet. */
+ ok(rad?.fassen === "none", `Wischen dreht den Ring, statt zu scrollen (${rad?.fassen})`);
+ ok(rad?.rolle === "slider", `und eine Sprachausgabe versteht ihn (${rad?.rolle})`);
+
+ /* ---- 4c. Drehen -- und kommt die richtige Farbe heraus? -------- */
+ if (rad) {
+ /* Winkel 90 heisst DREI UHR: der Verlauf beginnt oben und laeuft
+ im Uhrzeigersinn. Waere die Rechnung im Browser um 90 Grad
+ verdreht (der uebliche atan2-Fehler), kaeme hier eine andere
+ Farbe heraus als die, die unter dem Finger liegt. */
+ const mx = rad.links + rad.breit / 2;
+ const my = rad.oben + rad.hoch / 2;
+ const r80 = (rad.breit / 2) * 0.8;
+ await frida.seite.mouse.move(mx, my - r80);
+ await frida.seite.mouse.down();
+ await frida.seite.mouse.move(mx + r80, my, { steps: 12 });
+ await frida.seite.mouse.up();
+ await frida.seite.waitForTimeout(900);
+
+ const stand = await frida.seite.evaluate(() => {
+ const r = document.getElementById("kachelrad-ring");
+ const m = document.getElementById("kachelrad-mitte");
+ return { winkel: Number(r?.getAttribute("aria-valuenow")),
+ wort: r?.getAttribute("aria-valuetext") || "",
+ mitte: m ? getComputedStyle(m).backgroundColor : "" };
+ });
+ ok(Math.abs((stand.winkel || 0) - 90) <= 2,
+ `nach dem Drehen auf drei Uhr steht der Ring bei ${stand.winkel} Grad (erwartet 90)`);
+
+ const erwartet = zuZahl(KREIS[stand.winkel] || "");
+ const gezeigt = zuZahl(stand.mitte);
+ ok(!!erwartet && !!gezeigt
+ && Math.abs(erwartet[0] - gezeigt[0]) <= 1
+ && Math.abs(erwartet[1] - gezeigt[1]) <= 1
+ && Math.abs(erwartet[2] - gezeigt[2]) <= 1,
+ `die Mitte zeigt genau die Farbe dieses Winkels (${stand.mitte} gegen ${KREIS[stand.winkel]})`);
+ ok(/\d+ Grad/.test(stand.wort) && stand.wort.length > 8,
+ `und sie wird vorgelesen als "${stand.wort}"`);
+
+ /* ---- 4d. Wird es gespeichert? ------------------------------- */
+ const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+ const gespeichert = db3.prepare("SELECT chat_kachel FROM personen WHERE id = ?")
+ .get(idA)?.chat_kachel;
+ db3.close();
+ ok(gespeichert === "ton-" + stand.winkel,
+ `und in der Datenbank steht die Wahl (${gespeichert})`);
+
+ /* ---- 4e. Und mit der Tastatur? ------------------------------ */
+ await frida.seite.focus("#kachelrad-ring");
+ await frida.seite.keyboard.press("ArrowRight");
+ await frida.seite.waitForTimeout(200);
+ const nachher = await frida.seite.evaluate(() =>
+ Number(document.getElementById("kachelrad-ring")?.getAttribute("aria-valuenow")));
+ ok(nachher === (stand.winkel + 1) % 360,
+ `eine Pfeiltaste dreht um genau ein Grad weiter (${stand.winkel} -> ${nachher})`);
+ }
+}
+
const alleMeldungen = [...frida.meckern, ...nele.meckern];
console.log("");
ok(alleMeldungen.length === 0,
diff --git a/server/pruef-erwaehnung.mjs b/server/pruef-erwaehnung.mjs
index 3bd51faf..e9b7e08a 100644
--- a/server/pruef-erwaehnung.mjs
+++ b/server/pruef-erwaehnung.mjs
@@ -989,10 +989,22 @@ melde("\n" + "=== 7. Der Ruf ans Rudel geht durch die Tür ===");
/* WER ERWARTET WIRD, WIRD ABGELEITET -- nicht abgeschrieben. Eine
feste Liste hier waere beim naechsten Rollenzuschnitt still
- falsch, und die Pruefung haette den Fehler mitgemacht. */
+ falsch, und die Pruefung haette den Fehler mitgemacht.
+
+ SEIT DEM 23.09.2026 IST DAS HIER DER GANZE RAUM. Filipe: "wenn wir
+ da im chat @rudel machen will ich dass jeder in dem chat markiert
+ wird. nicht nur team ... ich rede nur von dem chat wo die ganze
+ community auch drin ist." Der Raum heisst "Das Rudel" -- wer drin
+ sitzt, gehoert dazu, Gast oder nicht.
+
+ DIE ALTE REGEL IST DAMIT NICHT WEG, sie gilt nur woanders. Dass
+ sie ueberall sonst weiterhin gilt, prueft die Gegenprobe unten in
+ der Gruppe. Ohne die waere diese Zeile hier eine Erlaubnis statt
+ einer Aussage: Haette jemand die Regel versehentlich im ganzen
+ Haus umgestellt, bliebe dieser Block gruen. */
const TEAM = new Set(["admin", "hand", "linke", "modi"]);
const erwartet = besetzung
- .filter((x) => TEAM.has(x.rolle) && x.id !== idModi)
+ .filter((x) => x.id !== idModi)
.map((x) => x.id).sort((x, y) => x - y);
/* ---- 1. DER MODI RUFT ------------------------------------------ */
@@ -1007,17 +1019,49 @@ melde("\n" + "=== 7. Der Ruf ans Rudel geht durch die Tür ===");
"SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ? ORDER BY person_id")
.all(nrId).map((x) => x.person_id).sort((x, y) => x - y);
ok(gemeint.length > 0 && JSON.stringify(gemeint) === JSON.stringify(erwartet),
- `gerufen wird genau das Team im Raum (${JSON.stringify(gemeint)}, erwartet ${JSON.stringify(erwartet)})`);
- /* DIE EIGENTLICHE AUSSAGE. "Rudel" heisst das Team -- nicht jeder im
- Raum. Im Treff sitzt die Community; ein Ruf, der dort jeden
- Zuschauer weckt, waere etwas anderes als der bestellte. */
- ok(!gemeint.includes(idGast),
- " der Gast im selben Raum aber NICHT — das Rudel ist das Team, nicht der Raum");
+ `gerufen wird jeder im Raum (${JSON.stringify(gemeint)}, erwartet ${JSON.stringify(erwartet)})`);
+ /* DIE EIGENTLICHE AUSSAGE, und sie hat sich am 23.09.2026 umgedreht.
+ Der Gast ist KEIN Sonderfall mehr, sondern der Beweis: In diesem
+ einen Raum meint der Ruf alle Anwesenden. Waere er weiterhin
+ draussen, waere die Bestellung nicht ausgefuehrt -- und zwar
+ unsichtbar, denn der Rufer sieht nicht, wen er erreicht hat. */
+ ok(gemeint.includes(idGast),
+ " der Gast im selben Raum AUCH — hier ist das Rudel der Raum, nicht nur das Team");
ok(!gemeint.includes(idModi),
" und der Rufer selbst auch nicht — man ruft nicht sich");
ok(gemeint.includes(idLinke),
" die linke Hand ist dabei — sie gehoert zum Team");
+ /* ---- GEGENPROBE: UEBERALL SONST GILT WEITER DAS TEAM -----------
+ "sonst nichts anfassen" ist eine Zusage, und eine Zusage ohne
+ Messung ist eine Hoffnung. Die Testgruppe besteht aus DogFather
+ und zwei Scouts; Scouts sind nicht Team Dogi. Ein Ruf dort darf
+ deshalb NIEMANDEN erreichen.
+
+ WARUM DAS TRENNSCHARF IST: Haette jemand die neue Regel im ganzen
+ Haus eingeschaltet, waeren hier die beiden Scouts markiert. Die
+ Pruefung kann also "nicht in Ordnung" sagen -- sonst waere sie
+ nur eine Verzierung.
+
+ ZWEI HAEUSER, EIN PORT: Die Testgruppe wurde ueber `ruf` angelegt
+ (Agentur-Haus, ohne Host-Kopf), der Treff-Block darueber misst
+ ueber `crewRuf` (crew., mit Host-Kopf). Hier gehoert deshalb
+ `ruf` hin -- mit dem crew-Keks in einem Agentur-Raum haette die
+ Gegenprobe vermutlich nur bewiesen, dass eine Anmeldung fehlt. */
+ const rufGruppe = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ aDogi.keks, "POST", { text: "@rudel gilt hier nicht" });
+ if (rufGruppe.code !== 201) {
+ ok(false, `Gegenprobe: DogFather kann in der Gruppe schreiben (${rufGruppe.code} ${rufGruppe.text.slice(0, 80)})`);
+ } else {
+ const inGruppe = drin.prepare(
+ "SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
+ .all(json(rufGruppe)?.nachricht?.id).map((x) => x.person_id);
+ ok(inGruppe.length === 0,
+ `Gegenprobe: in einer Gruppe ohne Team ruft @rudel niemanden (${inGruppe.length})`);
+ ok(!inGruppe.includes(idVan) && !inGruppe.includes(idFunny),
+ " die beiden Scouts bleiben unberuehrt — die neue Regel gilt NUR im Rudel-Raum");
+ }
+
/* Die Spalte bleibt stehen -- sie ist es, aus der beim Lesen die
Hervorhebung entsteht. */
const gespeichert = drin.prepare(
@@ -1028,6 +1072,12 @@ melde("\n" + "=== 7. Der Ruf ans Rudel geht durch die Tür ===");
const geladen = (verlauf.json?.nachrichten || []).find((x) => x.id === nrId);
ok(geladen?.rudel === true, "der Verlauf bringt es mit — auch nach dem Neuladen");
ok(verlauf.json?.darf_rudel === true, "und der Modi bekommt gesagt, dass er rufen darf");
+ /* UND WEN ER DAMIT MEINT. Ohne diese Auskunft stuende im
+ Auswahlfeld weiter "alle im Team" -- im Rudel-Raum schlicht
+ falsch, und zwar nach unten: Man drueckt leichter, wenn man
+ weniger Leute zu wecken glaubt. */
+ ok(verlauf.json?.rudel_wen === "raum",
+ `und wen er meint, steht dabei (${verlauf.json?.rudel_wen})`);
ok(verlauf.json?.rudel_wort === RUDEL_WORT,
`das Wort kommt vom Server, nicht aus dem Browser (${verlauf.json?.rudel_wort})`);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 1f65f64b..ca47fb00 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -50,6 +50,7 @@ import {
} from "./workspace.js";
import { erwaehnteIds, rudelGerufen, RUDEL_WORT } from "./chat-erwaehnung.js";
+import { KREIS, winkelAus } from "./chat-kreis.js";
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
@@ -409,6 +410,22 @@ export function schriftFuer(farbe) {
* „Standard", und das ist ein gueltiger Zustand. */
export function kachelPruefen(wert) {
if (!wert) return null;
+ /* SEIT DEM 23.09.2026 GIBT ES ZWEI SORTEN SCHLUESSEL.
+
+ Filipe wollte statt der Kachelreihe einen Kreis, auf dem man die
+ Farbe "ganz genau selber auswaehlen kann". Ein Winkel ist damit
+ eine gueltige Wahl: "ton-214" heisst 214 Grad.
+
+ WARUM NICHT EINFACH DIE FARBE SPEICHERN: Dann koennte sich jeder
+ ein "#ffffff" eintragen -- und die helle Schrift der Blase waere
+ darauf unlesbar. Der Winkel dagegen ist eine Nummer in einer
+ Tabelle, die aus der Kontrastrechnung stammt. Wer sich etwas
+ Unlesbares wuenscht, bekommt schlicht keinen Treffer.
+
+ Die alten Schluessel bleiben gueltig. Wer "veilchen" gewaehlt hat,
+ behaelt es -- eine Umstellung, die stillschweigend die Wahl von
+ fuenfzehn Leuten zuruecksetzt, waere kein Fortschritt. */
+ if (winkelAus(wert) !== null) return String(wert);
return CHAT_KACHELN.some((k) => k.wert === String(wert)) ? String(wert) : null;
}
@@ -958,6 +975,22 @@ chatRouter.get("/workspace/api/chat/kacheln", (req, res) => {
rechnen -- dann staende die Regel zweimal da, und beim
naechsten Ton waere eine der beiden Fassungen aelter. */
kacheln: CHAT_KACHELN.map((k) => ({ ...k, schrift: schriftFuer(k.farbe) })),
+ /* DER KREIS: 360 fertige Farben, Index = Winkel.
+
+ Der Browser rechnet sie NICHT selbst aus. Die Formel steht in
+ tools/chat-kacheln-rechnen.mjs und haengt an den Schriftfarben
+ aus dem CSS; sie ein zweites Mal in JavaScript zu fuehren
+ hiesse, dass die zweite Fassung ab dem naechsten
+ Schriftwechsel falsch ist -- und zwar unbemerkt, denn beide
+ liefern ja "irgendeine Farbe".
+
+ Rund 3 kB. Sie kommen mit derselben Anfrage, die es ohnehin
+ gibt; eine eigene waere eine zweite Wartezeit fuer nichts. */
+ kreis: KREIS,
+ /* Alle 360 liegen auf derselben Leuchtdichte wie die elf bunten
+ Kacheln -- also tragen sie auch dieselbe Schrift. Einmal
+ gerechnet und mitgeschickt, statt 360-mal dasselbe. */
+ kreis_schrift: schriftFuer(KREIS[0]),
/* Die eigene WAHL, nicht der getragene Ton -- sonst stuende im
Waehler ein Haken bei einer Farbe, die niemand gewaehlt hat. */
meine: gewaehlteKachelFuer(req.person.id),
@@ -1529,9 +1562,29 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
von Hand tippt, ruft eben niemanden -- genau wie ein "@Anna",
das Anna nicht beantwortet. Verboten ist es nicht, es ist nur
keine Hilfe, es vorzuschlagen. */
+ /* WEN DER VORSCHLAG ERREICHT, HAENGT AM RAUM -- und die
+ Schranke davor muss dieselbe Frage stellen.
+
+ Sonst entsteht ein stiller Widerspruch: Sitzt im Rudel-Raum
+ ein Modi mit neun Zuschauern, aber ohne zweites Teammitglied,
+ dann traefe "@rudel" jetzt neun Leute -- der Vorschlag beim
+ Tippen waere aber ausgeblendet, weil er nach einem zweiten
+ Teammitglied sucht. Die Funktion gaebe es, und niemand faende
+ sie. Also wird hier genau das gefragt, was der Ruf nachher
+ auch tut. */
darf_rudel: istTeamDogi(req.person)
- && leute.some((t) => t.id !== req.person.id && istTeamDogi(t)),
+ && (istTreffRaum(raumId)
+ ? leute.some((t) => t.id !== req.person.id)
+ : leute.some((t) => t.id !== req.person.id && istTeamDogi(t))),
rudel_wort: RUDEL_WORT,
+ /* UND WEN ER MEINT, IN WORTEN.
+
+ Der Browser koennte die Kategorie des Raums vergleichen --
+ dann stuende die Regel an zwei Stellen und liefe irgendwann
+ auseinander. Er bekommt stattdessen die Antwort: "raum" oder
+ "team". Die Worte dazu bildet er selbst, damit deutscher Text
+ nicht im Server liegt. */
+ rudel_wen: istTreffRaum(raumId) ? "raum" : "team",
/* DARF ICH SPRACHNACHRICHTEN SCHICKEN? (23.09.2026)
Aus derselben Funktion wie die Schranke in der Anhang-Route.
@@ -1754,24 +1807,38 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
const alleLeute = teilnehmerVon(raumId);
const rudel = rudelGerufen(text, alleLeute, { rudel: darfRudel });
- /* WEN DER RUF ERREICHT: die Leute IN DIESEM RAUM, die zu Team
- Dogi gehoeren -- nicht jeden im Raum.
+ /* WEN DER RUF ERREICHT -- und das haengt vom RAUM ab.
- Das Wort sagt es selbst. Ein Rudel ist das Team, und Filipe
- nennt in einem Atemzug dieselben vier Gruppen, die es auch
- rufen duerfen. In einem reinen Team-Kanal ist das jeder
- Anwesende, also genau "alle auf einmal".
+ Bis zum 23.09.2026 galt ueberall dasselbe: Der Ruf erreichte
+ nur die aus Team Dogi. Die Begruendung stand hier und war
+ nicht falsch -- im Treff sitzt die Community, und "@rudel"
+ haette dort jeden geweckt.
- DER UNTERSCHIED WIRD IM TREFF SICHTBAR, und dort haette die
- andere Lesart wehgetan: Dort sitzt die Community. "@rudel"
- haette zweihundert Zuschauer gleichzeitig geweckt -- und beim
- zweiten Mal haetten sie die Benachrichtigungen abgeschaltet.
- Genau davor warnt der erste Absatz dieser Datei seit dem
- 20.09. Ein Moderator, der im Treff "@rudel" schreibt, erreicht
- damit seine Kollegen; die Zuschauer lesen es einfach mit. */
- const ausDemRudel = rudel
- ? alleLeute.filter((t) => istTeamDogi(t)).map((t) => t.id)
- : [];
+ GENAU DAS WILL FILIPE JETZT, und zwar ausdruecklich nur dort:
+ "wenn wir da im chat @rudel machen will ich dass jeder in dem
+ chat markiert wird. nicht nur team ... und sehr wichtig ich
+ rede nur von dem chat wo die ganze community auch drin ist.
+ sonst nichts anfassen."
+
+ Also zwei Bedeutungen, nach Raum getrennt:
+
+ im Rudel-Raum -> ALLE, die drin sind. Der Raum IST das
+ Rudel; er heisst auch so.
+ ueberall sonst -> Team Dogi, wie bisher. Ein Gruppenchat
+ mit drei Creatorinnen bleibt unberuehrt.
+
+ WAS DIE ALTE SORGE ENTSCHAERFT, und warum das hier tragbar
+ ist: RUFEN darf weiterhin nur Team Dogi (siehe darfRudel eine
+ Zeile hoeher) -- kein Zuschauer kann alle wecken. Und die
+ Nachtruhe gilt in diesem Raum ohnehin, der Ruf kommt also
+ nicht um drei Uhr nachts. Bleibt die eine Stelle, an der man
+ hinsehen muss: Waechst der Raum auf Hunderte, unterbricht ein
+ Ruf sie alle. Das steht im Protokoll (chat_rudel mit Anzahl),
+ damit es eine Zahl bleibt und kein Gefuehl. */
+ const ausDemRudel = !rudel ? []
+ : istTreffRaum(raumId)
+ ? alleLeute.map((t) => t.id)
+ : alleLeute.filter((t) => istTeamDogi(t)).map((t) => t.id);
const erwaehnt = [...new Set([
...erwaehnteIds(text, alleLeute, { rudel: darfRudel }),
diff --git a/server/workspace-treffchat.js b/server/workspace-treffchat.js
index 0f6a29c8..db34a1b3 100644
--- a/server/workspace-treffchat.js
+++ b/server/workspace-treffchat.js
@@ -245,6 +245,38 @@ export function treffAngleichen() {
try {
const id = treffRaumId();
const d = db();
+
+ /* DER NAME GEHÖRT DAZU — und genau das fehlte.
+
+ treffRaumId() setzt TREFF_NAME nur BEIM ANLEGEN. Als der Raum
+ später von „Der Treff" in „Das Rudel" umbenannt wurde, änderte
+ sich damit die Konstante im Code — die Zeile in der Datenbank
+ aber nicht. Der Raum existierte ja längst. Ergebnis: Im Code
+ stand „Das Rudel", in der Gesprächsliste stand weiter „Der
+ Treff", und zwar so lange, bis Filipe es gesehen hat
+ (23.09.2026: „da steht links immer noch der treff anstatt das
+ rudel").
+
+ Das ist dieselbe Falle wie nebenan bei den Mitgliedern, und
+ der Kommentar dort hat sie sogar benannt — „drei Stellen, von
+ denen die dritte vergessen wird". Nur galt die Vorsorge für
+ die Teilnehmer und nicht für den Namen. Eine halbe
+ Selbstheilung heilt die andere Hälfte nicht.
+
+ Jetzt wird der Name ABGELEITET statt gepflegt: Er steht an
+ einer Stelle (TREFF_NAME), und dieser Abgleich zieht die
+ Datenbank bei jedem Start hinterher. Eine künftige
+ Umbenennung ist damit wieder eine Zeile.
+
+ "IS NOT" statt "!=": Wäre der Name NULL, ergäbe "name != ?" in
+ SQLite NULL — also weder wahr noch falsch, und die Zeile bliebe
+ unverändert liegen. Genau der Fall, den niemand von Hand
+ ausprobiert. */
+ const umbenannt = d.prepare(
+ "UPDATE chat_raeume SET name = ? WHERE id = ? AND name IS NOT ?")
+ .run(TREFF_NAME, id, TREFF_NAME).changes;
+ if (umbenannt) console.log("[treff] Raum heißt jetzt " + TREFF_NAME + ".");
+
const platz = TREFF_ROLLEN.map(() => "?").join(", ");
const fehlen = d.prepare(`
SELECT p.id FROM personen p
diff --git a/tools/chat-kacheln-rechnen.mjs b/tools/chat-kacheln-rechnen.mjs
index 9b58ba87..85a921b0 100644
--- a/tools/chat-kacheln-rechnen.mjs
+++ b/tools/chat-kacheln-rechnen.mjs
@@ -241,6 +241,125 @@ function buntest(hGrad) {
return besteRgb ? { c: besteC, rgb: besteRgb } : null;
}
+/* =====================================================================
+ DER GANZE KREIS (23.09.2026)
+
+ Filipe: "anstatt sowas um die farben auszusuchen, 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?"
+
+ ---------------------------------------------------------------------
+ WARUM DAS HIER STEHT UND NICHT IM BROWSER
+
+ Die zwoelf Kacheln sind nicht ausgesucht, sondern GERECHNET: Jede
+ liegt auf derselben Leuchtdichte, damit die Schrift ueberall gleich
+ gut lesbar ist und niemand sich durch seine Wahl unlesbar machen
+ kann. Ein Farbkreis, der einfach den Farbton dreht, wirft genau das
+ weg -- ein gesaettigtes Gelb ist um ein Vielfaches heller als ein
+ gesaettigtes Blau, und helle Schrift darauf ist nicht mehr zu lesen.
+
+ Der Kreis ist deshalb KEINE neue Rechnung, sondern dieselbe in 360
+ Schritten statt in zwoelf. buntest() entscheidet fuer jeden Winkel,
+ und die Kontrastpruefung darin gilt unveraendert. Was dabei
+ herauskommt, ist ein Ring gleicher Helligkeit: Er sieht ruhiger aus
+ als ein ueblicher Farbkreis -- kein blendendes Gelb, kein
+ abgesoffenes Blau -- und genau das ist der Punkt.
+
+ DER BROWSER RECHNET NICHTS. Er bekommt die 360 fertigen Farben. Wer
+ die Formel in JavaScript nachbaut, hat zwei Rechnungen, und die
+ zweite ist ab dem naechsten Schriftwechsel falsch -- ohne dass es
+ jemand merkt, weil beide "irgendeine Farbe" liefern.
+ ===================================================================== */
+if (process.argv.includes("--kreis")) {
+ const SCHRITTE = 360;
+ const toene = [];
+ /* DIE ZAHLEN BLEIBEN LIEGEN, nicht nur das Hex. kontrast() und
+ helligkeit() erwarten [r,g,b] -- gaebe man ihnen die
+ Zeichenkette "#aabbcc", zerlegt die Destrukturierung sie in
+ "#", "a", "a" und rechnet ab da mit NaN. Der Vergleich
+ "NaN < 1" ist immer falsch, die Pruefung waere also gruen und
+ haette nichts gemessen. */
+ const rgbs = [];
+ const schwach = [];
+ let fehlend = 0;
+
+ for (let h = 0; h < SCHRITTE; h++) {
+ const b = buntest(h);
+ if (!b) { fehlend++; toene.push(null); rgbs.push(null); continue; }
+ toene.push(hex(b.rgb));
+ rgbs.push(b.rgb);
+ if (b.c < MINDEST_BUNTHEIT) schwach.push(h);
+ }
+
+ /* DRITTER AUSGANG. Findet sich fuer einen Winkel ueberhaupt keine
+ Farbe, die den Kontrast haelt, ist der Ring unterbrochen -- dann
+ wird NICHT stillschweigend etwas Naheliegendes eingesetzt,
+ sondern gesagt, dass es nicht geht. Eine Luecke, die als Farbe
+ getarnt ist, faellt niemandem auf. */
+ if (fehlend) {
+ console.error("\nKONNTE NICHT NACHSEHEN: " + fehlend + " von " + SCHRITTE
+ + " Winkeln halten den Kontrast nicht."
+ + "\n Heisst: die Schrift auf der Blase ist zu dunkel."
+ + "\n Erst --blase-leise in chat.css aufhellen, dann erneut rechnen.");
+ process.exit(2);
+ }
+
+ /* DIE NACHPRUEFUNG AM AUSGELIEFERTEN WERT, nicht am gerechneten.
+ Ausgeliefert wird das gerundete Hex -- und gerundet wird nach der
+ Kontrastpruefung. Das ist die Stelle, an der am 22.09. schon
+ einmal drei Hundertstel gefehlt haben. */
+ let schlechtester = Infinity, schlechtesterWinkel = -1;
+ for (let h = 0; h < SCHRITTE; h++) {
+ for (const sf of pruefFarben) {
+ const k = kontrast(rgbs[h], sf.farbe);
+ if (k / sf.noetig < schlechtester) { schlechtester = k / sf.noetig; schlechtesterWinkel = h; }
+ }
+ }
+ if (schlechtester < 1) {
+ console.error("\nNICHT IN ORDNUNG: Winkel " + schlechtesterWinkel
+ + " verfehlt seine Schwelle (Faktor " + schlechtester.toFixed(3) + ").");
+ process.exit(2);
+ }
+
+ console.log("Kreis gerechnet: " + SCHRITTE + " Winkel, alle ueber ihrer Schwelle"
+ + " (knappster: Winkel " + schlechtesterWinkel + ", Faktor "
+ + schlechtester.toFixed(3) + ").");
+ if (schwach.length) {
+ console.log(" Unter Buntheit " + MINDEST_BUNTHEIT + ": " + schwach.length
+ + " Winkel. Das ist im Kreis in Ordnung -- dort darf es blasse"
+ + " Stellen geben, bei den zwoelf Kacheln nicht.");
+ }
+
+ const ziel = new URL("../server/chat-kreis.js", import.meta.url);
+ const zeilen = [];
+ for (let i = 0; i < SCHRITTE; i += 6) {
+ zeilen.push(" " + toene.slice(i, i + 6).map((t) => '"' + t + '"').join(", ") + ",");
+ }
+ writeFileSync(ziel, [
+ "/* ERZEUGT von tools/chat-kacheln-rechnen.mjs --kreis.",
+ " NICHT VON HAND AENDERN -- wer hier eine Farbe eintippt, umgeht",
+ " genau die Kontrastrechnung, die die Lesbarkeit garantiert.",
+ "",
+ " 360 Farbtoene, Schritt 1 Grad, alle auf derselben Leuchtdichte",
+ " wie die zwoelf Kacheln. Der Index IST der Winkel: KREIS[214] ist",
+ " 214 Grad. */",
+ "export const KREIS = [",
+ ...zeilen,
+ "];",
+ "",
+ "/** Der Winkel zu einem Schluessel der Form \"ton-214\", sonst null. */",
+ "export function winkelAus(wert) {",
+ " const t = /^ton-(\\d{1,3})$/.exec(String(wert ?? \"\"));",
+ " if (!t) return null;",
+ " const n = Number(t[1]);",
+ " return n >= 0 && n < KREIS.length ? n : null;",
+ "}",
+ "",
+ ].join("\n"), "utf8");
+ console.log("Geschrieben: server/chat-kreis.js");
+ process.exit(0);
+}
+
const bunte = HUES.map((h) => ({ h, ...(buntest(h) || {}) }));
const fehlen = bunte.filter((b) => !b.rgb || b.c < MINDEST_BUNTHEIT);
if (fehlen.length) {
diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css
index 4e18ba2f..31a371d3 100644
--- a/workspace/assets/css/chat.css
+++ b/workspace/assets/css/chat.css
@@ -2483,7 +2483,7 @@
Tönungen gar nicht sieht. Es steht in derselben Farbe wie die
Schrift -- eine zweite Farbe wäre dieselbe Wette wie oben. */
.chat-erwaehnung[data-rudel="ja"]::before {
- content: "C9 A0";
+ content: "\25C9\A0";
opacity: .72;
}
@@ -2721,7 +2721,7 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--akzent) 45%, transparent);
}
.chat-kachelwahl__ton[aria-checked="true"]::after {
- content: "¹3";
+ content: "\2713";
position: absolute; top: 3px; right: 6px;
font-size: .8rem; font-weight: 700;
color: var(--akzent);
@@ -3033,3 +3033,117 @@
.gif-kachel__knopf { border: 1px solid CanvasText; }
.chat__gif[aria-expanded="true"] { border: 2px solid Highlight; }
}
+
+/* =====================================================================
+ DER FARBRING IN DER KACHELWAHL (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
+ auswählen kann? ... es soll richtig krass geil aussehen."
+
+ Der Ring wird in chat.js mit 360 fertig gerechneten Farben gefüllt.
+ Hier steht nur, wie er aussieht und wie man ihn anfasst.
+
+ WARUM ER RUHIG WIRKT UND NICHT GRELL: Alle 360 Töne liegen auf
+ derselben Leuchtdichte. Ein üblicher Farbkreis hat ein blendendes
+ Gelb und ein fast schwarzes Blau; dieser hier nicht. Das ist keine
+ Zurückhaltung im Geschmack, sondern die Bedingung dafür, dass die
+ Schrift auf JEDER dieser Farben lesbar bleibt — und nebenbei sieht
+ ein Ring gleicher Helligkeit deutlich edler aus als der bunte
+ Standardkreis, den man von überall kennt.
+ ===================================================================== */
+
+.kachelrad {
+ position: relative;
+ width: min(17rem, 68vw);
+ aspect-ratio: 1;
+ margin: 18px auto 6px;
+ /* Der Griff liegt auf dem Ring und darf nicht abgeschnitten werden. */
+ overflow: visible;
+}
+.kachelrad[hidden] { display: none; }
+
+.kachelrad__ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ /* Der Farbverlauf selbst kommt aus chat.js -- 360 Werte. */
+ /* DIE MITTE WIRD AUSGESTANZT statt mit einer Scheibe zugedeckt:
+ Eine Scheibe darüber würde die Zeigerbewegung abfangen, und der
+ Ring reagierte in der Mitte nicht mehr. */
+ -webkit-mask: radial-gradient(circle, #0000 0 56%, #000 57% 100%);
+ mask: radial-gradient(circle, #0000 0 56%, #000 57% 100%);
+ /* OHNE DAS SCROLLT DAS HANDY. Wer auf einem Berührungsbildschirm
+ im Kreis wischt, schiebt sonst die Seite weg, und der Ring
+ bekommt die Bewegung nie zu sehen. */
+ touch-action: none;
+ cursor: grab;
+ /* Eine leise Tiefe, damit der Ring auf dem dunklen Grund steht und
+ nicht schwebt. Kein Leuchten, kein Puls -- augenschonend gilt
+ hier genauso. */
+ box-shadow:
+ inset 0 0 0 1px color-mix(in srgb, #000 55%, transparent),
+ 0 6px 20px -8px #000;
+}
+.kachelrad__ring:active { cursor: grabbing; }
+.kachelrad__ring:focus-visible {
+ outline: 3px solid var(--akzent);
+ outline-offset: 4px;
+}
+
+/* Der Arm dreht sich, der Griff sitzt an seinem oberen Ende.
+ So braucht der Griff keine eigene Winkelrechnung -- er wird
+ einfach mitgedreht. */
+.kachelrad__arm {
+ position: absolute;
+ inset: 0;
+ transform: rotate(var(--winkel, 0deg));
+ pointer-events: none;
+}
+.kachelrad__griff {
+ position: absolute;
+ top: 8.5%;
+ left: 50%;
+ width: 2.3rem;
+ height: 2.3rem;
+ margin: -1.15rem 0 0 -1.15rem;
+ border-radius: 50%;
+ border: 3px solid #fff;
+ background: transparent;
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, #000 70%, transparent),
+ 0 2px 8px -2px #000;
+}
+
+/* Die Mitte zeigt, wie die eigene Nachricht aussehen wird -- mit
+ echtem Text in der echten Schriftfarbe dieser Kachel. Ein reiner
+ Farbfleck beantwortet die Frage nicht, die man hier hat. */
+.kachelrad__mitte {
+ position: absolute;
+ inset: 22%;
+ border-radius: 999px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ font-weight: 600;
+ pointer-events: none;
+ box-shadow: 0 0 0 1px color-mix(in srgb, #000 45%, transparent);
+}
+.kachelrad__probe { opacity: .95; }
+
+/* Ein Wechsel der Farbe soll nicht springen, aber auch nicht
+ nachlaufen -- 90 ms sind gerade so viel, dass es weich wirkt. */
+@media (prefers-reduced-motion: no-preference) {
+ .kachelrad__arm { transition: transform .09s linear; }
+ .kachelrad__mitte { transition: background-color .09s linear; }
+}
+
+/* Im Kontrastmodus gibt es keine Farbflächen. Dann ist ein Farbring
+ sinnlos -- die Sonderknöpfe darunter tragen weiter, und der Ring
+ bekommt wenigstens einen sichtbaren Umriss statt zu verschwinden. */
+@media (forced-colors: active) {
+ .kachelrad__ring { border: 1px solid CanvasText; }
+ .kachelrad__griff { border-color: Highlight; }
+ .kachelrad__mitte { border: 1px solid CanvasText; }
+}
diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js
index f1968aee..c27f0c4f 100644
--- a/workspace/assets/js/chat.js
+++ b/workspace/assets/js/chat.js
@@ -54,6 +54,9 @@
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 = [];
let meineKachel = null;
let autoKachel = null;
@@ -73,6 +76,20 @@
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 : [];
+ kreisToene.forEach((farbe, winkel) => {
+ KACHEL_FARBE['ton-' + winkel] = farbe;
+ if (d.kreis_schrift) KACHEL_SCHRIFT['ton-' + winkel] = d.kreis_schrift;
+ });
kachelKnopfZeigen();
} catch { /* ohne die Liste bleibt alles beim Standard -- kein Grund
zu scheitern, der Chat funktioniert weiter */ }
@@ -96,11 +113,56 @@
}
}
+ /* 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;
@@ -133,9 +195,12 @@
halbe Sekunde nichts sieht, tippt noch einmal. Geht es schief,
wird es zurueckgenommen -- und zwar sichtbar. */
meineKachel = (wert === 'grund') ? null : wert;
- for (const b of knopf.parentElement.querySelectorAll('.chat-kachelwahl__ton')) {
- b.setAttribute('aria-checked', String(b === knopf));
- }
+ /* 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 {
@@ -185,7 +250,161 @@
}
}
+ /** 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;
+ let radUhr = null;
+
+ 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;
+ const t = /^ton-(\d{1,3})$/.exec(String(meineKachel || ''));
+ radWinkel = t ? Number(t[1]) : (jetzt ? naechsterWinkel(jetzt).winkel : 0);
+ 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". */
+ ring.setAttribute('aria-valuetext', farbwort(w) + ', ' + w + ' Grad');
+ if (mitte) {
+ const farbe = kreisToene[w];
+ mitte.style.background = farbe || '';
+ const s = KACHEL_SCHRIFT['ton-' + w];
+ mitte.style.color = (s && s.text) || '';
+ }
+ 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 = 'ton-' + w;
+ kachelKnopfZeigen();
+ eigeneKachelnFaerben();
+ auswahlMarkieren();
+ }
+ }
+
+ /** 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('ton-' + radWinkel), 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();
const knopf = $('kachel-oeffnen'), dlg = $('kachel-wahl'), zu = $('kachel-zu');
if (!knopf || !dlg) return;
knopf.addEventListener('click', () => { kachelwahlBauen(); dlg.showModal(); });
@@ -226,6 +445,12 @@
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
@@ -780,6 +1005,7 @@
darfAllesLoeschen = !!daten.darf_alles_loeschen;
darfRudel = !!daten.darf_rudel;
rudelWort = daten.rudel_wort || 'rudel';
+ rudelWen = daten.rudel_wen === 'raum' ? 'raum' : 'team';
/* DER MIKROFONKNOPF ERSCHEINT, WENN DER SERVER ES SAGT
(23.09.2026). Filipe: „also nur die modis rechte linke hand
und dogfather." Hier wird keine Rolle verglichen -- dieselbe
@@ -1881,9 +2107,18 @@
if (t.rudel) { b.dataset.rudel = 'ja'; z.dataset.rudel = 'ja'; }
b.append(z, el('span', 'erwaehn-liste__name', t.name));
/* WAS ES BEDEUTET, STEHT DANEBEN. "rudel" allein sagt keinem
- Neuen, dass damit das ganze Team gemeint ist -- und wer das
- erst nach dem Absenden erfaehrt, hat schon alle geweckt. */
- if (t.rudel) b.append(el('span', 'erwaehn-liste__rolle', 'alle im Team'));
+ Neuen, wen er damit weckt -- und wer das erst nach dem
+ Absenden erfaehrt, hat schon alle geweckt.
+
+ Und es sagt das RICHTIGE: Im Rudel-Raum trifft der Ruf jeden
+ Anwesenden, sonst nur Team Dogi. Stuende hier immer "alle im
+ Team", waere das im Rudel-Raum schlicht gelogen -- und zwar
+ nach unten, also in die Richtung, in der man leichtfertig
+ drueckt. */
+ if (t.rudel) {
+ b.append(el('span', 'erwaehn-liste__rolle',
+ rudelWen === 'raum' ? 'alle in diesem Chat' : 'alle im Team'));
+ }
else if (ROLLENKURZ[t.rolle]) b.append(el('span', 'erwaehn-liste__rolle', ROLLENKURZ[t.rolle]));
/* mousedown statt click: Ein Klick nimmt dem Textfeld zuerst den
Schreibzeiger, und danach weiss niemand mehr, wohin der Name
@@ -2923,7 +3158,27 @@
weg.setAttribute('aria-label', `GIF ${g.name} aus der Kiste nehmen`);
weg.addEventListener('click', async (e) => {
e.stopPropagation();
- if (!confirm(`„${g.name}" aus der Kiste nehmen? Verschickte GIFs bleiben stehen.`)) return;
+ /* MIT DER RUECKFRAGE DES HAUSES, nicht mit confirm().
+
+ Am 23.09.2026 stand hier ein natives confirm() -- meine
+ eigene Abkuerzung beim Bau der Kiste am selben Morgen.
+ pruef-nachfrage bewacht genau das und hat es gemeldet,
+ allerdings erst, nachdem eine kaputte Stelle in derselben
+ Datei repariert war (ein verlorener Backslash hatte ihr
+ Suchmuster stillgelegt).
+
+ WARUM DAS HAUS KEINE NATIVEN DIALOGE WILL: Sie sehen auf
+ jedem Geraet anders aus, lassen sich nicht auf Deutsch
+ beschriften, haben keinen Platz fuer die Frage "was bleibt
+ danach?" -- und auf manchen Handys unterdrueckt man sie
+ einmal und bekommt sie nie wieder, dann loescht der Knopf
+ kommentarlos. */
+ if (!await frageNach({
+ titel: 'Aus der Kiste nehmen?',
+ was: `„${g.name}" ist danach fuer das ganze Team weg.`,
+ bleibt: 'Schon verschickte GIFs bleiben in den Gespraechen stehen.',
+ endgueltig: true, ja: 'Herausnehmen',
+ })) return;
try {
const a = await hole(`/workspace/api/chat/gifs/${g.id}`, { method: 'DELETE' });
const j = await a.json().catch(() => ({}));
diff --git a/workspace/chat.html b/workspace/chat.html
index a28b3287..35b54188 100644
--- a/workspace/chat.html
+++ b/workspace/chat.html
@@ -92,13 +92,54 @@