B4 + B5: Loeschen heisst Loeschen, die Schrift wechselt mit, und Blau wird Babyblau

Die beiden Auftraege gehoeren zusammen, und zwar in dieser Reihenfolge:
Eine babyblaue Kachel ist HELL. Ohne mitwechselnde Schrift waere sie
unlesbar (1,63:1 gemessen). Erst B4 macht B5 moeglich.

B4a -- LOESCHEN HEISST LOESCHEN
  "Geloeschte Nachrichten verschwinden vollstaendig - keine Spur, kein
   'wurde geloescht'-Hinweis, bei niemandem, auch nicht bei DogFather."

Bis heute blieb die Zeile stehen: Text geleert, weg_am gesetzt, und im
Chat stand "Nachricht zurueckgenommen". Das war ausdruecklich so
begruendet ("ein Loch im Verlauf wirft mehr Fragen auf"). Das Argument
beantwortet aber eine andere Frage: Ein Hinweis "hier stand etwas"
MARKIERT die Stelle. Wer etwas aus Versehen schreibt, will es weg
haben und nicht unterstrichen.

Jetzt ein echtes DELETE. Vier Raender, an denen eine Spur bleiben
koennte, alle gemessen:
  1. die Zeile selbst
  2. chat_reaktionen    (ON DELETE CASCADE - nachgesehen, nicht angenommen)
  3. chat_erwaehnungen  (ebenso)
  4. letzte_am am Raum  - wird neu gerechnet, sonst stuende er oben in
                          der Liste mit einem Zeitpunkt, zu dem es
                          nichts mehr gibt
Dazu: ein Zitat auf eine geloeschte Nachricht wird weggelassen; in der
Gespraechsliste steht kein Hinweis mehr; der Knopf heisst "loeschen".

Die 5 alten zurueckgenommenen Zeilen (Text bereits leer) raeumt eine
einmalige, wiederholbare Umstellung ab - mit Sicherung davor, und nur
wenn es wirklich etwas zu tun gibt.

B4b -- WER DARF WAS
  "jeder nur seine eigenen - ausser DogFather und rechte Hand"
`darfJedeNachrichtLoeschen` = admin oder hand. NICHT fuehrtTeamDogi
(das schloesse die linke Hand ein) und nicht istLeitung (das schloesse
Spicy Media ein, die private Chats nicht einmal sehen darf). Das Recht
kommt vom Server ins Skript, nicht aus einer Rolle im Browser: chat.js
bekommt jeder, der die Seite oeffnet.

B4c -- DIE SCHRIFT WECHSELT MIT
  "am besten schwarz auf hellen Kacheln - und weiss, wenn jemand eine
   schwarze Kachel waehlt"
`schriftFuer(farbe)` waehlt zwischen zwei Paaren, gerechnet aus der
Leuchtdichte, mit denselben Schwellen wie das Rechenwerkzeug (7:1 fuer
den Text, 4,5:1 fuer die Fusszeile). Keine dritte Spalte in
CHAT_KACHELN, die jemand pflegen muesste. Flaeche und Schrift werden
im Browser in EINEM Griff gesetzt (blaseFaerben) - zwei Stellen waeren
irgendwann eine helle Kachel mit heller Schrift.

B4d -- DIE NAMEN
Der Name ist jetzt ein eigener Streifen mit Kante darunter, .84rem,
und der Rollenpunkt wird ein 3x14-Balken. Vorher stand er als erste
ZEILE in der Blase und las sich wie der Anfang des Satzes.

B5 -- BABYBLAU
  "jedes normales blaues herz durch babyblaues herz ersetzen ... jeder
   normale blaue farbe, sei es die kachel im chat oder emojis, nur
   babyblau bitte."
* Herz: U+1F499 -> U+1FA75. Nicht geglaubt, sondern gemessen: 43,9 px
  breit wie die anderen Herzen, ein Ersatzkaestchen waere 20,7. Die
  vier vorhandenen blauen Herzen in der Datenbank wandern mit - sonst
  waeren es vier Reaktionen, die ERLAUBT nicht mehr kennt: still weg.
