diff --git a/tools/community-blick.mjs b/tools/community-blick.mjs
new file mode 100644
index 00000000..bf3e36a1
--- /dev/null
+++ b/tools/community-blick.mjs
@@ -0,0 +1,198 @@
+/* =====================================================================
+ DIE SIEBEN COMMUNITY-BRETTER MIT DEN AUGEN EINES MITGLIEDS
+
+ Aufruf: node tools/community-blick.mjs
+ BREITE=412 node tools/community-blick.mjs (Handy)
+
+ WARUM ALS GAST UND NICHT ALS ADMIN. Als DogFather sieht man auf
+ jedem Brett Knoepfe, Vorschlaege und Felder, die ein Mitglied nie zu
+ Gesicht bekommt. Wer die Bretter so beurteilt, beurteilt eine Seite,
+ die es fuer die Community nicht gibt -- und uebersieht genau das,
+ was ihr fehlt.
+
+ Der Startkatalog wird vorher vom Team uebernommen, damit die Bretter
+ nicht leer sind. Leere Bretter zu fotografieren beantwortet nur die
+ Frage, ob sie leer sind.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawn } from "node:child_process";
+import { DatabaseSync } from "node:sqlite";
+import { scryptSync, randomBytes } from "node:crypto";
+import { request as anfrage } from "node:http";
+
+const HIER = join(dirname(fileURLToPath(import.meta.url)), "..", "server");
+const PORT = Number(process.env.PORT_BLICK || 4364);
+const BASIS = `http://127.0.0.1:${PORT}`;
+const ordner = mkdtempSync(join(tmpdir(), "ws-comm-"));
+const DB = join(ordner, "workspace.db");
+const BRETTER = ["treff", "anschlag", "ansteht", "wunsch", "highlight", "regeln", "mitmachen"];
+let kind = null;
+
+async function start() {
+ kind = spawn(process.execPath, [join(HIER, "index.js")], {
+ env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
+ SITE_ACCESS_SECRET: "lokaler-test",
+ SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
+ stdio: ["ignore", "pipe", "pipe"] });
+ let aus = "";
+ kind.stdout.on("data", (d) => { aus += d; });
+ kind.stderr.on("data", (d) => { aus += d; });
+ for (let i = 0; i < 80; i++) {
+ await new Promise((r) => setTimeout(r, 250));
+ if (kind.exitCode !== null) break;
+ try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
+ if (a.status < 500) return; } catch { /* noch nicht oben */ }
+ }
+ throw new Error("Server startete nicht:\n" + aus.slice(-1500));
+}
+
+function personAnlegen(name, rolle, code) {
+ const d = new DatabaseSync(DB);
+ const salz = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salz, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salz, 32768, new Date().toISOString());
+ d.close();
+}
+
+await start();
+kind.kill();
+await new Promise((r) => setTimeout(r, 600));
+personAnlegen("Filipe", "admin", "CODE-DOGI-0001");
+personAnlegen("Mara", "gast", "CODE-GAST-0001");
+await start();
+
+/** Meldet an -- mit Host-Kopfzeile, weil die Rolle davon abhaengt.
+ *
+ * „gast" gibt es nur im Haus der Community. Der Host entscheidet
+ * darueber, und ein `fetch` auf 127.0.0.1 schickt eben 127.0.0.1.
+ * Deshalb hier ueber node:http, wo sich der Kopf setzen laesst.
+ *
+ * ALTERSBESTAETIGUNG: Wer sich zum ersten Mal in der Community
+ * anmeldet, muss bestaetigen, dass er 18 ist -- sonst antwortet der
+ * Server mit 400 „alter_offen". */
+function rohAnfrage(pfad, host, koerper) {
+ return new Promise((f, s2) => {
+ const a = anfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: "POST",
+ headers: { Host: host, "Content-Type": "application/json" },
+ }, (an) => {
+ let t = "";
+ an.on("data", (c) => { t += c; });
+ an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
+ });
+ a.on("error", s2);
+ a.write(JSON.stringify(koerper));
+ a.end();
+ });
+}
+
+async function anmelden(rolle, code, host = "127.0.0.1") {
+ let a = await rohAnfrage("/workspace/api/anmelden", host, { rolle, code });
+ if (a.code === 400 && a.text.includes("alter_offen")) {
+ a = await rohAnfrage("/workspace/api/anmelden", host,
+ { rolle, code, alter_ok: true });
+ }
+ if (a.code !== 200) {
+ throw new Error(`Anmeldung ${rolle}@${host} -> ${a.code} ${a.text.slice(0, 120)}`);
+ }
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+/* ---- Das Team uebernimmt die Vorschlaege, damit nichts leer ist ---- */
+const kAdmin = await anmelden("admin", "CODE-DOGI-0001");
+console.log("=== Startkatalog uebernehmen ===");
+for (const b of BRETTER) {
+ const a = await fetch(`${BASIS}/workspace/api/bereich/${b}/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", cookie: kAdmin, origin: BASIS },
+ body: JSON.stringify({}),
+ });
+ const r = await a.json().catch(() => ({}));
+ console.log(` ${b.padEnd(11)} ${a.status} ${r.angelegt ?? r.anzahl ?? ""}`);
+}
+
+/* ---- Und jetzt als Mitglied hinsehen ------------------------------ */
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+/* DIE ECHTE ADRESSE, nicht 127.0.0.1.
+ Der Server entscheidet ueber den Host-Kopf, ob er im Haus der
+ Community steht („crew.dogfather-universe.com") oder in dem der
+ Agentur. Auf 127.0.0.1 ist er in der Agentur -- und dort gibt es die
+ Rolle „gast" nicht. Der erste Versuch hat deshalb sieben Mal die
+ ANMELDESEITE fotografiert, mit „0 Karten" und ohne Schreibfeld, und
+ das sah aus wie ein Befund ueber die Bretter.
+ Chromium loest den Namen auf Wunsch selbst auf. */
+const browser = await pw.chromium.launch();
+const BREITE = Number(process.env.BREITE || 1500);
+const ctx = await browser.newContext({
+ viewport: { width: BREITE, height: BREITE < 700 ? 915 : 1100 },
+ deviceScaleFactor: 1, isMobile: BREITE < 700, hasTouch: BREITE < 700 });
+const seite = await ctx.newPage();
+const fehlerAufSeite = [];
+seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); });
+/* Welche Dateien kommen NICHT an? Ohne diese Zeile sieht man nur eine
+ nackte Seite und raet, woran es liegt. */
+const nichtGeladen = [];
+seite.on("requestfailed", (r) => nichtGeladen.push(`${r.url().slice(-60)} (${r.failure()?.errorText})`));
+seite.on("response", (r) => { if (r.status() >= 400) nichtGeladen.push(`${r.url().slice(-60)} -> ${r.status()}`); });
+
+/* DIE SITZUNG EINES GASTES IN DEN BROWSER LEGEN.
+
+ Direkt im Browser anzumelden ginge nur ueber die echte Adresse --
+ und dort schickt der Server `Strict-Transport-Security`, woraufhin
+ Chromium alle Unterdateien auf https umbiegt. Der Testserver spricht
+ aber nur http: Ergebnis war eine nackte Seite ohne Stil und ohne
+ Skripte, auf der kein Knopf mehr etwas tat. Die Kopfzeile ist
+ richtig so und bleibt.
+
+ Also: anmelden, wo es geht (ueber die Kopfzeile), und das
+ Sitzungsplaetzchen in den Browser legen. Gemessen wird danach das,
+ was ein Mitglied wirklich sieht. */
+const kGast = await anmelden("gast", "CODE-GAST-0001", "crew.dogfather-universe.com");
+await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
+ const [name, ...rest] = z.split("=");
+ return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
+}));
+
+console.log("");
+console.log("=== Was ein Mitglied sieht ===");
+for (const b of BRETTER) {
+ await seite.goto(`${BASIS}/workspace/bereich.html?b=${b}`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1500);
+ const z = await seite.evaluate(() => ({
+ titel: document.querySelector("h1")?.textContent?.trim() || "",
+ karten: document.querySelectorAll('.eintrag-karte').length,
+ arten: [...new Set([...document.querySelectorAll('.eintrag-art, .art')].map((e) => e.textContent.trim()))].length,
+ knoepfeJeKarte: document.querySelector('.eintrag-karte')
+ ? document.querySelector('.eintrag-karte').querySelectorAll('button, a.schritt').length : 0,
+ schreibfeld: !!document.querySelector('#neu-form, .neu-form, form#neu'),
+ hoeheJeKarte: document.querySelector('.eintrag-karte')
+ ? Math.round(document.querySelector('.eintrag-karte').getBoundingClientRect().height) : 0,
+ startkatalog: !!document.querySelector("#startkatalog")?.children?.length,
+ leerText: document.querySelector(".leer, .leer-hinweis, .hinweis")?.textContent?.trim().slice(0, 80) || "",
+ hoehe: document.body.scrollHeight,
+ /* Sichtbare Sternchenpaare -- gemeint war Fettschrift. */
+ sternchen: (document.body.innerText.match(/\*\*/g) || []).length,
+ }));
+ console.log(`${b.padEnd(11)} „${z.titel}" · ${z.karten} Karten`
+ + ` · Schreibfeld: ${z.schreibfeld ? "ja" : "NEIN"}`
+ + ` · Arten: ${z.arten} · Karte ${z.hoeheJeKarte}px/${z.knoepfeJeKarte} Knöpfe`
+ + (z.sternchen ? ` · STERNCHEN: ${z.sternchen}` : "")
+ + (z.leerText ? ` · „${z.leerText}"` : ""));
+ const pfad = `C:/Users/qciga/AppData/Local/Temp/comm-${b}${BREITE < 700 ? "-handy" : ""}.png`;
+ await seite.screenshot({ path: pfad, fullPage: true });
+}
+
+console.log("");
+console.log(fehlerAufSeite.length
+ ? `ACHTUNG: ${fehlerAufSeite.length} Konsolenfehler: ${fehlerAufSeite.slice(0, 3).join(" | ")}`
+ : "Keine Konsolenfehler.");
+
+await browser.close();
+kind.kill();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest
index 1300784c..60a37fb8 100644
--- a/workspace/app.webmanifest
+++ b/workspace/app.webmanifest
@@ -22,25 +22,25 @@
},
"icons": [
{
- "src": "/assets/img/app-symbole/workspace-192.png?v=202609181708",
+ "src": "/assets/img/app-symbole/workspace-192.png?v=202609181833",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-512.png?v=202609181708",
+ "src": "/assets/img/app-symbole/workspace-512.png?v=202609181833",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181708",
+ "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181833",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
- "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181708",
+ "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181833",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js
index 37acf590..e7b7e503 100644
--- a/workspace/assets/js/bereich.js
+++ b/workspace/assets/js/bereich.js
@@ -544,7 +544,11 @@
k.append(zeile);
}
- if (e.text) k.append(el('p', 'eintrag-text', e.text));
+ if (e.text) {
+ const t = el('p', 'eintrag-text');
+ t.append(mitFett(e.text));
+ k.append(t);
+ }
if (stand) {
for (const [feld, name] of [['event_aufgaben', 'Aufgaben'], ['event_regeln', 'Regeln']]) {
@@ -1045,6 +1049,46 @@
} catch { /* dann gilt die alte Regel, siehe darfEintragen */ }
}
+ /* ---- FETTSCHRIFT IM TEXT (18.09.2026) --------------------------
+
+ Im Startkatalog steht an zehn Stellen `**Hinweis**`, `**Pause**`,
+ `**DogFather**` -- gemeint als Hervorhebung, angezeigt als
+ Sternchen. Auf „Regeln & Hilfe" stand damit woertlich:
+
+ Drei Stufen: **Hinweis** (jemand aus dem Team sagt dir, was
+ nicht ging), **Pause** ..., **Ausschluss**.
+
+ Das sind ausgerechnet die drei Stufen, um die es geht -- und sie
+ sahen aus wie ein Tippfehler.
+
+ KEIN innerHTML, AUCH NICHT HIER. Der Text kommt aus der Datenbank,
+ und dort schreiben Menschen hinein. Gebaut wird deshalb aus
+ einzelnen Knoten: Was nicht zwischen zwei Sternchenpaaren steht,
+ wird Text und kann nichts anderes werden. Ein ``
+ bleibt ein Wort.
+
+ Ein einzelnes Sternchen bleibt ein Sternchen: `3 * 4` ist keine
+ Hervorhebung, und wer das anders faende, haette eine Regel, die
+ beim Rechnen zuschlaegt. */
+ function mitFett(text) {
+ const stueck = document.createDocumentFragment();
+ const teile = String(text).split(/\*\*(.+?)\*\*/s);
+ /* split mit einer Gruppe liefert abwechselnd: normal, fett,
+ normal, fett … Der ungerade Platz ist immer der Inhalt der
+ Klammern. */
+ teile.forEach((t, i) => {
+ if (!t) return;
+ if (i % 2 === 1) {
+ const b = document.createElement('strong');
+ b.textContent = t;
+ stueck.append(b);
+ } else {
+ stueck.append(document.createTextNode(t));
+ }
+ });
+ return stueck;
+ }
+
function filterBauen(eintraege = []) {
const ziel = $('filter');
ziel.textContent = '';
@@ -1262,7 +1306,14 @@
const k = el('div', 'startkatalog__karte');
k.append(el('span', 'marke-art', einstellung?.arten?.[v.art] || v.art));
k.append(el('h3', 'startkatalog__name', v.titel));
- if (v.text) k.append(el('p', 'startkatalog__text', v.text));
+ if (v.text) {
+ /* Auch hier -- sonst sieht das Team im Vorschlag Sternchen und
+ auf dem Brett danach Fettschrift. Zwei Darstellungen
+ desselben Textes sind eine zu viel. */
+ const vt = el('p', 'startkatalog__text');
+ vt.append(mitFett(v.text));
+ k.append(vt);
+ }
const b = el('button', 'schritt', 'Übernehmen');
b.type = 'button';
b.addEventListener('click', async () => {
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index bcbd3ba6..b516b3ba 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
-
-
-
-
-
-
+
+
+
+
+
+
-
+