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.

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