From 7a6749630cc77780d5e24ba16f8bae74460268ca Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 23 Sep 2026 14:30:02 +0200 Subject: [PATCH] Das Rudel meint den ganzen Raum -- und die Kachelfarbe wird ein Ring Zwei Auftraege vom 23.09.2026. SCREEN 1: "da steht links immer noch der treff anstatt das rudel" Und er hatte recht, auf eine Art, die niemand sehen konnte: TREFF_NAME stand schon seit der Umbenennung auf "Das Rudel". Nur setzt treffRaumId() den Namen ausschliesslich BEIM ANLEGEN -- der Raum existierte laengst, also blieb die Zeile in der Datenbank auf "Der Treff" stehen. Im Code das eine, auf dem Bildschirm das andere. Dieselbe Falle stand direkt nebenan schon beschrieben ("drei Stellen, von denen die dritte vergessen wird") -- die Vorsorge galt aber nur fuer die Teilnehmer, nicht fuer den Namen. Eine halbe Selbstheilung heilt die andere Haelfte nicht. Jetzt gleicht treffAngleichen() auch den Namen ab; eine kuenftige Umbenennung ist wieder eine Zeile. (IS NOT statt !=: Bei NULL ergaebe != in SQLite NULL, und die Zeile bliebe unveraendert liegen.) "und 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." Bis heute erreichte der Ruf ueberall nur Team Dogi. Die Begruendung stand im Code und war nicht falsch -- im Rudel-Raum sitzt die Community. Genau das will Filipe jetzt, und zwar nur dort: im Rudel-Raum -> alle, die drin sind ueberall sonst -> Team Dogi, unveraendert Was die alte Sorge entschaerft: RUFEN darf weiterhin nur Team Dogi -- kein Zuschauer kann alle wecken. Und die Nachtruhe gilt dort ohnehin. darf_rudel folgt derselben Frage. Sonst entstuende ein stiller Widerspruch: Ein Modi mit neun Zuschauern und ohne zweites Teammitglied traefe mit @rudel neun Leute -- der Vorschlag beim Tippen waere aber ausgeblendet. Die Funktion gaebe es, und niemand faende sie. Das Schild sagt jetzt die Wahrheit: "alle in diesem Chat" statt "alle im Team". Stuende dort weiter das alte, waere es im Rudel-Raum gelogen -- und zwar nach unten, also in die Richtung, in der man leichtfertig drueckt. Gegenprobe in pruef-erwaehnung: In der Testgruppe sitzen DogFather und zwei Scouts; ein @rudel dort ruft NIEMANDEN. Waere die Regel versehentlich im ganzen Haus aktiv, waeren die Scouts markiert. "sonst nichts anfassen" ist damit gemessen, nicht versprochen. SCREEN 2: "so eine art diagramm, wo man mit einem kreis herum gehen kann und die farbe ganz genau selber auswaehlen kann" Der Haken daran ist nicht die Optik. Die zwoelf Kacheln waren GERECHNET, damit niemand sich unlesbar machen kann -- alle auf derselben Leuchtdichte. Ein Farbkreis, der einfach den Farbton dreht, wirft das weg: Ein gesaettigtes Gelb ist um ein Vielfaches heller als ein gesaettigtes Blau, und helle Schrift ist darauf nicht mehr zu lesen. Der Ring ist deshalb KEINE neue Rechnung, sondern dieselbe in 360 Schritten statt in zwoelf. tools/chat-kacheln-rechnen.mjs --kreis schreibt server/chat-kreis.js; buntest() und die Kontrastpruefung darin gelten unveraendert. Ergebnis: ein Ring gleicher Helligkeit -- kein blendendes Gelb, kein abgesoffenes Blau. Das sieht ruhiger aus als der uebliche Farbkreis, und genau das ist der Punkt. Der Browser rechnet nichts. Er bekommt die 360 fertigen Farben und legt sie in dieselbe Tabelle wie die Kacheln -- ab da ist "ton-214" ein Schluessel wie "veilchen", und jede Stelle, die eine Farbe nachschlaegt, funktioniert unveraendert. Welche Kacheln neben dem Ring bleiben, wird ABGELEITET statt aufgezaehlt: Grau hat keinen Farbton, Babyblau ist die eine helle mit eigener Schrift. Nachgemessen liegen die elf bunten 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 die Schwelle nicht knapp ist. Gespeichert wird erst beim Loslassen. Wer einmal um den Ring fuehrt, erzeugte sonst dreihundert Anfragen. Gemessen (pruef-chat-neu, jetzt 32 Pruefungen, als Modi auf crew. mit dem Finger): 265 px gross, aus 361 Farben gemalt, touch-action none (sonst schiebt das Handy die Seite weg statt zu drehen), Drehen auf drei Uhr ergibt genau Winkel 90, die Mitte zeigt exakt #675102, vorgelesen als "Goldbraun, 90 Grad", in der Datenbank steht ton-90, Pfeiltaste dreht ein Grad weiter. Und das eigentliche Versprechen: alle 360 Toene tragen die Schrift der Blase, knappster Faktor 1,000. Dabei zwei eigene Fehler, beide aufgeschrieben: Der erste Anlauf wartete 30 Sekunden auf den Farbknopf -- am Handy weicht die Spalte mit den Gespraechen zur Seite, sobald ein Chat offen ist. "Ist im HTML" und "ist erreichbar" sind zwei Aussagen. Und die Schriftfarben wurden an der falschen Klasse gemessen, was einen roten Haken an einer heilen Stelle ergab. Ausserdem: Das native confirm() beim Herausnehmen eines GIFs ist weg -- meine eigene Abkuerzung vom Morgen. Gemeldet hat es pruef-nachfrage, allerdings erst, nachdem der verlorene Backslash in derselben Datei repariert war. Zahlen: pruef-erwaehnung 122 (vorher 119), pruef-chat-neu 32 (vorher 19), pruef-nachfrage 46 bestanden. Co-Authored-By: Claude Opus 5 --- server/chat-kreis.js | 77 ++++++++++ server/pruef-chat-neu.mjs | 192 ++++++++++++++++++++++- server/pruef-erwaehnung.mjs | 66 +++++++- server/workspace-chat.js | 101 ++++++++++--- server/workspace-treffchat.js | 32 ++++ tools/chat-kacheln-rechnen.mjs | 119 +++++++++++++++ workspace/assets/css/chat.css | 118 ++++++++++++++- workspace/assets/js/chat.js | 269 ++++++++++++++++++++++++++++++++- workspace/chat.html | 53 ++++++- 9 files changed, 986 insertions(+), 41 deletions(-) create mode 100644 server/chat-kreis.js 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: "C9A0"; + 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 @@

Deine Kachel

- So sehen deine Nachrichten für alle anderen aus. Du hast schon - eine Farbe – hier suchst du dir eine andere aus. Alle Töne - leuchten gleich stark, damit die Schrift überall gleich gut - lesbar bleibt. + So sehen deine Nachrichten für alle anderen aus. Dreh am Ring, + bis dir die Farbe gefällt – in der Mitte siehst du sofort, wie + es aussieht. Alle Töne leuchten gleich stark, damit die Schrift + überall gleich gut lesbar bleibt.

-
+ + +
+
+ +
+ + +
+ + +