* Kachel: neue, HELLE Kachel "Babyblau" mit dem Wert von --akzent.
  Gemessen: Text 10,78:1, Fusszeile 4,87:1. Sie steht bewusst nicht in
  der Rechnung des Werkzeugs (das rechnet elf Toene auf EINE
  Leuchtdichte) - sie hat eine andere Leuchtdichte und dafuer ihre
  eigene Schrift. Dasselbe Versprechen, anderer Weg.

PRUEFUNGEN
  pruef-loeschen.mjs (NEU, 20/0): alle vier Raender, die vier Faelle
    der Rechtegrenze (auch: die linke Hand darf NICHT), das babyblaue
    Herz am laufenden Server, und dass der Satz "Nachricht
    zurueckgenommen" nur noch in Kommentaren steht - mit Gegenprobe,
    dass die Suche diesen Unterschied wirklich macht.
  pruef-chatkachel.mjs (33/0): misst jede Kachel mit IHRER Schrift und
    fragt dafuer dieselbe Funktion wie der Server. Neu: dass die
    Schrift ueberhaupt wechselt. Die Leuchtdichte-Regel gilt jetzt fuer
    die gerechneten Toene - das Versprechen dahinter loest die
    Kontrastzeile darueber direkt ein.
  Vier alte Pruefungen umgedreht, die den alten Zustand festgeschrieben
    hatten: pruef-chat, pruef-chat-ausbau, pruef-chat-aufloesen,
    pruef-chat-optik. Alle mit scharferer Messung als vorher (Zahl
    davor/danach statt "ein Hinweis ist da").
  Mitgelaufen und gruen: pruef-alle-sehen-es, pruef-treffchat (110/0).

