From a7b2ad77b882613908df585c063d4fdbc664c5a4 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Tue, 6 Oct 2026 13:26:43 +0200 Subject: [PATCH] Anleitung, Etappe 1: die Einweisung fuer Creator steht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erste von sieben Etappen aus dem geprueften Bauplan (`02 Projekte/Anleitung-Kachel – Bauplan.md`). Ein Creator oeffnet die neue Kachel und sieht seine vollstaendige Einweisung: drei Saetze „In 60 Sekunden", fuenf Erste Schritte, achtzehn Karten in drei Stufen, „Das kannst du liegen lassen", sein Rhythmus und vier Fragen. WAS NICHT IN DER DATENBANK STEHT -- und das ist der Kern. Eine Karte zeigt Name, Symbol, Farbe und Adresse ihrer echten Kachel. Nichts davon ist gespeichert. Es steht in bereiche.js, genau einmal, und wird ueber einen abgeleiteten Schluessel verbunden (`bereich.html?b=live` -> `bereich:live`). Haette ich den Namen danebengeschrieben, erklaerte die Anleitung nach der ersten Umbenennung eine Kachel, die es so nicht mehr gibt -- und niemandem fiele es auf. Gespeichert ist nur, was der Bauplan NEU bringt: die Stufe und die drei Saetze Wozu / Was du hier tust / Merke. Der Schluessel muss abgeleitet sein, weil zwei Faelle sonst zusammenfallen: `bereich.html` ist FUENF Kacheln, und `profil.html` heisst beim Creator „Mein Profil" und beim Manager „Creator-Profile". DIE WICHTIGSTE PRUEFUNG STEHT NICHT IM BAUPLAN. pruef-anleitung.mjs fragt in BEIDE Richtungen: Hat jede Kachel, die diese Rolle sieht, genau eine Karte -- und zeigt jede Karte auf eine Kachel, die es wirklich gibt? Beides kann lautlos kaputtgehen, und der Bauplan sah dafuer nur einen Hinweis in der Pflege-Ansicht vor. Der hilft nur, wenn jemand hinsieht. Dafuer wird bereiche.js in der Pruefung AUSGEFUEHRT (node:vm mit einem winzigen Browser-Ersatz), nicht mit einem Muster durchsucht. Ein Muster waere eine ungenaue Nachbildung und wuerde bei der ersten ungewohnten Schreibweise „alles in Ordnung" melden. DREI BEFUNDE BEIM BAUEN, ALLE GEMESSEN STATT GEAHNT: 1. Ton 48. Das Hauswerkzeug schlug #b0ec0a vor -- Abstand 0,0815 zur Kachel „Agentur", die ein Creator direkt daneben sieht, und 13,28:1 Kontrast. Der Grund: Es rechnet gegen alle 47 Toene, aber die Haelfte gehoert zum anderen Haus und erscheint nie auf demselben Bildschirm. Gegen die 24 Toene DIESES Hauses gerechnet: #ff7eed, Abstand 0,1168 (43 % mehr) bei sanfteren 8,46:1. Die Herleitung steht in start.css, samt Warnung, dass ein erneuter Werkzeuglauf es verschlechtern wuerde. 2. Die Erste-Schritte-Verweise waren 20 px hoch. Gemessen bei 412 px -- die halbe Hausgroesse fuer einen Daumen, auf der Seite, die fuer Leute gebaut ist, die zum ersten Mal hier sind und auf dem Handy sitzen. Jetzt ist die ganze Zeile das Ziel, 44 px. 3. Die Textbloecke standen ohne Flaeche auf der Seite. Auf dem ersten Bild lief „Fast alles andere fuellt deine Betreuung fuer dich" quer ueber das helle Wasserzeichen des Hintergrundbildes. Deshalb hat im Haus jeder Textträger `--flaeche`: Ein Kontrast, der vom Bildausschnitt abhaengt, ist keiner. NEBENBEI ZWEI LUECKEN GESCHLOSSEN: `manager-ziele.html` und `anleitung.html` fehlten in der Seitenliste von pruef-rollen. Ganz ungeprueft waren sie nicht -- der Kachel-Durchgang oeffnet jede Kachel --, aber er prueft nur, WO man landet. Konsolenfehler, 4xx-Antworten, tote Verweise und Ueberstehen liefen fuer beide nie. Dieselbe Sorte stiller Luecke wie am 28.08.2026 bei RunOne. ENTSCHEIDUNGEN VON FILIPE (06.10.2026): Pflege durch DogFather UND Spicy (nicht nur DogFather wie im Bauplan) -- dieselbe Regel wie bei den Manager-Zielen am 02.10. Vorerst nur das Agenturhaus, die Daten aber haus-bewusst angelegt. DogFather bekommt keine Kachel (er braucht keine Einweisung), darf die Seite aber oeffnen: Ueber den vorhandenen Sicht-Umschalter sieht er jede Fassung. Eine zweite Rollenauswahl auf der Seite waere dieselbe Funktion an einer Stelle, an der sie niemand sucht. Gemessen pruef-anleitung 47 Pruefungen, 0 Fehler (neu) pruef-rollen 401 Pruefungen, 0 Fehler (vorher 384) pruef-rechtetafel 19 Pruefungen, 0 Fehler pruef-css-klassen alles in Ordnung, jetzt 40 Seiten, 42 Stellen unter 11,5 px (unveraendert) mess-anleitung kein Querschieben bei 412 und 1440 px, keine Konsolenfehler, alle 18 Karten mit Kachelfarbe Sicherung vor dem Ausliefern: ~/sicherungen/workspace-vor-anleitung-20261006-1325.db (integrity ok) Co-Authored-By: Claude Opus 5 --- server/anleitung-tabellen.js | 376 +++++++++++++++++++++++ server/index.js | 2 + server/mess-anleitung.mjs | 190 ++++++++++++ server/pruef-anleitung.mjs | 394 ++++++++++++++++++++++++ server/pruef-rollen.mjs | 19 ++ server/rechte.js | 26 ++ server/workspace-anleitung.js | 133 ++++++++ server/workspace.js | 6 + workspace/anleitung.html | 153 ++++++++++ workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/anleitung.css | 474 +++++++++++++++++++++++++++++ workspace/assets/css/start.css | 46 +++ workspace/assets/js/anleitung.js | 281 +++++++++++++++++ workspace/assets/js/bereiche.js | 80 +++++ workspace/aufgaben.html | 38 +-- workspace/automation.html | 32 +- workspace/befinden.html | 34 +-- workspace/bereich.html | 44 +-- workspace/bewerben.html | 38 +-- workspace/bewerbungen.html | 36 +-- workspace/buehne.html | 6 +- workspace/calls.html | 34 +-- workspace/chat.html | 40 +-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 36 +-- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +- workspace/manager-ziele.html | 28 +- workspace/material.html | 32 +- workspace/notizen.html | 28 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +-- workspace/reaktion.html | 38 +-- workspace/rechte.html | 34 +-- workspace/report.html | 36 +-- workspace/scouting.html | 32 +- workspace/start.html | 32 +- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 +-- workspace/support.html | 28 +- workspace/tafel.html | 6 +- workspace/talente.html | 36 +-- workspace/team.html | 32 +- workspace/teamlage.html | 34 +-- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 32 +- workspace/werdegang.html | 32 +- workspace/willkommen.html | 30 +- workspace/wissen.html | 34 +-- 59 files changed, 2864 insertions(+), 684 deletions(-) create mode 100644 server/anleitung-tabellen.js create mode 100644 server/mess-anleitung.mjs create mode 100644 server/pruef-anleitung.mjs create mode 100644 server/workspace-anleitung.js create mode 100644 workspace/anleitung.html create mode 100644 workspace/assets/css/anleitung.css create mode 100644 workspace/assets/js/anleitung.js 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 @@ + + + + + +Anleitung · Creator Workspace + + + + + + + + + + + + + + + + + +
+ +

Creator Workspace · Anleitung

+
+ … + +
+
+ +
+ +
+
+

Einweisung

+

Anleitung

+

+ Was du brauchst und was nicht. Fang oben an – der Rest ist + zugeklappt, bis du ihn brauchst. +

+
+
+ + + + + + + + +
+ + + + + + + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index ab291add..6eb64f00 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +