diff --git a/server/index.js b/server/index.js index 722405b4..2718aee3 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,7 @@ import { bereicheRouter } from "./workspace-bereiche.js"; import { treffRouter } from "./workspace-treff.js"; import { entwicklungRouter } from "./workspace-entwicklung.js"; import { rechteRouter, tafelLaden } from "./workspace-rechte.js"; +import { erklaerungRouter } from "./workspace-erklaerung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -252,6 +253,7 @@ app.use(treffRouter); app.use(entwicklungRouter); /* Die Rechtetafel -- ansehen und umstellen (11.09.2026). */ app.use(rechteRouter); +app.use(erklaerungRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-erklaerung.mjs b/server/pruef-erklaerung.mjs new file mode 100644 index 00000000..46a1bc45 --- /dev/null +++ b/server/pruef-erklaerung.mjs @@ -0,0 +1,455 @@ +/* ===================================================================== + Die kurze Erklärung auf jeder Seite (14.09.2026) + + Filipe: „ich will in jeder seite auch eine kleine detaillierte aber + schnelle erklärung kurz und knapps noch zu jeder seite." + + --------------------------------------------------------------------- + WAS HIER WIRKLICH GEMESSEN WIRD + + Dass ein Text ankommt, ist die leichteste und unwichtigste Frage. + Die drei, auf die es ankommt: + + 1. GIBT ES ZU JEDER SEITE EINEN? — abgeleitet aus der Rechtetafel, + nicht gegen eine Zahl. Kommt morgen eine 26. Seite dazu und + niemand schreibt ihr einen Text, muss das hier rot werden. + + 2. IST DIE ZEILE „SIEHT" GERECHNET ODER BEHAUPTET? — geprüft, + indem die Rechtetafel wirklich umgestellt wird. Wandert der + Satz mit, kommt er aus der Tafel. Ohne diese Gegenprobe wäre + „steht da" auch dann grün, wenn der Satz danebengeschrieben + wäre — und das ist die eine Angabe, die niemals lügen darf. + + 3. IST DIE ERKLÄRUNG SO VERSCHLOSSEN WIE DIE SEITE? — ein Modi + fragt nach „Personen & Zugänge" und muss 404 bekommen, nicht + den Text und nicht „verboten". Sonst ist dieser Weg eine + Landkarte des Hauses. + + Dazu, im Browser: Der Kasten muss auf einer Seite MIT Kopfzeile + erscheinen und auf einer OHNE. Der zweite Fall ist der, der lautlos + ausfallen würde — fünf Seiten haben keine `.kopf-zeile`, darunter + Chat und Kalender. + + Aufrufen mit: node server/pruef-erklaerung.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4406, "pruef-erklaerung"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-erkl-")); +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, "pruef-erklaerung"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +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++; }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; +const BASIS = `http://127.0.0.1:${PORT}`; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const rechte = await import("./rechte.js"); +const { ERKLAERUNGEN } = await import("./workspace-erklaerung.js"); + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +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", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Rieke", "hand", "CODE-HAND-0001"); +anlegen("Frida", "modi", "CODE-MODI-0001"); +d.close(); + +function roh(pfad, host, kopf = {}, koerper = null, methode = null) { + if (methode === "DELETE") koerper = null; + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (koerper ? "POST" : "GET"), + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); +ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet"); + +const erkl = (datei, host, keks) => + roh("/workspace/api/erklaerung?seite=" + datei, host, { cookie: keks }); + +/* ======================================================================= + 1. ZU JEDER SEITE EIN TEXT — abgeleitet, nicht gegen eine Zahl + ======================================================================= */ +melde(""); +melde("=== 1. Jede Seite hat einen Text ==="); +{ + const seiten = rechte.alleSeiten().map((p) => p.replace("/workspace/", "")); + ok(seiten.length > 20, `${seiten.length} Seiten in der Rechtetafel`); + + const ohne = seiten.filter((s) => !ERKLAERUNGEN[s]); + ok(ohne.length === 0, + ohne.length ? `OHNE TEXT: ${ohne.join(", ")}` : `zu allen ${seiten.length} Seiten steht ein Text`); + + /* Und umgekehrt: kein Text zu einer Seite, die es gar nicht gibt. + Sonst sammelt sich hier Prosa zu Seiten, die längst weg sind. */ + const verwaist = Object.keys(ERKLAERUNGEN).filter((s) => !seiten.includes(s)); + ok(verwaist.length === 0, + verwaist.length ? `TEXT OHNE SEITE: ${verwaist.join(", ")}` : "und kein Text zu einer Seite, die es nicht gibt"); + + /* GEGENPROBE zum Sucher selbst: Eine erfundene Seite MUSS als fehlend + auffallen -- sonst wäre die Zeile darüber auch bei leerer Tabelle grün. */ + ok(!ERKLAERUNGEN["gibt-es-nicht.html"], + "eine erfundene Seite hat erwartungsgemäß keinen Text"); +} + +/* ======================================================================= + 2. KURZ UND KNAPP — seine Bedingung, also gemessen + ======================================================================= */ +melde(""); +melde("=== 2. Kurz und knapp ==="); +{ + const alle = Object.entries(ERKLAERUNGEN); + ok(alle.length > 20, `${alle.length} Erklärungen geprüft`); + + const zuLang = alle.filter(([, e]) => + [e.wofuer, e.tun, e.merke].filter(Boolean).some((s) => s.length > 135)); + ok(zuLang.length === 0, + zuLang.length ? `ZU LANG: ${zuLang.map(([s]) => s).join(", ")}` + : "kein Satz über 135 Zeichen — „kurz und knapp“ war die Bedingung"); + + const leer = alle.filter(([, e]) => !e.wofuer?.trim() || !e.tun?.trim()); + ok(leer.length === 0, "jede Erklärung hat „Wofür“ und „Du tust“"); + + /* KEINE KLICKANLEITUNGEN. Sie altern mit jedem Umbau der Oberfläche + und zeigen danach ins Leere -- schlimmer als gar kein Text. */ + const klick = alle.filter(([, e]) => + /klicke|tippe auf|oben rechts|unten links|Schaltfläche/i.test( + [e.wofuer, e.tun, e.merke].filter(Boolean).join(" "))); + ok(klick.length === 0, + klick.length ? `KLICKANLEITUNG: ${klick.map(([s]) => s).join(", ")}` + : "keine Klickanleitungen — erklärt wird der Zweck, nicht der Weg"); + ok(/klicke/i.test("dann klicke oben rechts"), "und der Sucher findet so eine auch wirklich"); + + /* HAUSREGEL: echte Umlaute in allem, was ein Mensch liest. */ + const ascii = alle.filter(([, e]) => + /\b(fuer|koenn|muess|ueber|waer|moecht|oeffn|schliess)/i.test( + [e.wofuer, e.tun, e.merke].filter(Boolean).join(" "))); + ok(ascii.length === 0, + ascii.length ? `ASCII-UMLAUTE: ${ascii.map(([s]) => s).join(", ")}` : "keine ASCII-Ersatzumlaute"); +} + +/* ======================================================================= + 3. DIE ZEILE „SIEHT" IST GERECHNET, NICHT BEHAUPTET + + Das ist die Prüfung, derentwegen es diese Datei gibt. + ======================================================================= */ +melde(""); +melde("=== 3. Gerechnet, nicht behauptet ==="); +{ + const vorher = json(await erkl("talente.html", HAUS, kDogi)); + ok(vorher.hat_text === true, "DogFather bekommt die Erklärung zu „Talente“"); + ok(Array.isArray(vorher.sichtbar) && vorher.sichtbar.includes("Rechte Hand"), + `dort steht, wer sie sieht: ${(vorher.sichtbar || []).join(" · ")}`); + /* Die Rangfolge gilt auch hier: die rechte Hand direkt hinter DogFather. */ + ok(vorher.sichtbar[0] === "DogFather" && vorher.sichtbar[1] === "Rechte Hand", + "in der Rangfolge des Hauses, nicht alphabetisch"); + + /* JETZT DIE TAFEL WIRKLICH UMSTELLEN. */ + const zu = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi }, + { pfad: "/workspace/talente.html", rolle: "hand", erlaubt: false }, "PUT"); + ok(zu.code === 200, `die rechte Hand wird für „Talente“ abgeschaltet (${zu.code})`); + + const nachher = json(await erkl("talente.html", HAUS, kDogi)); + ok(!(nachher.sichtbar || []).includes("Rechte Hand"), + `danach steht sie nicht mehr da: ${(nachher.sichtbar || []).join(" · ")}`); + ok(vorher.sichtbar.length - 1 === nachher.sichtbar.length, + "genau eine Rolle weniger — der Satz kommt also aus der Tafel"); + + /* Und sie bekommt die Erklärung jetzt auch selbst nicht mehr. */ + const ihre = await erkl("talente.html", CREW, kHand); + ok(ihre.code === 404, + `und sie selbst bekommt jetzt 404 statt des Textes (${ihre.code})`); + + /* ZURÜCK — eine Prüfung, die den Stand verändert zurücklässt, ist + eine Falle für die nächste. */ + const auf = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi }, + { pfad: "/workspace/talente.html", rolle: "hand", erlaubt: true }, "PUT"); + ok(auf.code === 200, "danach ist alles wieder wie vorher"); + const zurueck = json(await erkl("talente.html", HAUS, kDogi)); + ok((zurueck.sichtbar || []).join() === vorher.sichtbar.join(), + "und der Satz steht wieder genau so da wie am Anfang"); +} + +/* ======================================================================= + 4. SO VERSCHLOSSEN WIE DIE SEITE SELBST + ======================================================================= */ +melde(""); +melde("=== 4. Dieselbe Schranke ==="); +{ + const fremd = await erkl("personen.html", CREW, kModi); + ok(fremd.code === 404, + `ein Modi bekommt zu „Personen & Zugänge“ ein 404 (${fremd.code})`); + ok(!/Zugangscode|Rolle/.test(fremd.text), + "und kein Wort aus dem Text — die Absage verrät nichts über die Seite"); + + /* GEGENPROBE: Eine Seite, die er öffnen darf, bekommt er auch erklärt. + Ohne sie wäre „404“ oben auch dann grün, wenn der Weg gar nicht + funktioniert. */ + const seine = json(await erkl("entwicklung.html", CREW, kModi)); + ok(seine.hat_text === true, "seine eigene Entwicklungsseite bekommt er erklärt"); + + const ohneAnmeldung = await erkl("start.html", HAUS, ""); + ok(ohneAnmeldung.code === 401, `ohne Anmeldung gar nichts (${ohneAnmeldung.code})`); + + /* Kein Weg aus dem Verzeichnis heraus. */ + for (const bösartig of ["../../etc/passwd", "..%2Fgeheim.html", "start.html%00", "start"]) { + const r = await erkl(encodeURIComponent(bösartig), HAUS, kDogi); + ok(r.code === 400, `„${bösartig}“ wird abgelehnt (${r.code})`); + } +} + +/* ======================================================================= + 5. AUF JEDER SEITE EINGEBUNDEN — auch auf denen ohne Kopfzeile + ======================================================================= */ +melde(""); +melde("=== 5. Auf jeder Seite eingebunden ==="); +{ + const dateien = readdirSync("workspace").filter((n) => n.endsWith(".html")); + const mitKopf = dateien.filter((n) => + /assets\/js\/kopf\.js/.test(readFileSync("workspace/" + n, "utf8"))); + ok(mitKopf.length > 20, `${mitKopf.length} Seiten laden kopf.js`); + + const ohne = mitKopf.filter((n) => + !/assets\/js\/erklaerung\.js/.test(readFileSync("workspace/" + n, "utf8"))); + ok(ohne.length === 0, + ohne.length ? `OHNE erklaerung.js: ${ohne.join(", ")}` : "alle davon laden auch erklaerung.js"); + + /* Die fünf ohne Kopfzeile sind der Fall, der lautlos ausfallen würde + -- der Kasten hängt sich dort an
statt hinter die Kopfzeile. + Die Zahl gehört in die Bedingung: Fände die Suche keine, wäre der + zweite Weg nie geprüft worden. */ + const ohneKopfzeile = mitKopf.filter((n) => + !/class="kopf-zeile"/.test(readFileSync("workspace/" + n, "utf8"))); + ok(ohneKopfzeile.length > 0, + `${ohneKopfzeile.length} Seiten haben keine Kopfzeile: ${ohneKopfzeile.join(", ")}`); +} + +/* ======================================================================= + 6. UND STEHT SIE AUCH WIRKLICH AUF DEM BILDSCHIRM? + ======================================================================= */ +melde(""); +melde("=== 6. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + + /** Wartet, bis der Kasten da ist -- oder bis feststeht, dass er nicht kommt. */ + async function ansehen(datei) { + await seite.goto(`${BASIS}/workspace/${datei}`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {}); + return seite.evaluate(() => { + const k = document.querySelector(".erkl"); + if (!k) return { da: false }; + const r = k.getBoundingClientRect(); + const schilder = [...k.querySelectorAll(".erkl__schild")].map((s) => s.textContent.trim()); + return { + da: true, hoehe: Math.round(r.height), breite: Math.round(r.width), + inMain: !!k.closest("main"), + schilder, text: k.innerText.trim(), + /* Steht er VOR dem ersten Inhalt und nicht irgendwo mittendrin? */ + erstesDrittel: r.top < window.innerHeight, + }; + }); + } + + /* Eine Seite MIT Kopfzeile ... */ + const mit = await ansehen("talente.html"); + ok(mit.da === true, "auf „Talente“ (mit Kopfzeile) steht der Kasten"); + ok(mit.inMain && mit.erstesDrittel, "im Inhalt und ohne Scrollen sichtbar"); + ok(mit.schilder.join(",").includes("SIEHT") || /SIEHT/i.test(mit.text), + `mit allen Zeilen: ${mit.schilder.join(" / ")}`); + ok(mit.hoehe > 30 && mit.hoehe < 260, `und er bleibt klein (${mit.hoehe} px hoch)`); + + /* =================================================================== + STEHT DER TEXT AUF EINER FLAECHE -- UND IST ER LESBAR? + + BEIDE MAENGEL WAREN IN DER ERSTEN FASSUNG GRUEN. Gefunden hat sie + nicht diese Datei, sondern ein Blick auf das Bildschirmfoto: Der + Kasten stand mitten auf der Buehne, mit einem Lichtfleck hinter + „SIEHT". Eine Pruefung, die Groesse und Position misst und nicht + die Lesbarkeit, prueft das Bequeme statt das Wichtige. + + DREI AUSGAENGE, wie ueberall: lesbar / zu dunkel / konnte nicht + nachsehen. Der dritte ist hier der eigentliche Fund -- Text auf + einem Foto hat gar keinen berechenbaren Kontrast, und genau das + war der Zustand. */ + const KONTRAST = `(() => { + const leuchte = (r, g, b) => { + const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + const zahlen = (s) => (s.match(/[\d.]+/g) || []).map(Number); + /* Der erste Vorfahr mit einer WIRKLICH deckenden Flaeche. Alles + darunter ist Durchsicht -- und ein Foto ist keine Flaeche. */ + const grund = (e) => { + let n = e; + while (n && n !== document.documentElement) { + const st = getComputedStyle(n); + const f = zahlen(st.backgroundColor); + const deckend = f.length >= 3 && (f.length < 4 || f[3] >= 0.92); + if (deckend && st.backgroundImage === 'none') return f.slice(0, 3); + if (deckend) return null; // deckend, aber ein Bild darauf + n = n.parentElement; + } + return null; + }; + const kasten = document.querySelector('.erkl'); + if (!kasten) return { fehlt: true }; + const stuecke = [...kasten.querySelectorAll('.erkl__schild, .erkl__satz')]; + const schlecht = [], geraten = []; + let gemessen = 0; + for (const s of stuecke) { + if (!s.textContent.trim()) continue; + const h = grund(s); + if (!h) { geraten.push(s.textContent.trim().slice(0, 30)); continue; } + const v = zahlen(getComputedStyle(s).color).slice(0, 3); + const a = leuchte(...v), b = leuchte(...h); + const v2 = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + gemessen++; + if (v2 < 4.5) schlecht.push(s.textContent.trim().slice(0, 30) + ' = ' + v2.toFixed(2)); + } + return { gemessen, schlecht, geraten }; + })()`; + + const k = await seite.evaluate(KONTRAST); + /* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Faende die Messung kein + Textstueck, waere „alles lesbar" trivial wahr. */ + ok(k.gemessen >= 6 && k.schlecht.length === 0, + `${k.gemessen} Textstücke im Kasten, alle lesbar` + + (k.schlecht.length ? " -- ZU DUNKEL: " + k.schlecht.join(" | ") : "")); + ok(k.geraten.length === 0, + `jedes davon steht auf einer deckenden Fläche` + + (k.geraten.length ? ` -- AUF DER BÜHNE (${k.geraten.length}): ` + k.geraten.slice(0, 3).join(" | ") : "")); + + /* =================================================================== + SPRICHT „WOFUER" DIE UNTERZEILE NACH? + + Auch das war gruen und trotzdem falsch: Auf „Talente" stand oben + „Zuschauer, die ins Team passen koennten. Ein Trichter, keine + Liste" -- und zwei Zeilen darunter fast woertlich dasselbe. Das + liest sich wie ein Kopierfehler und verschenkt die Zeile. + + Gemessen wird der Anteil der langen Woerter, die in BEIDEN Saetzen + vorkommen. Ueber der Haelfte ist es keine Erklaerung mehr, sondern + ein Echo. */ + const echo = await seite.evaluate(() => { + const u = document.querySelector('main .unterzeile')?.textContent?.trim() || ''; + const zeilen = [...document.querySelectorAll('.erkl__zeile')]; + const w = zeilen.find((z) => /wof/i.test(z.querySelector('.erkl__schild')?.textContent || '')); + const wofuer = w?.querySelector('.erkl__satz')?.textContent?.trim() || ''; + if (!u || !wofuer) return { pruefbar: false, u, wofuer }; + const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || [])); + const a = lang(wofuer), b = lang(u); + if (!a.size) return { pruefbar: false, u, wofuer }; + let gleich = 0; + for (const x of a) if (b.has(x)) gleich++; + return { pruefbar: true, anteil: gleich / a.size, gleich, gesamt: a.size, u, wofuer }; + }); + ok(echo.pruefbar === true, + echo.pruefbar ? "Unterzeile und „Wofür“ stehen beide da — vergleichbar" + : `nicht vergleichbar (Unterzeile: „${(echo.u || "").slice(0, 40)}")`); + ok(echo.pruefbar && echo.anteil < 0.5, + `„Wofür“ sagt etwas anderes als die Unterzeile (${echo.gleich} von ${echo.gesamt} langen Wörtern gleich)`); + + + /* ... und eine OHNE. Das ist der Fall, der lautlos ausfallen würde. */ + const ohne = await ansehen("kalender.html"); + ok(ohne.da === true, "auf „Kalender“ (OHNE Kopfzeile) steht er genauso"); + ok(ohne.inMain, "und zwar im Inhalt, nicht davor"); + + /* Am Handy: bricht nichts seitlich aus? */ + const handy = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const s2 = await handy.newPage(); + await s2.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await s2.click('.rolle[data-rolle="admin"]'); + await s2.fill("#code", "CODE-DOGI-0001"); + await s2.click("button[type=submit]"); + await s2.waitForURL("**/start.html", { timeout: 20000 }); + await s2.goto(BASIS + "/workspace/talente.html", { waitUntil: "networkidle" }); + await s2.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {}); + const eng = await s2.evaluate(() => { + const k = document.querySelector(".erkl"); + if (!k) return null; + const r = k.getBoundingClientRect(); + return { raus: Math.round(r.right - document.documentElement.clientWidth), hoehe: Math.round(r.height) }; + }); + ok(eng !== null && eng.raus <= 1, `am Handy nichts seitlich draußen (${eng?.raus} px)`); + ok(eng !== null && eng.hoehe < 340, `und immer noch kompakt (${eng?.hoehe} px)`); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + + await browser.close(); +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-tempo-workspace.mjs b/server/pruef-tempo-workspace.mjs index 7e97fc1f..8f5c540a 100644 --- a/server/pruef-tempo-workspace.mjs +++ b/server/pruef-tempo-workspace.mjs @@ -28,6 +28,7 @@ GEGENPROBE am Ende: eine kuenstlich erzeugte Doppelanfrage und ein kuenstlicher Layout-Sprung muessen gefunden werden. */ +import { gzipSync } from "node:zlib"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -153,9 +154,45 @@ await seite.waitForTimeout(600); /* Grenzen. Grosszuegig -- sie sollen den Ausreisser finden, nicht das Kilobyte. */ const GRENZE_ANFRAGEN = 40; -const GRENZE_BYTES = 1_200_000; const GRENZE_MS = 4000; +/* ---- DAS GEWICHT: GEMESSEN WIRD, WAS UEBER DIE LEITUNG GEHT ---------- + + BIS ZUM 14.09.2026 WURDE HIER DAS FALSCHE GEMESSEN, und der Kommentar + daneben behauptete sogar das Richtige („das ist, was wirklich ueber + die Leitung ging"). Der Pruefstand spricht mit dem nackten + Express-Server auf 127.0.0.1, und der komprimiert nicht. Live steht + davor Caddy. + + NACHGEMESSEN an der echten Adresse, nicht vermutet: + curl -H 'Accept-Encoding: zstd' workspace.dogfather-universe.com/… + start.css 345 KB -> 111 KB (Content-Encoding: zstd) + gate.css 120 KB -> 40 KB + kopf.js 65 KB -> 24 KB + + Die Prueferei verglich also rund 1,2 MB gegen eine Grenze, die fuer + die Leitung gedacht war -- waehrend Filipe rund 500 KB bekommt. Die + Folge war eine Warnung, die nichts ueber sein Erlebnis sagt: genau + die Sorte, die man nach dreimal Lesen wegklickt. + + JETZT WIRD BEIDES GEMESSEN. Komprimierbares wird im Pruefstand + gzip-gepackt (gemessen: start.css 356 KB -> 115 KB gzip gegen 111 KB + zstd live -- ein Stellvertreter, der auf vier Prozent genau ist), + alles andere zaehlt roh. Geurteilt wird ueber die uebertragene + Groesse, berichtet werden beide. + + DIE GRENZE: 900 KB uebertragen. Die schwerste Seite liegt heute bei + rund 500 KB, davon 286 KB Buehnenbild -- das ist der Ausreisser, den + diese Zahl finden soll, mit Luft fuer eine zweite. Die rohe Groesse + bleibt in der Ausgabe stehen: Sie kostet zwar keine Leitung, aber + Rechenzeit beim Auswerten, und sie ist die Zahl, die man senken kann. */ +const GRENZE_UEBERTRAGEN = 900_000; + +/* Was Caddy komprimiert. Bilder, Schriften und Videos sind bereits + gepackt -- sie noch einmal durch gzip zu schicken kostet Zeit und + spart nichts. */ +const KOMPRIMIERBAR = /^(text\/|application\/(javascript|json|xml|manifest)|image\/svg)/i; + /* ---- Layout-Sprung: eine GRUNDLINIE, kein Wunschwert ------------------ Gemessen am 04.09.2026 (Audit): Die Seiten springen beim Aufbau zwischen 0 und 0,54. Nach Web Vitals gilt unter 0,1 als gut. Der @@ -175,6 +212,7 @@ const ZIEL_SPRUNG = 0.1; // Web Vitals "gut" -- noch nicht erreicht const zeilen = []; let seitenGesamt = 0, anfragenGesamt = 0, bytesGesamt = 0; +const nichtGewogen = []; const doppelte = []; const langsame = []; const springende = []; @@ -184,9 +222,34 @@ const zuGross = []; for (const datei of SEITEN) { const gesehen = new Map(); let bytes = 0; + let roheBytes = 0, uebertragen = 0; + const warten = []; const zaehler = (antwort) => { const u = antwort.url(); gesehen.set(u, (gesehen.get(u) || 0) + 1); + /* Den Koerper JETZT holen -- nach dem Seitenwechsel ist er weg. + Ein Fehlschlag (Umleitung, 204, abgebrochen) ist kein Befund, + sondern schlicht nichts zu wiegen. */ + const art = antwort.headers()["content-type"] || ""; + /* EIN EREIGNISSTROM ENDET NIE -- und `body()` darauf auch nicht. + Genau daran ist am 06.09.2026 schon einmal ein ganzer Lauf + stehengeblieben, und hier ist es sofort wieder passiert: Der + Workspace haelt eine offene Verbindung fuer Benachrichtigungen. + Erst gar nicht anfassen -- und fuer alles andere trotzdem eine + Notbremse, weil die naechste Sorte Dauerverbindung noch nicht + erfunden ist. */ + if (/event-stream/i.test(art)) return; + warten.push((async () => { + try { + const k = await Promise.race([ + antwort.body(), + new Promise((r) => setTimeout(() => r(null), 3000)), + ]); + if (!k?.length) return; + roheBytes += k.length; + uebertragen += KOMPRIMIERBAR.test(art) ? gzipSync(k).length : k.length; + } catch { /* nichts zu wiegen */ } + })()); }; seite.on("response", zaehler); @@ -195,6 +258,7 @@ for (const datei of SEITEN) { await seite.waitForTimeout(500); const dauer = Date.now() - start; seite.off("response", zaehler); + await Promise.all(warten); /* Uebertragene Groesse aus der Zeitleiste des Browsers -- das ist, was wirklich ueber die Leitung ging. */ @@ -309,12 +373,23 @@ for (const datei of SEITEN) { schwankend.push(`${datei} ${sprung} → beim Nachmessen ${zweitens.wert}`); } } - if (anfragen > GRENZE_ANFRAGEN || bytes > GRENZE_BYTES) { - zuGross.push(`${datei} ${anfragen} Anfragen / ${Math.round(bytes / 1024)} KB`); + /* KONNTE ueberhaupt gewogen werden? Kommt aus den Koerpern nichts + zurueck (Browser aus dem Zwischenspeicher, alles abgebrochen), + waere „unter der Grenze" trivial wahr. Dann gilt die Zeitleiste + des Browsers als Rueckfall -- und der dritte Ausgang steht in der + Ausgabe. */ + const gewogen = uebertragen > 0; + const zaehlwert = gewogen ? uebertragen : bytes; + if (!gewogen) nichtGewogen.push(datei); + + if (anfragen > GRENZE_ANFRAGEN || zaehlwert > GRENZE_UEBERTRAGEN) { + zuGross.push(`${datei} ${anfragen} Anfragen / ${Math.round(zaehlwert / 1024)} KB übertragen`); } zeilen.push(` ${datei.padEnd(22)} ${String(anfragen).padStart(3)} Anfragen ` - + `${String(Math.round(bytes / 1024)).padStart(4)} KB ${String(dauer).padStart(4)} ms ` + + `${String(Math.round(zaehlwert / 1024)).padStart(4)} KB über die Leitung ` + + `(roh ${String(Math.round((roheBytes || bytes) / 1024)).padStart(4)}) ` + + `${String(dauer).padStart(4)} ms ` + `Sprung ${String(sprung).padStart(5)} ${String(messung.knoten).padStart(4)} Knoten`); } @@ -388,7 +463,13 @@ console.log(` hinw ${ueberZiel} von ${seitenGesamt} Seiten liegen ueber dem Z + `${ZIEL_SPRUNG} fuer den Layout-Sprung (bauartbedingt: Geruest zuerst, Daten danach)`); ok(zuGross.length === 0, zuGross.length ? `zu schwer: ${zuGross.join(", ")}` - : `keine Seite ueber ${GRENZE_ANFRAGEN} Anfragen oder ${Math.round(GRENZE_BYTES / 1024)} KB`); + : `keine Seite ueber ${GRENZE_ANFRAGEN} Anfragen oder ${Math.round(GRENZE_UEBERTRAGEN / 1024)} KB ueber die Leitung`); +/* DER DRITTE AUSGANG: Konnte ueberhaupt gewogen werden? Eine Seite, + deren Koerper der Browser aus dem Zwischenspeicher genommen hat, + waere sonst lautlos „leicht“. */ +ok(nichtGewogen.length === 0, + nichtGewogen.length ? `KONNTE NICHT WIEGEN: ${nichtGewogen.join(", ")} -- Ergebnis unbrauchbar` + : `alle ${seitenGesamt} Seiten wirklich gewogen`); /* ======================================================================= GEGENPROBE diff --git a/server/pruef-ueberlappung.mjs b/server/pruef-ueberlappung.mjs index 5a89e133..8148f3f3 100644 --- a/server/pruef-ueberlappung.mjs +++ b/server/pruef-ueberlappung.mjs @@ -133,9 +133,29 @@ for (const breite of BREITEN) { /* Den letzten Knopf genau auf den ersten legen. */ const erst = teile[0].getBoundingClientRect(); const letzt = teile[teile.length - 1]; - letzt.style.position = "fixed"; - letzt.style.left = erst.left + "px"; - letzt.style.top = erst.top + "px"; + /* MIT transform STATT position:fixed (14.09.2026). + + `position: fixed` bezieht sich NICHT immer auf das Fenster: Hat + ein Vorfahr `transform`, `filter` oder `backdrop-filter`, wird er + selbst zum Bezugsrahmen. Die Kopfleiste hat so einen. Der Knopf + landete dadurch 6 px neben dem Ziel -- gemessen {x:-6, y:36} --, + die Gegenprobe meldete „nicht erkannt“, und damit konnte diese + Pruefung tagelang nicht mehr zeigen, dass sie eine Ueberlappung + ueberhaupt findet. Eine Pruefung, die das nicht kann, bestaetigt + nichts. + + `transform` verschiebt relativ zur EIGENEN aktuellen Lage. Wo der + Bezugsrahmen liegt, ist damit gleichgueltig. */ + /* UND OHNE UEBERBLENDUNG. Die Knoepfe der Kopfleiste haben eine + `transition`; wer unmittelbar nach dem Setzen misst, bekommt die + Lage von VORHER zurueck -- die Bewegung hat noch nicht + stattgefunden. Das war die zweite Haelfte desselben Fehlers. + Derselbe Grund, aus dem in pruef-dabei-optik das Haekchen + „0 → 0“ meldete, bis dort die Ueberblendung abgewartet wurde. */ + letzt.style.transition = "none"; + const vorher = letzt.getBoundingClientRect(); + letzt.style.transform = + `translate(${erst.left - vorher.left}px, ${erst.top - vorher.top}px)`; letzt.style.zIndex = "99"; // Messen wie oben, aber nur dieses eine Paar. const a2 = teile[0].getBoundingClientRect(), b2 = letzt.getBoundingClientRect(); diff --git a/server/workspace-erklaerung.js b/server/workspace-erklaerung.js new file mode 100644 index 00000000..e9d9811a --- /dev/null +++ b/server/workspace-erklaerung.js @@ -0,0 +1,229 @@ +/* ===================================================================== + Die kurze Erklärung auf jeder Seite (14.09.2026) + + Filipe: *„ich will in jeder seite auch eine kleine detaillierte aber + schnelle erklärung kurz und knapps noch zu jeder seite."* + + --------------------------------------------------------------------- + DREI ZEILEN, UND DIE DRITTE RECHNET SICH SELBST AUS + + WOFÜR wozu es diese Seite gibt + DU TUST was man hier konkret macht — nicht was man könnte + SIEHT wer diese Seite überhaupt öffnen kann + + Die ersten beiden sind geschriebener Text. Die dritte ist es NICHT: + Sie wird aus der Rechtetafel gerechnet, aus derselben, aus der auch + die Schranke ihre Entscheidung holt. Das ist der ganze Grund, warum + diese Zeile hier stehen darf. + + Hätte ich sie danebengeschrieben, wäre sie beim ersten Umstellen in + „Wer sieht was" falsch geworden — und zwar STILL falsch, in einem + Satz, der behauptet, wer etwas sieht. Genau diese Sorte Zweitschrift + hat im Projekt schon dreimal Daten oder Vertrauen gekostet (die + abgeschriebene Spaltenliste, die zweite Rollenreihenfolge im + Browser, das fest eingetragene Wort „Agentur"). Eine Angabe über + Sichtbarkeit ist der schlechteste Ort, um es ein viertes Mal zu tun. + + --------------------------------------------------------------------- + WARUM DIE ERKLÄRUNG HINTER DERSELBEN SCHRANKE STEHT WIE DIE SEITE + + Wer eine Seite nicht öffnen darf, bekommt auch ihre Erklärung nicht + — mit 404, nicht mit 403. Sonst wäre dieser Weg eine Landkarte des + Hauses: Ein Modi könnte abfragen, dass es „Personen & Zugänge" gibt, + wozu die Seite dient und wer sie sieht. Die Seite selbst antwortet + ihm mit „gibt es nicht"; die Erklärung dazu muss dasselbe sagen. + + --------------------------------------------------------------------- + WAS HIER BEWUSST NICHT STEHT + + Keine Anleitung in Schritten („klicke oben rechts auf …"). Solche + Texte altern mit jedem Umbau der Oberfläche und werden dann zu + Anweisungen, die ins Leere zeigen. Was hier steht, ist der ZWECK — + der ändert sich, wenn die Seite sich ändert, und nicht vorher. + ===================================================================== */ + +import express from "express"; +import { sitzungLesen, ROLLEN_NAME, ROLLEN_REIHE } from "./workspace.js"; +import { darfSeite, tafelJetzt } from "./rechte.js"; + +export const erklaerungRouter = express.Router(); + +/** Der Text zu jeder Seite. + * + * `wofuer` beantwortet „wozu gibt es das?", `tun` beantwortet „was + * mache ich hier?". `merke` ist der eine Satz, der am häufigsten + * missverstanden wird — er steht nur dort, wo es ihn wirklich gibt. + * Ein Feld, das auf jeder Seite gefüllt ist, wird nicht mehr gelesen. + */ +export const ERKLAERUNGEN = { + "start.html": { + wofuer: "Deine Startseite. Jede Kachel führt zu einem Bereich, und du siehst nur die, die dir offenstehen.", + tun: "Aussuchen, wo du hinwillst – oder oben auf die Glocke schauen, wenn etwas auf dich wartet.", + }, + "uebersicht.html": { + wofuer: "Alle betreuten Creator nebeneinander, mit Zahlen, offenen Aufgaben und dem Datum des letzten Kontakts.", + tun: "Die oberste Karte zuerst ansehen – sortiert wird nach dem, was Handlung verlangt, nicht nach Namen.", + }, + "aufgaben.html": { + wofuer: "Alle Aufgaben des Teams in vier Spalten, mit Frist, Zuständigkeit und Rückmeldungen.", + tun: "Eine Aufgabe weiterschieben, sobald sich ihr Stand ändert – nicht sammeln und später nachtragen.", + merke: "Was hier nicht steht, passiert nicht. Aufgaben aus einem Call landen automatisch hier.", + }, + "kalender.html": { + wofuer: "Alle Termine des Teams an einer Stelle, mit Serien und Feiertagen.", + tun: "Termin anlegen und unten auswählen, wer ihn im eigenen Kalender sehen soll.", + merke: "Wen du nicht auswählst, sieht den Termin nicht – auch nicht, dass es ihn gibt.", + }, + "chat.html": { + wofuer: "Nachrichten an einzelne Menschen oder an einen Kanal.", + tun: "Schreiben. Auf eine Nachricht kannst du auch nur reagieren, statt zu antworten.", + }, + "calls.html": { + wofuer: "Alle Gespräche mit Datum, Teilnehmern und Protokoll.", + tun: "Nach dem Call das Protokoll schreiben; die To-dos daraus landen direkt im Aufgabenbrett.", + merke: "Ein Call ohne Protokoll bleibt auf der Startseite stehen, bis eines da ist.", + }, + "dateien.html": { + wofuer: "Alle Dateien zu einem Creator, jede mit Stand und Versionsverlauf.", + tun: "Hochladen und den Stand setzen – die aktuelle Fassung ist dadurch immer erkennbar.", + }, + "content.html": { + wofuer: "Alle Videoideen mit Säule, Hook, Format und geplantem Datum.", + tun: "Eine Idee eintragen und ihr eine Säule geben; die Strecke danach ist immer dieselbe.", + }, + "bereich.html": { + wofuer: "Die Einträge dieses Bretts, je nach Brett mit Art, Datum, Dringlichkeit oder Bewertung.", + tun: "Eintrag anlegen, Art wählen, und bei „Gilt für“ entscheiden, ob er dir gehört oder allen.", + }, + "wissen.html": { + wofuer: "Anleitungen, PDFs und Kurzfassungen – durchsuchbar, nach Themen sortiert.", + tun: "Nachschlagen, bevor du fragst – und selbst etwas dazulegen, wenn du es erklärt hast.", + }, + "report.html": { + wofuer: "Je Creator und Zeitraum: was lief, was nicht, was als Nächstes kommt.", + tun: "Den Zeitraum durchgehen und drei Dinge festhalten: was lief, was nicht, was als Nächstes kommt.", + }, + "leistung.html": { + wofuer: "Livestunden, Zuschauer und Einnahmen je Tag – aus Backstage oder von Hand.", + tun: "Zeitraum importieren oder einen Tag von Hand eintragen, dann mit den Wochenzielen vergleichen.", + }, + "scouting.html": { + wofuer: "Alle Kontakte mit Stufe, Kanal, Reichweite und dem, was zuletzt passiert ist.", + tun: "Jeden Kontakt eine Stufe weiterschieben – oder bewusst beenden, statt ihn liegen zu lassen.", + }, + "startcheck.html": { + wofuer: "Ein Fragebogen je neuem Creator und die Arbeitspakete, die daraus entstehen.", + tun: "Die Fragen durchgehen; daraus entstehen automatisch die ersten Arbeitspakete.", + }, + "profil.html": { + wofuer: "Zahlen, Aufgaben, Dateien, Termine und Verlauf zu einer Creatorin an einer Stelle.", + tun: "Nachsehen, bevor du mit ihr oder ihm sprichst.", + }, + "steckbrief.html": { + wofuer: "Dein Bild, ein paar Worte über dich und deine Kanäle.", + tun: "Ausfüllen. Dein Bild steht danach überall dort, wo dein Name auftaucht.", + merke: "Das hier gehört dir. Niemand außer dir ändert daran etwas.", + }, + "personen.html": { + wofuer: "Zugänge: wer hereinkommt, mit welcher Rolle, und wer wen betreut.", + tun: "Zugang anlegen und den Code persönlich weitergeben.", + merke: "Ein Zugangscode wird genau einmal angezeigt. Danach gibt es nur einen neuen, keinen alten.", + }, + "automation.html": { + wofuer: "Jede Automatik des Hauses mit Zustand, letzter Ausführung und Schalter.", + tun: "Nachsehen, was gerade greift, bevor du dich über etwas wunderst, das du nicht angestoßen hast.", + }, + "team.html": { + wofuer: "Die Arbeitslast von Managern und Scouts nebeneinander.", + tun: "Stellen suchen, an denen jemand zu viel hat – und dort umverteilen.", + merke: "Das ist keine Kontrolle. Die Seite gibt es, um fehlende Unterstützung zu finden.", + }, + "teamlage.html": { + wofuer: "Was in deinem Team gerade offen ist – und bei wem.", + tun: "Die offenen Punkte durchgehen und ansprechen.", + merke: "„Offen“ heißt: darüber habt ihr noch nicht geredet. Es ist keine Note über jemanden.", + }, + "entwicklung.html": { + wofuer: "34 Beobachtungen je Person, gesetzt von zwei Augenpaaren unabhängig voneinander.", + tun: "Pro Person die Punkte setzen, die du wirklich beobachtet hast. Den Rest lässt du offen.", + merke: "Keine Noten, keine Punktzahl. Das Ergebnis ist die Abweichung zwischen euch beiden – ein Gesprächsanlass.", + }, + "talente.html": { + wofuer: "Kandidaten für dein Mod-Team in fünf Stufen – mit 21 Merkmalen und 6 Warnzeichen je Karte.", + tun: "Pro Karte den einen nächsten Schritt machen, der dort steht.", + merke: "Hier stehen Namen von Menschen, die nichts davon wissen. Nichts davon geht nach außen.", + }, + "rechte.html": { + wofuer: "Jede Rolle mal jede Seite als Tafel – und jedes Feld lässt sich umstellen.", + tun: "Ein Feld umstellen und speichern. Die Änderung gilt sofort, auch für alle, die gerade angemeldet sind.", + merke: "Drei Felder lassen sich nicht abschalten – sonst könntest du dich selbst aussperren.", + }, + "treff-regeln.html": { + wofuer: "Die Regeln des Treffs und was passiert, wenn sie jemand nicht einhält.", + tun: "Einmal lesen. Was hier steht, gilt auch – und du siehst, was passiert, wenn es jemand nicht einhält.", + }, + "treff-moderation.html": { + wofuer: "Offene Meldungen, erledigte Fälle und jede Maßnahme mit Begründung.", + tun: "Eine Meldung ansehen und entscheiden: Hinweis, Pause oder Ausschluss – immer mit Begründung.", + merke: "Jede Maßnahme steht nachher mit Grund da. Das schützt auch dich.", + }, +}; + +/** Die Rollen, die diese Seite gerade öffnen dürfen — als Namen. + * + * AUS DER TAFEL GERECHNET, NICHT DANEBENGESCHRIEBEN. Wer in „Wer + * sieht was" ein Feld umstellt, ändert damit auch diesen Satz; eine + * zweite Liste hier wäre spätestens dann eine Behauptung. + * + * In der Reihenfolge des Hauses (ROLLEN_REIHE), damit die rechte Hand + * auch hier direkt hinter DogFather steht. */ +function sichtbarFuer(pfad) { + const erlaubt = tafelJetzt()[pfad] || []; + const bekannt = ROLLEN_REIHE.filter((r) => erlaubt.includes(r)); + /* Rollen, die (noch) nicht in der Rangfolge stehen -- heute nur + 'gast'. Sie faellt ans Ende, statt stillschweigend zu fehlen. */ + const rest = erlaubt.filter((r) => !bekannt.includes(r)); + return [...bekannt, ...rest].map((r) => ROLLEN_NAME[r] || r); +} + +erklaerungRouter.get("/workspace/api/erklaerung", (req, res) => { + try { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + + /* Nur der Dateiname, und nur aus dem eigenen Verzeichnis. Ohne das + waere `?seite=../../etwas` eine Frage, die dieser Weg gar nicht + beantworten koennen soll. */ + const datei = String(req.query?.seite || "").trim(); + if (!/^[a-z0-9-]+\.html$/.test(datei)) { + return res.status(400).json({ fehler: "ungueltig" }); + } + const pfad = "/workspace/" + datei; + + /* DIESELBE SCHRANKE WIE FUER DIE SEITE SELBST -- und dieselbe + Antwort, wenn sie zu ist: 404, nicht 403. Wer "kein Zugriff" + liest, weiss, dass es die Seite gibt. */ + if (!darfSeite(person, pfad)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const text = ERKLAERUNGEN[datei]; + /* Eine Seite ohne Text ist KEIN Fehler, aber auch keine leere + Erklaerung: Der Browser bekommt `hat_text: false` und zeichnet + gar nichts. Ein Kasten mit drei leeren Zeilen waere schlechter + als keiner. */ + if (!text) return res.json({ hat_text: false, seite: datei }); + + res.json({ + hat_text: true, + seite: datei, + wofuer: text.wofuer, + tun: text.tun, + merke: text.merke || null, + sichtbar: sichtbarFuer(pfad), + }); + } catch (fehler) { + console.error("[erklaerung]", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index fa1df43b..ebd0cc78 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609141634", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609141819", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609141634", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609141819", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141634", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141819", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141634", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141819", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 83d9376f..5d51dafd 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -7878,3 +7878,59 @@ body.start .k-leiste .feldschild::after { word-break: break-all; } .code-kasten__hinweis { margin: 0 0 14px; font-size: .85rem; color: var(--text-leise); } + +/* ===================================================================== + DIE KURZE ERKLAERUNG AUF JEDER SEITE (14.09.2026) + + Drei Zeilen, Schild links, Satz rechts. Sie soll gelesen werden + koennen und danach nicht mehr auffallen -- deshalb kleine Schrift, + gedaempfte Farbe und keine eigene Flaeche, die mit den Karten + darunter um Aufmerksamkeit kaempft. Nur eine Kante links. + ===================================================================== */ + +.erkl { + margin: 0 0 22px; + padding: 13px 17px 13px 15px; + /* DECKEND, und das ist keine Kosmetik: Auf mehreren Seiten liegt + hinter dem Inhalt die Buehne -- ein Foto. Text darauf hat keinen + berechenbaren Kontrast, und „gut lesbar" waere dann eine + Vermutung statt einer Messung. */ + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + border-left: 3px solid color-mix(in srgb, var(--akzent) 55%, transparent); + display: grid; + gap: 5px; +} +.erkl__zeile { + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: 10px; + align-items: baseline; +} +/* .75rem = 12 px. Die erste Fassung stand auf .68rem = 10,88 px und + lag damit unter der Hausgrenze von 11,5 px -- gefunden von + pruef-css-klassen, nicht vom Auge. */ +.erkl__schild { + font-size: .75rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-still); +} +.erkl__satz { + font-size: .84rem; line-height: 1.6; + max-width: 78ch; + color: var(--text-leise); +} +/* „Merke" ist die Zeile, die jemand einmal falsch verstanden hat. + Sie darf etwas deutlicher sein als die anderen -- aber nicht + alarmierend, sonst liest man die drei darueber nicht mehr. */ +.erkl__zeile[data-merke="ja"] .erkl__satz { color: var(--text); } +.erkl__zeile[data-merke="ja"] .erkl__schild { + color: color-mix(in srgb, var(--akzent) 70%, var(--text-still)); +} + +/* Am Handy untereinander: 5,5rem Schild plus ein Satz gehen bei 390 px + nicht nebeneinander, ohne dass jedes zweite Wort umbricht. */ +@media (max-width: 560px) { + .erkl__zeile { grid-template-columns: 1fr; gap: 1px; } + .erkl__satz { font-size: .83rem; } +} diff --git a/workspace/assets/js/erklaerung.js b/workspace/assets/js/erklaerung.js new file mode 100644 index 00000000..92a709bf --- /dev/null +++ b/workspace/assets/js/erklaerung.js @@ -0,0 +1,86 @@ +/* ===================================================================== + Die kurze Erklärung auf jeder Seite (14.09.2026) + + Filipe: „ich will in jeder seite auch eine kleine detaillierte aber + schnelle erklärung kurz und knapps noch zu jeder seite." + + --------------------------------------------------------------------- + WARUM SIE OFFEN DASTEHT UND NICHT HINTER EINEM KLICK + + „Schnell" war seine Bedingung. Eine Erklärung, die man erst + aufklappen muss, ist genau dann nicht da, wenn man sie braucht — + nämlich beim ersten Mal, wenn man noch nicht weiß, dass es sie gibt. + Drei kurze Zeilen kosten weniger Platz als ein Aufklapper kostet: + Aufmerksamkeit. + + --------------------------------------------------------------------- + WO SIE HINGEHÄNGT WIRD + + Nach der Kopfzeile, wenn es eine gibt — sonst als erstes Element im + Inhalt. Fünf Seiten haben keine `.kopf-zeile` (Chat, Kalender, + Start, Team, Teamlage); ohne den zweiten Fall hätten ausgerechnet + die am häufigsten benutzten Seiten keine Erklärung bekommen, und + zwar lautlos. + + --------------------------------------------------------------------- + SIE BAUT NICHTS UM, WENN NICHTS DA IST + + Kein Text zur Seite, keine Antwort, nicht angemeldet: dann entsteht + auch kein Kasten. Ein leerer Rahmen mit drei Überschriften wäre + schlechter als gar keiner — er sieht aus wie ein Fehler. + ===================================================================== */ +(() => { + const datei = (location.pathname.split('/').pop() || '').toLowerCase(); + if (!/^[a-z0-9-]+\.html$/.test(datei)) return; // Anmeldeseiten u. ä. + + const el = (t, k, text) => { + const n = document.createElement(t); + if (k) n.className = k; + if (text !== undefined) n.textContent = text; + return n; + }; + + /** Eine Zeile: Schild links, Satz rechts. */ + function zeile(schild, satz) { + const z = el('div', 'erkl__zeile'); + z.append(el('span', 'erkl__schild', schild)); + z.append(el('span', 'erkl__satz', satz)); + return z; + } + + function zeichne(d) { + const kasten = el('section', 'erkl'); + /* Ein Bereich mit Namen: Ein Vorleseprogramm sagt dadurch an, was + das hier ist, statt drei Wortpaare vorzulesen. */ + kasten.setAttribute('aria-label', 'Kurze Erklärung zu dieser Seite'); + + kasten.append(zeile('Wofür', d.wofuer)); + kasten.append(zeile('Du tust', d.tun)); + if (d.merke) { + const z = zeile('Merke', d.merke); + z.dataset.merke = 'ja'; + kasten.append(z); + } + if (Array.isArray(d.sichtbar) && d.sichtbar.length) { + /* DIESE ZEILE IST GERECHNET, NICHT GESCHRIEBEN -- sie kommt aus + derselben Rechtetafel wie die Schranke selbst. Deshalb darf + sie hier so bestimmt dastehen. */ + kasten.append(zeile('Sieht', d.sichtbar.join(' · '))); + } + + const kopf = document.querySelector('main .kopf-zeile'); + if (kopf) { kopf.insertAdjacentElement('afterend', kasten); return; } + const inhalt = document.querySelector('main'); + if (inhalt) inhalt.insertBefore(kasten, inhalt.firstElementChild); + } + + (async () => { + try { + const a = await fetch('/workspace/api/erklaerung?seite=' + encodeURIComponent(datei), + { credentials: 'same-origin' }); + if (!a.ok) return; // 401/404: dann eben nicht + const d = await a.json(); + if (d?.hat_text) zeichne(d); + } catch { /* ohne Erklärung ist die Seite vollständig benutzbar */ } + })(); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index ec1ac400..f38beb82 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. --> - - - - - - + + + + + + - + @@ -252,10 +252,11 @@ - - - - - + + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index eaec99c2..43436a31 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,11 @@
- - - - - + + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 9fdb9b20..1bb07653 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,15 @@ - - - - - - - - - + + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index e7d17b57..7fdc878e 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,12 @@ - - - - - - + + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index afc9a568..d5af0658 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,11 @@ - - - - - + + + + + + diff --git a/workspace/content.html b/workspace/content.html index 9633b5c5..1050ac4a 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,13 @@ - - - - - - - + + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 0b9da1c0..f9bdf226 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index abd628b0..1579fdc7 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609141634", + "src": "/assets/img/app-symbole/crew-192.png?v=202609141819", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609141634", + "src": "/assets/img/app-symbole/crew-512.png?v=202609141819", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141634", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141819", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141634", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141819", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 4a8ea19c..d823a527 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,11 @@ - - - - - + + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 412779e3..bbb1f3f5 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,10 @@ - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 1e14ba6e..eb474bce 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index ed865034..228de212 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,12 @@ - - - - - - + + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 60e6ed72..70209d03 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -412,10 +412,11 @@ - - - - - + + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 38dd17c5..565af7a9 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,11 @@ - - - - - + + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index ba9ae4fb..4eaea129 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,12 @@ - - - - - - + + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 35f4df79..dc4dc7b5 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index d74d021c..5fe1cbc9 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,12 @@ - - - - - - + + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index e018d18e..f9af26f5 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,11 @@ - - - - - + + + + + + diff --git a/workspace/start.html b/workspace/start.html index 3fd68827..f941dcfb 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,11 @@

- - - - - + + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 06989fd9..9748a798 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,12 @@ - - - - - - + + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 415c750d..3bc215b6 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,11 @@ - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 1ee13a84..678cb149 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,10 @@ - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index f7157bbe..8f46ae75 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,11 @@ - - - - - + + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 3da4ee20..9a2d0c88 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 4e3bd721..b59d7f44 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,10 @@ - - - - + + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 5cc49ad8..4aac4718 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,9 +190,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 8d7d7a4d..4d991c6a 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,12 @@ - + - - - - + + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 1424883a..936dd2ed 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,11 @@ - - - - - + + + + + +