diff --git a/server/anleitung-tabellen.js b/server/anleitung-tabellen.js
new file mode 100644
index 00000000..a86c3650
--- /dev/null
+++ b/server/anleitung-tabellen.js
@@ -0,0 +1,376 @@
+/* =====================================================================
+ DIE ANLEITUNG — Tabellen und Inhalte
+
+ Bauplan: "Kachel Anleitung — Bauplan fuer die Einweisung neuer
+ Leute", Stand 06.10.2026. Geprueft und ergaenzt in
+ `02 Projekte/Anleitung-Kachel – Bauplan.md` (Obsidian).
+
+ ---------------------------------------------------------------------
+ WAS HIER *NICHT* STEHT, UND WARUM DAS DER KERN IST
+
+ Eine Karte der Anleitung zeigt Symbol, Farbe, Name und Ziel der
+ echten Kachel. Nichts davon steht in dieser Datenbank.
+
+ Es steht in `workspace/assets/js/bereiche.js`, und zwar genau
+ einmal. Wuerde man es hier noch einmal hinschreiben, haette man zwei
+ Listen: Die eine wird gepflegt, wenn eine Kachel umbenannt wird, die
+ andere nicht — und ab da erklaert die Anleitung eine Kachel, die es
+ so nicht mehr gibt. Das ist derselbe Fehler, der am 11.09.2026 beim
+ Spaltenumbau drei Spalten gekostet hat: eine abgeschriebene Liste.
+
+ Gespeichert wird deshalb nur, was der Bauplan NEU hinzufuegt:
+ die Stufe und die drei Saetze (Wozu / Was du hier tust / Merke).
+ Verbunden wird ueber den Kachel-Schluessel.
+
+ ---------------------------------------------------------------------
+ DER KACHEL-SCHLUESSEL wird aus dem Ziel ABGELEITET, nicht vergeben:
+
+ uebersicht.html -> uebersicht
+ bereich.html?b=live -> bereich:live
+
+ Das muss sein, weil zwei Faelle sonst zusammenfallen:
+
+ * `bereich.html` ist FUENF Kacheln (live, community, technik,
+ schutz, agentur). Ueber die Seite allein waeren sie eine.
+ * `profil.html` heisst beim Creator "Mein Profil" und beim Manager
+ "Creator-Profile" — zwei Karten, eine Adresse. Hier trennt sie
+ die Rolle im Schluessel (haus, rolle, kachel).
+
+ ---------------------------------------------------------------------
+ DIE HAUS-SPALTE ist leer gelaufene Vorsorge und soll es bleiben.
+
+ Entscheidung Filipe, 06.10.2026: "Jetzt nur Agentur, aber
+ vorbereitet." Team Dogi bekommt heute keine Anleitung. Die Spalte
+ kostet jetzt ein Feld; sie spaeter nachzuruesten hiesse, jede Zeile
+ und jeden Schluessel anzufassen.
+ ===================================================================== */
+
+/** Die drei Stufen. Reihenfolge ist die Anzeigereihenfolge. */
+export const STUFEN = [
+ { schluessel: "muss", name: "Muss",
+ titel: "Das brauchst du sofort",
+ unter: "Ohne das laeuft der Alltag nicht.",
+ offen: true },
+ { schluessel: "regelmaessig", name: "Regelmäßig",
+ titel: "Das kommt regelmäßig",
+ unter: "Woechentlich, monatlich oder bei jedem neuen Creator.",
+ offen: false },
+ { schluessel: "bedarf", name: "Bei Bedarf",
+ titel: "Das brauchst du nur bei Bedarf",
+ unter: "Nur oeffnen, wenn es einen Anlass gibt.",
+ offen: false },
+];
+
+export const STUFEN_SCHLUESSEL = STUFEN.map((s) => s.schluessel);
+
+/** Die Rollen, die eine eigene Fassung bekommen.
+ *
+ * OHNE 'admin'. DogFather bekommt keine Einweisung (Bauplan,
+ * Kapitel 11) — bei ihm oeffnet dieselbe Adresse ab Etappe 4 die
+ * Pflege-Ansicht. */
+export const FASSUNGEN = ["creator", "scout", "manager", "spicy"];
+
+/** Wer die Texte aendern darf.
+ *
+ * DogFather UND Spicy — Entscheidung Filipe am 06.10.2026, und zwar
+ * ausdruecklich dieselbe Regel wie bei den Manager-Zielen am
+ * 02.10.2026 ("dogfather und spicy rolle sollen gleichzeitig es immer
+ * aendern koennen fuer alle"). Der Bauplan hatte hier "nur DogFather"
+ * stehen; zwei verschiedene Regeln fuer dieselbe Frage sind eine
+ * Regel, die man sich nicht merken kann. */
+export const PFLEGE_ROLLEN = ["admin", "spicy"];
+
+/** Die Arten von Listenzeilen in `anl_zeile`.
+ *
+ * EINE TABELLE STATT FUENF. Satz, Schritt, Nicht-deins, Takt und Frage
+ * sind alle dasselbe: eine geordnete Zeile mit hoechstens zwei Texten
+ * und manchmal einem Ziel. Fuenf Tabellen waeren fuenfmal dieselbe
+ * Spaltenliste, fuenf Indizes und fuenf Stellen, an denen die
+ * Haus-Spalte vergessen werden kann. */
+export const ZEILEN_ARTEN = {
+ /* "In 60 Sekunden" — drei Saetze. Nur `text`. */
+ satz: { titel: false, ziel: false },
+ /* "Erste Schritte" — `titel` ist die Kachel, `ziel` der Sprung. */
+ schritt: { titel: true, ziel: true },
+ /* "Das kannst du liegen lassen" — `titel` die Taetigkeit,
+ `text` wer sie erledigt. */
+ nicht: { titel: true, ziel: false },
+ /* "Dein Rhythmus" — `titel` ist das Wann, `text` das Was. */
+ takt: { titel: true, ziel: false },
+ /* "Haeufige Fragen" — `titel` die Frage, `text` die Antwort. */
+ frage: { titel: true, ziel: false },
+};
+
+export const ARTEN_SCHLUESSEL = Object.keys(ZEILEN_ARTEN);
+
+/* =====================================================================
+ WO DIE UMRECHNUNG "Ziel -> Schluessel" STEHT
+
+ NICHT hier. Der Server bildet nie einen Schluessel: Die Schluessel
+ stehen woertlich in INHALTE weiter unten, er schreibt sie nur weg.
+ Gebraucht wird die Rechnung ausschliesslich im Browser, wo die
+ Kachelliste liegt — also steht sie dort, als
+ `window.Bereiche.schluesselVonZiel`.
+
+ Ich hatte sie zuerst hier gebaut und exportiert. Benutzt haette sie
+ niemand. Eine Funktion, die nur dasteht, liest sich beim naechsten
+ Mal wie eine Zusage ("der Server kennt die Kacheln") und ist keine.
+ Wenn die Pflege-Ansicht in Etappe 4 Schreibzugriffe pruefen muss,
+ kommt sie zurueck — dann aber mit einem Aufrufer.
+ ===================================================================== */
+
+const TABELLEN = `
+CREATE TABLE IF NOT EXISTS anl_karte (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ haus TEXT NOT NULL DEFAULT 'agentur',
+ rolle TEXT NOT NULL,
+ kachel TEXT NOT NULL,
+ stufe TEXT NOT NULL,
+ wozu TEXT NOT NULL DEFAULT '',
+ tust TEXT NOT NULL DEFAULT '',
+ merke TEXT NOT NULL DEFAULT '',
+ reihe INTEGER NOT NULL DEFAULT 0,
+ geaendert TEXT NOT NULL,
+ geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ UNIQUE (haus, rolle, kachel)
+);
+CREATE INDEX IF NOT EXISTS idx_anl_karte_sicht ON anl_karte (haus, rolle, stufe, reihe);
+
+CREATE TABLE IF NOT EXISTS anl_zeile (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ haus TEXT NOT NULL DEFAULT 'agentur',
+ rolle TEXT NOT NULL,
+ art TEXT NOT NULL,
+ reihe INTEGER NOT NULL DEFAULT 0,
+ titel TEXT NOT NULL DEFAULT '',
+ text TEXT NOT NULL DEFAULT '',
+ ziel TEXT,
+ geaendert TEXT NOT NULL,
+ geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_anl_zeile_sicht ON anl_zeile (haus, rolle, art, reihe);
+
+CREATE TABLE IF NOT EXISTS anl_rolle (
+ haus TEXT NOT NULL DEFAULT 'agentur',
+ rolle TEXT NOT NULL,
+ leitgedanke TEXT NOT NULL DEFAULT '',
+ geaendert TEXT NOT NULL,
+ geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ PRIMARY KEY (haus, rolle)
+);
+`;
+
+/* =====================================================================
+ DIE INHALTE
+
+ Woertlich aus dem Bauplan vom 06.10.2026, Kapitel 06. Gewonnen mit
+ `pdftotext -raw`, NICHT mit `-layout`: Der Layout-Auszug verschiebt
+ Tabellenzellen. Dort stand "Schulung und Community-Call laufen
+ woechentlich von allein" beim Eintrag *Mein Profil* statt bei
+ *Kalender* — eine Zeile, die man ohne Verdacht uebernommen und nie
+ wiedergefunden haette.
+
+ Die Stufen stammen aus Kapitel 05. Sie sind ab Etappe 4 in der
+ Pflege-Ansicht aenderbar, ohne dass hier jemand etwas anfasst —
+ deshalb ist diese Liste eine ERSTBEFUELLUNG und keine Wahrheit.
+ ===================================================================== */
+
+export const INHALTE = {
+ creator: {
+ leitgedanke:
+ "Das ist dein eigener Bereich. Du siehst nur, was zu dir gehört. "
+ + "Deine Betreuung trägt ein, du liest mit, antwortest und hakst ab.",
+ saetze: [
+ "Du musst nicht alles kennen. Fünf Kacheln reichen für den Anfang.",
+ "Was heute dran ist, steht immer auf der Zentrale unter „Was ist dran“.",
+ "Fast alles andere füllt deine Betreuung für dich. Du liest und antwortest.",
+ ],
+ schritte: [
+ { titel: "Mein Profil", ziel: "profil.html",
+ text: "Bild wählen, einen Satz über dich schreiben, TikTok-Namen eintragen, speichern." },
+ { titel: "Schutz & Regeln", ziel: "bereich.html?b=schutz",
+ text: "Unterweisungen lesen und zusammen mit deiner Betreuung bestätigen." },
+ { titel: "Aufgaben", ziel: "aufgaben.html",
+ text: "Nachsehen, ob schon etwas für dich offen ist." },
+ { titel: "Kalender", ziel: "kalender.html",
+ text: "Termine in deinen eigenen Kalender holen (Google, Apple, Outlook)." },
+ /* KEIN ZIEL. "Installieren" ist keine Kachel, sondern der Knopf
+ in der Kopfleiste. Ein Sprung auf eine Seite waere hier falsch
+ -- und ein Sprung ins Leere erst recht. */
+ { titel: "Installieren", ziel: null,
+ text: "Oben antippen, dann hast du den Workspace als App auf dem Handy." },
+ ],
+ karten: [
+ { kachel: "aufgaben", stufe: "muss",
+ wozu: "Was du zu tun hast.",
+ tust: "Annehmen, bearbeiten, zur Freigabe geben, Rückmeldung schreiben.",
+ merke: "„Zur Freigabe“ heißt: Jemand schaut noch drauf." },
+ { kachel: "chat", stufe: "muss",
+ wozu: "Nachrichten mit deinem Team.",
+ tust: "Mit „Neu“ ein Gespräch beginnen.",
+ merke: "Fehler an der Seite gehören in Support, nicht in den Chat." },
+ { kachel: "kalender", stufe: "muss",
+ wozu: "Termine und Calls.",
+ tust: "Termine ansehen, eigene anlegen.",
+ merke: "Schulung und Community-Call laufen wöchentlich von allein." },
+ { kachel: "profil", stufe: "muss",
+ wozu: "Wer du bist und wohin du willst.",
+ tust: "Einmal ausfüllen: Bild, Kanäle, Stammdaten, Ziele, 90-Tage-Plan.",
+ merke: "Nur du schreibst deinen Eintrag." },
+ { kachel: "bereich:schutz", stufe: "muss",
+ wozu: "Regeln, die du kennen musst.",
+ tust: "Einmal: Dokument öffnen, durchgehen, bestätigen.",
+ merke: "Nach der Bestätigung von beiden Seiten nicht mehr änderbar." },
+
+ { kachel: "bereich:live", stufe: "regelmaessig",
+ wozu: "Dein LIVE besser machen.",
+ tust: "Vor und nach dem LIVE: Bewertungen und Notizen lesen, an jedem Punkt antworten.",
+ merke: "Geordnet nach vor, während, nach der Sendung." },
+ { kachel: "leistung", stufe: "regelmaessig",
+ wozu: "Diamanten, LIVE-Tage, Verweildauer.",
+ tust: "Wochenzahlen und Ziele ansehen.",
+ merke: "Kommen aus Backstage und werden eingetragen, nicht automatisch geholt." },
+ { kachel: "calls", stufe: "regelmaessig",
+ wozu: "Gespräche mit To-dos.",
+ tust: "Nachlesen, was besprochen wurde. Der Call-Raum ist verlinkt.",
+ merke: "To-dos landen direkt bei deinen Aufgaben." },
+ { kachel: "bereich:agentur", stufe: "regelmaessig",
+ wozu: "Events und offizielle Wege.",
+ tust: "Event-Aufgaben ansehen, Rückmeldung geben, Anliegen an die Agentur stellen.",
+ merke: "Teilnahmebedingungen stehen beim Event." },
+ { kachel: "content", stufe: "regelmaessig",
+ wozu: "Von der Idee bis zum Upload.",
+ tust: "Sehen, was geplant ist, und antworten.",
+ merke: "Fünf Stufen: Idee, Hook & Skript, Gedreht, Fertig & geplant, Veröffentlicht." },
+ { kachel: "report", stufe: "regelmaessig",
+ wozu: "Rückblick und nächste Schritte.",
+ tust: "Einmal im Monat: Zeitraum wählen (7, 30 oder 90 Tage).",
+ merke: "Ein Review endet mit einer Entscheidung." },
+
+ { kachel: "uebersicht", stufe: "bedarf",
+ wozu: "Dein Stand auf einen Blick.",
+ tust: "Nachsehen: nächster Termin, offene Aufgaben, Start-Check.",
+ merke: "Was dran ist, zeigt dir schon die Zentrale." },
+ { kachel: "startcheck", stufe: "bedarf",
+ wozu: "Erstanalyse deines Kanals.",
+ tust: "Ansehen, was passt und was nicht, in 16 Punkten.",
+ merke: "Grundlage für deinen 90-Tage-Plan." },
+ { kachel: "bereich:community", stufe: "bedarf",
+ wozu: "Moderation und Konflikte.",
+ tust: "Nachlesen, wenn es im Chat unruhig wird.",
+ merke: "Ruhig statt Drama." },
+ { kachel: "bereich:technik", stufe: "bedarf",
+ wozu: "Setup, Fehler, Lösungen.",
+ tust: "Nachschlagen, wenn etwas klemmt.",
+ merke: "Erst hier schauen, dann fragen." },
+ { kachel: "dateien", stufe: "bedarf",
+ wozu: "Ablage für dich.",
+ tust: "Dateien öffnen, die für dich abgelegt wurden.",
+ merke: "Du kannst hier nichts hochladen." },
+ { kachel: "wissen", stufe: "bedarf",
+ wozu: "Anleitungen zu TikTok und Technik.",
+ tust: "Nach Stufe, Gerät oder Tag filtern.",
+ merke: "Hier geht es um Streaming, nicht um den Workspace." },
+ { kachel: "support", stufe: "bedarf",
+ wozu: "Etwas ist kaputt.",
+ tust: "Kurz beschreiben, bis zu drei Bilder anhängen, abschicken.",
+ merke: "DogFather und die rechte Hand kümmern sich." },
+ ],
+ nicht: [
+ { titel: "Zahlen eintragen.", text: "Das macht deine Betreuung." },
+ { titel: "Start-Check und LIVE-Analyse bewerten.", text: "Du liest und antwortest nur." },
+ { titel: "Protokolle schreiben.", text: "Du liest sie nach." },
+ { titel: "Dateien hochladen.", text: "Geht in deiner Ansicht nicht." },
+ { titel: "Jede Kachel täglich öffnen.", text: "Die Zentrale sagt dir, was dran ist." },
+ ],
+ takt: [
+ { titel: "Täglich", text: "Zentrale („Was ist dran“), Aufgaben, Chat." },
+ { titel: "Vor und nach dem LIVE", text: "LIVE-Analyse und Zahlen." },
+ { titel: "Wöchentlich", text: "Kalender, Calls & Protokolle, Agentur." },
+ { titel: "Monatlich", text: "Reports & Review und dein 90-Tage-Plan in Mein Profil." },
+ ],
+ fragen: [
+ { titel: "Muss ich jede Kachel kennen?",
+ text: "Nein. Fang mit den fünf Muss-Kacheln an. Der Rest kommt, wenn du ihn brauchst." },
+ { titel: "Ich habe meinen Code verloren.",
+ text: "Sag deiner Betreuung oder DogFather Bescheid. Du bekommst einen neuen, "
+ + "der alte gilt dann nicht mehr." },
+ { titel: "Warum sehe ich keine Zahlen?",
+ text: "Für die Woche wurde noch kein Tag eingetragen. Sprich deine Betreuung an." },
+ { titel: "Wer sieht mein Profil?",
+ text: "Dein Team: Bild, Name und was du über dich geschrieben hast." },
+ ],
+ },
+};
+
+/* =====================================================================
+ ANLEGEN UND ERSTBEFUELLEN
+ ===================================================================== */
+
+/** Spielt eine Rolle ein — aber nur, wenn fuer sie noch GAR NICHTS da ist.
+ *
+ * DAS IST DIE WICHTIGSTE ZEILE DIESER DATEI. Ein Einspielen bei jedem
+ * Serverstart wuerde jede Aenderung aus der Pflege-Ansicht beim
+ * naechsten Neustart ueberschreiben — lautlos, und man wuerde es erst
+ * Wochen spaeter merken, wenn jemand fragt, warum sein Text wieder
+ * der alte ist.
+ *
+ * Gepruefte Bedingung ist "keine einzige Karte dieser Rolle", nicht
+ * "Tabelle leer": Sonst bekaeme Rolle zwei nichts mehr, sobald Rolle
+ * eins drin ist. */
+function rolleEinspielen(d, haus, rolle, inhalt, jetzt) {
+ const schon = d.prepare(
+ "SELECT COUNT(*) n FROM anl_karte WHERE haus = ? AND rolle = ?").get(haus, rolle)?.n ?? 0;
+ if (schon > 0) return 0;
+
+ const karte = d.prepare(
+ `INSERT INTO anl_karte (haus, rolle, kachel, stufe, wozu, tust, merke, reihe, geaendert)
+ VALUES (?,?,?,?,?,?,?,?,?)`);
+ const zeile = d.prepare(
+ `INSERT INTO anl_zeile (haus, rolle, art, reihe, titel, text, ziel, geaendert)
+ VALUES (?,?,?,?,?,?,?,?)`);
+
+ let n = 0;
+ inhalt.karten.forEach((k, i) => {
+ karte.run(haus, rolle, k.kachel, k.stufe, k.wozu, k.tust, k.merke, i, jetzt);
+ n++;
+ });
+ const listen = [
+ ["satz", inhalt.saetze.map((t) => ({ titel: "", text: t, ziel: null }))],
+ ["schritt", inhalt.schritte],
+ ["nicht", inhalt.nicht],
+ ["takt", inhalt.takt],
+ ["frage", inhalt.fragen],
+ ];
+ for (const [art, liste] of listen) {
+ liste.forEach((z, i) => {
+ zeile.run(haus, rolle, art, i, z.titel ?? "", z.text ?? "", z.ziel ?? null, jetzt);
+ n++;
+ });
+ }
+ d.prepare(
+ `INSERT INTO anl_rolle (haus, rolle, leitgedanke, geaendert) VALUES (?,?,?,?)
+ ON CONFLICT(haus, rolle) DO NOTHING`).run(haus, rolle, inhalt.leitgedanke, jetzt);
+ return n;
+}
+
+export function anleitungTabellen(d) {
+ d.exec(TABELLEN);
+
+ const jetzt = new Date().toISOString();
+ let gesamt = 0;
+ const neu = [];
+ for (const rolle of FASSUNGEN) {
+ const inhalt = INHALTE[rolle];
+ /* Eine Rolle ohne Inhalte ist kein Fehler, sondern der Stand der
+ Arbeit: Etappe 1 bringt nur den Creator, die drei anderen kommen
+ in Etappe 3. Still uebergehen — aber die Zuordnungspruefung
+ weiss davon und meldet es als offen, nicht als in Ordnung. */
+ if (!inhalt) continue;
+ const n = rolleEinspielen(d, "agentur", rolle, inhalt, jetzt);
+ if (n) { gesamt += n; neu.push(`${rolle} ${n}`); }
+ }
+ if (gesamt) {
+ console.log(`[workspace] Anleitung: ${gesamt} Zeilen erstbefuellt (${neu.join(", ")}).`);
+ }
+}
diff --git a/server/index.js b/server/index.js
index 10566a3f..176f23c8 100644
--- a/server/index.js
+++ b/server/index.js
@@ -72,6 +72,7 @@ import { videoRouter, videoWacheStarten } from "./workspace-video.js";
import { bewerbungRouter } from "./workspace-bewerbung.js";
import { leistungRouter } from "./workspace-leistung.js";
import { managerZieleRouter } from "./workspace-manager-ziele.js";
+import { anleitungRouter } from "./workspace-anleitung.js";
import { fruehwarnungRouter } from "./workspace-fruehwarnung.js";
import { icsRouter } from "./workspace-ics.js";
@@ -356,6 +357,7 @@ app.use(scoutRouter);
ueberschneiden sich nicht), aber wer suchen muss, sucht sie
nebeneinander. */
app.use(managerZieleRouter);
+app.use(anleitungRouter);
app.use(callRouter);
app.use(hinweisRouter);
app.use(zentraleRouter);
diff --git a/server/mess-anleitung.mjs b/server/mess-anleitung.mjs
new file mode 100644
index 00000000..66a04618
--- /dev/null
+++ b/server/mess-anleitung.mjs
@@ -0,0 +1,190 @@
+/* =====================================================================
+ WIE DIE ANLEITUNG AUSSIEHT (06.10.2026, Etappe 1)
+
+ SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
+ auf denen man nachsehen kann, und misst vier Dinge, die man auf
+ einem Bild schlecht sieht: waagerechtes Schieben, Beruehrziele unter
+ 40 px, Fehler in der Browserkonsole und ob der Filter wirklich etwas
+ tut.
+
+ Deshalb `mess-` und nicht `pruef-`.
+
+ ZWEI BREITEN, und die kleinere ist die wichtigere: 412 px ist die
+ haeufigste Android-Breite. Am 06.09.2026 hat genau diese Breite im
+ Creator Workspace eine Ueberdeckung aufgedeckt, bei der ein Knopf
+ ueber einem anderen lag -- auf dem Computer war nichts zu sehen.
+
+ EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner. In einem laufenden
+ System waeren das Testdaten, und die sind verboten.
+
+ Aufrufen mit: node server/mess-anleitung.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request as httpAnfrage } from "node:http";
+
+import { eigenerPort } from "./helfer-port.mjs";
+const PORT = await eigenerPort(import.meta, "mess-anleitung");
+const ordner = mkdtempSync(join(tmpdir(), "ws-anl-bild-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(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 });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(240_000, "mess-anleitung");
+await new Promise((r) => setTimeout(r, 900));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+/* ---------- Leute ----------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetztIso = new Date().toISOString();
+const kenn = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, jetztIso);
+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,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kenn).update(code).digest("hex"), jetztIso);
+}
+anlegen("Luna", "creator", "CODE-LUNA-0001");
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+d.close();
+
+function anfrage(pfad, methode, koerper) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper ? JSON.stringify(koerper) : null;
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Origin: `http://127.0.0.1:${PORT}`,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function keksFuer(rolle, code) {
+ const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
+ if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); }
+ return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
+}
+const keksCreator = await keksFuer("creator", "CODE-LUNA-0001");
+const keksDogi = await keksFuer("admin", "CODE-DOGI-0001");
+
+/* ---------- Bilder ---------------------------------------------------- */
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+const nachmessen = () => ({
+ schiebtQuer: document.documentElement.scrollWidth > window.innerWidth + 2,
+ zuKlein: [...document.querySelectorAll("button, a[href], summary")]
+ .filter((e) => {
+ const k = e.getBoundingClientRect();
+ return k.width > 0 && k.height > 0 && k.height < 40;
+ })
+ .map((e) => `${e.tagName.toLowerCase()}.${(e.className || "-")}: ${Math.round(e.getBoundingClientRect().height)}px`),
+ karten: document.querySelectorAll(".an-karte").length,
+ /* Je Stufe: wie viele Karten, und ist der Kasten offen? */
+ stufen: [...document.querySelectorAll(".an-stufe")].map((s) => ({
+ stufe: s.dataset.stufe,
+ zahl: s.querySelector(".an-stufe__zahl")?.textContent,
+ offen: !!s.querySelector(".an-stufe__falte")?.open,
+ sichtbar: !s.hidden,
+ })),
+ /* Tragen alle Karten den Ton ihrer Kachel? Eine ohne waere grau. */
+ ohneTon: [...document.querySelectorAll(".an-karte")]
+ .filter((k) => !k.dataset.ton)
+ .map((k) => k.querySelector(".an-karte__name")?.textContent),
+ schritte: document.querySelectorAll(".an-schritt").length,
+ saetze: document.querySelectorAll(".an-saetze__zeile").length,
+ nicht: document.querySelectorAll(".an-nicht__zeile").length,
+ takt: document.querySelectorAll(".an-takt__wann").length,
+ fragen: document.querySelectorAll(".an-frage").length,
+ ohne: document.getElementById("ohne-fassung")?.hidden === false
+ ? document.getElementById("ohne-fassung").textContent.slice(0, 70) : null,
+});
+
+async function bild(name, keks, breite, hoehe, vorher) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
+ });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => {
+ if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160));
+ });
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
+
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/anleitung.html`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1000);
+ if (vorher) await vorher(seite);
+ await seite.waitForTimeout(400);
+
+ const blick = await seite.evaluate(nachmessen);
+ await seite.screenshot({ path: `bild-anleitung-${name}.png`, fullPage: true });
+
+ console.log(`\n--- ${name} (${breite}px) ---`);
+ if (blick.ohne) console.log(` Ohne Fassung: ${blick.ohne}…`);
+ console.log(` Karten: ${blick.karten} Schritte: ${blick.schritte} `
+ + `Saetze: ${blick.saetze} Nicht deins: ${blick.nicht} `
+ + `Takt: ${blick.takt} Fragen: ${blick.fragen}`);
+ for (const s of blick.stufen) {
+ console.log(` ${String(s.stufe).padEnd(13)} ${String(s.zahl).padEnd(3)} `
+ + `${s.offen ? "offen" : "zu "} ${s.sichtbar ? "" : "(ausgeblendet)"}`);
+ }
+ console.log(` Karten ohne Farbe: ${blick.ohneTon.length ? blick.ohneTon.join(", ") : "keine"}`);
+ console.log(` Quer schieben: ${blick.schiebtQuer ? "JA — Befund!" : "nein"}`);
+ console.log(` Zu kleine Ziele: ${blick.zuKlein.length ? blick.zuKlein.join(", ") : "keine"}`);
+ if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
+ await kontext.close();
+ return blick;
+}
+
+await bild("creator-handy", keksCreator, 412, 950);
+await bild("creator-rechner", keksCreator, 1440, 1000);
+
+/* Der Filter muss etwas TUN. Auf einem Bild sieht man nur den
+ gedrueckten Knopf -- ob die anderen Kaesten verschwinden, nicht. */
+await bild("creator-nur-muss", keksCreator, 412, 950, async (seite) => {
+ await seite.click('.an-filter__knopf[data-wert="muss"]');
+});
+
+/* DogFather ohne gewaehlte Sicht: Er bekommt keine Fassung, sondern
+ eine Auskunft. Das Bild zeigt, ob die wie eine Erklaerung aussieht
+ und nicht wie ein Fehler. */
+await bild("dogfather-ohne-sicht", keksDogi, 412, 950);
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log("\nBilder geschrieben: bild-anleitung-*.png");
+process.exit(0);
diff --git a/server/pruef-anleitung.mjs b/server/pruef-anleitung.mjs
new file mode 100644
index 00000000..60c1bab4
--- /dev/null
+++ b/server/pruef-anleitung.mjs
@@ -0,0 +1,394 @@
+/* =====================================================================
+ DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1)
+ =====================================================================
+
+ DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie
+ kam beim Nachmessen dazu, und sie ist der Grund, warum es diese
+ Pruefung ueberhaupt gibt:
+
+ Erklaert die Anleitung genau die Kacheln, die es gibt --
+ und zwar JEDE und KEINE DARUEBER HINAUS?
+
+ Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer
+ eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der
+ Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel
+ entfernt, laesst die Karte stehen -- dann erklaert die Einweisung
+ etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere
+ fuehrt.
+
+ Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor
+ ("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand
+ hinsieht. Diese Pruefung sieht immer hin.
+
+ ---------------------------------------------------------------------
+ WOHER DIE KACHELLISTE KOMMT
+
+ Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei
+ AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster
+ durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der
+ Datei; sie wuerde bei der ersten ungewohnten Schreibweise still
+ danebenliegen und dann "alles in Ordnung" melden.
+
+ Der Server kennt diese Liste nicht (bereicheFuer gibt fuers
+ Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich
+ selbst holen -- genau wie der Browser es tut.
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import vm from "node:vm";
+import { eigenerPort } from "./helfer-port.mjs";
+
+const PORT = await eigenerPort(import.meta, "pruef-anleitung");
+const ordner = mkdtempSync(join(tmpdir(), "ws-anl-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(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 }); };
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(180_000, "pruef-anleitung");
+await new Promise((r) => setTimeout(r, 800));
+
+const BASIS = `http://127.0.0.1:${PORT}`;
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+function braucht(bedingung, was) {
+ if (bedingung) return;
+ console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`);
+ console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre");
+ console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n");
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(2);
+}
+
+/* =====================================================================
+ DIE KACHELLISTE — bereiche.js wirklich ausfuehren
+ ===================================================================== */
+function bereicheLaden() {
+ const quelle = readFileSync(
+ new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8");
+ const fenster = {};
+ const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } });
+ const umgebung = { window: fenster, URLSearchParams,
+ document: { createElement: tot, createElementNS: tot } };
+ umgebung.globalThis = umgebung;
+ vm.createContext(umgebung);
+ vm.runInContext(quelle, umgebung, { filename: "bereiche.js" });
+ return fenster.Bereiche;
+}
+
+const B = bereicheLaden();
+braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0,
+ "bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen");
+braucht(typeof B.schluesselVonZiel === "function",
+ "bereiche.js kennt schluesselVonZiel nicht");
+
+melde("\n=== Der Kachel-Schluessel rechnet richtig ===");
+const SCHLUESSEL_PROBEN = [
+ ["uebersicht.html", "uebersicht"],
+ ["bereich.html?b=live", "bereich:live"],
+ ["bereich.html?b=agentur", "bereich:agentur"],
+ ["profil.html", "profil"],
+ ["chat.html?raum=treff", "chat"],
+ ["", ""],
+];
+for (const [ziel, erwartet] of SCHLUESSEL_PROBEN) {
+ const ist = B.schluesselVonZiel(ziel);
+ ok(ist === erwartet, `"${ziel || "(leer)"}" -> "${ist}" (erwartet "${erwartet}")`);
+}
+/* GEGENPROBE: Die Rechnung muss auch TRENNEN koennen. Lieferte sie fuer
+ alles dasselbe, waeren die sechs Zeilen oben trotzdem teils gruen. */
+ok(B.schluesselVonZiel("bereich.html?b=live")
+ !== B.schluesselVonZiel("bereich.html?b=technik"),
+ "Gegenprobe: zwei Bereiche derselben Seite bekommen verschiedene Schluessel");
+
+function kachelnFuer(rolle) {
+ const nach = new Map();
+ for (const g of B.GRUPPEN) {
+ for (const k of (g.bereiche || [])) {
+ if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue;
+ const s = B.schluesselVonZiel(k.ziel);
+ if (s) nach.set(s, k);
+ }
+ }
+ return nach;
+}
+
+/* =====================================================================
+ LEUTE UND ANMELDUNG
+ ===================================================================== */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetztIso = new Date().toISOString();
+const kennSchluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kennSchluessel, jetztIso);
+
+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,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kennSchluessel).update(code).digest("hex"), jetztIso);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+const ROLLEN = [["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"],
+ ["scout", "Kevin"], ["creator", "Luna"], ["modi", "Miss"], ["hand", "VanVan"]];
+const CODES = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ CODES[rolle] = `CODE-A${String(i).padStart(2, "0")}-000001`;
+ anlegen(name, rolle, CODES[rolle]);
+}
+
+/* Wie viele Zeilen hat die Erstbefuellung angelegt? Direkt in der
+ Datenbank nachgezaehlt und nicht ueber die Schnittstelle: Sonst
+ pruefte man die Schnittstelle mit sich selbst. */
+const inDb = d.prepare(
+ "SELECT COUNT(*) n FROM anl_karte WHERE haus = 'agentur' AND rolle = 'creator'").get().n;
+d.close();
+
+const { request } = await import("node:http");
+const CREW_HOST = "crew.dogfather-universe.com";
+
+function roh(pfad, host, koerper, keks) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper ? JSON.stringify(koerper) : null;
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: daten ? "POST" : "GET",
+ headers: {
+ ...(host ? { Host: host } : {}),
+ Origin: host ? `https://${host}` : BASIS,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, host = null) {
+ const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[rolle] });
+ if (r.code !== 200) return null;
+ return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null;
+}
+
+melde("\n=== Alle Rollen kommen herein ===");
+const kekse = {};
+for (const [rolle] of ROLLEN) {
+ const haus = (rolle === "modi" || rolle === "hand") ? CREW_HOST : null;
+ kekse[rolle] = await anmelden(rolle, haus);
+ ok(!!kekse[rolle], `${rolle}: angemeldet`);
+}
+braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden");
+
+const holen = async (rolle, host = null) => {
+ const r = await roh("/workspace/api/anleitung", host, null, kekse[rolle]);
+ let j = null;
+ try { j = JSON.parse(r.text); } catch { /* bleibt null */ }
+ return { code: r.code, j };
+};
+
+/* =====================================================================
+ DIE ERSTBEFUELLUNG
+ ===================================================================== */
+melde("\n=== Die Inhalte sind eingespielt ===");
+ok(inDb === 18, `Creator hat 18 Karten in der Datenbank (${inDb})`);
+
+const creator = await holen("creator");
+ok(creator.code === 200, `Creator bekommt die Anleitung (HTTP ${creator.code})`);
+braucht(creator.code === 200 && creator.j, "die Schnittstelle antwortet dem Creator nicht");
+ok(creator.j.hat_fassung === true, "Creator hat eine eigene Fassung");
+ok(creator.j.anzahl === 18, `18 Karten ueber die Schnittstelle (${creator.j.anzahl})`);
+ok((creator.j.saetze || []).length === 3, `drei Saetze „In 60 Sekunden" (${(creator.j.saetze || []).length})`);
+ok((creator.j.schritte || []).length === 5, `fuenf Erste Schritte (${(creator.j.schritte || []).length})`);
+ok((creator.j.nicht || []).length === 5, `fuenf Zeilen „Nicht deins" (${(creator.j.nicht || []).length})`);
+ok((creator.j.takt || []).length === 4, `vier Rhythmus-Zeilen (${(creator.j.takt || []).length})`);
+ok((creator.j.fragen || []).length === 4, `vier haeufige Fragen (${(creator.j.fragen || []).length})`);
+ok(!!creator.j.leitgedanke && creator.j.leitgedanke.length > 40,
+ "der Leitgedanke steht da");
+
+/* DIE ZAHLEN DER ABNAHMELISTE — GERECHNET, NICHT ABGESCHRIEBEN.
+ Der Bauplan nennt 5/6/7. Diese Zeile prueft nicht gegen die Zahl,
+ sondern gegen die Summe: Zusammen muessen es so viele sein, wie es
+ Karten gibt, und keine Karte darf eine unbekannte Stufe tragen. */
+melde("\n=== Die Stufen ===");
+const jeStufe = {};
+for (const k of creator.j.karten) jeStufe[k.stufe] = (jeStufe[k.stufe] || 0) + 1;
+const bekannt = new Set((creator.j.stufen || []).map((s) => s.schluessel));
+ok([...Object.keys(jeStufe)].every((s) => bekannt.has(s)),
+ `jede Karte traegt eine bekannte Stufe (${Object.keys(jeStufe).join(", ")})`);
+ok(Object.values(jeStufe).reduce((a, b) => a + b, 0) === creator.j.karten.length,
+ "die Stufen zusammen ergeben alle Karten");
+ok(jeStufe.muss === 5 && jeStufe.regelmaessig === 6 && jeStufe.bedarf === 7,
+ `Verteilung 5/6/7 wie im Bauplan (${jeStufe.muss}/${jeStufe.regelmaessig}/${jeStufe.bedarf})`);
+
+/* =====================================================================
+ DIE ZUORDNUNG — IN BEIDE RICHTUNGEN
+ ===================================================================== */
+melde("\n=== Jede Kachel hat eine Karte, jede Karte eine Kachel ===");
+const kacheln = kachelnFuer("creator");
+/* Die Anleitung selbst bekommt keine Karte. Eine Karte, die die Seite
+ erklaert, auf der man gerade steht, ist Fuellmaterial. */
+kacheln.delete("anleitung");
+braucht(kacheln.size > 0, "fuer den Creator findet sich keine einzige Kachel");
+
+const habenKarte = new Set(creator.j.karten.map((k) => k.kachel));
+const ohneKarte = [...kacheln.keys()].filter((s) => !habenKarte.has(s));
+const ohneKachel = [...habenKarte].filter((s) => !kacheln.has(s));
+
+ok(ohneKarte.length === 0 && kacheln.size > 0,
+ ohneKarte.length
+ ? `${ohneKarte.length} Kacheln OHNE Karte: ${ohneKarte.join(", ")}`
+ : `alle ${kacheln.size} Kacheln des Creators haben eine Karte`);
+ok(ohneKachel.length === 0 && habenKarte.size > 0,
+ ohneKachel.length
+ ? `${ohneKachel.length} Karten OHNE Kachel: ${ohneKachel.join(", ")}`
+ : `alle ${habenKarte.size} Karten zeigen auf eine Kachel, die es gibt`);
+
+/* GEGENPROBE. Beide Zeilen oben koennen "in Ordnung" sagen. Koennen sie
+ auch "nicht in Ordnung"? Ohne diesen Beweis waere eine leere Menge
+ auf beiden Seiten gruen -- und genau so sieht ein kaputter Abgleich
+ aus. */
+const erfundeneKachel = new Set([...kacheln.keys(), "gibtesnicht"]);
+ok([...erfundeneKachel].filter((s) => !habenKarte.has(s)).length === 1,
+ "Gegenprobe: eine Kachel ohne Karte wird gefunden");
+const erfundeneKarte = new Set([...habenKarte, "auchnicht"]);
+ok([...erfundeneKarte].filter((s) => !kacheln.has(s)).length === 1,
+ "Gegenprobe: eine Karte ohne Kachel wird gefunden");
+
+/* Jede Karte muss alle drei Saetze haben. Eine halb gefuellte Karte
+ sieht aus wie ein Ladefehler. */
+const luecken = creator.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke);
+ok(luecken.length === 0,
+ luecken.length ? `${luecken.length} Karten ohne vollstaendige drei Zeilen: `
+ + luecken.map((k) => k.kachel).join(", ")
+ : "jede Karte hat Wozu, Was du hier tust und Merke");
+
+/* Die Erste-Schritte-Ziele muessen Kacheln dieser Rolle sein -- oder
+ gar kein Ziel haben ("Installieren" ist ein Knopf, keine Seite).
+ Ein Sprung auf eine Seite, die ein Creator nicht oeffnen darf, waere
+ ein Knopf, der auf die Startseite zurueckwirft. */
+const schrittFehler = (creator.j.schritte || [])
+ .filter((s) => s.ziel)
+ .filter((s) => !kachelnFuer("creator").has(B.schluesselVonZiel(s.ziel)));
+ok(schrittFehler.length === 0,
+ schrittFehler.length
+ ? `Erste Schritte zeigen ins Leere: ${schrittFehler.map((s) => s.ziel).join(", ")}`
+ : "jeder Erste Schritt mit Ziel fuehrt auf eine Kachel dieser Rolle");
+
+/* =====================================================================
+ WER DARF, WER NICHT
+ ===================================================================== */
+melde("\n=== Die drei Schranken ===");
+
+const admin = await holen("admin");
+ok(admin.code === 200 && admin.j?.hat_fassung === false,
+ `DogFather bekommt keine Fassung, sondern eine Auskunft (HTTP ${admin.code}, `
+ + `hat_fassung ${admin.j?.hat_fassung})`);
+ok(!!admin.j?.satz && admin.j.satz.length > 30,
+ "…und die Auskunft sagt ihm, wie er trotzdem hineinsieht");
+ok(admin.j?.darf_pflegen === true, "DogFather darf pflegen");
+
+const spicy = await holen("spicy");
+ok(spicy.code === 200, `Spicy Media bekommt eine Antwort (HTTP ${spicy.code})`);
+ok(spicy.j?.darf_pflegen === true,
+ "Spicy Media darf pflegen (Entscheidung 06.10.2026, wie bei den Manager-Zielen)");
+/* Etappe 1 bringt nur den Creator. Scout, Manager und Spicy haben noch
+ keine Inhalte -- das ist der Stand der Arbeit und kein Fehler. Es
+ wird aber AUSGESPROCHEN, nicht uebersprungen. */
+melde(` -- Scout, Manager und Spicy Media haben noch keine Inhalte `
+ + `(Etappe 3) — Spicy meldet ${spicy.j?.anzahl ?? 0} Karten`);
+
+const creatorPflege = creator.j.darf_pflegen;
+ok(creatorPflege === false, "ein Creator darf NICHT pflegen");
+
+const modi = await holen("modi", CREW_HOST);
+ok(modi.code === 404,
+ `ein Modi bekommt die Anleitung nicht (HTTP ${modi.code}, erwartet 404)`);
+const hand = await holen("hand", CREW_HOST);
+ok(hand.code === 404,
+ `die rechte Hand bekommt sie auf der Crew-Adresse nicht (HTTP ${hand.code}, erwartet 404)`);
+
+/* Die Seite selbst, nicht nur die Schnittstelle.
+ =====================================================================
+ GEPRUEFT WIRD DAS ZIEL, NICHT NUR DIE 302.
+
+ Das Haus beantwortet "darfst du nicht" mit einer Umleitung und nicht
+ mit 403 (workspace.js, bei `darfSeite`) -- ausdruecklich, damit es
+ auf "nein" nur EINE Antwort gibt und niemand aus dem Unterschied
+ etwas ablesen kann.
+
+ Genau deshalb reicht hier "Status ist 302" nicht: NICHT ANGEMELDET
+ gibt ebenfalls 302. Eine Pruefung auf die blosse Zahl waere auch
+ dann gruen, wenn die Sitzung des Modi gar nicht erkannt wuerde --
+ sie haette dann die Anmeldung gemessen und nicht die Schranke.
+ Unterscheidbar sind die beiden nur am Ziel:
+
+ nicht angemeldet -> /workspace/
+ angemeldet, aber gesperrt -> /workspace/start.html
+ ===================================================================== */
+const seiteCreator = await roh("/workspace/anleitung.html", null, null, kekse.creator);
+ok(seiteCreator.code === 200, `anleitung.html oeffnet fuer den Creator (${seiteCreator.code})`);
+
+const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi);
+ok(seiteModi.code === 302 && seiteModi.kopf.location === "/workspace/start.html",
+ `ein angemeldeter Modi wird zur Startseite umgeleitet `
+ + `(${seiteModi.code} -> ${seiteModi.kopf.location})`);
+
+const seiteOhne = await roh("/workspace/anleitung.html", null, null, null);
+ok(seiteOhne.code === 302 && seiteOhne.kopf.location === "/workspace/",
+ `ohne Anmeldung zur Anmeldung, nicht zur Startseite `
+ + `(${seiteOhne.code} -> ${seiteOhne.kopf.location})`);
+
+/* GEGENPROBE ZU DEN BEIDEN: Sie unterscheiden wirklich. Zeigten beide
+ Umleitungen auf dasselbe, waeren die Zeilen oben zusammen trotzdem
+ gruen zu bekommen -- und die Schranke waere ungeprueft. */
+ok(seiteModi.kopf.location !== seiteOhne.kopf.location,
+ "Gegenprobe: gesperrt und nicht angemeldet fuehren an verschiedene Orte");
+
+/* =====================================================================
+ DIE ERSTBEFUELLUNG DARF NICHTS UEBERSCHREIBEN
+ =====================================================================
+ Die wichtigste Zeile in anleitung-tabellen.js: Eingespielt wird nur,
+ wenn fuer eine Rolle noch gar nichts da ist. Liefe sie bei jedem
+ Start, waere jede Aenderung aus der Pflege-Ansicht beim naechsten
+ Neustart weg -- lautlos, und man merkte es Wochen spaeter. */
+melde("\n=== Ein zweiter Lauf laesst Aenderungen in Ruhe ===");
+const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
+d2.prepare("UPDATE anl_karte SET merke = ? WHERE haus='agentur' AND rolle='creator' AND kachel='chat'")
+ .run("VON HAND GEAENDERT");
+const { anleitungTabellen } = await import("./anleitung-tabellen.js");
+anleitungTabellen(d2);
+const nachher = d2.prepare(
+ "SELECT merke FROM anl_karte WHERE haus='agentur' AND rolle='creator' AND kachel='chat'").get();
+const anzahlNachher = d2.prepare(
+ "SELECT COUNT(*) n FROM anl_karte WHERE haus='agentur' AND rolle='creator'").get().n;
+d2.close();
+ok(nachher?.merke === "VON HAND GEAENDERT",
+ `der geaenderte Text steht noch da ("${(nachher?.merke || "").slice(0, 24)}")`);
+ok(anzahlNachher === 18, `und es sind keine Karten dazugekommen (${anzahlNachher})`);
+
+/* ---------- Abschluss ------------------------------------------------ */
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-rollen.mjs b/server/pruef-rollen.mjs
index cfe0743f..9b63b9b6 100644
--- a/server/pruef-rollen.mjs
+++ b/server/pruef-rollen.mjs
@@ -178,11 +178,30 @@ d.close();
/* ---------- Der Rundgang ------------------------------------------------ */
+/* ZWEI SEITEN FEHLTEN HIER (06.10.2026).
+
+ `manager-ziele.html` (seit 02.10.) und `anleitung.html` (seit heute)
+ standen nicht in dieser Liste. Ganz ungeprueft waren sie nicht -- der
+ Kachel-Durchgang weiter unten oeffnet jede Kachel jeder Rolle und
+ haette gemerkt, wenn eine ins Leere fuehrt. Er prueft aber nur, WO
+ man landet. Die eigentliche Arbeit dieser Liste -- Fehler in der
+ Browserkonsole, 4xx-Antworten vom Server, tote Verweise, leere
+ Seiten, waagerechtes Ueberstehen -- lief fuer beide nie.
+
+ Das ist dieselbe Sorte Luecke wie am 28.08.2026 bei RunOne: Der Lauf
+ war gruen, und acht Pruefungen je Geraet fielen still weg. Hier
+ waren es zwei Seiten mal acht Rollen.
+
+ Eine abgeschriebene Liste bleibt es trotzdem, und sie wird wieder
+ veralten. Richtig waere, sie aus `rechte.js` abzuleiten -- dort
+ steht jede Seite des Hauses genau einmal. Das ist ein eigener
+ Umbau; bis dahin gilt: Wer eine Seite anlegt, traegt sie hier ein. */
const SEITEN = [
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
"content.html", "bereich.html?b=live", "dateien.html", "personen.html",
"scouting.html", "steckbrief.html", "profil.html", "report.html",
"wissen.html", "startcheck.html", "automation.html",
+ "manager-ziele.html", "anleitung.html",
];
/* Fünf Durchgänge, nicht vier.
diff --git a/server/rechte.js b/server/rechte.js
index ca9f3240..aa94c836 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -203,6 +203,32 @@ export const SEITEN = {
ihre Daten offen zu lassen waere dieselbe Luecke wie am
11.09.2026 bei content.html, nur andersherum. */
"/workspace/manager-ziele.html": ["spicy", "admin", "manager", "scout"],
+ /* ====================================================================
+ DIE ANLEITUNG -- MIT 'admin', OBWOHL DOGFATHER KEINE KACHEL HAT
+ (06.10.2026)
+
+ Das sieht nach einem Widerspruch zu bereiche.js aus, wo 'admin'
+ bei der Kachel fehlt, und ist keiner. Zwei verschiedene Fragen:
+
+ bereiche.js -- WAS WIRD ANGEBOTEN. DogFather bekommt keine
+ Einweisung (so der Bauplan), also keine Kachel.
+ hier -- WER DARF HEREIN, wenn er die Adresse kennt.
+
+ Er muss herein duerfen: Ab Etappe 4 oeffnet dieselbe Adresse fuer
+ ihn die Pflege-Ansicht, und schon vorher muss er nachsehen koennen,
+ was seine Leute zu lesen bekommen -- sonst pflegt er blind.
+
+ DIE CREW BLEIBT DRAUSSEN. 'hand', 'modi', 'linke' und 'gast' stehen
+ nicht in dieser Zeile und bekommen damit 403. Die Anleitung ist
+ eine Sache des Agenturhauses; Team Dogi bekommt spaeter eine
+ eigene oder keine (Entscheidung 06.10.2026: erst einmal keine,
+ aber die Daten sind haus-bewusst angelegt).
+
+ UND SIE IST NICHT DIE EINZIGE SCHRANKE: workspace-anleitung.js
+ prueft Haus und Rolle noch einmal selbst, und die Hinweise tun es
+ ein drittes Mal. Am 15.09.2026 ist genau dort ein Weg fuer die
+ Community durchgerutscht, der in dieser Tafel sauber gesperrt war. */
+ "/workspace/anleitung.html": ["spicy", "admin", "manager", "scout", "creator"],
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
jeher ("Manager -- dieselben Rechte, ausser der
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
diff --git a/server/workspace-anleitung.js b/server/workspace-anleitung.js
new file mode 100644
index 00000000..07f38800
--- /dev/null
+++ b/server/workspace-anleitung.js
@@ -0,0 +1,133 @@
+/* =====================================================================
+ workspace-anleitung.js — die Einweisung je Rolle (Etappe 1)
+
+ Bauplan: „Kachel Anleitung", Stand 06.10.2026, geprueft in
+ `02 Projekte/Anleitung-Kachel – Bauplan.md`.
+
+ ---------------------------------------------------------------------
+ WAS DIESE SCHNITTSTELLE LIEFERT UND WAS NICHT
+
+ Sie liefert die TEXTE und die STUFE je Kachel. Sie liefert NICHT den
+ Namen, das Symbol, die Farbe oder die Adresse einer Kachel — die
+ stehen in `workspace/assets/js/bereiche.js`, und nur dort. Die Seite
+ verbindet beides ueber den Kachel-Schluessel
+ (`window.Bereiche.schluesselVonZiel`).
+
+ Das ist Absicht und der Grund, warum diese Datei so kurz ist: Der
+ Server kennt die Kacheln des Agenturhauses gar nicht. `bereicheFuer`
+ gibt dort `null` zurueck — „nimm die Liste aus dem Browser". Haette
+ ich sie hier noch einmal hingeschrieben, um „alles an einer Stelle"
+ zu haben, waere genau das die zweite Liste, die beim naechsten Umbau
+ veraltet und dann eine Kachel erklaert, die es nicht mehr gibt.
+
+ ---------------------------------------------------------------------
+ DREI SCHRANKEN, NICHT EINE
+
+ 1. `rechte.js` sperrt die Seite fuer die Crew.
+ 2. Diese Datei prueft Haus und Rolle noch einmal selbst.
+ 3. Die Hinweise (ab Etappe 2) pruefen es ein drittes Mal.
+
+ Am 15.09.2026 ist ein Weg fuer die Community genau durch Luecke 3
+ gerutscht, obwohl Tafel und Seite sauber gesperrt waren. Die
+ Hinweise sind eine eigene Tuer.
+ ===================================================================== */
+
+import express from "express";
+import { db, sitzungLesen } from "./workspace.js";
+import {
+ STUFEN, FASSUNGEN, PFLEGE_ROLLEN,
+} from "./anleitung-tabellen.js";
+
+export const anleitungRouter = express.Router();
+
+const HAUS = "agentur";
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ req.person = person;
+ next();
+}
+
+anleitungRouter.use("/workspace/api/anleitung", angemeldet);
+
+/** Wessen Fassung wird gezeigt?
+ *
+ * `req.sicht` ist der HAUSEIGENE Umschalter: DogFather kann die Sicht
+ * eines anderen einnehmen, und dann zeigt jede Seite das, was dieser
+ * andere sieht. Hier gilt dasselbe — und genau deshalb braucht die
+ * Anleitung keine eigene „Vorschau": Die gibt es schon, sie heisst
+ * „Meine Sicht" und steht in der Kopfleiste.
+ *
+ * Ohne gewaehlte Sicht ist DogFather `admin`, und fuer `admin` gibt es
+ * keine Fassung (Bauplan, Kapitel 11). Dann sagt die Seite das, statt
+ * ihm irgendeine fremde Rolle unterzuschieben. */
+function fassungFuer(req) {
+ const person = req.sicht || req.person;
+ return { person, rolle: String(person?.rolle || "") };
+}
+
+anleitungRouter.get("/workspace/api/anleitung", (req, res) => {
+ try {
+ const { person, rolle } = fassungFuer(req);
+
+ /* DIE CREW HAT HIER NICHTS ZU SUCHEN. 404 und nicht 403: Auf der
+ Crew-Adresse gibt es diese Seite nicht, und „gibt es nicht" ist
+ die richtige Auskunft — 403 waere der Hinweis, dass es sie
+ woanders doch gibt. */
+ if (person?.haus === "crew") return res.status(404).json({ fehler: "nicht_hier" });
+
+ const d = db();
+ const darfPflegen = PFLEGE_ROLLEN.includes(String(req.person?.rolle || ""));
+
+ if (!FASSUNGEN.includes(rolle)) {
+ /* DogFather ohne gewaehlte Sicht. Kein Fehler, sondern eine
+ Auskunft: Er bekommt keine Einweisung, kann aber jede ansehen.
+ Die Rollen stehen MIT Namen dabei, damit die Seite keine
+ zweite Rollenliste braucht. */
+ return res.json({
+ rolle, hat_fassung: false, darf_pflegen: darfPflegen,
+ fassungen: FASSUNGEN,
+ satz: "Für dich gibt es keine Einweisung – du kennst den Laden. "
+ + "Wähle oben unter „Meine Sicht“ eine Rolle, dann siehst du genau das, "
+ + "was diese Person zu lesen bekommt.",
+ });
+ }
+
+ const kopf = d.prepare(
+ "SELECT leitgedanke FROM anl_rolle WHERE haus = ? AND rolle = ?").get(HAUS, rolle);
+
+ const karten = d.prepare(
+ `SELECT kachel, stufe, wozu, tust, merke, reihe, geaendert
+ FROM anl_karte WHERE haus = ? AND rolle = ?
+ ORDER BY reihe, id`).all(HAUS, rolle);
+
+ const zeilen = d.prepare(
+ `SELECT art, titel, text, ziel, reihe
+ FROM anl_zeile WHERE haus = ? AND rolle = ?
+ ORDER BY art, reihe, id`).all(HAUS, rolle);
+
+ const nachArt = (art) => zeilen.filter((z) => z.art === art);
+
+ /* DIE ZAHL GEHOERT IN DIE ANTWORT, nicht nur in die Liste. Die
+ Seite kann damit sagen „18 Karten" — und eine Pruefung kann
+ nachrechnen, ohne die Liste selbst zu zaehlen. */
+ res.json({
+ rolle,
+ hat_fassung: true,
+ darf_pflegen: darfPflegen,
+ leitgedanke: kopf?.leitgedanke || "",
+ stufen: STUFEN,
+ saetze: nachArt("satz").map((z) => z.text),
+ schritte: nachArt("schritt").map((z) => ({ titel: z.titel, text: z.text, ziel: z.ziel })),
+ karten,
+ nicht: nachArt("nicht").map((z) => ({ titel: z.titel, text: z.text })),
+ takt: nachArt("takt").map((z) => ({ titel: z.titel, text: z.text })),
+ fragen: nachArt("frage").map((z) => ({ titel: z.titel, text: z.text })),
+ anzahl: karten.length,
+ });
+ } catch (fehler) {
+ console.error("[workspace] Anleitung:", fehler?.message);
+ res.status(500).json({ fehler: "serverfehler" });
+ }
+});
diff --git a/server/workspace.js b/server/workspace.js
index 2ff3aef2..171ca6e2 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -36,6 +36,7 @@ import { hilfeTabellen } from "./hilfe-tabellen.js";
import { supportTabellen } from "./support-tabellen.js";
import { notizTabellen } from "./notiz-tabellen.js";
import { managerZieleTabellen } from "./manager-ziele-tabellen.js";
+import { anleitungTabellen } from "./anleitung-tabellen.js";
import { reaktionTabellen } from "./reaktion-tabellen.js";
import { spendenTabellen } from "./spenden-tabellen.js";
import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js";
@@ -6448,6 +6449,11 @@ export function db() {
muss diese Zeile danach kommen. Stünde sie davor, legte SQLite
die Fremdschlüssel auf eine Tabelle an, die es noch nicht gibt. */
managerZieleTabellen(d);
+ /* DIE ANLEITUNG nach den Personen -- sie verweist mit
+ `geaendert_von` darauf. Dieselbe Begruendung wie eine Zeile
+ hoeher: Stuende sie davor, legte SQLite den Fremdschluessel
+ auf eine Tabelle, die es noch nicht gibt. */
+ anleitungTabellen(d);
reaktionTabellen(d);
spendenTabellen(d);
/* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen
diff --git a/workspace/anleitung.html b/workspace/anleitung.html
new file mode 100644
index 00000000..d9293022
--- /dev/null
+++ b/workspace/anleitung.html
@@ -0,0 +1,153 @@
+
+
+