Stufe 6 und 7: Der Treff wird ein Gespraech, jedes Brett bekommt seine Form

Damit sind alle acht Stufen des Plans gebaut.

STUFE 6 -- DAS GESPRAECH
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten.
Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, zwang
sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, niemand
antwortete jemandem.

Jetzt: Antworten eingerueckt unter ihrem Beitrag, aelteste zuerst (so
liest man ein Gespraech), mit Namen und Rolle. Das Feld steht immer da
statt hinter einem "Antworten"-Knopf -- ein leeres Feld ist die
deutlichste Einladung, die es gibt. Nur EINE Stufe eingerueckt: Auf
390 px ist bei der zweiten Schluss.

Eine EIGENE Tabelle, kein Eintrag mit "antwort_auf": Eine Antwort hat
keine Art, keinen Zustand, keine Frist und gehoert in keinen Filter.
Als Eintrag gefuehrt haette sie zwanzig immer leere Spalten -- und
tauchte in jeder Zaehlung auf, die Beitraege zaehlt.

Und nicht ueberall: nur Treff und Wunschliste. Aus dem Plan, §9 -- ein
Antwortfeld unter jedem Aushang hiesse, jeder Aushang muss betreut
werden.

ZWEI FEHLER, BEIDE VON DER PRUEFUNG GEFUNDEN

1. EIN PFAD, DER AUSSAH WIE EIN BEREICH.
   `/api/bereich/antwort/:id` lief in die Middleware auf
   `/api/bereich/:bereich` -- "antwort" ist kein Bereich. Fuer
   DogFather ging es (ein frueherer Zweig liess ihn durch), fuer einen
   Modi kam 404. Ein Weg, der je nach ROLLE an voellig anderer Stelle
   scheitert, sucht man lange. Liegt jetzt unter `/api/antwort/:id`.

2. UND EIN ECHTES LOCH.
   Die Loeschregel hiess `!darfSchreiben(req.person, "treff")` -- also
   "wer schreiben darf", und das duerfen Gaeste ab der Stufe "dabei".
   JEDER GAST HAETTE JEDE FREMDE ANTWORT LOESCHEN KOENNEN.

   Gefunden hat es eine Pruefung mit einer FALSCHEN Erwartung: Sie
   verlangte, dass ein Modi keine fremde Antwort loeschen kann. Das war
   falsch -- er moderiert ja --, aber der Weg dorthin hat das echte
   Problem freigelegt. Jetzt entscheidet TREFF_TEAM_ROLLEN.

   Der Gast-Fall ist ueber HTTP nicht messbar (er kommt nur ueber die
   crew-Adresse herein, und den Host-Kopf kann fetch nicht setzen).
   Statt stillschweigend zu ueberspringen prueft die Pruefung die REGEL
   selbst -- und dass die Route wirklich diese Menge benutzt.

STUFE 7 -- JEDES BRETT BEKOMMT SEINE FORM
- "Regeln & Hilfe" ist ein DOKUMENT: Sprungmarken oben, vier
  Abschnitte (Regel, Was passiert wenn, Hilfe, Haeufige Frage). Man
  schlaegt es im Streitfall auf und will FINDEN, nicht scrollen.
  scroll-margin-top, sonst verschwindet die angesprungene Ueberschrift
  unter der Kopfleiste und der Sprung sieht aus wie ins Leere.
- "Anschlagbrett" zeigt grosse AUSHAENGE statt Zeilen -- gemessen
  21 px Titel statt 17. Ein Aushang zwischen dreissig anderen ist kein
  Aushang.
- "Mitmachen" gruppiert nach Art, die drei Schritte zuerst.

UND EINE SCHRIFT, DIE ZU KLEIN WAR: Die Rollen-Marke an einer Antwort
stand auf 11,2 px, die Hausgrenze liegt bei 11,5. Die Grenze anzuheben
waere der bequeme Weg gewesen -- und ab da haette pruef-css-klassen
nichts mehr gehalten.