Gesichert: workspace-vor-loeschumbau-20260922-233313.db
(946 KB, integrity_check ok, 111 Nachrichten, 49 Reaktionen).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-22 23:33:43 +02:00
co-authored by Claude Opus 5
parent 95da1345fc
commit 60802170a1
50 changed files with 1464 additions and 690 deletions
+188 -34
View File
@@ -235,6 +235,30 @@ function bildWegFuer(personId) {
===================================================================== */
export const CHAT_KACHELN = [
{ wert: "grund", name: "Neutral", farbe: "#4f545c" },
/* BABYBLAU -- die einzige helle Kachel (22.09.2026).
------------------------------------------------------------------
Filipe: „jeder normale blaue farbe, sei es die kachel im chat oder
emojis, nur babyblau bitte." Und im selben Auftrag: „Textfarbe
soll mitwechseln, am besten schwarz auf hellen Kacheln -- und
weiss, wenn jemand eine schwarze Kachel waehlt."
Die zwei Saetze gehoeren zusammen, und in dieser Reihenfolge.
Babyblau ist HELL (Leuchtdichte 0,547); die helle Schrift der
Blase kaeme darauf auf 1,63:1 -- unlesbar. Erst weil die Schrift
jetzt mitwechselt (schriftFuer weiter unten), ist diese Kachel
ueberhaupt moeglich.
Sie steht deshalb NICHT in der Rechnung von
tools/chat-kacheln-rechnen.mjs: Das Werkzeug rechnet elf bunte
Toene auf EINE gemeinsame Leuchtdichte, damit niemand sich durch
seine Wahl schlechter lesbar machen kann. Diese hier hat eine
andere Leuchtdichte und dafuer ihre eigene Schrift -- gemessen:
Text 10,78:1 (noetig 7), Fusszeile 4,87:1 (noetig 4,5). Dasselbe
Versprechen, auf anderem Weg eingeloest.
Der Wert ist exakt `--akzent` des Hauses. Eine zweite Zahl daneben
waere zwei Babyblau, und beim naechsten Mal drei. */
{ wert: "babyblau", name: "Babyblau", farbe: "#8ec9ff" },
{ wert: "tinte", name: "Tinte", farbe: "#0150af" },
{ wert: "veilchen", name: "Veilchen", farbe: "#6a01e5" },
{ wert: "pflaume", name: "Pflaume", farbe: "#9301a2" },
@@ -248,6 +272,71 @@ export const CHAT_KACHELN = [
{ wert: "ozean", name: "Ozean", farbe: "#045b74" },
];
/* =====================================================================
WELCHE SCHRIFT AUF WELCHER KACHEL (22.09.2026)
=====================================================================
Filipe: „Textfarbe soll mitwechseln, am besten schwarz auf hellen
Kacheln -- und weiss, wenn jemand eine schwarze Kachel waehlt."
GERECHNET, NICHT JE KACHEL EINGETRAGEN. Eine dritte Spalte in
CHAT_KACHELN waere eine Liste, die jemand pflegen muss -- und beim
naechsten Ton vergisst. Hier steht stattdessen die Regel, und die
Farbe folgt aus der Flaeche.
ZWEI PAARE, NICHT BELIEBIGE FARBEN: Ein frei gerechneter Grauwert je
Kachel ergaebe zwoelf verschiedene Schriften, und der Chat saehe aus
wie zwoelf verschiedene Programme. Es gibt ein helles Paar (das
bisherige, aus chat.css) und ein dunkles; genommen wird das, das die
noetigen Kontraste schafft.
DIE SCHWELLEN SIND DIESELBEN WIE IM RECHENWERKZEUG: 7:1 (AAA) fuer
den Text -- der Chat ist die Seite, auf der am laengsten gelesen
wird -- und 4,5:1 fuer die Fusszeile. Sie stehen hier einmal und
werden von pruef-chatkachel.mjs aus DIESER Datei gelesen; zwei
Zahlen in zwei Dateien waeren der Widerspruch, den niemand bemerkt.
===================================================================== */
export const SCHRIFT_NOETIG = { text: 7, leise: 4.5 };
/** Das helle Paar -- dieselben Werte wie `--blase-text` / `--blase-leise`
* in chat.css. Sie stehen dort als Rueckfall fuer den Fall, dass gar
* keine Kachel gesetzt ist. */
export const SCHRIFT_HELL = { text: "#f3f7fc", leise: "#c4d2e2" };
/** Das dunkle Paar fuer helle Kacheln. Gemessen auf Babyblau:
* 10,78:1 und 4,87:1. */
export const SCHRIFT_DUNKEL = { text: "#0b1119", leise: "#3b4e63" };
const alsLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
function leuchtdichte(hex) {
const z = String(hex).replace("#", "");
const [r, g, b] = [0, 2, 4]
.map((i) => parseInt(z.slice(i, i + 2), 16) / 255).map(alsLinear);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function kontrastZwischen(a, b) {
const x = leuchtdichte(a), y = leuchtdichte(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
/** Welches Schriftpaar traegt auf dieser Flaeche?
*
* GIBT IMMER EINES ZURUECK. Ein dritter Ausgang ("keines passt")
* waere hier falsch: Der Chat muss etwas anzeigen. Dass beide Paare
* reichen, ist die Aufgabe von pruef-chatkachel.mjs -- sie misst
* jeden Ton der Liste gegen genau diese Funktion. Faellt einer durch,
* ist das ein Befund und keine Notlage zur Laufzeit. */
export function schriftFuer(farbe) {
const passt = (paar) =>
kontrastZwischen(paar.text, farbe) >= SCHRIFT_NOETIG.text
&& kontrastZwischen(paar.leise, farbe) >= SCHRIFT_NOETIG.leise;
if (passt(SCHRIFT_HELL)) return SCHRIFT_HELL;
if (passt(SCHRIFT_DUNKEL)) return SCHRIFT_DUNKEL;
/* Schafft keines beide Schwellen, entscheidet der TEXT -- er ist
das, was man liest. */
return kontrastZwischen(SCHRIFT_HELL.text, farbe)
>= kontrastZwischen(SCHRIFT_DUNKEL.text, farbe) ? SCHRIFT_HELL : SCHRIFT_DUNKEL;
}
/** Nur ein Ton aus der Liste kommt durch -- sonst nichts.
*
* DIE PRUEFUNG STEHT HIER UND NICHT NUR IM FORMULAR. Was aus der
@@ -727,7 +816,10 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => {
chatRouter.get("/workspace/api/chat/kacheln", (req, res) => {
try {
res.json({
kacheln: CHAT_KACHELN,
/* DIE SCHRIFT GEHT MIT. Der Browser soll sie nicht selbst
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) })),
/* 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),
@@ -1270,6 +1362,12 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
Ja/Nein verraet nichts ueber die Rollen dahinter. */
darf_anheften: darfAnheften(req.person, raumId),
/* WER JEDE NACHRICHT LOESCHEN DARF -- vom Server, nicht aus
der Rolle im Browser geraten. assets/js/chat.js bekommt
JEDER, der die Seite oeffnet; ein `rolle === 'hand'` darin
waere eine Auskunft ueber das Haus an jeden Leser. Und es
waere eine zweite Wahrheit neben der Schranke im Server. */
darf_alles_loeschen: darfJedeNachrichtLoeschen(req.person),
angeheftet: angeheftet.map((a) => ({
id: a.id,
von: a.von || "Gelöscht",
@@ -2239,60 +2337,116 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
}
});
/** Eine eigene Nachricht zurücknehmen. */
/* =====================================================================
LÖSCHEN HEISST LÖSCHEN (22.09.2026)
=====================================================================
Filipe: „Gelöschte Nachrichten verschwinden vollständig – keine
Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem, auch nicht bei
DogFather." Und: „Löschen darf weiterhin jeder nur seine eigenen –
außer DogFather und rechte Hand: die dürfen jede Nachricht löschen,
für den Notfall."
---------------------------------------------------------------------
WAS SICH DAMIT UMDREHT
Hier stand bis heute das Gegenteil, und zwar begründet: „Ein Loch im
Verlauf wirft mehr Fragen auf als der Hinweis, dass hier etwas
zurückgenommen wurde." Die Zeile blieb, der Text wurde geleert, und
der Chat zeigte „Nachricht zurückgenommen".
Das Argument war nicht falsch -- es beantwortet nur eine andere
Frage als die, die Filipe stellt. Ein Hinweis „hier stand etwas"
ist für den, der gelöscht hat, oft schlimmer als der Satz selbst:
Er markiert die Stelle. Wer etwas aus Versehen schreibt, will es
weg haben und nicht unterstrichen.
DESHALB JETZT EIN ECHTES DELETE. Das geht sauber, weil die beiden
abhängigen Tabellen es mittragen:
chat_reaktionen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE
chat_erwaehnungen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE
und `PRAGMA foreign_keys = ON` beim Öffnen steht. Nachgesehen, nicht
angenommen.
DREI DINGE, DIE SONST EINE SPUR HINTERLIESSEN:
1. `antwort_auf` zeigt weiter auf die Nummer. Der Verlauf holt das
Zitat über einen LEFT JOIN -- es kommt nichts mehr zurück, und
die Anzeige lässt das Zitat dann weg. Ein Zitat auf etwas, das
es nicht gibt, wäre die Spur.
2. `letzte_am` am Raum. War die gelöschte die letzte Nachricht,
stünde die Gesprächsliste weiter auf ihrem Zeitpunkt -- ein
Raum, der ganz oben steht und nichts Neues hat. Wird neu
gerechnet.
3. Der Anhang auf der Platte. Das war schon vorher richtig gelöst
und bleibt so: erst die Zeile, dann die Datei.
WAS BLEIBT: die Protokollzeile. Sie nennt WER WESSEN Nachricht
gelöscht hat -- keinen Text, keinen Inhalt. Beim Notfall-Löschen
durch DogFather oder die rechte Hand ist das der Unterschied
zwischen einer Befugnis und einer unsichtbaren Macht. Das Protokoll
ist kein Chatverlauf; es steht an einer anderen Stelle und
beantwortet eine andere Frage.
===================================================================== */
/** Wer JEDE Nachricht löschen darf -- für den Notfall.
*
* NICHT `fuehrtTeamDogi`: Das schlösse die linke Hand ein, und Filipe
* hat ausdrücklich zwei genannt. Nicht `istLeitung`: Das schlösse
* Spicy Media ein -- die private Chats ausdrücklich nicht einmal
* sehen darf. Zwei Rollen, wörtlich die zwei aus dem Auftrag. */
const darfJedeNachrichtLoeschen = (person) =>
istDogFather(person) || person?.rolle === "hand";
chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const n = db().prepare(
const d = db();
const n = d.prepare(
"SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
if (!n || !istDrin(n.raum_id, req.person)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff
in die Geschichte -- und niemand könnte ihm nachher glauben. */
if (n.person_id !== req.person.id) {
const fremd = n.person_id !== req.person.id;
if (fremd && !darfJedeNachrichtLoeschen(req.person)) {
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
}
/* EIN ANHANG WIRD WIRKLICH GELÖSCHT, nicht nur ausgeblendet.
Beim Text reicht das Leeren -- er steht nur in der Zeile. Eine
Datei liegt daneben auf der Platte und hätte ihre eigene Adresse
behalten. "Zurückgenommen" hiesse dann: im Verlauf weg, über den
Link weiter da. Das wäre keine Rücknahme, sondern der Anschein
einer. Erst die Zeile leeren, dann die Datei -- scheitert das
Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
const dateiWeg = n.anhang_datei;
/* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei
Spalten stuende die Nachricht weiter oben angeheftet -- mit dem
Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle
des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber
der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme
darauf, warum sich nichts mehr anheften laesst. */
db().prepare(`UPDATE chat_nachrichten
SET text = '', weg_am = ?,
angeheftet_am = NULL, angeheftet_von = NULL,
anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
anhang_typ = NULL, anhang_groesse = NULL,
anhang_breite = NULL, anhang_hoehe = NULL
WHERE id = ?`).run(jetzt(), id);
/* ERST DIE ZEILE, DANN DIE DATEI. Scheitert das Entfernen (Datei
schon weg), ist der Verweis trotzdem fort -- andersherum bliebe
im schlechten Fall eine Zeile ohne Datei stehen. */
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id);
if (dateiWeg) {
try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
}
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
/* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende
er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem
es nichts mehr gibt. */
d.prepare(`UPDATE chat_raeume
SET letzte_am = (SELECT MAX(erstellt) FROM chat_nachrichten WHERE raum_id = ?)
WHERE id = ?`).run(n.raum_id, n.raum_id);
protokolliere("chat_zurueckgenommen", {
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
/* `geloescht: true` STATT `zurueckgenommen`. Das Wort entscheidet,
was der Browser tut: Das alte hiess "zeig einen Hinweis", das
neue heisst "nimm es aus dem Bild". Beides gleichzeitig zu
schicken waere die Fassung, bei der eine alte Seite den Hinweis
weiter anzeigt. */
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, geloescht: true }, [], "weg");
protokolliere("chat_geloescht", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id}`,
detail: fremd ? `#${id} (fremde Nachricht von #${n.person_id})` : `#${id}`,
});
res.json({ ok: true, empfaenger: empfaenger.length });
res.json({ ok: true, geloescht: true, empfaenger: empfaenger.length });
} catch (fehler) {
console.error("[chat] Zurücknehmen:", fehler?.message);
console.error("[chat] Löschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});