diff --git a/server/index.js b/server/index.js
index 97b0f858..825b130d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -18,6 +18,7 @@ import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
import { workspaceRouter, sichtSetzen } from "./workspace.js";
import { aufgabenRouter } from "./workspace-aufgaben.js";
import { zuteilungRouter } from "./workspace-zuteilung.js";
+import { willkommenRouter } from "./workspace-willkommen.js";
import { personenRouter } from "./workspace-personen.js";
import { profilRouter } from "./workspace-profil.js";
import { kalenderRouter } from "./workspace-kalender.js";
@@ -278,6 +279,7 @@ app.use(teamlageRouter);
/* VOR aufgabenRouter: Der faengt "/workspace/api/aufgaben/:id" ab und
wuerde "/workspace/api/aufgaben/resuemee" als Aufgabe mit der Nummer
"resuemee" behandeln. Die Reihenfolge ist hier keine Stilfrage. */
+app.use(willkommenRouter);
app.use(zuteilungRouter);
app.use(aufgabenRouter);
app.use(personenRouter);
diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs
index d52a6cb1..2340d59e 100644
--- a/server/pruef-buehne.mjs
+++ b/server/pruef-buehne.mjs
@@ -70,6 +70,8 @@ const ALLE_SEITEN = [
"werdegang.html", "entwicklung.html", "talente.html", "befinden.html",
"hilfe.html", "rechte.html", "teamlage.html", "teilen.html",
"bewerbungen.html", "treff-moderation.html", "treff-regeln.html",
+ /* Neu am 21.09.2026 -- sie traegt eine Buehne und viel Text. */
+ "willkommen.html",
"unsere-seiten.html",
];
const SEITEN = process.env.BUEHNE_SEITEN
diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs
index 09d292d1..2bb98658 100644
--- a/server/pruef-treff.mjs
+++ b/server/pruef-treff.mjs
@@ -251,8 +251,22 @@ melde("=== 1b. Eine Wand für beide ===");
ok(wand.code === 200 && /Team Dogi|Team Dogi/.test(wand.text),
`die Wand von Team Dogi steht (${wand.code})`);
const kacheln = [...wand.text.matchAll(/data-rolle="([a-z]+)"/g)].map((m) => m[1]);
- ok(kacheln.length === 4 && kacheln.includes("gast"),
- `vier Kacheln, eine davon die Community: ${kacheln.join(", ")}`);
+ /* ABGELEITET, NICHT GEZAEHLT (berichtigt 21.09.2026).
+ ----------------------------------------------------------------
+ Hier stand `kacheln.length === 4`. Am 21.09. kam die linke Hand
+ dazu, und die Zeile wurde rot, obwohl die Wand richtig war.
+
+ Die eigentliche Aussage ist auch eine andere und wiegt schwerer:
+ Die Anmeldung sucht `WHERE rolle = ?`. Eine Rolle OHNE Kachel
+ kommt damit ueberhaupt nicht herein -- der Zugang existiert, und
+ niemand kann ihn benutzen. Geprueft wird deshalb, dass jede Rolle
+ dieser Adresse ihre Kachel hat. Das kann nicht veralten. */
+ const sollRollen = [...w.TREFF_ROLLEN];
+ const ohneKachel = sollRollen.filter((r) => !kacheln.includes(r));
+ ok(ohneKachel.length === 0, ohneKachel.length === 0
+ ? `jede Rolle dieser Adresse hat ihre Kachel: ${kacheln.join(", ")}`
+ : `OHNE KACHEL und damit unerreichbar: ${ohneKachel.join(", ")}`);
+ ok(kacheln.includes("gast"), "die Community ist eine davon");
ok(kacheln[kacheln.length - 1] === "gast",
"und sie steht zuletzt -- wer täglich kommt, findet oben, was er täglich braucht");
@@ -334,12 +348,16 @@ let namen = [];
rechnet zusaetzlich nach, ob daraus ein Loch entsteht. */
ok(namen.join(" | ") === erwartet.join(" | "),
`und zwar diese: ${namen.join(", ")}`);
- /* DIE BREITE KACHEL STEHT VORNE, und das bleibt eine echte Aussage:
- Das Raster hat drei Spalten, "Der Treff" ist doppelt breit. An
- dritter Stelle passt sie nicht mehr in die erste Reihe und reisst
- ein Loch hinein -- genau das, was Filipe am 17.09. auf dem
- Bildschirmfoto gesehen hat. */
- ok(namen[0] === "Der Treff", `und „Der Treff“ steht vorne (${namen[0]})`);
+ /* UEBER DIE EIGENSCHAFT, NICHT UEBER DEN NAMEN (berichtigt
+ 21.09.2026). Hier stand `namen[0] === "Der Treff"`. Am 21.09.
+ wurde aus der Kachel die Willkommensseite, und die Zeile wurde
+ rot, obwohl das Raster stimmte. Gemeint war nie der Name, sondern:
+ die DOPPELT BREITE Kachel steht vorne. Genau das wird jetzt
+ gefragt. */
+ const breite = bereicheFuer({ rolle: "gast" }).filter((k) => k.gross);
+ ok(breite.length === 1, `genau eine Kachel ist doppelt breit (${breite.length})`);
+ ok(namen[0] === breite[0]?.name,
+ `und sie steht vorne (${namen[0]})`);
/* NICHTS VOM TEAM. Der eigentliche Zweck dieses Abschnitts: Eine
Kachel des Teams bei der Community waere lautlos falsch -- sie
saehe aus wie die anderen, und erst beim Klicken sagte der Server
@@ -849,6 +867,97 @@ melde("=== 7. Das Wort für „Gilt für“ kommt vom Server ===");
"und der Sucher findet so ein Wort auch wirklich");
}
+/* =====================================================================
+ KEIN BRETT DARF ZWISCHEN DIE REGELN FALLEN (21.09.2026)
+ =====================================================================
+
+ Dreimal derselbe Fehler, dreimal derselbe Hergang:
+
+ 19.09. Die Kachel "Regeln & Hilfe" bekam ein neues Ziel.
+ `regeln` fiel aus TREFF_BRETTER -- und war fuer die
+ Community 404, obwohl treff-regeln.html darauf verweist.
+ 20.09. Dasselbe noch einmal, bei MODI_BEREICHE_ERLAUBT.
+ 21.09. Die Kachel "Der Treff" wurde zur Willkommensseite.
+ `treff` fiel heraus -- und diesmal ging es in die andere
+ Richtung: Die Schranke laesst eine AGENTURROLLE unbesehen
+ durch, wenn ein Brett in keiner Liste steht. Eine
+ Creatorin konnte das Brett des Treffs lesen.
+
+ DER GEMEINSAME KERN: Die Schranke kennt drei Toere -- Treff-Bretter,
+ Bretter mit eigener Kachel, Bretter hinter einer Seite. Ein Brett,
+ das durch KEINES davon abgedeckt ist, faellt nicht etwa heraus,
+ sondern DURCH: Fuer eine Agenturrolle endet der Weg bei
+ `return next()`.
+
+ Genau das wird hier gezaehlt. Nicht "ist treff drin" -- das waere
+ die Reparatur von gestern -- sondern: faellt IRGENDEIN Brett durch
+ alle Regeln? Wer morgen eine Kachel umleitet, bekommt es damit
+ gesagt, statt es zu verschenken.
+ ===================================================================== */
+melde("\n--- Kein Brett faellt zwischen die Regeln ---");
+{
+ const bModul = await import("./workspace-bereiche.js");
+ const alleBretter = Object.keys(bModul.BEREICHE || {});
+ ok(alleBretter.length > 10,
+ `${alleBretter.length} Bretter angesehen`);
+
+ /* Die Tore der Schranke, aus dem Code geholt statt abgeschrieben. */
+ const durchTreff = new Set(w.TREFF_BRETTER);
+ const durchKachel = w.MODI_BEREICHE_ERLAUBT;
+
+ /* DIE EHRLICHE REGEL, GEMESSEN STATT GERATEN.
+ ----------------------------------------------------------------
+ Mein erster Entwurf fragte "haengt dieses Brett an irgendeiner
+ Regel" -- und meldete sofort `content` und `schutz`. Beides sind
+ AGENTURBRETTER ("Content-Ideen", "Schutz & Regeln"), die eine
+ Agenturrolle zu Recht liest. Die Zeile war also zu breit und
+ haette zwei Fehlalarme dauerhaft festgeschrieben. Eine Pruefung,
+ die zu viel meldet, wird abgeschaltet -- dann haelt sie gar
+ nichts mehr.
+
+ Nachgemessen ist die Trennung exakt und in BEIDE Richtungen
+ deckungsgleich: Die Bretter mit `fuerAlle` sind genau die des
+ Treffs, plus die Agenturablage. Das ist eine Eigenschaft des
+ BRETTS und wandert nicht, wenn jemand eine Kachel umleitet --
+ genau der Unterschied, an dem es dreimal gescheitert ist. */
+ const fuerAlle = Object.entries(bModul.BEREICHE)
+ .filter(([, v]) => v && v.fuerAlle).map(([k]) => k);
+ ok(fuerAlle.length >= 7, `${fuerAlle.length} Bretter stehen allen offen`);
+
+ /* Die Agenturablage ist der eine gewollte Fall: Sie steht allen
+ offen, aber im ANDEREN Haus. Sie namentlich auszunehmen ist etwas
+ anderes als eine Liste zu pflegen -- es ist eine Ausnahme, die
+ man sieht. */
+ const GEMEINSCHAFT = fuerAlle.filter((b) => b !== "agentur");
+ const ungeschuetzt = GEMEINSCHAFT.filter((b) => !durchTreff.has(b));
+ ok(ungeschuetzt.length === 0, ungeschuetzt.length === 0
+ ? `alle ${GEMEINSCHAFT.length} Bretter des Treffs stehen in TREFF_BRETTER`
+ : `NICHT GESCHUETZT -- eine Agenturrolle kaeme hinein: ${ungeschuetzt.join(", ")}`);
+
+ /* UND DIE ANDERE RICHTUNG: Nichts darf in TREFF_BRETTER stehen, das
+ dort nicht hingehoert -- sonst spannte die Liste einen Schutz ueber
+ ein Brett, das der Agentur gehoert, und sperrte sie aus. */
+ const zuviel = [...durchTreff].filter((b) => !GEMEINSCHAFT.includes(b));
+ ok(zuviel.length === 0, zuviel.length === 0
+ ? "und nichts Fremdes steht darin"
+ : `ZUVIEL in TREFF_BRETTER: ${zuviel.join(", ")}`);
+
+ /* UND EIN MODI MUSS SIE AUCH OEFFNEN KOENNEN. Dieselbe Falle, zweite
+ Liste: `MODI_BEREICHE_ERLAUBT` wird ebenfalls aus Kachelzielen
+ abgeleitet und verliert ein Brett, sobald eine Kachel umzieht. */
+ const fuerModiZu = GEMEINSCHAFT.filter((b) => !durchKachel.has(b));
+ ok(fuerModiZu.length === 0, fuerModiZu.length === 0
+ ? "und ein Modi kommt in jedes davon hinein"
+ : `fuer einen Modi gesperrt: ${fuerModiZu.join(", ")}`);
+
+ /* GEGENPROBE: Wuerden diese Zeilen ein ungeschuetztes Brett
+ ueberhaupt sehen? Ein erfundener Name steht in keiner Menge und
+ muss deshalb auffallen. */
+ const erfunden = "brett-das-es-nicht-gibt";
+ ok(!durchTreff.has(erfunden) && !durchKachel.has(erfunden),
+ "und ein Brett ohne Schutz wuerde hier wirklich auffallen");
+}
+
melde("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
diff --git a/server/pruef-willkommen.mjs b/server/pruef-willkommen.mjs
new file mode 100644
index 00000000..51efc910
--- /dev/null
+++ b/server/pruef-willkommen.mjs
@@ -0,0 +1,424 @@
+/* =====================================================================
+ pruef-willkommen.mjs — DIE SEITE, DIE ERKLAERT, WAS ES HIER GIBT
+ =====================================================================
+
+ Auftrag vom 21.09.2026, Abschnitte 7 und 8.
+
+ WAS HIER GEMESSEN WIRD — und warum gerade das:
+
+ (1) DASS DIE ERKLAERUNG ZU DEN RECHTEN PASST. Abschnitt 8 verlangt
+ woertlich, die Erklaerung solle sich "automatisch an die
+ tatsaechlich vorhandenen Rechte" anpassen. Der Fehler, den man
+ dabei macht, ist eine gepflegte Liste: Sie stimmt am ersten Tag
+ und erzaehlt am zweiten von einer Seite, die es fuer diesen
+ Menschen nie gab. Geprueft wird deshalb nicht "steht da etwas",
+ sondern: JEDE erklaerte Kachel muss diese Person auch oeffnen
+ duerfen — und zwar gemessen an derselben Rechtetafel, die der
+ Server durchsetzt.
+
+ (2) DIE GEGENPROBE DAZU. Eine Pruefung, die nur "alles erlaubt"
+ bestaetigt, bestaetigt nichts. Also wird ausdruecklich gezaehlt,
+ dass ein Modi die Seiten, die er NICHT hat (Personen, Hilfe,
+ Bewerbungen), auch nicht erklaert bekommt — und dass DogFather
+ mehr erklaert bekommt als er.
+
+ (3) DASS DIE ROLLEN WIRKLICH VERSCHIEDENE TEXTE BEKOMMEN. "Sie muss
+ vollstaendig rollenbasiert aufgebaut werden" ist nur erfuellt,
+ wenn sich die Texte auch unterscheiden. Vier gleiche Absaetze
+ mit vier verschiedenen Namen davor waeren keine Rollenseite.
+
+ (4) DASS DIE AUSGELIEFERTE DATEI KEINE ROLLENNAMEN TRAEGT. Sie geht
+ an jeden Browser. Steht die Aufteilung dort, ist sie lesbar und
+ veraltet ausserdem. Dieselbe Regel wie bei pruef-modi-wortleck.
+
+ (5) UND AM ECHTEN BILDSCHIRM, dass ein Mensch es lesen und antippen
+ kann. Ein richtiger Datensatz, den niemand sieht, ist kein
+ Erfolg.
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import http from "node:http";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-willk-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Willkommenspruefung");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(300_000, "pruef-willkommen");
+await new Promise((r) => setTimeout(r, 1200));
+
+let fehler = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+const HOST = "crew.dogfather-universe.com";
+function rufe(pfad, wie = {}) {
+ return new Promise((fertig) => {
+ const daten = wie.body === undefined ? null : JSON.stringify(wie.body);
+ const a = http.request({
+ host: "127.0.0.1", port: PORT, path: pfad, method: wie.method || "GET",
+ headers: {
+ Host: HOST,
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}),
+ ...(wie.keks ? { Cookie: wie.keks } : {}),
+ Origin: `https://${HOST}`, Referer: `https://${HOST}/workspace/`,
+ },
+ }, (a2) => {
+ let t = "";
+ a2.on("data", (c) => (t += c));
+ a2.on("end", () => fertig({
+ code: a2.statusCode, kopf: a2.headers, text: t,
+ json: (() => { try { return JSON.parse(t); } catch { return null; } })(),
+ }));
+ });
+ a.on("error", (e) => fertig({ code: 0, text: e.message, json: null }));
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle: "admin", code: "x" } });
+for (let i = 0; i < 60; i++) {
+ try {
+ const t = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { t.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { t.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Funny", "hand", "CODE-HAND-0001");
+anlegen("Lino", "linke", "CODE-LINK-0001");
+anlegen("Kessi", "modi", "CODE-MOD1-0001");
+/* Ein Gast muss sein Alter bestaetigt haben, sonst kommt er nicht durch. */
+const idGast = anlegen("Robin", "gast", "CODE-GAST-0001");
+d.prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?").run(jetzt, idGast);
+d.close();
+
+async function alsWer(rolle, code) {
+ const a = await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle, code } });
+ return (a.kopf?.["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+/* ---------------------------------------------------------------------
+ 1. DIE SCHRANKE
+ --------------------------------------------------------------------- */
+melde("\n=== Die Schranke ===");
+{
+ const ohne = await rufe("/workspace/api/willkommen");
+ ok(ohne.code === 401, `unangemeldet gibt es nichts (${ohne.code})`);
+}
+
+/* ---------------------------------------------------------------------
+ 2. JEDE ROLLE BEKOMMT IHRE EIGENE SEITE
+ --------------------------------------------------------------------- */
+melde("\n=== Jede Rolle bekommt ihre eigene Seite ===");
+const LEUTE = [
+ { rolle: "admin", code: "CODE-DOGI-0001", name: "Filipe" },
+ { rolle: "hand", code: "CODE-HAND-0001", name: "Funny" },
+ { rolle: "linke", code: "CODE-LINK-0001", name: "Lino" },
+ { rolle: "modi", code: "CODE-MOD1-0001", name: "Kessi" },
+ { rolle: "gast", code: "CODE-GAST-0001", name: "Robin" },
+];
+const gesehen = {};
+for (const p of LEUTE) {
+ const keks = await alsWer(p.rolle, p.code);
+ const a = await rufe("/workspace/api/willkommen", { keks });
+ gesehen[p.rolle] = { keks, daten: a.json };
+ ok(a.code === 200, `${p.rolle}: die Seite antwortet (${a.code})`);
+ ok(a.json?.gruss === `Willkommen, ${p.name}!`,
+ ` und begruesst mit Namen ("${a.json?.gruss}")`);
+ ok(!!a.json?.einleitung?.was && !!a.json?.einleitung?.dazu,
+ " mit einer Einleitung, die aus zwei Teilen besteht");
+}
+
+/* DIE EINLEITUNGEN MUESSEN SICH UNTERSCHEIDEN. Sonst ist es keine
+ rollenbasierte Seite, sondern eine mit Namensfeld. */
+{
+ const saetze = LEUTE.map((p) => gesehen[p.rolle]?.daten?.einleitung?.was);
+ const verschieden = new Set(saetze).size;
+ ok(verschieden === LEUTE.length,
+ `${verschieden} von ${LEUTE.length} Einleitungen sind verschieden`);
+}
+
+/* ---------------------------------------------------------------------
+ 3. DER KERN: ERKLAERT WIRD NUR, WAS MAN AUCH DARF
+ ---------------------------------------------------------------------
+
+ Gemessen gegen die Rechtetafel selbst, nicht gegen eine Liste in
+ dieser Datei -- sonst pruefe ich meine Abschrift gegen seine. */
+melde("\n=== Erklaert wird nur, was man auch oeffnen darf ===");
+const { darfSeite } = await import("./rechte.js");
+const { bereicheFuer, zusatzBereicheFuer } = await import("./workspace.js");
+const { istCrewAdresse } = await import("./crew-adresse.js");
+
+/* DAS HAUS GEHOERT DAZU, und es wird ABGELEITET wie beim Server.
+ Gemessen am 21.09.: DogFather hat auf der Crew-Adresse 30 Kacheln
+ und auf der Agenturadresse 1 (die Tuer hinueber) -- er ist die
+ einzige Rolle, die in beiden Haeusern lebt. Eine Vergleichsrechnung
+ ohne Haus misst deshalb ausgerechnet bei ihm das falsche Haus und
+ meldet einen Fehler, den es nicht gibt. */
+const HAUS = istCrewAdresse(HOST) ? "crew" : "agentur";
+const alsPerson = (rolle) => ({ rolle, haus: HAUS });
+
+for (const p of LEUTE) {
+ const daten = gesehen[p.rolle]?.daten;
+ const kacheln = (daten?.gruppen || []).flatMap((g) => g.kacheln || []);
+ ok(kacheln.length > 0, `${p.rolle}: es wird ueberhaupt etwas erklaert (${kacheln.length})`);
+
+ const verboten = kacheln.filter((k) => {
+ const ziel = String(k.ziel || "").split("?")[0];
+ if (!ziel || /^https?:/.test(ziel)) return false;
+ return !darfSeite(alsPerson(p.rolle), "/workspace/" + ziel);
+ });
+ ok(verboten.length === 0, verboten.length === 0
+ ? " keine einzige davon ist fuer ihn gesperrt"
+ : ` ERKLAERT GESPERRTE SEITEN: ${verboten.map((v) => v.ziel).join(", ")}`);
+
+ /* Und die andere Richtung: die Zahl muss zu seinen echten Kacheln
+ passen. Zu WENIG waere genauso falsch -- dann verschweigt die
+ Seite etwas, das er hat. */
+ const roh = [
+ ...(bereicheFuer(alsPerson(p.rolle)) || []),
+ ...(zusatzBereicheFuer(alsPerson(p.rolle)) || []),
+ ];
+ const sollte = roh.filter((k) => {
+ if (k?.aussen) return true;
+ const ziel = String(k?.ziel || "");
+ if (!ziel) return false;
+ return darfSeite(alsPerson(p.rolle), "/workspace/" + ziel.split("?")[0]);
+ }).length;
+ ok(kacheln.length === sollte,
+ ` und es sind genau seine ${sollte} Kacheln (erklaert: ${kacheln.length})`);
+}
+
+/* GEGENPROBE MIT ZAHLEN: Kann die Pruefung ueberhaupt Unterschiede
+ sehen? Wenn alle gleich viel bekaemen, waere oben nichts bewiesen. */
+{
+ const zahl = (r) => (gesehen[r]?.daten?.gruppen || []).flatMap((g) => g.kacheln || []).length;
+ const chef = zahl("admin"), modi = zahl("modi"), gast = zahl("gast");
+ ok(chef > modi && modi > gast,
+ `DogFather ${chef} > Modi ${modi} > Community ${gast} -- die Seite unterscheidet wirklich`);
+
+ /* DIE DREI, DIE EIN MODI NICHT HAT. Namentlich, damit ein stiller
+ Durchrutscher auffaellt. */
+ const modiZiele = (gesehen.modi?.daten?.gruppen || [])
+ .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]);
+ for (const seite of ["personen.html", "bewerbungen.html", "rechte.html"]) {
+ ok(!modiZiele.includes(seite), `einem Modi wird "${seite}" nicht erklaert`);
+ }
+ /* Und der Gegenbeweis, dass diese Namen ueberhaupt vorkommen koennen: */
+ const chefZiele = (gesehen.admin?.daten?.gruppen || [])
+ .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]);
+ ok(chefZiele.includes("personen.html"),
+ "DogFather dagegen schon (sonst waere die Probe oben wertlos)");
+}
+
+/* ---------------------------------------------------------------------
+ 4. DIE LINKE HAND BEKOMMT IHRE GRENZEN GESAGT
+ --------------------------------------------------------------------- */
+melde("\n=== Die linke Hand ===");
+{
+ const h = gesehen.linke?.daten?.hinweise || [];
+ ok(h.some((x) => /nicht/i.test(x.titel + x.text)),
+ "ihr wird gesagt, was sie nicht hat -- einmal, klar, ohne Liste");
+ const ziele = (gesehen.linke?.daten?.gruppen || [])
+ .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]);
+ ok(!ziele.includes("personen.html") && !ziele.includes("hilfe.html"),
+ "und Personen/Meldeweg stehen nicht in ihrer Erklaerung");
+ ok(ziele.includes("aufgaben.html"),
+ "die Aufgaben dagegen schon");
+}
+
+/* ---------------------------------------------------------------------
+ 5. DIE AUSGELIEFERTE DATEI TRAEGT KEINE ROLLENAUFTEILUNG
+ --------------------------------------------------------------------- */
+melde("\n=== Was im Browser landet ===");
+{
+ const js = readFileSync(new URL("../workspace/assets/js/willkommen.js", import.meta.url), "utf8");
+ const html = readFileSync(new URL("../workspace/willkommen.html", import.meta.url), "utf8");
+ const ohneKommentar = js
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+
+ for (const wort of ["admin", "linke", "gast"]) {
+ const treffer = new RegExp("[\"'`]" + wort + "[\"'`]").test(ohneKommentar);
+ ok(!treffer, `die Datei kennt "${wort}" nicht (sie kommt vom Server)`);
+ }
+ ok(!/aufgaben\.html|personen\.html/.test(ohneKommentar),
+ "und fuehrt auch keine eigene Kachelliste");
+ ok(html.includes("willkommen.css"), "die Seite laedt ihre Gestaltung");
+ ok(html.includes("meldung.js"), "und den gemeinsamen Meldeweg");
+
+ /* GEGENPROBE: Findet dieses Muster ueberhaupt etwas? Sonst waere die
+ Aussage oben nur die Bestaetigung eines kaputten Suchmusters. */
+ const probe = 'const x = "admin";';
+ ok(new RegExp("[\"'`]admin[\"'`]").test(probe),
+ "das Suchmuster wuerde einen Rollennamen finden");
+}
+
+/* ---------------------------------------------------------------------
+ 6. DIE KACHEL FUEHRT HIN
+ --------------------------------------------------------------------- */
+melde("\n=== Der Weg zur Seite ===");
+{
+ const kacheln = [
+ ...(bereicheFuer(alsPerson("gast")) || []),
+ ...(zusatzBereicheFuer(alsPerson("gast")) || []),
+ ];
+ const willk = kacheln.filter((k) => String(k.ziel || "").startsWith("willkommen.html"));
+ ok(willk.length === 1, `die Community hat genau eine Kachel dorthin (${willk.length})`);
+ ok(willk[0]?.name === "Willkommen", `und sie heisst so (${willk[0]?.name})`);
+
+ /* DAS TREFF-BRETT DARF NICHT STILL VERSCHWINDEN. Gemessen am
+ 21.09.: null Eintraege, 29 Chatnachrichten daneben -- geredet wird
+ im Chat. Falls das Brett je wieder eine Kachel bekommt, soll diese
+ Zeile daran erinnern, dass hier einmal eine war. */
+ const brett = kacheln.filter((k) => String(k.ziel || "").includes("b=treff"));
+ ok(brett.length === 0,
+ `das leere Treff-Brett hat keine eigene Kachel mehr (${brett.length})`);
+ const chat = kacheln.filter((k) => String(k.ziel || "").includes("chat.html"));
+ ok(chat.length >= 1, `der Treff-Chat dagegen schon (${chat.length})`);
+}
+
+/* ---------------------------------------------------------------------
+ 7. AM ECHTEN BILDSCHIRM
+ --------------------------------------------------------------------- */
+melde("\n=== Am echten Bildschirm ===");
+{
+ const { execFileSync } = await import("node:child_process");
+ const { createServer: httpsServer } = await import("node:https");
+ const { request: httpAnfrage } = await import("node:http");
+
+ const schluessel = join(ordner, "pruef.key");
+ const zertifikat = join(ordner, "pruef.crt");
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluessel, "-out", zertifikat, "-days", "2",
+ "-subj", `/CN=${HOST}`, "-addext", `subjectAltName=DNS:${HOST}`],
+ { stdio: "ignore" });
+
+ const HTTPS_PORT = PORT + 1;
+ const vorbau = httpsServer({
+ key: readFileSync(schluessel), cert: readFileSync(zertifikat),
+ }, (von, nach) => {
+ const weiter = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${HOST}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ von.pipe(weiter);
+ });
+ await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+ const SEITE = `https://${HOST}:${HTTPS_PORT}`;
+
+ const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${HOST} 127.0.0.1`, "--ignore-certificate-errors"],
+ });
+ const seitenFehler = [];
+
+ async function alsSeite(rolle, code, breite = 1280) {
+ const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } });
+ const s = await ctx.newPage();
+ s.on("pageerror", (e) => seitenFehler.push(`${rolle}: ${String(e).slice(0, 110)}`));
+ await s.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
+ await s.click(`.rolle[data-rolle="${rolle}"]`);
+ await s.fill("#code", code);
+ await s.click("button[type=submit]");
+ await s.waitForTimeout(800);
+ await s.goto(`${SEITE}/workspace/willkommen.html`, { waitUntil: "networkidle" });
+ await s.waitForTimeout(900);
+ return { ctx, seite: s };
+ }
+
+ /* ---- Ein Modi, am Handy ---- */
+ const pModi = await alsSeite("modi", "CODE-MOD1-0001", 390);
+ try {
+ const bild = await pModi.seite.evaluate(() => {
+ const kacheln = [...document.querySelectorAll(".w-kachel")];
+ const breite = document.documentElement.scrollWidth;
+ return {
+ gruss: document.getElementById("gruss")?.textContent || "",
+ rolle: document.getElementById("rollentext")?.textContent || "",
+ was: document.getElementById("einleitung-was")?.textContent || "",
+ kacheln: kacheln.length,
+ mitLang: kacheln.filter((k) => k.querySelector(".w-kachel__lang")).length,
+ laedtWeg: document.getElementById("laedt")?.hidden === true,
+ busy: document.getElementById("haupt")?.getAttribute("aria-busy"),
+ breite,
+ ueberlauf: kacheln.filter((k) => k.getBoundingClientRect().right > breite + 1).length,
+ zuKlein: kacheln.filter((k) => k.getBoundingClientRect().height < 44).length,
+ ersteZiel: kacheln[0]?.getAttribute("href") || "",
+ };
+ });
+ ok(bild.gruss.includes("Kessi"), `der Name steht oben ("${bild.gruss}")`);
+ ok(bild.was.length > 20, "darunter ein Satz, der etwas sagt");
+ ok(bild.kacheln > 0, `${bild.kacheln} Kacheln werden erklaert`);
+ ok(bild.mitLang > 0, `davon ${bild.mitLang} mit ausfuehrlichem Satz`);
+ ok(bild.laedtWeg, "der Ladehinweis ist weg");
+ ok(bild.busy !== "true", "und die Seite meldet sich nicht mehr als ladend");
+ ok(bild.breite <= 390, `nichts sprengt das Handy (${bild.breite}px)`);
+ ok(bild.ueberlauf === 0, `keine Kachel ragt hinaus (${bild.ueberlauf})`);
+ ok(bild.zuKlein === 0, `jede ist mindestens 44px hoch (${bild.zuKlein} zu klein)`);
+
+ /* SIE MUSS AUCH HINFUEHREN. Eine Erklaerung ohne Weg ist ein
+ Prospekt. */
+ await pModi.seite.click(".w-kachel");
+ await pModi.seite.waitForTimeout(700);
+ const wohin = pModi.seite.url();
+ ok(!wohin.includes("willkommen.html"),
+ `ein Antippen fuehrt wirklich hin (${wohin.split("/").pop()})`);
+ } finally { await pModi.ctx.close(); }
+
+ /* ---- DogFather, am Rechner ---- */
+ const pChef = await alsSeite("admin", "CODE-DOGI-0001");
+ try {
+ const bild = await pChef.seite.evaluate(() => ({
+ gruss: document.getElementById("gruss")?.textContent || "",
+ kacheln: document.querySelectorAll(".w-kachel").length,
+ gruppen: [...document.querySelectorAll(".w-gruppe__titel")].map((t) => t.textContent),
+ hinweise: document.querySelectorAll(".w-hinweis").length,
+ }));
+ ok(bild.gruss.includes("Filipe"), `sein Name steht oben ("${bild.gruss}")`);
+ ok(bild.gruppen.length >= 2,
+ `nach Gruppen geordnet (${bild.gruppen.join(" · ")})`);
+ ok(bild.hinweise > 0, `mit ${bild.hinweise} Hinweisen zu seiner Rolle`);
+ ok(bild.kacheln > 10, `und allem, was er hat (${bild.kacheln})`);
+ } finally { await pChef.ctx.close(); }
+
+ ok(seitenFehler.length === 0, seitenFehler.length === 0
+ ? "kein Skript ist unterwegs gestolpert"
+ : `Skriptfehler: ${seitenFehler.join(" | ")}`);
+
+ await browser.close();
+ await new Promise((r) => vorbau.close(r));
+}
+
+melde(fehler === 0
+ ? "\nAlles in Ordnung.\n"
+ : `\n${fehler} FEHLER.\n`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index 3719826e..71306585 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -309,6 +309,12 @@ export const SEITEN = {
AUSDRUECKLICH NICHT fuer Manager, Scouts, Creator und Spicy: Sie
haben mit dem Treff nichts zu tun, und die Seite nennt Fristen,
Stufen und die Bedingungen des Ausschlusses. */
+ /* WILLKOMMEN (21.09.2026). Die Orientierungsseite. Sie erklaert nur,
+ was die Person ohnehin hat -- deshalb ist sie fuer JEDE gueltige
+ Rolle offen, genau wie die Startseite. Wer sie sperrt, sperrt die
+ Erklaerung, nicht den Zugang. */
+ "/workspace/willkommen.html": [...ALLE, "gast"],
+
"/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"],
/* DIE ANRUF-PROBE (19.09.2026). Sie sagt einem Menschen in seinem
eigenen Browser, warum es nicht klingelt -- die Frage, die sich
diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js
new file mode 100644
index 00000000..409dc267
--- /dev/null
+++ b/server/workspace-willkommen.js
@@ -0,0 +1,257 @@
+/* =====================================================================
+ WILLKOMMEN — die Seite, die erklärt, was es hier gibt
+ =====================================================================
+
+ Auftrag vom 21.09.2026, Abschnitte 7 und 8:
+
+ *"Beim Öffnen soll die Person persönlich begrüßt werden ...
+ Willkommen, [Name]! – danach folgt eine verständliche Einführung
+ in die Website ... Die Willkommensseite darf nicht für alle Nutzer
+ identisch sein. Sie muss vollständig rollenbasiert und individuell
+ aufgebaut werden ... Die Erklärung soll sich automatisch an die
+ tatsächlich vorhandenen Rechte des jeweiligen Nutzers anpassen."*
+
+ DAS LETZTE IST DER GANZE BAU. Eine Erklärung, die aufzählt, was es
+ gibt, ist am Tag danach falsch — jemand baut eine Kachel dazu, und
+ die Seite verschweigt sie. Deshalb erklärt sie GENAU DIE KACHELN,
+ die diese Person wirklich hat: dieselbe Liste, aus der die
+ Startseite gebaut wird (`bereicheFuer`), durch denselben Filter
+ (`darfSeite`). Wer morgen eine Kachel bekommt, bekommt automatisch
+ auch ihre Erklärung.
+
+ DREI EBENEN, und jede hat einen Grund:
+
+ Der Untertitel steht schon an der Kachel. Er ist die kürzeste
+ wahre Auskunft und wird deshalb nicht neu
+ erfunden.
+ Der lange Satz nur bei den Seiten, wo eine Zeile zu wenig ist.
+ NACH ZIEL abgelegt, nicht nach Name: Namen
+ ändern sich (am 21.09. gleich zweimal), das
+ Ziel bleibt.
+ Die Einleitung je Rolle ein eigener Absatz. Das ist die eine
+ Stelle, an der wirklich nach Rolle unterschieden
+ wird — und es sind vier Sätze, keine vier
+ Seitenlisten.
+
+ WAS NICHT ERKLÄRT WIRD, STEHT AUCH NICHT DA. Der Auftrag lässt die
+ Wahl zwischen "gar nicht erklären" und "deutlich als nicht relevant
+ behandeln". Gar nicht ist richtig: Eine Liste von Dingen, die man
+ nicht darf, ist keine Orientierung, sondern eine Kränkung.
+ ===================================================================== */
+
+import express from "express";
+import {
+ sitzungLesen, bereicheFuer, zusatzBereicheFuer, rollentextFuer,
+ ROLLEN_NAME, istDogFather, darfAufgabenVerteilen,
+} from "./workspace.js";
+import { darfSeite } from "./rechte.js";
+
+export const willkommenRouter = express.Router();
+
+/* ---------------------------------------------------------------------
+ DIE EINLEITUNG JE ROLLE
+ ---------------------------------------------------------------------
+
+ Sie sagt in zwei, drei Sätzen, WOZU jemand hier ist. Die Kacheln
+ darunter sagen, WOMIT. Beides zu mischen ergäbe einen Text, den
+ niemand liest.
+
+ Der Ton ist bewusst derselbe wie im ganzen Haus: auf Augenhöhe,
+ ohne jemanden über jemanden zu stellen. */
+const EINLEITUNG = {
+ admin: {
+ was: "Das hier ist deine Seite — von hier aus läuft alles.",
+ dazu: "Du siehst jede Kachel, die es gibt, und entscheidest am Ende, "
+ + "was passiert. Wenn dir etwas Vorgeschlagenes nicht passt, lehnst "
+ + "du es ab; nichts geschieht ohne dich.",
+ },
+ hand: {
+ was: "Du vertrittst DogFather im Alltag und hältst das Team zusammen.",
+ dazu: "Du siehst dieselben Aufgaben, Ideen und Auswertungen wie er und "
+ + "kannst Aufgaben an die Modis verteilen. Private Bereiche von "
+ + "DogFather bleiben seine.",
+ },
+ linke: {
+ was: "Du führst das Team durch den Tag — mit denselben Werkzeugen wie "
+ + "die rechte Hand.",
+ dazu: "Aufgaben verteilen, Stände verfolgen, Rückmeldung geben: alles "
+ + "wie sie. Was du NICHT hast: Personen anlegen, Rollen ändern und "
+ + "den vertraulichen Meldeweg. Das ist kein Versehen, sondern so "
+ + "eingerichtet.",
+ },
+ modi: {
+ was: "Schön, dass du da bist. Hier findest du alles für deinen Tag.",
+ dazu: "Unter „Aufgaben\" steht, was gerade für dich dran ist — du "
+ + "nimmst an, was du schaffst, und sagst ab, was nicht geht. Eine "
+ + "Absage ist völlig in Ordnung, schreib einfach kurz warum.",
+ },
+ gast: {
+ was: "Schön, dass du da bist.",
+ dazu: "Hier triffst du die anderen aus der Community. Schau dich um, "
+ + "schreib etwas, oder lies erst mal mit — beides ist richtig.",
+ },
+};
+
+/* ---------------------------------------------------------------------
+ DIE LANGEN SÄTZE — nach ZIEL, nicht nach Name
+ ---------------------------------------------------------------------
+
+ Nur dort, wo eine Zeile zu wenig ist. Wer zu jeder Kachel einen
+ Absatz schreibt, schreibt eine Seite, die niemand zu Ende liest.
+
+ Der Schlüssel ist der Dateiname ohne Parameter. Am 21.09. sind in
+ diesem Haus zwei Kachelnamen gewandert; ein Text, der am Namen
+ hängt, wäre dabei stumm geworden, ohne dass es jemand merkt. */
+const LANG = {
+ "aufgaben.html":
+ "Oben steht, wie es bei dir gerade aussieht — was offen ist, was läuft, "
+ + "was erledigt ist. Darunter die Karten. Bekommst du eine Aufgabe, "
+ + "kannst du sie annehmen oder ablehnen; wenn du ablehnst, schreib "
+ + "kurz warum, dann muss niemand nachfragen.",
+ "chat.html":
+ "Einzelgespräche und Gruppen. Mit @ und dem Namen sprichst du jemanden "
+ + "direkt an — er bekommt dann eine eigene Meldung, auch wenn er den "
+ + "Chat gerade nicht offen hat.",
+ "kalender.html":
+ "Alles mit Datum an einer Stelle. Termine über mehrere Tage sind "
+ + "durchgehend markiert, am Handy tippst du einen Tag an und siehst, "
+ + "was dort ansteht.",
+ "bereich.html":
+ "Ein Brett zum Sammeln. Schreib hinein, was dir einfällt — es geht "
+ + "nichts verloren, und niemand muss es sofort beantworten.",
+ "befinden.html":
+ "Ein paar Fragen über dich, nur für dich. Niemand aus dem Team sieht "
+ + "deine Antworten — am Ende bekommst du eine kurze Zusammenfassung.",
+ "steckbrief.html":
+ "Deine Seite: Foto, ein paar Worte über dich. Du entscheidest, was "
+ + "darauf steht.",
+ "hilfe.html":
+ "Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather "
+ + "und die rechte Hand sehen diese Meldungen.",
+ "treff-regeln.html":
+ "Wie wir hier miteinander umgehen. Kurz, und es lohnt sich einmal.",
+ "unsere-seiten.html":
+ "Ob gerade gestreamt wird, wann es losgeht, und wo du die Kanäle "
+ + "findest.",
+ "personen.html":
+ "Zugänge anlegen, sperren und Rollen ändern. Der Code wird genau "
+ + "einmal angezeigt — danach nie wieder.",
+ "teamlage.html":
+ "Wer gerade wie unterwegs ist. Gedacht zum Hinsehen, nicht zum "
+ + "Vergleichen.",
+ "werdegang.html":
+ "Wie sich jemand über die Zeit entwickelt hat. Keine Note, kein "
+ + "Ranking — nur die Bewegung.",
+ "willkommen.html": null, /* sich selbst erklärt sie nicht */
+};
+
+/* ---------------------------------------------------------------------
+ DIE ROUTE
+ --------------------------------------------------------------------- */
+willkommenRouter.get("/workspace/api/willkommen", (req, res) => {
+ try {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+
+ /* DIESELBE LISTE WIE DIE STARTSEITE, durch denselben Filter. Eine
+ eigene Aufzählung wäre die, die beim nächsten Umbau veraltet. */
+ const roh = [
+ ...(bereicheFuer(person) || []),
+ ...(zusatzBereicheFuer(person) || []),
+ ];
+ const offen = roh.filter((k) => {
+ if (k?.aussen) return true;
+ const ziel = String(k?.ziel || "");
+ if (!ziel) return false;
+ return darfSeite(person, "/workspace/" + ziel.split("?")[0]);
+ });
+
+ /* NACH GRUPPEN, in der Reihenfolge, in der sie auf der Startseite
+ stehen -- wer dort von oben liest, findet hier dasselbe wieder. */
+ const gruppen = [];
+ for (const k of offen) {
+ const name = k.gruppe || "Sonst";
+ let g = gruppen.find((x) => x.name === name);
+ if (!g) { g = { name, kacheln: [] }; gruppen.push(g); }
+ const datei = String(k.ziel || "").split("?")[0];
+ g.kacheln.push({
+ name: k.name,
+ unter: k.unter || "",
+ ziel: k.ziel,
+ zeichen: k.zeichen || null,
+ /* Der lange Satz nur, wenn es einen gibt -- `null` heißt
+ ausdrücklich "hier reicht die Zeile". */
+ lang: Object.prototype.hasOwnProperty.call(LANG, datei) ? LANG[datei] : null,
+ });
+ }
+
+ const ein = EINLEITUNG[person.rolle] || {
+ was: "Schön, dass du da bist.",
+ dazu: "Hier findest du alles, was für dich freigeschaltet ist.",
+ };
+
+ res.json({
+ name: person.name,
+ rolle: person.rolle,
+ rollenname: ROLLEN_NAME[person.rolle] || null,
+ rollentext: rollentextFuer(person),
+ gruss: `Willkommen, ${person.name}!`,
+ einleitung: ein,
+ gruppen,
+ /* Ein paar Sätze, die nur für bestimmte Rollen gelten und zu
+ keiner einzelnen Kachel gehören. */
+ hinweise: hinweiseFuer(person),
+ /* Damit die Seite die Zahl nennen kann, ohne sie zu zählen. */
+ kachelzahl: offen.length,
+ });
+ } catch (f) {
+ console.error("[willkommen]:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Sätze, die zu keiner Kachel gehören, aber zur Rolle. */
+function hinweiseFuer(person) {
+ const aus = [];
+ if (darfAufgabenVerteilen(person)) {
+ aus.push({
+ titel: "Aufgaben verteilen",
+ text: "Beim Anlegen einer Aufgabe kannst du eine Person aussuchen — "
+ + "oder mehrere. Bei mehreren fragst du noch, ob jede ihren Teil "
+ + "macht oder ob die Erste, die Zeit hat, übernimmt. Ist jemand "
+ + "fertig, kannst du kurz Rückmeldung geben.",
+ });
+ }
+ if (person.rolle === "modi") {
+ aus.push({
+ titel: "Du musst nicht alles annehmen",
+ text: "Wenn eine Aufgabe gerade nicht geht, lehn sie ab und schreib "
+ + "einen Satz dazu. Das ist ausdrücklich vorgesehen und niemandem "
+ + "unangenehm.",
+ });
+ }
+ if (person.rolle === "linke") {
+ aus.push({
+ titel: "Was du nicht siehst",
+ text: "Personen anlegen, Rollen ändern, Bewerbungen und der "
+ + "vertrauliche Meldeweg sind nicht für dich freigeschaltet. "
+ + "Alles, was mit Aufgaben und dem Team zu tun hat, schon.",
+ });
+ }
+ if (istDogFather(person)) {
+ aus.push({
+ titel: "Du entscheidest am Ende",
+ text: "Überall, wo dir etwas vorgeschlagen wird, kannst du annehmen "
+ + "oder ablehnen. Ein Vorschlag ist ein Vorschlag.",
+ });
+ }
+ if (person.rolle === "gast") {
+ aus.push({
+ titel: "Nachts ist Ruhe",
+ text: "Zwischen 0 und 6 Uhr schläft der Chat — gelesen werden kann "
+ + "weiter, geschrieben ab 6 Uhr wieder. Das gilt für alle, auch "
+ + "für DogFather.",
+ });
+ }
+ return aus;
+}
diff --git a/server/workspace.js b/server/workspace.js
index a62ab663..c9aec03b 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -1183,8 +1183,13 @@ const TREFF_GRUPPE = {
Bereichs -- dass er vorne steht, ist keine Notloesung fuer das
Raster, sondern die Reihenfolge, die ohnehin stimmt. */
const TREFF_BEREICHE = [
- { ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben",
- zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" },
+ /* AUS DEM TREFF-BRETT WIRD DIE WILLKOMMENSSEITE (21.09.2026, Auftrag
+ Abschnitt 7). Gemessen davor: Das Brett "treff" hatte NULL Eintraege,
+ der Treff-Chat daneben 29 Nachrichten -- geredet wird im Chat, das
+ Brett war eine Kachel ohne Inhalt. Sie zeigt jetzt auf die Seite,
+ die erklaert, was es hier ueberhaupt gibt. */
+ { ...TREFF_GRUPPE, name: "Willkommen", unter: "Was es hier gibt, kurz erklaert",
+ zeichen: "community", ton: 34, gross: true, ziel: "willkommen.html", szene: "lounge" },
/* DER CHAT (19.09.2026). Filipe: "eine geile mischung von punkt 2
und drei ... nach mitternacht bis morgens 6 uhr keiner schreiben
kann damit auch die privatsphaere beruecksichtig wird ueber nacht
@@ -1367,6 +1372,25 @@ export const TREFF_BRETTER = [...new Set([
zu haben. Wer die Ableitung liest und diese Zeile nicht sieht,
haelt die Liste fuer vollstaendig. */
"regeln",
+
+ /* UND DASSELBE EIN DRITTES MAL (21.09.2026) -- deshalb steht unten
+ jetzt eine Pruefung, die es laut macht.
+ ----------------------------------------------------------------
+ Am 21.09. wurde die Kachel "Der Treff" zur Willkommensseite
+ (Auftrag Abschnitt 7). Damit fiel `treff` aus dieser Ableitung.
+
+ WAS DAS ANRICHTETE, ist schlimmer als beim letzten Mal: Die
+ Schranke in workspace-bereiche.js faengt Aussenrollen und
+ Team-Dogi-Rollen ab, laesst eine AGENTURROLLE aber unbesehen
+ durch ("if (!TEAM_DOGI_ROLLEN.has(rolle)) return next()"). Solange
+ `treff` hier stand, fing der Zweig darueber sie ab -- danach nicht
+ mehr. Eine Creatorin konnte das Brett des Treffs lesen.
+
+ Gefunden hat es wieder pruef-treff, nicht das Nachdenken. Und
+ wieder war es kein neuer Fehler, sondern derselbe: Ein Brett
+ gehoert zum Treff, WEIL es dazugehoert -- nicht, weil gerade
+ jemand eine Kachel darauf zeigen laesst. */
+ "treff",
])];
/* Wer den Treff ueberhaupt sehen darf: die Community selbst und das
@@ -1652,6 +1676,11 @@ export const MODI_BEREICHE_ERLAUBT = new Set([
Wer eine Kachel umleitet, nimmt einem Brett damit stillschweigend
die Erreichbarkeit. Das muss sichtbar sein, nicht gerechnet. */
"regeln",
+ /* Und `treff` aus demselben Grund (21.09.2026): Seine Kachel zeigt
+ seit heute auf die Willkommensseite. Ohne diese Zeile sieht ein
+ Modi sechs von sieben Treff-Brettern -- und das siebte ist
+ ausgerechnet das, das dem Bereich den Namen gibt. */
+ "treff",
]);
/** Und die Bretter hinter einer Seite -- nur fuer den, der die Seite darf.
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 8a833e76..836e9492 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+