NEU: pruef-gespraech (27 Pruefungen)
pruef-treff-start jetzt 34 (Stufe 7 mitgeprueft)
Alle Community-Pruefungen gruen: gespraech 27, treff-start 34,
kreislauf 23, countdown 22, wunschliste 30, galerie 26, bremse 16,
treff 66, bereiche-lesend, css-klassen, deutsche-texte 12.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-17 12:12:21 +02:00
co-authored by Claude Opus 5
parent b396e548ab
commit 79e675b846
36 changed files with 1197 additions and 359 deletions
+136
View File
@@ -987,3 +987,139 @@
.kette__weg { color: var(--text); text-decoration: none; }
.kette__weg:hover { text-decoration: underline; }
.kette__wer { color: var(--text-still); }
/* =====================================================================
DAS GESPRÄCH (17.09.2026) — Stufe 6 des Plans
„Der Treff" war eine Liste aus Karten. Der einzige Bereich, in dem
Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu reden.
EINE STUFE EINGERÜCKT, NICHT MEHR. Auf 390 px ist bei der zweiten
Schluss — dann steht der Text in einer Spalte von vier Wörtern. Die
Linie links macht die Zugehörigkeit deutlicher als jeder Abstand.
------------------------------------------------------------------- */
.antworten {
margin: 12px 0 0 0; padding-left: 14px;
border-left: 2px solid color-mix(in srgb, var(--akzent) 22%, var(--rand));
}
.antwort { padding: 8px 0; }
.antwort + .antwort { border-top: 1px solid color-mix(in srgb, var(--rand) 60%, transparent); }
.antwort__kopf {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px;
margin: 0 0 3px; font-size: .78rem;
}
.antwort__wer { font-weight: 640; color: var(--text); }
/* Wer aus dem Team antwortet, ist eine andere Auskunft als wer
mitredet — deshalb sichtbar, aber leise. */
.antwort__rolle {
padding: 0 7px; border-radius: 999px;
/* .74rem und nicht .7rem: Bei 11,2 px liegt die Marke unter der
Grenze, die pruef-css-klassen im Haus haelt (11,5 px). Die Grenze
anzuheben waere der bequeme Weg gewesen -- und ab da haette die
Pruefung nichts mehr gehalten. */
font-size: .74rem; font-weight: 600;
color: color-mix(in srgb, var(--akzent) 70%, #ffffff);
background: color-mix(in srgb, var(--akzent) 13%, transparent);
}
.antwort__wann { color: var(--text-still); margin-left: auto; }
.antwort__text {
margin: 0; font-size: .88rem; line-height: 1.55; color: var(--text-leise);
overflow-wrap: anywhere;
}
.antwort__weg {
margin-top: 4px; padding: 0;
background: none; border: 0; cursor: pointer;
font: inherit; font-size: .74rem; color: var(--text-still);
text-decoration: underline;
}
.antwort__weg:hover { color: var(--text-leise); }
/* Das Feld steht immer da — ein leeres Feld ist die deutlichste
Einladung, die es gibt. */
.antwort-neu { display: flex; gap: 8px; margin-top: 10px; }
.antwort-neu__feld {
flex: 1 1 auto; min-width: 0;
font: inherit; font-size: .88rem;
}
.antwort-neu__ab {
flex: 0 0 auto;
font: inherit; font-size: .82rem; font-weight: 600;
padding: 0 14px; border-radius: 999px; cursor: pointer;
color: var(--text-leise);
background: transparent;
border: 1px solid var(--rand-hell);
transition: color var(--tempo), border-color var(--tempo), background var(--tempo);
}
.antwort-neu__ab:hover:not(:disabled) {
color: var(--text); background: rgba(255, 255, 255, .05);
}
/* =====================================================================
STUFE 7: JEDES BRETT BEKOMMT SEINE FORM (17.09.2026)
„Regeln & Hilfe" ist ein Nachschlagewerk, kein Feed — man schlägt es
im Streitfall auf und will **finden**, nicht scrollen. „Mitmachen"
ist ein Weg. „Anschlagbrett" ist ein schwarzes Brett: wenige, große
Aushänge.
------------------------------------------------------------------- */
/* Die Sprungmarken — der Unterschied zwischen einem Dokument und einer
langen Seite. */
.brettmarken {
display: flex; flex-wrap: wrap; gap: 6px;
margin: 0 0 18px; padding-bottom: 14px;
border-bottom: 1px solid var(--rand);
}
.brettmarken__weg {
padding: 5px 12px; border-radius: 999px;
font-size: .8rem; font-weight: 600; text-decoration: none;
color: var(--text-leise);
border: 1px solid var(--rand-hell);
transition: color var(--tempo), border-color var(--tempo), background var(--tempo);
}
.brettmarken__weg:hover {
color: var(--text); background: rgba(255, 255, 255, .04);
}
/* Die Abschnittsüberschrift. scroll-margin, damit die Überschrift beim
Anspringen nicht unter der Kopfleiste verschwindet — ohne das landet
man auf dem Text darunter und glaubt, der Sprung sei ins Leere
gegangen. */
.brettabschnitt {
display: flex; align-items: baseline; gap: 10px;
margin: 30px 0 12px; scroll-margin-top: 80px;
font-size: .82rem; font-weight: 650;
letter-spacing: .09em; text-transform: uppercase;
color: var(--text-still);
}
.brettabschnitt:first-child { margin-top: 4px; }
.brettabschnitt::after {
content: ""; flex: 1; height: 1px;
background: linear-gradient(90deg, var(--rand), transparent);
}
.brettabschnitt__zahl {
order: 3; padding: 1px 8px; border-radius: 999px;
background: rgba(255, 255, 255, .06);
font-size: .74rem; letter-spacing: normal; color: var(--text-still);
font-variant-numeric: tabular-nums;
}
/* ---------------------------------------------------------------------
DAS ANSCHLAGBRETT — wenige, große Aushänge
Ein Aushang zwischen dreißig anderen ist kein Aushang. Größerer
Titel, mehr Luft, ein Rahmen, der nach Papier aussieht statt nach
Zeile.
------------------------------------------------------------------- */
#liste[data-aushang="ja"] { display: grid; gap: 18px; }
#liste[data-aushang="ja"] .eintrag-karte { padding: 22px 24px; }
#liste[data-aushang="ja"] .eintrag-titel {
font-size: 1.32rem; line-height: 1.25;
}
#liste[data-aushang="ja"] .eintrag-text {
font-size: .98rem; line-height: 1.6;
}
@media (max-width: 560px) {
#liste[data-aushang="ja"] .eintrag-karte { padding: 17px 16px; }
#liste[data-aushang="ja"] .eintrag-titel { font-size: 1.12rem; }
}
+137
View File
@@ -646,6 +646,86 @@
if (zeile.childNodes.length) k.append(zeile);
}
/* ---- DAS GESPRAECH (17.09.2026), Stufe 6 des Plans -------------
"Der Treff" war eine Liste aus Karten. Der einzige Bereich, in
dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander
zu reden: Jeder schrieb eine eigene Karte, niemand antwortete
jemandem.
ANTWORTEN STEHEN EINGERUECKT UNTER IHREM BEITRAG, aelteste
zuerst -- so liest man ein Gespraech. Und nur EINE Stufe
eingerueckt: Auf 390 px ist bei der zweiten Schluss, dann steht
der Text in einer Spalte von vier Woertern. */
if (Array.isArray(e.antworten)) {
const strang = el('div', 'antworten');
for (const a of e.antworten) {
const z = el('div', 'antwort');
const kopf2 = el('p', 'antwort__kopf');
kopf2.append(el('span', 'antwort__wer', a.von_name || 'jemand'));
if (a.von_rolle_name) {
kopf2.append(el('span', 'antwort__rolle', a.von_rolle_name));
}
kopf2.append(el('span', 'antwort__wann', datum(String(a.erstellt).slice(0, 10))));
z.append(kopf2);
z.append(el('p', 'antwort__text', a.text));
/* Die eigene immer, fremde nur das Team -- was der Server
genauso entscheidet. Ein Knopf, den der Server ablehnt,
waere schlimmer als keiner. */
if (a.meine || darfEintragen()) {
const w = el('button', 'antwort__weg', 'löschen');
w.type = 'button';
w.addEventListener('click', async () => {
w.disabled = true;
const r = await hole(`/workspace/api/antwort/${a.id}`, { method: 'DELETE' });
if (!r.ok) { melde('Ging nicht.'); w.disabled = false; return; }
await laden();
});
z.append(w);
}
strang.append(z);
}
/* DAS FELD STEHT IMMER DA, nicht hinter einem "Antworten"-Knopf.
Ein Gespraech faengt nicht an, wenn man erst einen Knopf
suchen muss -- und ein leeres Feld ist die deutlichste
Einladung, die es gibt.
ABER NUR FUER DEN, DER AUCH DARF. Der Server entscheidet das
mit derselben Regel (`darfSchreiben`); ein Feld, dessen
Absenden der Server ablehnt, ist schlimmer als keins -- man
tippt umsonst und bekommt eine Absage fuer etwas, das man
angeboten bekommen hat. */
if (darfEintragen()) {
const form = el('form', 'antwort-neu');
const feld = el('input', 'antwort-neu__feld');
feld.type = 'text';
feld.maxLength = 1200;
feld.placeholder = e.antworten.length ? 'Antworten …' : 'Als Erster antworten …';
feld.setAttribute('aria-label', `Antwort auf „${e.titel}“`);
const ab = el('button', 'antwort-neu__ab', 'Senden');
ab.type = 'submit';
form.append(feld, ab);
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const text = feld.value.trim();
if (!text) return;
ab.disabled = true;
const r = await hole(`/workspace/api/bereich/${bereich}/${e.id}/antwort`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!r.ok) {
melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.');
ab.disabled = false; return;
}
feld.value = '';
await laden();
});
strang.append(form);
}
k.append(strang);
}
if (!darfEintragen()) { k.append(fuss); return k; }
const knoepfe = el('div', 'eintrag-knoepfe');
@@ -916,6 +996,12 @@
sieben davon falsch. */
if (bereich === 'highlight') ziel.dataset.galerie = 'ja';
else delete ziel.dataset.galerie;
/* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE
Aushaenge. Ein Aushang zwischen dreissig anderen ist kein
Aushang -- die Wirkung entsteht durch die Groesse und die
Begrenzung. */
if (bereich === 'anschlag') ziel.dataset.aushang = 'ja';
else delete ziel.dataset.aushang;
const sichtbar = daten.eintraege.filter((e) => {
if (!filter) return true;
@@ -952,6 +1038,57 @@
return;
}
const karten = new Map();
/* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------
"Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man
schlaegt es im Streitfall auf und will finden, nicht scrollen.
Also nach Art gruppiert, mit Ueberschriften -- und ganz oben
Sprungmarken.
"Mitmachen" ist ein WEG: erst die drei Schritte, dann die
Gebiete, dann die Fragen. Eine Liste beantwortet "was gibt
es"; die Frage lautet aber "was tue ich als Erstes". */
const NACH_ART = {
regeln: ['regel', 'folge', 'hilfe', 'frage'],
mitmachen: ['gesucht', 'lust', 'frage'],
};
if (NACH_ART[bereich] && !filter) {
const reihe = NACH_ART[bereich];
const faecher = new Map(reihe.map((a) => [a, []]));
for (const e of sichtbar) {
if (!faecher.has(e.art)) faecher.set(e.art, []);
faecher.get(e.art).push(e);
}
/* DIE SPRUNGMARKEN ZUERST. Sie sind der Unterschied zwischen
einem Dokument und einer langen Seite. */
const marken = el('nav', 'brettmarken');
marken.setAttribute('aria-label', 'Zu einem Abschnitt springen');
for (const a of faecher.keys()) {
if (!faecher.get(a).length) continue;
const m = el('a', 'brettmarken__weg', einstellung?.arten?.[a] || a);
m.href = `#abschnitt-${a}`;
marken.append(m);
}
if (marken.childNodes.length > 1) ziel.append(marken);
for (const a of faecher.keys()) {
const fach = faecher.get(a);
if (!fach.length) continue;
const kopf2 = el('h2', 'brettabschnitt');
kopf2.id = `abschnitt-${a}`;
kopf2.textContent = einstellung?.arten?.[a] || a;
kopf2.append(el('span', 'brettabschnitt__zahl', String(fach.length)));
ziel.append(kopf2);
for (const e of fach) {
const k = karte(e);
karten.set(e.id, k);
ziel.append(k);
}
}
sprungZeigen(ziel, karten);
return;
}
if (bereich === 'ansteht') {
/* ALS ZEITSTRAHL, NICHT ALS LISTE (17.09.2026).