diff --git a/server/crew-adresse.js b/server/crew-adresse.js
index 1abb1f05..238c5015 100644
--- a/server/crew-adresse.js
+++ b/server/crew-adresse.js
@@ -376,6 +376,16 @@ export function istWorkspacePfad(pfad) {
export const GEHOERT_ZU_ADRESSE = [
{ datei: "/workspace/crew-index.html", zuhause: istCrewAdresse },
{ datei: "/workspace/assets/css/crew-haus.css", zuhause: istCrewAdresse },
+ /* DER NOTIZBLOCK (26.09.2026). Er gehoert zum Haus von Team Dogi,
+ und zwar vollstaendig: Auf der Agenturadresse gibt es die Seite
+ nicht -- auch nicht fuer DogFather, der in beiden Haeusern
+ arbeitet. Das ist dieselbe Trennung wie bei Chat, Kalender und
+ Aufgaben; ohne diesen Eintrag waere die Seite dort einfach
+ abrufbar, weil express.static jede Datei ausliefert, die man
+ beim Namen nennt. Die Schnittstelle prueft es ein zweites Mal
+ (nurTeamDogi in workspace-notizen.js) -- die Seite ist nur HTML,
+ die Daten sind die Sache. */
+ { datei: "/workspace/notizen.html", zuhause: istCrewAdresse },
];
/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
diff --git a/server/index.js b/server/index.js
index 9ba234b7..74297970 100644
--- a/server/index.js
+++ b/server/index.js
@@ -38,6 +38,7 @@ import { uebernahmeRouter } from "./workspace-uebernahme.js";
import { befindenRouter } from "./workspace-befinden.js";
import { hilfeRouter } from "./workspace-hilfe.js";
import { supportRouter } from "./workspace-support.js";
+import { notizenRouter } from "./workspace-notizen.js";
import { unterstuetzungRouter } from "./workspace-unterstuetzung.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
@@ -312,6 +313,10 @@ app.use(uebernahmeRouter);
app.use(befindenRouter);
app.use(hilfeRouter);
app.use(supportRouter);
+/* Der Notizblock (26.09.2026): vier Rollen des Teamhauses, und jeder
+ sieht nur seinen eigenen -- auch DogFather. Begruendung im Kopf von
+ notiz-tabellen.js. */
+app.use(notizenRouter);
/* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas
beitragen kann – lesen alle, ändern nur DogFather. */
app.use(unterstuetzungRouter);
@@ -569,3 +574,17 @@ process.on("uncaughtException", (fehler) => console.error("[uncaughtException]",
app.listen(PORT, "127.0.0.1", () => {
console.log(`DogFather Universe – Server läuft auf http://127.0.0.1:${PORT}`);
});
+
+/* =====================================================================
+ DIE ANWENDUNG NACH AUSSEN (26.09.2026)
+
+ Nur fuer Pruefungen. `pruef-notizen` geht damit den Routenbaum von
+ express durch und sucht Wege, die zweimal vergeben sind -- genau so
+ ein Fall hat beim Bau des Notizblocks eine Stunde gekostet
+ (DELETE /workspace/api/notizen/:id gab es schon in
+ workspace-aufgaben.js, und express nimmt schweigend die erste).
+
+ Der Export aendert am Betrieb nichts: Wer diese Datei importiert,
+ startet den Server ohnehin -- das tut sie beim Laden. Er macht nur
+ sichtbar, was vorher nur im Speicher stand. */
+export default app;
diff --git a/server/mess-notizblock.mjs b/server/mess-notizblock.mjs
new file mode 100644
index 00000000..05cc7005
--- /dev/null
+++ b/server/mess-notizblock.mjs
@@ -0,0 +1,241 @@
+/* =====================================================================
+ WIE SIEHT DER NOTIZBLOCK AUS? (26.09.2026)
+
+ Ein Messwerkzeug, keine Pruefung: Es meldet nichts, es macht
+ Bilder. Rechner und Handy, leeres Blatt und beschriebenes.
+
+ node server/mess-notizblock.mjs
+
+ Filipe: „für die modis, rechte und linke hand und dogfather eine
+ kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
+ auch wie ein notizblock erstellst."
+
+ ---------------------------------------------------------------------
+ DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT
+
+ 1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst —
+ auch nicht über die Schnittstelle, auch nicht von der anderen
+ Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben,
+ sondern aus dem Programm gelesen und gegen die Rechtetafel
+ gehalten: Zwei Listen für dieselbe Frage laufen auseinander.
+
+ 2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht.
+ Das ist die Zusage, die auf der Kachel steht („nur du siehst
+ ihn"), und die einzige, deren Bruch man nicht bemerken würde:
+ Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber
+ genauso aus wie eine, die niemand liest.
+
+ 3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht
+ aus einem unsichtbaren Schreibfeld und einer sichtbaren
+ Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines
+ Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen
+ beide Schichten an verschiedenen Stellen um — und der Cursor
+ steht neben dem Text. Das sieht aus wie ein Browserfehler und
+ wäre einer in notizen.css. Gemessen wird deshalb am echten
+ Umbruch, nicht an der Absicht.
+
+ ---------------------------------------------------------------------
+ WARUM DIESE PRÜFUNG ÜBER https SPRICHT
+
+ Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com`
+ steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die
+ Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse
+ (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block
+ antwortet zu Recht mit 404.
+
+ Aufrufen mit: node server/pruef-notizen.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request } from "node:http";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+
+/* Eine feste Nummer -- `eigenerPort` leitet seine aus der Stelle im
+ Alphabet der PRUEFdateien ab, und dieses Werkzeug ist keine.
+ Geprueft wird trotzdem, ob sie frei ist: Sonst misst man an einem
+ fremden Server und bekommt ein Bild von einem anderen Stand. */
+const PORT = 5461;
+{
+ const netz = await import("node:net");
+ const frei = await new Promise((fertig) => {
+ const s = netz.createServer();
+ s.once("error", () => fertig(false));
+ s.once("listening", () => s.close(() => fertig(true)));
+ s.listen(PORT, "127.0.0.1");
+ });
+ if (!frei) { console.error(`Port ${PORT} ist belegt. Abgebrochen.`); process.exit(3); }
+}
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(200_000, "mess-notizblock");
+await new Promise((r) => setTimeout(r, 900));
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
+
+const CREW = "crew.dogfather-universe.com";
+const AGENTUR = "workspace.dogfather-universe.com";
+
+/* ---------- Zugänge anlegen -------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const nun = 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, nun);
+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"), nun);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
+ ["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]];
+const CODES = {};
+const IDS = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`;
+ IDS[rolle] = anlegen(name, rolle, CODES[rolle]);
+}
+d.close();
+
+/* ---------- Sprechen mit Hausadresse ------------------------------------
+ `fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile),
+ und an ihr entscheidet das Haus alles. Deshalb node:http. */
+function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: host,
+ Origin: origin === undefined ? `https://${host}` : origin,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, host) {
+ const a = await roh(host, "/workspace/api/anmelden", {
+ method: "POST", rumpf: { rolle, code: CODES[rolle] },
+ });
+ const keks = []
+ .concat(a.kopf["set-cookie"] || [])
+ .map((z) => z.split(";")[0]).join("; ");
+ return a.code === 200 && keks ? keks : null;
+}
+
+const kekse = { modi: await anmelden("modi", CREW) };
+if (!kekse.modi) { console.error("Anmeldung ging nicht"); process.exit(1); }
+
+const PROBE = [
+ "# Sendung am Freitag",
+ "",
+ "[x] Gaesteliste an VanVan",
+ "[ ] Technik durchgehen -- Ton am Mischpult",
+ "[ ] Ansage schreiben",
+ "",
+ "- Idee: Zuschauerfragen in der letzten halben Stunde",
+ "- Idee: Gewinnspiel, aber ohne Punkte",
+ "",
+ "> Nicht vergessen: Casper braucht um 19 Uhr seine Runde.",
+ "",
+ "Unterlagen: https://crew.dogfather-universe.com/workspace/dateien.html",
+].join(String.fromCharCode(10));
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const { execFileSync } = await import("node:child_process");
+const { createServer: httpsServer } = await import("node:https");
+const { request: httpAnfrage } = await import("node:http");
+const schluesselDatei = join(ordner, "m.key");
+const zertDatei = join(ordner, "m.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
+const HTTPS_PORT = PORT + 1;
+const vorbau = httpsServer({ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei) },
+ (von, nach) => {
+ const weiter = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url,
+ method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` } },
+ (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+ });
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+/* Drei Blaetter, damit der Stapel etwas zeigt. */
+const BR = String.fromCharCode(10);
+await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
+ rumpf: { text: ["Einkauf fuers Studio", "[ ] Kabel", "[ ] Kaffee"].join(BR) } });
+await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
+ rumpf: { text: ["Woerter, die ich nicht mehr sagen will", "- eigentlich", "- quasi"].join(BR),
+ farbe: "rosa" } });
+const haupt = await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
+ rumpf: { text: PROBE, farbe: "sonne" } });
+await roh(CREW, `/workspace/api/notizblock/${haupt.j.notiz.id}`, { method: "PATCH",
+ keks: kekse.modi, rumpf: { angeheftet: true } });
+
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"] });
+const kontext = await browser.newContext({ viewport: { width: 1440, height: 980 },
+ ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+await kontext.addCookies(kekse.modi.split("; ").filter(Boolean).map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+}));
+
+const seite = await kontext.newPage();
+await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
+await seite.waitForTimeout(1200);
+await seite.screenshot({ path: "server/mess-notizblock-rechner.png" });
+console.log("server/mess-notizblock-rechner.png");
+
+const handy = await kontext.newPage();
+await handy.setViewportSize({ width: 390, height: 844 });
+await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
+await handy.waitForTimeout(900);
+await handy.screenshot({ path: "server/mess-notizblock-handy-liste.png" });
+await handy.locator(".n-zettel").first().click();
+await handy.waitForTimeout(600);
+await handy.screenshot({ path: "server/mess-notizblock-handy-blatt.png" });
+console.log("server/mess-notizblock-handy-liste.png");
+console.log("server/mess-notizblock-handy-blatt.png");
+
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+process.exit(0);
diff --git a/server/notiz-tabellen.js b/server/notiz-tabellen.js
new file mode 100644
index 00000000..f47d5b66
--- /dev/null
+++ b/server/notiz-tabellen.js
@@ -0,0 +1,157 @@
+/* =====================================================================
+ DER NOTIZBLOCK — die Tabelle und die Regeln dahinter (26.09.2026)
+ =====================================================================
+
+ Filipe: „für die modis, rechte und linke hand und dogfather eine
+ kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
+ auch wie ein notizblock erstellst."
+
+ ---------------------------------------------------------------------
+ DIE EINE ENTSCHEIDUNG, DIE ALLES ANDERE NACH SICH ZIEHT
+
+ EIN NOTIZBLOCK GEHÖRT EINEM MENSCHEN. Nicht dem Team, nicht dem
+ Haus, nicht der Leitung. Wer seine Gedanken aufschreibt, schreibt
+ anders, wenn jemand mitlesen könnte — und dann ist es kein Block
+ mehr, sondern ein Bericht.
+
+ Deshalb gibt es hier KEINE Rolle, die mehr sieht. `siehtAlles()`
+ wird in diesem Modul nicht gefragt, und das ist Absicht: DogFather
+ sieht die Notizen eines Modis so wenig wie umgekehrt. Jede Abfrage
+ hat `person_id = ?` fest eingebaut, nicht als Bedingung, die man
+ weglassen könnte.
+
+ Das ist dieselbe Linie wie bei `befinden.html` („Ein paar Fragen
+ über dich, nur für dich. Niemand aus dem Team sieht deine
+ Antworten") — dort steht sie schon im Haus, hier wird sie
+ fortgesetzt.
+
+ ---------------------------------------------------------------------
+ WARUM ON DELETE CASCADE UND NICHT SET NULL
+
+ Bei Aufgaben ist es richtig, den Namen zu entfernen und die Sache
+ stehen zu lassen: Sie gehört dem Projekt. Eine Notiz gehört
+ niemandem sonst. Wird ein Zugang gelöscht, hat die Zeile keinen
+ Leser mehr — sie wäre ein Text ohne Menschen, den irgendwann jemand
+ in der Datenbank findet. Also geht sie mit.
+
+ ---------------------------------------------------------------------
+ DER PAPIERKORB IST EIN DATUM, KEINE ZWEITE TABELLE
+
+ `weg_am` gesetzt heißt „liegt im Papierkorb, seit …". Eine zweite
+ Tabelle wäre dieselbe Struktur noch einmal, und beim nächsten neuen
+ Feld hätte eine davon es nicht. Geräumt wird über die
+ Aufbewahrungsliste (workspace-aufbewahrung.js) — dort steht die
+ Frist, dort wird sie eingelöst. Ein Versprechen ohne Eintrag dort
+ ist kein Versprechen; genau das ist dem Support am 24.09. passiert.
+ ===================================================================== */
+
+/** Wie lange etwas im Papierkorb liegt, bevor es wirklich weg ist.
+ *
+ * Dreißig Tage. Kürzer wäre eine Falle für den, der drei Wochen im
+ * Urlaub war; länger würde aus dem Papierkorb ein zweites Archiv,
+ * und dann räumt ihn niemand mehr aus. */
+export const PAPIERKORB_TAGE = 30;
+
+/** Wie lang eine einzelne Notiz werden darf.
+ *
+ * Zwanzigtausend Zeichen sind rund acht eng beschriebene Seiten.
+ * Die Zahl steht nicht gegen den Benutzer, sondern gegen ein
+ * hängendes Skript: Ohne Grenze schreibt ein Fehler in einer Schleife
+ * die Platte voll, und das merkt man erst, wenn nichts mehr geht. */
+export const TEXT_MAX = 20000;
+
+/** Wie viele Blätter ein Block hat.
+ *
+ * Dreihundert. Wer so viele hat, sucht ohnehin über die Suche und
+ * nicht über die Liste — und auch diese Zahl steht gegen ein Skript,
+ * nicht gegen einen Menschen. */
+export const BLAETTER_MAX = 300;
+
+/* =====================================================================
+ DIE PAPIERFARBEN
+
+ Sechs, und keine davon ist ein Papierweiß. Auf einer dunklen Wand
+ wäre ein cremefarbenes Blatt eine Lampe — die Hausregel heißt
+ augenschonend, und sie gilt auch für ein Blatt Papier.
+
+ Die Farbe ist deshalb die TINTE: Rand, Linien und Kante des Blattes
+ tragen sie, die Fläche bleibt dunkel. Das ist näher an einer
+ schwarzen Kladde mit farbigem Lesebändchen als an einem Klebezettel
+ — und man kann sechs Blätter nebeneinander auseinanderhalten, ohne
+ dass eines blendet.
+
+ ALLE SECHS HALTEN 4,5:1 GEGEN DEN DUNKLEN GRUND (#0a121e) —
+ gemessen, nicht geschätzt, und nachgeprüft in pruef-notizen. Sie
+ tragen ja auch Text.
+
+ WARUM NICHT DIE 46 KACHELFARBEN: Die sind gegeneinander
+ auseinandergerechnet, damit man 46 Kacheln unterscheidet. Hier
+ müssen sechs auseinandergehen, und dafür sind größere, ruhigere
+ Abstände besser als sechs Nachbarn aus einer vollen Palette. */
+export const FARBEN = [
+ { name: "block", wort: "Block", hex: "#9cde90" },
+ { name: "sonne", wort: "Sonne", hex: "#e8bd63" },
+ { name: "rosa", wort: "Rosa", hex: "#f2a0c0" },
+ { name: "himmel", wort: "Himmel", hex: "#86c5f5" },
+ { name: "flieder", wort: "Flieder", hex: "#c3a6f2" },
+ { name: "minze", wort: "Minze", hex: "#7ee0c4" },
+];
+export const FARB_NAMEN = FARBEN.map((f) => f.name);
+
+/**
+ * Legt die Tabelle an. Wird aus workspace.js gerufen, nachdem
+ * `personen` steht — die Notiz hängt daran.
+ *
+ * ACHTUNG, EIN GEGEN-APOSTROPH IN DIESEM TEXT BEENDET DAS
+ * TEMPLATE-LITERAL. Das hat in diesem Haus schon zweimal den Start
+ * gekostet (support-tabellen.js, workspace.js). Deshalb steht in den
+ * Kommentaren hier drin kein einziger.
+ */
+export function notizTabellen(d) {
+ d.exec(`
+ CREATE TABLE IF NOT EXISTS notizen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ /* CASCADE: Eine Notiz ohne ihren Menschen ist ein Text, den
+ niemand lesen darf und niemand vermisst. */
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ /* DER GANZE TEXT IN EINEM FELD -- Titel, Haken, Absaetze.
+
+ Eine eigene Titelspalte waere die naheliegende Loesung und
+ die falsche: Dann muesste die Oberflaeche zwei Eingabefelder
+ haben, und ein Notizblock mit Formular ist kein Notizblock
+ mehr. Die erste Zeile IST der Titel, und zwar dort, wo sie
+ hingehoert -- im Text. */
+ text TEXT NOT NULL DEFAULT '',
+ farbe TEXT NOT NULL DEFAULT 'block',
+ /* Angeheftet: bleibt oben. Als Zahl und nicht als Datum -- hier
+ geht es um ein Ja oder Nein, und ein Datum waere ein Feld,
+ das etwas anderes verspricht, als es haelt. */
+ angeheftet INTEGER NOT NULL DEFAULT 0,
+ erstellt TEXT NOT NULL,
+ geaendert TEXT NOT NULL,
+ /* Im Papierkorb seit. NULL heisst: auf dem Block. */
+ weg_am TEXT
+ );
+
+ /* Die eine Frage, die dieses Modul stellt: „meine Blaetter, die
+ angehefteten zuerst, danach das zuletzt Angefasste". Der Index
+ beantwortet sie in genau dieser Reihenfolge. */
+ CREATE INDEX IF NOT EXISTS idx_notizen_person
+ ON notizen (person_id, weg_am, angeheftet DESC, geaendert DESC);
+ /* Und die Frage des Aufraeumers: „was liegt lange genug im
+ Papierkorb?" */
+ CREATE INDEX IF NOT EXISTS idx_notizen_weg
+ ON notizen (weg_am);
+ `);
+
+ /* DIE FARBPRUEFUNG STEHT NICHT IN DER TABELLE.
+
+ Ein CHECK (farbe IN (...)) waere die naheliegende Stelle -- und
+ die, an der die Liste ein zweites Mal steht. Waechst FARBEN oben
+ um eine Farbe, muesste die Tabelle umgebaut werden, damit ein
+ Wert erlaubt wird, der im Programm laengst gilt. Genau diese
+ Sorte Abschrift hat am 11.09. drei Spalten gekostet.
+
+ Geprueft wird deshalb an der Route, gegen FARB_NAMEN -- eine
+ Liste, die niemand pflegen muss, weil sie abgeleitet ist. */
+}
diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs
index 91e40c3e..5d3b3240 100644
--- a/server/pruef-buehne.mjs
+++ b/server/pruef-buehne.mjs
@@ -73,6 +73,10 @@ const ALLE_SEITEN = [
/* Neu am 21.09.2026 -- sie traegt eine Buehne und viel Text. */
"willkommen.html",
"unsere-seiten.html",
+ /* Neu am 26.09.2026: der Notizblock. Er traegt eine Buehne, und
+ auf ihm steht die leiseste Schrift des Hauses direkt ueber
+ einem Foto -- genau der Fall, fuer den diese Datei gebaut ist. */
+ "notizen.html",
];
const SEITEN = process.env.BUEHNE_SEITEN
? process.env.BUEHNE_SEITEN.split(",").map((s) => s.trim()).filter(Boolean)
diff --git a/server/pruef-crew-adresse.mjs b/server/pruef-crew-adresse.mjs
index fa5c0041..6f8cfbf1 100644
--- a/server/pruef-crew-adresse.mjs
+++ b/server/pruef-crew-adresse.mjs
@@ -645,11 +645,36 @@ for (const eintrag of GEHOERT_ZU_ADRESSE) {
ok((draussen.text || "").length < 200,
`workspace.: und es kommt auch kein Inhalt mit (${(draussen.text || "").length} Zeichen)`);
+ /* ==== DRINNEN HEISST NICHT IMMER 200 (26.09.2026) ==============
+
+ Hier stand „muss 200 sein, und mehr als 200 Zeichen lang".
+ Das stimmte, solange in der Tafel nur OEFFENTLICHE Dateien
+ standen -- die Eingangswand und eine Stilvorlage.
+
+ Mit notizen.html steht dort zum ersten Mal eine Seite HINTER
+ der Anmeldung. Ohne Sitzung antwortet sie mit 302 auf die
+ Anmeldung, und das ist richtig so. Die Pruefung meldete
+ trotzdem einen Fehler -- nicht am Haus, sondern an ihrer
+ eigenen, zu engen Erwartung.
+
+ Die Frage dieser Tafel ist nicht „liefert es Inhalt aus?",
+ sondern „GIBT es das hier?". Genau das wird jetzt gemessen:
+ drinnen alles ausser 404/410, draussen nur 404/410. Beide
+ Richtungen bleiben, nur die Innenseite ist nicht mehr auf eine
+ einzige Zahl festgelegt. */
const drinnen = await hole(weg, { host: CREW });
- ok(drinnen.status === 200,
- `crew.: ${weg} kommt dort an (${drinnen.status})`);
- ok((drinnen.text || "").length > 200,
- `crew.: und zwar mit Inhalt (${(drinnen.text || "").length} Zeichen)`);
+ ok(drinnen.status !== 404 && drinnen.status !== 410,
+ `crew.: ${weg} gibt es dort (${drinnen.status})`);
+ /* Und eine offene Datei kommt dort auch wirklich an. Bei einer
+ Seite hinter der Anmeldung waere „mehr als 200 Zeichen" eine
+ Aussage ueber die Weiterleitung, nicht ueber die Seite. */
+ if (drinnen.status === 200) {
+ ok((drinnen.text || "").length > 200,
+ `crew.: und zwar mit Inhalt (${(drinnen.text || "").length} Zeichen)`);
+ } else {
+ ok(drinnen.status === 302 || drinnen.status === 401 || drinnen.status === 403,
+ `crew.: und schickt ohne Anmeldung zur Anmeldung (${drinnen.status})`);
+ }
}
/* DIE UMBIEGUNG DARF DIE SPERRE NICHT MITNEHMEN.
diff --git a/server/pruef-kachelfarben.mjs b/server/pruef-kachelfarben.mjs
index f9e7c754..f383f75a 100644
--- a/server/pruef-kachelfarben.mjs
+++ b/server/pruef-kachelfarben.mjs
@@ -133,6 +133,63 @@ for (const r of rollen) {
/* DIE ZAHL GEHÖRT IN DIE BEDINGUNG. Fände diese Prüfung null benutzte
Töne, wäre alles Folgende trivial grün — und bliebe es für immer. */
+/* =====================================================================
+ DIE ZWEITE QUELLE -- UND DER BLINDE FLECK, DEN SIE SCHLIESST
+ ===================================================================== (26.09.2026)
+
+ `benutzt` oben entsteht aus bereicheFuer() je Rolle. Das ist die
+ richtige Quelle fuer die Frage „welche Kachel traegt welchen Ton
+ in welcher Ansicht" -- und es ist die falsche fuer die Frage
+ „welche Toene sind ueberhaupt in Gebrauch".
+
+ Denn fuer die fuenf Rollen des AGENTURHAUSES gibt bereicheFuer()
+ `null` zurueck. Das heisst „nimm die Liste aus dem Browser", und
+ die steht in workspace/assets/js/bereiche.js. Dort stehen die
+ Toene 1 bis 22 und 44 -- zweiundzwanzig Stueck, und keiner davon
+ ist hier je angekommen.
+
+ WAS DAS GEKOSTET HAT: Diese Pruefung meldete „33 benutzte Toene"
+ und war gruen. Am 25.09. hat ein Farblauf daraufhin zwoelf
+ vermeintlich freie Toene verschoben und drei davon unter die
+ Buntheitsgrenze gedrueckt -- darunter „Dashboard", „Zahlen" und
+ „Scout-Pipeline", Kacheln, die jeden Tag jemand ansieht. Die
+ Pruefung blieb gruen. Ausserdem lagen Ton 7, 16 und 22 SEIT JEHER
+ unter der Grenze, ohne dass es je gemeldet wurde.
+
+ Das ist die Sorte gruener Haken, vor der die Hausregel warnt: Er
+ sagt nur, dass die Bedingung erfuellt war -- nicht, dass sie das
+ Richtige angesehen hat.
+
+ Ab hier gelten die REGELN (Kontrast, Buntheit) fuer jeden Ton, der
+ irgendwo an einer Kachel steht. Gelesen werden dieselben fuenf
+ Quellen wie in pruef-kachel-universum. Die Fragen nach ANSICHTEN
+ (zwei gleiche nebeneinander, die Willkommenskachel) bleiben bei
+ `benutzt` -- dort geht es wirklich um das, was der Server
+ ausgibt. */
+const QUELLEN = ["workspace.js", "../workspace/assets/js/bereiche.js",
+ "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js",
+ "../workspace/assets/js/befinden.js"];
+const IN_GEBRAUCH = new Set();
+/* Und WELCHE Kachel einen Ton traegt -- auch das aus den Quellen,
+ nicht nur aus dem, was der Server ausgibt. Gesucht wird der Name
+ VOR der Tonnummer im selben Objekt; `[^{}]*?` sorgt dafuer, dass
+ die Suche an der naechsten geschweiften Klammer endet und nicht
+ den Namen der vorigen Kachel einsammelt. */
+const TRAEGT = new Map();
+for (const q of QUELLEN) {
+ const text = readFileSync(join(HIER, q), "utf8");
+ for (const m of text.matchAll(/ton:\s*(\d+)/g)) IN_GEBRAUCH.add(Number(m[1]));
+ for (const m of text.matchAll(/name:\s*["']([^"']+)["'][^{}]*?ton:\s*(\d+)/g)) {
+ const n = Number(m[2]);
+ if (!TRAEGT.has(n)) TRAEGT.set(n, new Set());
+ TRAEGT.get(n).add(m[1]);
+ }
+}
+pruef(`${IN_GEBRAUCH.size} Toene stehen an einer Kachel -- gelesen aus `
+ + `${QUELLEN.length} Dateien, nicht nur aus dem, was der Server ausgibt`,
+ IN_GEBRAUCH.size >= benutzt.size,
+ `nur ${IN_GEBRAUCH.size} gefunden, der Server gibt schon ${benutzt.size} aus`);
+
pruef(`es gibt benutzte Kachelfarben (${benutzt.size} Toene, ${ansichten.length} Ansichten)`,
benutzt.size >= 20 && ansichten.length >= 5,
`nur ${benutzt.size} Toene in ${ansichten.length} Ansichten gefunden`);
@@ -243,7 +300,13 @@ console.log("\n=== Keine Ansicht zeigt zwei gleiche ===");
/* ---- 3. Festgelegt -------------------------------------------------- */
console.log("\n=== Was festgelegt ist, bleibt ===");
for (const f of FESTGELEGT) {
- const traeger = [...(benutzt.get(f.ton) || [])];
+ /* AUS BEIDEN QUELLEN. Bis zum 26.09. stand hier nur `benutzt` --
+ die Kacheln, die der Server ausgibt. Ton 1 traegt „Dashboard"
+ im Agenturhaus, und das steht im Browser; die Zeile meldete
+ deshalb „traegt stattdessen: nichts" fuer eine Kachel, die es
+ gibt. */
+ const traeger = [...new Set([...(benutzt.get(f.ton) || []),
+ ...(TRAEGT.get(f.ton) || [])])];
pruef(`Ton ${f.ton} traegt "${f.kachel}" (${f.warum})`,
traeger.includes(f.kachel),
`Ton ${f.ton} traegt stattdessen: ${traeger.join(", ") || "nichts"}`);
@@ -263,15 +326,23 @@ console.log("\n=== Lesbar und kraeftig ===");
beim naechsten Mal vergessen wird. */
const darfBlassSein = new Map(
FESTGELEGT.filter((f) => f.blassErlaubt).map((f) => [f.ton, f.blassErlaubt]));
- for (const n of benutzt.keys()) {
+ /* WIE BLASS GENAU -- eine Ausnahme mit Zahl statt eines Freibriefs
+ (26.09.2026). Vorher hiess `blassErlaubt` schlicht „diese
+ Pruefung sieht hier weg"; ein Ton haette danach beliebig
+ verblassen koennen, ohne dass es auffaellt. Jetzt steht neben
+ jeder Ausnahme, wie weit sie reicht. */
+ const blassBis = new Map(
+ FESTGELEGT.filter((f) => f.blassBis).map((f) => [f.ton, f.blassBis]));
+ for (const n of IN_GEBRAUCH) {
const hex = farben.get(n);
if (!hex) continue;
if (kontrast(hex) < 4.5) dunkel.push(`Ton ${n} ${hex} (${kontrast(hex).toFixed(2)}:1)`);
- if (buntheit(hex) < MINDEST_BUNTHEIT && !darfBlassSein.has(n)) {
- blass.push(`Ton ${n} ${hex} (${buntheit(hex).toFixed(3)})`);
+ const grenze = blassBis.get(n) ?? MINDEST_BUNTHEIT;
+ if (buntheit(hex) < grenze) {
+ blass.push(`Ton ${n} ${hex} (${buntheit(hex).toFixed(3)}, erlaubt ab ${grenze})`);
}
}
- pruef(`alle ${benutzt.size} benutzten Toene erreichen 4,5:1 gegen den Grund`,
+ pruef(`alle ${IN_GEBRAUCH.size} Toene an Kacheln erreichen 4,5:1 gegen den Grund`,
dunkel.length === 0, dunkel.join(", "));
pruef(`keiner ist blasser als ${MINDEST_BUNTHEIT}`
+ (darfBlassSein.size ? ` (ausser ${darfBlassSein.size} benannten)` : ""),
@@ -287,7 +358,7 @@ console.log("\n=== Lesbar und kraeftig ===");
if (!hex) { pruef(`Ton ${ton} ist festgelegt, steht aber nicht in start.css`, false); continue; }
let naechste = Infinity, wer = "-";
for (const [m, h2] of farben) {
- if (m === ton || !benutzt.has(m)) continue;
+ if (m === ton || !IN_GEBRAUCH.has(m)) continue;
const d = abstand(oklab(hex), oklab(h2));
if (d < naechste) { naechste = d; wer = `Ton ${m}`; }
}
diff --git a/server/pruef-notizen.mjs b/server/pruef-notizen.mjs
new file mode 100644
index 00000000..a3af8149
--- /dev/null
+++ b/server/pruef-notizen.mjs
@@ -0,0 +1,690 @@
+/* =====================================================================
+ DER NOTIZBLOCK (26.09.2026)
+
+ Filipe: „für die modis, rechte und linke hand und dogfather eine
+ kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
+ auch wie ein notizblock erstellst."
+
+ ---------------------------------------------------------------------
+ DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT
+
+ 1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst —
+ auch nicht über die Schnittstelle, auch nicht von der anderen
+ Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben,
+ sondern aus dem Programm gelesen und gegen die Rechtetafel
+ gehalten: Zwei Listen für dieselbe Frage laufen auseinander.
+
+ 2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht.
+ Das ist die Zusage, die auf der Kachel steht („nur du siehst
+ ihn"), und die einzige, deren Bruch man nicht bemerken würde:
+ Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber
+ genauso aus wie eine, die niemand liest.
+
+ 3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht
+ aus einem unsichtbaren Schreibfeld und einer sichtbaren
+ Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines
+ Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen
+ beide Schichten an verschiedenen Stellen um — und der Cursor
+ steht neben dem Text. Das sieht aus wie ein Browserfehler und
+ wäre einer in notizen.css. Gemessen wird deshalb am echten
+ Umbruch, nicht an der Absicht.
+
+ ---------------------------------------------------------------------
+ WARUM DIESE PRÜFUNG ÜBER https SPRICHT
+
+ Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com`
+ steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die
+ Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse
+ (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block
+ antwortet zu Recht mit 404.
+
+ Aufrufen mit: node server/pruef-notizen.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request } from "node:http";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Notizblockpruefung");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(260_000, "pruef-notizen");
+await new Promise((r) => setTimeout(r, 900));
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
+
+const CREW = "crew.dogfather-universe.com";
+const AGENTUR = "workspace.dogfather-universe.com";
+
+/* ---------- Zugänge anlegen -------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const nun = 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, nun);
+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"), nun);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
+ ["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]];
+const CODES = {};
+const IDS = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`;
+ IDS[rolle] = anlegen(name, rolle, CODES[rolle]);
+}
+d.close();
+
+/* ---------- Sprechen mit Hausadresse ------------------------------------
+ `fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile),
+ und an ihr entscheidet das Haus alles. Deshalb node:http. */
+function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: host,
+ Origin: origin === undefined ? `https://${host}` : origin,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, host) {
+ const a = await roh(host, "/workspace/api/anmelden", {
+ method: "POST", rumpf: { rolle, code: CODES[rolle] },
+ });
+ const keks = []
+ .concat(a.kopf["set-cookie"] || [])
+ .map((z) => z.split(";")[0]).join("; ");
+ return a.code === 200 && keks ? keks : null;
+}
+
+/* =======================================================================
+ 1. WER DARF — und woher die Liste kommt
+ ======================================================================= */
+melde("=== 1. Wer einen Block hat ===");
+
+const { ROLLEN_MIT_BLOCK } = await import("./workspace-notizen.js");
+const { SEITEN } = await import("./rechte.js");
+{
+ const ausTafel = [...(SEITEN["/workspace/notizen.html"] || [])].sort();
+ const ausProgramm = [...ROLLEN_MIT_BLOCK].sort();
+ ok(ausTafel.length === 4,
+ `die Rechtetafel nennt vier Rollen (${ausTafel.join(", ") || "keine"})`);
+ /* BEIDE SEITEN GEGENEINANDER. Die Tafel bewacht die Seite, die
+ Menge im Modul die Daten. Wer nur eine pflegt, baut entweder eine
+ Tuer ohne Raum oder einen Raum ohne Tuer -- und beides faellt
+ niemandem auf. */
+ ok(JSON.stringify(ausTafel) === JSON.stringify(ausProgramm),
+ `Tafel und Schnittstelle nennen dieselben Rollen (${ausProgramm.join(", ")})`,
+ );
+ ok(ausTafel.includes("modi") && ausTafel.includes("hand")
+ && ausTafel.includes("linke") && ausTafel.includes("admin"),
+ "und es sind genau die vier aus dem Auftrag");
+}
+
+const kekse = {};
+for (const [rolle] of ROLLEN) kekse[rolle] = await anmelden(rolle, CREW);
+ok(["admin", "hand", "linke", "modi"].every((r) => kekse[r]),
+ "die vier melden sich auf der Team-Adresse an");
+
+melde("");
+melde("=== 2. Und wer nicht ===");
+for (const rolle of ["gast", "creator"]) {
+ if (!kekse[rolle]) { ok(true, `${rolle}: kommt auf dieser Adresse gar nicht herein`); continue; }
+ const a = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] });
+ ok(a.code === 404, `${rolle}: bekommt 404 an der Schnittstelle (${a.code})`);
+}
+{
+ /* DIE ANDERE ADRESSE. DogFather arbeitet in beiden Haeusern -- und
+ genau deshalb ist er hier der richtige Pruefstein: Bei ihm
+ scheitert es nicht an der Rolle, sondern an der Adresse. */
+ const keksAgentur = await anmelden("admin", AGENTUR);
+ ok(!!keksAgentur, "DogFather meldet sich auch auf der Agenturadresse an");
+ const a = await roh(AGENTUR, "/workspace/api/notizblock", { keks: keksAgentur });
+ ok(a.code === 404, `und bekommt dort 404 auf seinen Block (${a.code})`);
+ const s = await roh(AGENTUR, "/workspace/notizen.html", { keks: keksAgentur });
+ ok(s.code === 404, `auch die Seite selbst gibt es dort nicht (${s.code})`);
+ const s2 = await roh(CREW, "/workspace/notizen.html", { keks: kekse.admin });
+ ok(s2.code === 200, `auf der Team-Adresse schon (${s2.code})`);
+}
+{
+ const ohne = await roh(CREW, "/workspace/api/notizblock");
+ ok(ohne.code === 401, `ohne Anmeldung: 401 (${ohne.code})`);
+}
+
+/* =======================================================================
+ 3. SCHREIBEN, LESEN, ÄNDERN
+ ======================================================================= */
+melde("");
+melde("=== 3. Ein Blatt ===");
+let blattId = null;
+{
+ const leer = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ ok(leer.code === 200 && Array.isArray(leer.j?.notizen) && leer.j.notizen.length === 0,
+ `am Anfang ist der Block leer (${leer.j?.notizen?.length})`);
+ ok(Array.isArray(leer.j?.farben) && leer.j.farben.length >= 4,
+ `die Papierfarben kommen vom Server (${leer.j?.farben?.length})`);
+
+ const neu = await roh(CREW, "/workspace/api/notizblock", {
+ method: "POST", keks: kekse.modi, rumpf: { text: "Erste Zeile\nZweite Zeile" },
+ });
+ blattId = neu.j?.notiz?.id;
+ ok(neu.code === 201 && blattId > 0, `ein Blatt anlegen geht (${neu.code})`);
+ ok(neu.j?.notiz?.titel === "Erste Zeile",
+ `der Titel wird aus der ersten Zeile abgeleitet ("${neu.j?.notiz?.titel}")`);
+
+ const aend = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" },
+ });
+ ok(aend.code === 200 && typeof aend.j?.geaendert === "string",
+ `aendern geht und gibt den Zeitstempel zurueck (${aend.code})`);
+
+ const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ ok(nach.j?.notizen?.[0]?.text?.includes("[x] Brot"), "und der Text steht drin");
+ ok(nach.j?.notizen?.[0]?.titel === "Einkauf",
+ `die Raute zaehlt nicht zum Titel ("${nach.j?.notizen?.[0]?.titel}")`);
+}
+{
+ const falsch = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { farbe: "knallorange" },
+ });
+ ok(falsch.code === 400, `eine erfundene Papierfarbe wird abgelehnt (${falsch.code})`);
+ const echt = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { farbe: "rosa" },
+ });
+ ok(echt.code === 200, `eine echte nicht (${echt.code})`);
+}
+{
+ const lang = "x".repeat(30000);
+ const a = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { text: lang },
+ });
+ const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ const drin = nach.j?.notizen?.find((n) => n.id === blattId)?.text || "";
+ /* ABGESCHNITTEN UND NICHT ABGELEHNT. Wer zwanzigtausend Zeichen
+ getippt hat, soll nicht alles verlieren, weil er hundert zu viel
+ hatte -- er soll merken, dass hier Schluss ist. */
+ ok(a.code === 200 && drin.length === nach.j.text_max,
+ `zu langer Text wird gekuerzt statt abgelehnt (${drin.length} von ${nach.j?.text_max})`);
+ await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" },
+ });
+}
+{
+ const fremd = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { text: "x" }, origin: "https://boese.example",
+ });
+ ok(fremd.code === 403, `eine Anfrage von einer fremden Seite wird abgewiesen (${fremd.code})`);
+}
+
+/* =======================================================================
+ 4. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN
+
+ Die Zusage steht auf der Kachel. Sie ist auch die einzige, deren
+ Bruch der Schreiber nicht bemerken würde.
+ ======================================================================= */
+melde("");
+melde("=== 4. Niemand sonst ===");
+for (const rolle of ["admin", "hand", "linke"]) {
+ const liste = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] });
+ const fremdeDrin = (liste.j?.notizen || []).some((n) => n.id === blattId);
+ ok(liste.code === 200 && !fremdeDrin,
+ `${rolle}: sieht das Blatt des Modis nicht in seiner Liste`);
+ const holen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse[rolle], rumpf: { text: "hier war ich" },
+ });
+ ok(holen.code === 404, `${rolle}: kann es auch nicht aendern (${holen.code})`);
+ const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "DELETE", keks: kekse[rolle],
+ });
+ ok(weg.code === 404, `${rolle}: und nicht wegwerfen (${weg.code})`);
+}
+{
+ /* GEGENPROBE. Ohne sie wuerde die Messung oben auch dann gruen, wenn
+ das Blatt gar nicht existiert. */
+ const eigen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "PATCH", keks: kekse.modi, rumpf: { angeheftet: true },
+ });
+ ok(eigen.code === 200, `Gegenprobe: dem Modi selbst gelingt genau dasselbe (${eigen.code})`);
+ const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ ok(nach.j?.notizen?.[0]?.angeheftet === true, "und das Anheften kommt an");
+}
+
+/* =======================================================================
+ 5. DER PAPIERKORB
+ ======================================================================= */
+melde("");
+melde("=== 5. Der Papierkorb ===");
+{
+ const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, {
+ method: "DELETE", keks: kekse.modi,
+ });
+ ok(weg.code === 200, `wegtun geht (${weg.code})`);
+ const block = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ ok(!(block.j?.notizen || []).some((n) => n.id === blattId), "und es ist vom Block verschwunden");
+ ok(block.j?.papierkorb === 1, `der Papierkorb zaehlt es (${block.j?.papierkorb})`);
+
+ const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi });
+ const drin = (korb.j?.notizen || []).find((n) => n.id === blattId);
+ ok(!!drin, "es liegt im Papierkorb");
+ ok(typeof drin?.endgueltig_am === "string"
+ && Date.parse(drin.endgueltig_am) > Date.now(),
+ `und es steht dabei, wann es von selbst geht (${drin?.endgueltig_am?.slice(0, 10)})`);
+
+ const zurueck = await roh(CREW, `/workspace/api/notizblock/${blattId}/zurueck`, {
+ method: "POST", keks: kekse.modi,
+ });
+ ok(zurueck.code === 200, `zurueckholen geht (${zurueck.code})`);
+ const block2 = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ ok((block2.j?.notizen || []).some((n) => n.id === blattId), "und es ist wieder da");
+}
+{
+ /* ENDGUELTIG NUR AUS DEM PAPIERKORB. Vom Block aus gibt es diesen
+ Weg nicht -- wer danebentippt, soll nicht alles verlieren. */
+ const zuFrueh = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, {
+ method: "DELETE", keks: kekse.modi,
+ });
+ ok(zuFrueh.code === 404,
+ `endgueltig loeschen geht vom Block aus NICHT (${zuFrueh.code})`);
+ await roh(CREW, `/workspace/api/notizblock/${blattId}`, { method: "DELETE", keks: kekse.modi });
+ const jetzt = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, {
+ method: "DELETE", keks: kekse.modi,
+ });
+ ok(jetzt.code === 200, `aus dem Papierkorb schon (${jetzt.code})`);
+ const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi });
+ ok((korb.j?.notizen || []).length === 0, "und dann ist es wirklich weg");
+}
+
+/* =======================================================================
+ 6. DAS AUFRÄUMEN HÄLT SEINE FRIST
+
+ Eine Frist ohne Werkzeug ist ein Satz in einem Kommentar. Geprüft
+ wird deshalb an echten Daten, in beide Richtungen.
+ ======================================================================= */
+melde("");
+melde("=== 6. Dreissig Tage ===");
+{
+ const { notizenAufraeumen } = await import("./workspace-notizen.js");
+ const { PAPIERKORB_TAGE } = await import("./notiz-tabellen.js");
+ const { db } = await import("./workspace.js");
+ const alt = new Date(Date.now() - (PAPIERKORB_TAGE + 2) * 86400000).toISOString();
+ const frisch = new Date(Date.now() - 86400000).toISOString();
+ const einfuegen = (weg) => db().prepare(
+ "INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert, weg_am)"
+ + " VALUES (?,?,?,0,?,?,?) RETURNING id")
+ .get(IDS.modi, "Probe", "block", nun, nun, weg).id;
+ const idAlt = einfuegen(alt);
+ const idNeu = einfuegen(frisch);
+ const idOffen = db().prepare(
+ "INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert)"
+ + " VALUES (?,?,?,0,?,?) RETURNING id").get(IDS.modi, "Auf dem Block", "block", nun, nun).id;
+
+ const weg = notizenAufraeumen();
+ const da = (id) => !!db().prepare("SELECT 1 FROM notizen WHERE id = ?").get(id);
+ ok(weg === 1, `genau eines wird geraeumt (${weg})`);
+ ok(!da(idAlt), `das alte ist weg (nach ${PAPIERKORB_TAGE} Tagen)`);
+ ok(da(idNeu), "das von gestern liegt noch da");
+ /* DIE WICHTIGERE HAELFTE: Was auf dem Block liegt, wird NIE
+ geraeumt. Ohne diese Zeile waere ein Aufraeumer, der alles
+ loescht, hier gruen. */
+ ok(da(idOffen), "und was gar nicht weggelegt wurde, bleibt unberuehrt");
+ db().prepare("DELETE FROM notizen WHERE id IN (?,?)").run(idNeu, idOffen);
+}
+{
+ /* Steht die Frist auch in der Aufbewahrungsliste? Sie ist die
+ einzige Stelle im Haus, die wirklich raeumt -- am 24.09. war
+ genau dieser Eintrag beim Support vergessen worden, und die
+ Meldungen waeren fuer immer liegen geblieben. */
+ const { FRISTEN } = await import("./workspace-aufbewahrung.js");
+ const eintrag = (FRISTEN || []).find((e) => e.schluessel === "notizen_papierkorb");
+ ok(!!eintrag, "der Papierkorb steht in der Aufbewahrungsliste");
+ ok(typeof eintrag?.raeumen === "function",
+ "und hat ein Werkzeug, das ihn wirklich raeumt");
+}
+
+/* =======================================================================
+ 7. DAS BLATT AUF DEM BILDSCHIRM
+
+ Jetzt der Teil, den keine Schnittstelle beantworten kann.
+ ======================================================================= */
+melde("");
+melde("=== 7. Das Blatt auf dem Bildschirm ===");
+
+const { execFileSync } = await import("node:child_process");
+const { createServer: httpsServer } = await import("node:https");
+const { request: httpAnfrage } = await import("node:http");
+
+const schluesselDatei = join(ordner, "pruef.key");
+const zertDatei = join(ordner, "pruef.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
+ { stdio: "ignore" });
+
+const HTTPS_PORT = PORT + 1;
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`,
+ "--ignore-certificate-errors"],
+});
+const kontext = await browser.newContext({
+ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true,
+});
+await kontext.addCookies((kekse.modi || "").split("; ").filter(Boolean).map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+}));
+const seite = await kontext.newPage();
+const konsole = [];
+seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+
+await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
+await seite.waitForTimeout(700);
+
+ok(await seite.locator("#block").count() === 1, "die Seite kommt an");
+
+/* Ein Blatt anlegen und Text hineinschreiben, der alle Auszeichnungen
+ enthaelt -- und eine SEHR lange Zeile, die umbrechen MUSS. Genau an
+ ihr faellt eine Breitenaenderung auf: Brechen die zwei Schichten an
+ verschiedenen Stellen um, sind sie verschieden hoch. */
+const PROBE = [
+ "# Eine Ueberschrift",
+ "[ ] Offener Punkt",
+ "[x] Erledigter Punkt",
+ "- Ein Aufzaehlungspunkt",
+ "> Ein Zitat",
+ "Ein Link: https://example.org/pfad",
+ "Eine sehr lange Zeile, die auf jeden Fall umbrechen muss, damit man sieht, "
+ + "ob beide Schichten an derselben Stelle umbrechen, und deshalb ist sie so "
+ + "lang wie sie ist, mit vielen Woertern hintereinander ohne Punkt und Komma "
+ + "damit der Umbruch wirklich mitten im Satz passiert und nicht am Ende.",
+].join("\n");
+
+await seite.click("#neu");
+await seite.waitForTimeout(400);
+await seite.fill("#feld", PROBE);
+await seite.waitForTimeout(400);
+
+{
+ const m = await seite.evaluate(() => {
+ const f = document.getElementById("feld");
+ const dk = document.getElementById("deck");
+ const cf = getComputedStyle(f), cd = getComputedStyle(dk);
+ const gleich = (n) => cf[n] === cd[n];
+ return {
+ hoeheFeld: f.scrollHeight, hoeheDeck: dk.scrollHeight,
+ breiteFeld: f.clientWidth, breiteDeck: dk.clientWidth,
+ schriftGleich: ["fontFamily", "fontSize", "fontWeight", "fontStyle",
+ "letterSpacing", "wordSpacing", "lineHeight", "whiteSpace",
+ "overflowWrap", "tabSize", "paddingLeft", "paddingRight",
+ "paddingTop"].filter((n) => !gleich(n)),
+ /* Und dasselbe fuer JEDES ausgezeichnete Stueck im Deck: Wenn
+ eine Auszeichnung die Schrift veraendert, steht es hier. */
+ stuecke: [...dk.querySelectorAll("span, a")].map((s) => {
+ const c = getComputedStyle(s);
+ return ["fontFamily", "fontSize", "fontWeight", "fontStyle",
+ "letterSpacing", "wordSpacing"]
+ .filter((n) => c[n] !== cf[n])
+ .map((n) => `${s.className || s.tagName}.${n}=${c[n]}`);
+ }).flat(),
+ haken: dk.querySelectorAll(".n-hk").length,
+ hakenAn: dk.querySelectorAll('.n-hk[data-an="ja"]').length,
+ links: dk.querySelectorAll("a.n-link").length,
+ ueber: dk.querySelectorAll(".n-h").length,
+ text: dk.textContent,
+ };
+ });
+
+ ok(m.schriftGleich.length === 0,
+ `beide Schichten schreiben gleich (${m.schriftGleich.join(", ") || "alles gleich"})`);
+ ok(m.stuecke.length === 0,
+ `keine Auszeichnung aendert die Schrift (${m.stuecke.slice(0, 3).join(", ") || "keine"})`);
+ ok(m.breiteFeld === m.breiteDeck,
+ `beide sind gleich breit (${m.breiteFeld} / ${m.breiteDeck})`);
+ /* DIE EIGENTLICHE MESSUNG. Zwei Bildpunkte Spielraum fuers Runden;
+ ein verschobener Umbruch kostet eine ganze Zeile (30 px). */
+ ok(Math.abs(m.hoeheFeld - m.hoeheDeck) <= 2,
+ `und gleich hoch -- der Umbruch sitzt an derselben Stelle `
+ + `(${m.hoeheFeld} / ${m.hoeheDeck})`);
+ ok(m.text.replace(/\s+$/, "") === PROBE,
+ "die Deckschicht zeigt genau den Text, Zeichen fuer Zeichen");
+ ok(m.haken === 2 && m.hakenAn === 1,
+ `zwei Kaestchen, eines angehakt (${m.haken}/${m.hakenAn})`);
+ ok(m.links === 1, `die Adresse ist anklickbar (${m.links})`);
+ ok(m.ueber === 1, `die Ueberschrift ist ausgezeichnet (${m.ueber})`);
+}
+
+/* GEGENPROBE ZUR HOEHENMESSUNG. Ohne sie wuerde die Zeile oben auch
+ dann gruen, wenn die Messung gar nichts ansieht. Ein Fettdruck auf
+ der Deckschicht MUSS sie rot machen. */
+{
+ const unterschied = await seite.evaluate(() => {
+ const f = document.getElementById("feld");
+ const dk = document.getElementById("deck");
+ const vorher = Math.abs(f.scrollHeight - dk.scrollHeight);
+ dk.style.fontWeight = "800";
+ dk.style.letterSpacing = "0.4px";
+ const nachher = Math.abs(f.scrollHeight - dk.scrollHeight);
+ dk.style.fontWeight = ""; dk.style.letterSpacing = "";
+ return { vorher, nachher };
+ });
+ ok(unterschied.nachher > 2,
+ `Gegenprobe: Fettdruck auf der Deckschicht bricht die Deckung `
+ + `(${unterschied.vorher} -> ${unterschied.nachher} px Unterschied)`);
+}
+
+/* Ein Klick auf ein Kaestchen schaltet es um. */
+{
+ const vorher = await seite.inputValue("#feld");
+ await seite.locator("#deck .n-hk").first().click();
+ await seite.waitForTimeout(250);
+ const nachher = await seite.inputValue("#feld");
+ ok(vorher.includes("[ ] Offener Punkt") && nachher.includes("[x] Offener Punkt"),
+ "ein Klick auf das Kaestchen setzt den Haken");
+ ok(nachher.length === vorher.length,
+ `und der Text bleibt zeichengenau gleich lang (${nachher.length}/${vorher.length})`);
+ await seite.locator("#deck .n-hk").first().click();
+ await seite.waitForTimeout(250);
+ ok((await seite.inputValue("#feld")).includes("[ ] Offener Punkt"),
+ "noch ein Klick nimmt ihn wieder weg");
+}
+
+/* Die Tastatur fuehrt die Liste fort. */
+{
+ await seite.fill("#feld", "- Erster Punkt");
+ await seite.click("#feld");
+ await seite.keyboard.press("Control+End");
+ await seite.keyboard.press("Enter");
+ await seite.keyboard.type("Zweiter");
+ await seite.waitForTimeout(200);
+ const t = await seite.inputValue("#feld");
+ ok(t === "- Erster Punkt\n- Zweiter",
+ `Enter setzt die Aufzaehlung fort ("${t.replace(/\n/g, "\\n")}")`);
+
+ await seite.fill("#feld", "[x] Fertig");
+ await seite.click("#feld");
+ await seite.keyboard.press("Control+End");
+ await seite.keyboard.press("Enter");
+ await seite.keyboard.type("Noch offen");
+ await seite.waitForTimeout(200);
+ const t2 = await seite.inputValue("#feld");
+ /* DER HAKEN WIRD NICHT MITGENOMMEN -- die naechste Aufgabe ist noch
+ nicht erledigt. Das ist die Sorte Kleinigkeit, die den
+ Unterschied macht zwischen „funktioniert" und „denkt mit". */
+ ok(t2 === "[x] Fertig\n[ ] Noch offen",
+ `und uebernimmt einen gesetzten Haken NICHT ("${t2.replace(/\n/g, "\\n")}")`);
+}
+
+/* Es sichert sich von selbst -- gemessen am Neuladen, nicht an der
+ Anzeige. Eine Anzeige, die „gesichert" sagt, ist eine Behauptung. */
+{
+ const wort = `Selbstgesichert-${Date.now()}`;
+ await seite.fill("#feld", wort);
+ await seite.waitForTimeout(1400);
+ const stand = (await seite.locator("#stand").textContent() || "").trim();
+ /* GEFRAGT WIRD DER SERVER, nicht die Seite nach dem Neuladen.
+
+ Der erste Anlauf lud neu und sah ins Schreibfeld -- und wurde
+ rot, obwohl alles richtig war: Die Seite schlaegt nach dem Laden
+ das OBERSTE Blatt auf, und das war ein anderes (ein angeheftetes
+ aus Abschnitt 4). Gemessen wurde also ein Blatt, in das nie
+ jemand getippt hat. Eine Messung, die das Falsche ansieht, ist
+ schlimmer als keine -- sie schickt einen los, einen Fehler zu
+ suchen, den es nicht gibt. */
+ const drueben = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi });
+ const gefunden = (drueben.j?.notizen || []).some((n) => String(n.text).includes(wort));
+ ok(gefunden, `der Text ist ohne Knopfdruck beim Server angekommen `
+ + `(Anzeige sagte "${stand}")`);
+ ok(stand === "gesichert", `und die Anzeige sagt es auch ("${stand}")`);
+}
+
+/* Und auf dem Handy: Das Blatt legt sich ueber die Liste, der
+ Zurueck-Knopf holt sie wieder. */
+{
+ const handy = await kontext.newPage();
+ await handy.setViewportSize({ width: 390, height: 780 });
+ await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
+ await handy.waitForTimeout(700);
+ const listeDa = await handy.locator(".n-zettel").count();
+ ok(listeDa >= 1, `auf dem Handy steht zuerst der Stapel (${listeDa} Blaetter)`);
+ ok(await handy.locator("#blatt").isHidden(),
+ "und kein Blatt liegt davor -- sonst kaeme man nie an die Liste");
+ await handy.locator(".n-zettel").first().click();
+ await handy.waitForTimeout(400);
+ ok(await handy.locator("#blatt").isVisible(), "ein Tipp schlaegt das Blatt auf");
+ ok(await handy.locator("#zurueck").isVisible(),
+ "und der Zurueck-Knopf erscheint");
+ /* KEIN WAAGERECHTES SCHIEBEN. Die Hausgrenze ist die Breite des
+ Schirms; ein Blatt, das darueber hinausragt, macht die ganze
+ Seite schiebbar. */
+ const breit = await handy.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ ok(breit <= 1, `nichts ragt seitlich hinaus (${breit} px)`);
+ await handy.locator("#zurueck").click();
+ await handy.waitForTimeout(400);
+ ok(await handy.locator("#blatt").isHidden(), "der Zurueck-Knopf schliesst es wieder");
+ await handy.close();
+}
+
+/* =======================================================================
+ 8. ZWEI WEGE MIT DEMSELBEN NAMEN -- EINE HAUSWEITE PRUEFUNG
+
+ Sie steht in dieser Datei, weil sie hier gefunden wurde, und sie
+ gilt fuer das ganze Haus.
+
+ BEIM BAUEN DIESES BLOCKS: Die Route DELETE /workspace/api/notizen/:id
+ gab es schon -- in workspace-aufgaben.js, fuer die Notizen AN einer
+ Aufgabe. Weil aufgabenRouter frueher eingehaengt wird, hat sie jeden
+ Loeschversuch des Blocks abgefangen und mit 404 beantwortet.
+
+ VON AUSSEN SAH DAS AUS WIE EIN FEHLER IM NEUEN MODUL: Anlegen ging,
+ Aendern ging, Loeschen nicht. Man sucht dann im eigenen Code, und
+ dort ist nichts. Express meldet so etwas nicht -- es nimmt die
+ erste Route, die passt, und schweigt ueber die zweite.
+
+ Geprueft wird der Routenbaum von express selbst, nicht eine Liste.
+ Findet sich ein Paar aus Methode und Pfad zweimal, steht hier, wo.
+ ======================================================================= */
+melde("");
+melde("=== 8. Kein Weg heisst zweimal gleich ===");
+{
+ const app = (await import("./index.js")).default;
+ const wege = new Map();
+ const doppelt = [];
+ const sammeln = (schicht, vorn = "") => {
+ if (schicht.route) {
+ const pfad = vorn + (schicht.route.path || "");
+ for (const m of Object.keys(schicht.route.methods || {})) {
+ if (m === "_all") continue;
+ const name = `${m.toUpperCase()} ${pfad}`;
+ if (wege.has(name)) doppelt.push(name); else wege.set(name, true);
+ }
+ } else if (schicht.handle?.stack) {
+ for (const s of schicht.handle.stack) sammeln(s, vorn);
+ }
+ };
+ const stapel = app?._router?.stack || app?.router?.stack || [];
+ for (const s of stapel) sammeln(s);
+ /* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Faende diese Messung gar keine
+ Wege (weil express seinen Baum eines Tages anders ablegt), waere
+ „keine Dubletten" trivial wahr -- und bliebe es fuer immer. */
+ ok(wege.size > 200, `${wege.size} Wege im Baum gefunden`);
+ ok(doppelt.length === 0,
+ doppelt.length ? `doppelt vergeben: ${[...new Set(doppelt)].join(", ")}`
+ : "kein Paar aus Methode und Pfad kommt zweimal vor");
+ /* GEGENPROBE: Die Messung MUSS eine Dublette finden koennen. */
+ const probe = new Map(); const gefunden = [];
+ for (const n of ["GET /x", "GET /x"]) {
+ if (probe.has(n)) gefunden.push(n); else probe.set(n, true);
+ }
+ ok(gefunden.length === 1, "Gegenprobe: zwei gleiche Wege wuerden auffallen");
+}
+
+ok(konsole.length === 0, `keine Fehler in der Konsole (${konsole.slice(0, 2).join(" | ") || "keine"})`);
+
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "FEHLGESCHLAGEN" : "ALLES IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index bdb3690b..032ed40f 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -503,6 +503,28 @@ export const SEITEN = {
Weiterhin ausdruecklich NICHT fuer die Agentur und nicht fuer die
Modis: Die Seite ist eine vollstaendige Karte dieses Hauses -- jede
Seite, jede Rolle. Wer sie hat, weiss, wo er es versuchen muesste. */
+ /* DER NOTIZBLOCK (26.09.2026).
+
+ Filipe: „fuer die modis, rechte und linke hand und dogfather
+ eine kachel hinzufuegst, sie soll: Notizen, heissen."
+
+ GENAU DIESE VIER, und die linke Hand steht ausdruecklich dabei
+ statt sich aus der Schleife weiter unten zu ergeben: Sie ist
+ hier namentlich genannt, und eine namentliche Nennung gehoert
+ in die Tafel, die man liest, wenn man wissen will, wer wohin
+ darf.
+
+ WAS AUF DER SEITE ZU SEHEN IST, entscheidet nicht diese Zeile:
+ In workspace-notizen.js hat jede Abfrage `person_id = ?` fest
+ eingebaut. Es gibt keine Rolle, die mehr sieht -- auch
+ DogFather nicht. Diese Tafel beantwortet nur, wer die Tuer
+ aufbekommt.
+
+ DIE AGENTUR STEHT NICHT DABEI, und die Adresse auch nicht: Die
+ Seite gehoert zum Haus von Team Dogi und ist auf der
+ Agenturadresse ein 404 (GEHOERT_ZU_ADRESSE in crew-adresse.js). */
+ "/workspace/notizen.html": ["admin", "hand", "linke", "modi"],
+
"/workspace/rechte.html": ["admin", "hand"],
};
diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js
index 6406c01b..c1610a38 100644
--- a/server/workspace-aufbewahrung.js
+++ b/server/workspace-aufbewahrung.js
@@ -45,6 +45,8 @@ import express from "express";
import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js";
import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js";
import { supportAufraeumen } from "./workspace-support.js";
+import { notizenAufraeumen } from "./workspace-notizen.js";
+import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js";
import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js";
import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js";
import {
@@ -299,6 +301,28 @@ export const FRISTEN = [
lassen — und ein Bild ohne Zeile findet danach niemand mehr. */
raeumen: () => supportAufraeumen(),
},
+ /* ==== DER PAPIERKORB DES NOTIZBLOCKS (26.09.2026) ==============
+
+ Der Eintrag entsteht ZUSAMMEN mit der Frist und nicht danach.
+ Am 24.09. ist genau das schiefgegangen: `supportAufraeumen()`
+ war geschrieben, exportiert und dokumentiert -- und wurde
+ nirgends aufgerufen. Im Kopf der Datei stand „90 Tage", und
+ geräumt wurde nie. */
+ {
+ schluessel: "notizen_papierkorb",
+ was: "Weggelegte Notizblätter",
+ art: "raeumen",
+ frist: `${NOTIZ_TAGE} Tage im Papierkorb`,
+ zweck: "Ein versehentlich weggetipptes Blatt zurückholen können.",
+ grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung",
+ wirkung: `Das Blatt verschwindet ganz. Was auf dem Block liegt, wird `
+ + `nie geräumt – nur was jemand selbst weggelegt hat, und erst `
+ + `${NOTIZ_TAGE} Tage danach.`,
+ offen: (d) => zaehle(d,
+ "SELECT COUNT(*) AS n FROM notizen WHERE weg_am IS NOT NULL AND weg_am < ?",
+ vorTagen(NOTIZ_TAGE)),
+ raeumen: () => notizenAufraeumen(),
+ },
{
schluessel: "unterstuetzung_striche",
was: "Tagesstriche der Unterstützen-Seite",
diff --git a/server/workspace-notizen.js b/server/workspace-notizen.js
new file mode 100644
index 00000000..f9b7fe28
--- /dev/null
+++ b/server/workspace-notizen.js
@@ -0,0 +1,402 @@
+/* =====================================================================
+ DER NOTIZBLOCK — die Wege (26.09.2026)
+
+ Die Regeln und ihre Begründung stehen im Kopf von
+ notiz-tabellen.js. Hier sind die Routen — und die vier Stellen, an
+ denen etwas schiefgehen kann:
+
+ 1. EINE NOTIZ DARF NIE BEIM FALSCHEN LANDEN. Jede Abfrage hat
+ `person_id = ?` fest eingebaut. Es gibt keine Rolle, die mehr
+ sieht, und deshalb auch keinen Zweig, der die Bedingung
+ weglassen könnte. `blattFuer()` gibt den Datensatz zurück oder
+ `null` — nie `true`/`false`. So kann keine Route erst „darf
+ sehen" beantworten und danach trotzdem das falsche Blatt laden.
+
+ 2. NUR AUF DER TEAM-ADRESSE. Der Block gehört zum Haus von Team
+ Dogi. Auf der Agenturadresse ist er ein 404 — auch für
+ DogFather, der in beiden Häusern arbeitet. Das ist dieselbe
+ Trennung wie bei Chat, Kalender und Aufgaben, und sie wird hier
+ geprüft und nicht nur in der Rechtetafel: Die Tafel beantwortet,
+ wer die SEITE öffnen darf, nicht von welcher Adresse aus.
+
+ 3. GESPEICHERT WIRD OHNE KNOPF. Die Oberfläche schickt beim Tippen
+ alle 800 ms. Das ist viel Verkehr für wenig Inhalt — deshalb
+ antwortet PATCH knapp (nur Zeitstempel), und es gibt eine
+ Schreibbremse, die ein hängendes Skript stoppt, ohne einen
+ schnell Tippenden zu treffen.
+
+ 4. ENDGÜLTIG IST ENDGÜLTIG. Löschen legt ins Papierkorbfach;
+ erst das zweite Löschen entfernt wirklich, und nur von dort.
+ Ein direkter Weg von „auf dem Block" zu „weg" existiert nicht.
+ ===================================================================== */
+
+import express from "express";
+import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
+import { PAPIERKORB_TAGE, TEXT_MAX, BLAETTER_MAX, FARBEN, FARB_NAMEN }
+ from "./notiz-tabellen.js";
+
+export const notizenRouter = express.Router();
+
+/** Wer einen Block hat.
+ *
+ * Genau die vier Rollen aus Filipes Satz. Die Liste steht hier UND
+ * in rechte.js, und das ist kein Versehen: Die Tafel bewacht die
+ * Seite, diese Menge die Daten. Wer nur die Tafel prüfte, hätte eine
+ * Schnittstelle, die jeder Angemeldete aufrufen kann — die Seite ist
+ * nur HTML, die Daten sind die Sache.
+ *
+ * pruef-notizen hält beide gegeneinander und wird rot, wenn sie
+ * auseinanderlaufen. */
+const MIT_BLOCK = new Set(["admin", "hand", "linke", "modi"]);
+
+const jetzt = () => new Date().toISOString();
+
+/* ---------- Schranken --------------------------------------------------- */
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ req.person = person;
+ next();
+}
+
+/** Team-Adresse und passende Rolle — sonst gibt es diese Seite nicht.
+ *
+ * 404 UND NICHT 403: Ein „verboten" verrät, dass es hier etwas gibt.
+ * Auf der Agenturadresse soll der Block nicht einmal als verschlossene
+ * Tür sichtbar sein. */
+function nurTeamDogi(req, res, next) {
+ if (req.person?.haus !== "crew") return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (!MIT_BLOCK.has(req.person?.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ next();
+}
+
+/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den
+ übrigen Modulen. Fehlt der Kopf ganz (curl), ist es kein
+ Browser-Angriff über eine fremde Seite; dann zählt allein das
+ Cookie. */
+function gleicheHerkunft(req, res, next) {
+ const herkunft = req.get("origin");
+ if (!herkunft) return next();
+ let erlaubt;
+ try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
+ if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
+ next();
+}
+
+/* ==== DIE SCHREIBBREMSE ================================================
+
+ Automatisches Speichern heißt: Bei jedem Tippen kann eine Anfrage
+ kommen. Die Oberfläche wartet 800 ms Ruhe ab, ein schneller
+ Schreiber löst also höchstens rund 75 Anfragen je Minute aus, und
+ das auch nur, wenn er die ganze Minute durchtippt.
+
+ DIE GRENZE IST DAS VIERFACHE DAVON. Sie steht nicht gegen den
+ Menschen, sondern gegen ein Skript, das in einer Schleife hängt --
+ und sie ist absichtlich so hoch, dass niemand sie im Alltag je
+ sieht. Eine Bremse, die beim normalen Arbeiten greift, wird
+ umgangen und ist ab da wirkungslos (siehe die Hausregel dazu).
+
+ Im Speicher und nicht in der Datenbank: Ein Neustart setzt sie
+ zurück, und das ist richtig -- sie schützt vor einem laufenden
+ Fehler, nicht vor einem Angreifer mit Geduld. */
+const SCHREIB_FENSTER_MS = 60_000;
+const SCHREIB_MAX = 300;
+const schreibZaehler = new Map(); // personId -> [Zeitpunkte]
+
+function schreibBremse(req, res, next) {
+ const id = req.person?.id;
+ if (!id) return next();
+ const jetztMs = Date.now();
+ const liste = (schreibZaehler.get(id) || []).filter((t) => jetztMs - t < SCHREIB_FENSTER_MS);
+ if (liste.length >= SCHREIB_MAX) {
+ return res.status(429).json({
+ fehler: "Zu viele Änderungen in kurzer Zeit. Warte einen Moment — "
+ + "dein Text ist nicht verloren, er wird gleich erneut gesichert.",
+ });
+ }
+ liste.push(jetztMs);
+ schreibZaehler.set(id, liste);
+ /* Damit die Karte nicht ewig wächst: Wer lange nichts geschrieben
+ hat, fliegt beim nächsten Durchgang heraus. */
+ if (schreibZaehler.size > 500) {
+ for (const [k, v] of schreibZaehler) {
+ if (!v.some((t) => jetztMs - t < SCHREIB_FENSTER_MS)) schreibZaehler.delete(k);
+ }
+ }
+ next();
+}
+
+notizenRouter.use("/workspace/api/notizblock", angemeldet, nurTeamDogi);
+
+/* ---------- Hilfen ------------------------------------------------------ */
+
+/** Ein Blatt dieser Person — oder null.
+ *
+ * Gibt den DATENSATZ zurück und nicht ein Ja/Nein: Eine Route, die
+ * erst fragt „darf ich?" und danach selbst lädt, kann die zweite
+ * Abfrage anders stellen als die erste. So kann sie es nicht. */
+function blattFuer(person, id, { ausPapierkorb = false } = {}) {
+ const n = Number(id);
+ if (!Number.isInteger(n) || n <= 0) return null;
+ const zeile = db().prepare("SELECT * FROM notizen WHERE id = ? AND person_id = ?")
+ .get(n, person.id);
+ if (!zeile) return null;
+ if (ausPapierkorb ? !zeile.weg_am : zeile.weg_am) return null;
+ return zeile;
+}
+
+/** Mehrzeiliger Text: Absätze bleiben, Leerzeilenwüsten nicht.
+ *
+ * Kein `trim()` am Anfang jeder Zeile — Einrückung ist in einer
+ * Notiz Inhalt („ - Unterpunkt"). Entfernt werden nur Wagenrückläufe
+ * (damit die Länge überall dieselbe ist) und mehr als zwei leere
+ * Zeilen am Stück. */
+function textSauber(s) {
+ return String(s ?? "")
+ .replace(/\r\n?/g, "\n")
+ .replace(/\n{4,}/g, "\n\n\n")
+ .slice(0, TEXT_MAX);
+}
+
+/** Die erste nicht leere Zeile, ohne ihre Auszeichnung.
+ *
+ * Wird NUR für die Liste gebraucht (und für die Suche). Der Titel
+ * wird nicht gespeichert: Zwei Orte für dieselbe Sache laufen
+ * auseinander, sobald jemand die erste Zeile ändert und das Feld
+ * nicht. Hier wird er jedes Mal aus dem Text abgeleitet und ist
+ * damit immer der, der wirklich oben steht. */
+function titelVon(text) {
+ const zeile = String(text || "").split("\n").find((z) => z.trim().length) || "";
+ return zeile
+ .replace(/^\s*#{1,3}\s*/, "")
+ .replace(/^\s*[-*]\s+/, "")
+ .replace(/^\s*\[[ xX]\]\s*/, "")
+ .trim()
+ .slice(0, 120);
+}
+
+function nachDraussen(z) {
+ return {
+ id: z.id,
+ text: z.text,
+ titel: titelVon(z.text),
+ farbe: z.farbe,
+ angeheftet: !!z.angeheftet,
+ erstellt: z.erstellt,
+ geaendert: z.geaendert,
+ weg_am: z.weg_am || null,
+ };
+}
+
+/* ---------- Lesen -------------------------------------------------------- */
+
+/** Der Block: alle eigenen Blätter, angeheftete zuerst. */
+notizenRouter.get("/workspace/api/notizblock", (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT * FROM notizen
+ WHERE person_id = ? AND weg_am IS NULL
+ ORDER BY angeheftet DESC, geaendert DESC
+ LIMIT ?`).all(req.person.id, BLAETTER_MAX);
+ /* DIE ZAHL KOMMT AUS DER DATENBANK, nicht aus der Liste oben: Die
+ ist bei BLAETTER_MAX abgeschnitten, und eine Zählung darauf
+ wäre ab dem 301. Blatt still falsch. */
+ const imKorb = db().prepare(`SELECT COUNT(*) AS n FROM notizen
+ WHERE person_id = ? AND weg_am IS NOT NULL`).get(req.person.id).n;
+ res.json({
+ notizen: zeilen.map(nachDraussen),
+ papierkorb: imKorb,
+ text_max: TEXT_MAX,
+ blaetter_max: BLAETTER_MAX,
+ papierkorb_tage: PAPIERKORB_TAGE,
+ farben: FARBEN,
+ });
+ } catch (fehler) {
+ console.error("[notizen] Liste:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Was im Papierkorb liegt — mit dem Tag, an dem es von selbst geht. */
+notizenRouter.get("/workspace/api/notizblock/papierkorb", (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT * FROM notizen
+ WHERE person_id = ? AND weg_am IS NOT NULL
+ ORDER BY weg_am DESC LIMIT ?`).all(req.person.id, BLAETTER_MAX);
+ res.json({
+ notizen: zeilen.map((z) => ({
+ ...nachDraussen(z),
+ /* WANN ES WIRKLICH WEG IST -- gerechnet und nicht „in 30
+ Tagen" geschrieben. Wer gestern geloescht hat, will wissen,
+ wie viel Zeit er noch hat, und nicht, wie lang die Frist
+ ist. */
+ endgueltig_am: new Date(Date.parse(z.weg_am) + PAPIERKORB_TAGE * 86400000)
+ .toISOString(),
+ })),
+ papierkorb_tage: PAPIERKORB_TAGE,
+ });
+ } catch (fehler) {
+ console.error("[notizen] Papierkorb:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Schreiben ----------------------------------------------------- */
+
+/** Ein neues Blatt. */
+notizenRouter.post("/workspace/api/notizblock", gleicheHerkunft, schreibBremse,
+ express.json({ limit: "256kb" }), (req, res) => {
+ try {
+ const offen = db().prepare(`SELECT COUNT(*) AS n FROM notizen
+ WHERE person_id = ? AND weg_am IS NULL`).get(req.person.id).n;
+ if (offen >= BLAETTER_MAX) {
+ return res.status(429).json({
+ fehler: `Dein Block hat ${BLAETTER_MAX} Blätter — mehr gehen nicht. `
+ + "Räum ein paar in den Papierkorb, dann geht es weiter.",
+ });
+ }
+ const text = textSauber(req.body?.text);
+ const farbe = FARB_NAMEN.includes(req.body?.farbe) ? req.body.farbe : FARB_NAMEN[0];
+ const n = jetzt();
+ const { lastInsertRowid } = db().prepare(`
+ INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert)
+ VALUES (?,?,?,0,?,?)`).run(req.person.id, text, farbe, n, n);
+ const frisch = db().prepare("SELECT * FROM notizen WHERE id = ?").get(lastInsertRowid);
+ res.status(201).json({ notiz: nachDraussen(frisch) });
+ } catch (fehler) {
+ console.error("[notizen] Anlegen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Ändern: Text, Farbe, angeheftet. Einzeln oder zusammen.
+ *
+ * ANTWORTET KNAPP. Diese Route läuft beim Tippen alle 800 ms; das
+ * ganze Blatt zurückzuschicken wäre bei einer langen Notiz
+ * zwanzigtausend Zeichen je Tastendruckpause, für eine Information,
+ * die der Browser schon hat. Zurück kommt der Zeitstempel — das
+ * Einzige, was er NICHT weiß. */
+notizenRouter.patch("/workspace/api/notizblock/:id(\\d+)", gleicheHerkunft, schreibBremse,
+ express.json({ limit: "256kb" }), (req, res) => {
+ try {
+ const z = blattFuer(req.person, req.params.id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ const felder = [], werte = [];
+ if (req.body?.text !== undefined) {
+ felder.push("text = ?"); werte.push(textSauber(req.body.text));
+ }
+ if (req.body?.farbe !== undefined) {
+ if (!FARB_NAMEN.includes(req.body.farbe)) {
+ return res.status(400).json({ fehler: "Diese Papierfarbe gibt es nicht." });
+ }
+ felder.push("farbe = ?"); werte.push(req.body.farbe);
+ }
+ if (req.body?.angeheftet !== undefined) {
+ felder.push("angeheftet = ?"); werte.push(req.body.angeheftet ? 1 : 0);
+ }
+ if (!felder.length) return res.status(400).json({ fehler: "Nichts zu ändern." });
+
+ const n = jetzt();
+ felder.push("geaendert = ?"); werte.push(n);
+ werte.push(z.id, req.person.id);
+ db().prepare(`UPDATE notizen SET ${felder.join(", ")}
+ WHERE id = ? AND person_id = ?`).run(...werte);
+ res.json({ geaendert: n });
+ } catch (fehler) {
+ console.error("[notizen] Aendern:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** In den Papierkorb. */
+notizenRouter.delete("/workspace/api/notizblock/:id(\\d+)", gleicheHerkunft, schreibBremse,
+ (req, res) => {
+ try {
+ const z = blattFuer(req.person, req.params.id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const n = jetzt();
+ db().prepare("UPDATE notizen SET weg_am = ?, angeheftet = 0 WHERE id = ? AND person_id = ?")
+ .run(n, z.id, req.person.id);
+ res.json({ weg_am: n, papierkorb_tage: PAPIERKORB_TAGE });
+ } catch (fehler) {
+ console.error("[notizen] Wegtun:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Wieder heraus. */
+notizenRouter.post("/workspace/api/notizblock/:id(\\d+)/zurueck", gleicheHerkunft, schreibBremse,
+ (req, res) => {
+ try {
+ const z = blattFuer(req.person, req.params.id, { ausPapierkorb: true });
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const offen = db().prepare(`SELECT COUNT(*) AS n FROM notizen
+ WHERE person_id = ? AND weg_am IS NULL`).get(req.person.id).n;
+ if (offen >= BLAETTER_MAX) {
+ return res.status(429).json({
+ fehler: `Dein Block ist voll (${BLAETTER_MAX} Blätter). `
+ + "Tu erst eines weg, dann kommt dieses zurück.",
+ });
+ }
+ db().prepare("UPDATE notizen SET weg_am = NULL, geaendert = ? WHERE id = ? AND person_id = ?")
+ .run(jetzt(), z.id, req.person.id);
+ const frisch = db().prepare("SELECT * FROM notizen WHERE id = ?").get(z.id);
+ res.json({ notiz: nachDraussen(frisch) });
+ } catch (fehler) {
+ console.error("[notizen] Zurueck:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Endgültig — und nur aus dem Papierkorb.
+ *
+ * Es gibt keinen Weg von „auf dem Block" direkt zu „weg". Wer
+ * danebentippt, landet im Papierkorb; erst wer dort noch einmal
+ * drückt, meint es. */
+notizenRouter.delete("/workspace/api/notizblock/:id(\\d+)/endgueltig", gleicheHerkunft,
+ schreibBremse, (req, res) => {
+ try {
+ const z = blattFuer(req.person, req.params.id, { ausPapierkorb: true });
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ db().prepare("DELETE FROM notizen WHERE id = ? AND person_id = ?")
+ .run(z.id, req.person.id);
+ /* PROTOKOLLIERT WIRD NUR DAS UNWIDERRUFLICHE -- und ohne eine
+ Silbe des Inhalts. Jede Textänderung zu protokollieren würde
+ aus einem privaten Block eine Aktivitätsspur machen, die
+ andere lesen können; genau das soll er nicht sein. Dass etwas
+ endgültig verschwunden ist, gehört dagegen in die Ablage:
+ Sonst sucht jemand eines Tages eine Zeile, die niemand
+ vermisst hat. */
+ protokolliere("notiz_endgueltig_weg", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${z.id}`,
+ });
+ res.json({ weg: true });
+ } catch (fehler) {
+ console.error("[notizen] Endgueltig:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* =====================================================================
+ AUFRAEUMEN
+
+ Wird aus der Aufbewahrungsliste gerufen (workspace-aufbewahrung.js)
+ und nirgends sonst. Das ist die einzige Stelle im Haus, die
+ wirklich räumt — eine Frist ohne Eintrag dort ist kein Versprechen,
+ sondern ein Satz in einem Kommentar.
+ ===================================================================== */
+export function notizenAufraeumen() {
+ const grenze = new Date(Date.now() - PAPIERKORB_TAGE * 86400000).toISOString();
+ const weg = db().prepare("DELETE FROM notizen WHERE weg_am IS NOT NULL AND weg_am < ?")
+ .run(grenze);
+ return Number(weg?.changes || 0);
+}
+
+/* Für die Prüfung: dieselbe Menge, die `nurTeamDogi` benutzt. Sie
+ wird exportiert, damit pruef-notizen sie gegen die Rechtetafel
+ halten kann, statt sie abzuschreiben. */
+export const ROLLEN_MIT_BLOCK = MIT_BLOCK;
diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js
index b8e59dd0..0d23e6d7 100644
--- a/server/workspace-willkommen.js
+++ b/server/workspace-willkommen.js
@@ -140,6 +140,9 @@ const LANG = {
"hilfe.html":
"Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather "
+ "und die rechte Hand sehen diese Meldungen.",
+ "notizen.html":
+ "Dein Block. Schreib hinein, was dir einfällt — es speichert sich von "
+ + "selbst, und niemand außer dir sieht es. Auch DogFather nicht.",
"support.html":
"Etwas an der Seite funktioniert nicht oder sieht falsch aus? Schreib "
+ "einen Satz dazu und häng ein Bildschirmfoto an — DogFather und die "
diff --git a/server/workspace.js b/server/workspace.js
index 28648236..b975cf00 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -34,6 +34,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js";
import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js";
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { supportTabellen } from "./support-tabellen.js";
+import { notizTabellen } from "./notiz-tabellen.js";
import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN, CREW_ADRESSE, WORKSPACE_ADRESSE, AGENTUR_ROLLEN, BEIDE_HAEUSER_ROLLEN,
@@ -1049,6 +1050,49 @@ const MODI_BEREICHE = [
{ gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
name: "Mein Steckbrief", unter: "Dein Bild und deine Kanäle", zeichen: "steckbrief",
ton: 10, ziel: "steckbrief.html", szene: "halle" },
+
+ /* =================================================================
+ DER NOTIZBLOCK (26.09.2026)
+
+ Filipe: „fuer die modis, rechte und linke hand und dogfather
+ eine kachel hinzufuegst, sie soll: Notizen, heissen. ich will
+ dass du das auch wie ein notizblock erstellst."
+
+ GENAU DIESE VIER ROLLEN, und dafuer muss hier nichts aufgezaehlt
+ werden: MODI_BEREICHE ist die Grundliste, aus der HAND_BEREICHE
+ durch Erweitern entsteht und LINKE_MIT_TREFF durch Wegnehmen
+ einer einzigen Kachel. Wer hier eintraegt, trifft Modi, rechte
+ Hand, linke Hand und DogFather auf der Team-Adresse -- und
+ sonst niemanden. Eine eigene Liste waere die, die beim naechsten
+ Rollenzuschnitt auseinanderlaeuft.
+
+ ER GEHOERT ZU „FUER DICH" UND NICHT ZU „TAEGLICH", obwohl man
+ ihn taeglich aufmacht. Die Gruppe sagt die wichtigste
+ Eigenschaft, bevor man die Kachel anfasst: Diese Notizen sieht
+ NIEMAND ausser dem, der sie schreibt -- auch DogFather nicht.
+ Stuende die Kachel zwischen Chat und Kalender, waere die erste
+ Frage „wer liest das mit?", und die Antwort stuende erst auf der
+ Seite.
+
+ NUR AUF DER TEAM-ADRESSE. Die Seite steht in
+ GEHOERT_ZU_ADRESSE (crew-adresse.js) und ist auf der
+ Agenturadresse ein 404 -- auch fuer DogFather, der in beiden
+ Haeusern arbeitet. Das ist dieselbe Trennung wie bei Chat,
+ Kalender und Aufgaben: Auf jeder Adresse sieht er nur deren
+ Bestand.
+
+ TON 46 UND DIE OBERFLAECHE `papier`. Der Ton ist die groesste
+ freie Luecke der Palette; warum es kein Bernstein wurde, steht
+ mit allen Messungen in tools/kachelton-regeln.mjs. Das
+ Papiergefuehl traegt nicht die Farbe, sondern die OBERFLAECHE --
+ Linien und ein perforierter Rand, so wie `silber` bei den
+ Aufgaben und `regenbogen` bei Willkommen. Faellt sie aus (alter
+ Zwischenspeicher, Kontrastmodus), bleibt die Kachel in ihrer
+ Farbe und damit unterscheidbar. */
+ { gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
+ name: "Notizen", unter: "Dein Block – nur du siehst ihn",
+ zeichen: "notizen", ton: 46, papier: true,
+ ziel: "notizen.html", szene: "halle" },
/* ==== DIE WISSENS-KACHEL IST HIER RAUS (24.09.2026) =============
Filipe, zur Frage, wohin die neun Dokumente gehören: „diese
@@ -5990,6 +6034,9 @@ export function db() {
kommt deshalb hierher, nicht in den Bauplan darueber. */
hilfeTabellen(d);
supportTabellen(d);
+ /* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und
+ kommt deshalb hierher, nicht in den Bauplan darueber. */
+ notizTabellen(d);
/* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen
(geaendert_von) und kommen deshalb hierher, nicht in den Bauplan
darüber. */
diff --git a/tools/kachelton-entzerren.mjs b/tools/kachelton-entzerren.mjs
index c65077a5..8fcc57b8 100644
--- a/tools/kachelton-entzerren.mjs
+++ b/tools/kachelton-entzerren.mjs
@@ -1,35 +1,55 @@
/* =====================================================================
- NUR DIE FARBEN BEWEGEN, DIE SICH WIRKLICH IN DIE QUERE KOMMEN
- (25.09.2026, zweiter Anlauf)
+ DIE KACHELFARBEN AUSEINANDERZIEHEN -- SO WENIG WIE MOEGLICH
+ (25./26.09.2026)
- Der erste Anlauf hat ALLE 45 Farben neu verteilt (die Rechnung
- dazu steht in der Geschichte des Verzeichnisses, Commit d7bb0f7e).
- Das Ergebnis war rechnerisch besser und praktisch falsch: Er ist
- dabei ueber zwei ausdrueckliche Wuensche gelaufen (das knallrote
- #ff1a1a fuer "Vertraulich melden", das Babyblau #a8d8ff fuer
- "Support") und hat drei Toene unter die Buntheitsgrenze von 0,12
- gedrueckt. pruef-kachelfarben hat alles fuenf gefunden.
+ Filipe, 17.09.: „ich will das jede kachel eine andere farbe hat, es
+ soll keine die gleiche farben haben bitte und auch keine die sich
+ irgendwie aehnlich sind ... die farben sollen auch richtig geil und
+ speziell sein."
- DIE LEHRE IST NICHT "die Rechnung war falsch", sondern: Eine
- Gesamtloesung fuer ein LOKALES Problem bezahlt man mit allem, was
- an den nicht betroffenen Stellen schon richtig war. Acht Paare
- standen zu eng -- also werden acht Paare auseinandergezogen und
- sonst nichts angefasst.
+ Gemessen lagen acht Paare zwischen 0,015 und 0,054 OKLab-Abstand.
+ #ff8fb4 gegen #fe8ebe ist mit blossem Auge DIESELBE Farbe.
- VORGEHEN. Solange irgendein Paar unter dem Mindestabstand liegt:
- das engste nehmen, den beweglicheren der beiden auf die naechste
- erlaubte Farbe schieben, die alle Bedingungen haelt. Festgelegte
- Toene sind unbeweglich.
+ ---------------------------------------------------------------------
+ ZWEI ANLAEUFE HAT DAS GEKOSTET, UND BEIDE FEHLER STEHEN HIER
- ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs. Keine Zahl
- wird hier abgeschrieben -- genau dafuer gibt es die Datei.
+ ERSTER ANLAUF: alle 45 Farben neu verteilt. Rechnerisch besser,
+ praktisch falsch -- er lief ueber zwei ausdrueckliche Wuensche
+ hinweg (das knallrote #ff1a1a, das Babyblau #a8d8ff) und drueckte
+ drei Toene unter die Buntheitsgrenze.
+
+ ZWEITER ANLAUF: nur die Kollisionen angefasst, dafuer immer nur
+ EINEN der beiden Toene bewegt -- den, dessen Ausweichen billiger
+ war. Das sieht sparsam aus und ist es nicht: Ist die Nachbarschaft
+ voll, muss dieser eine quer durch den Farbraum. Gemessen wanderte
+ Ton 1 von einem Tuerkis (#0c86af) auf ein Altrosa (#b4649c), 0,197
+ weit. Eine Kachel, die von Tuerkis auf Rosa springt, erkennt
+ niemand wieder -- und der Zweck der ganzen Uebung war ja, dass man
+ sie auseinanderhaelt.
+
+ DRITTER ANLAUF, und der steht hier: BEIDE duerfen sich bewegen, und
+ zwar beide nur ein bisschen. Zwei Toene, die 0,02 auseinanderliegen
+ und 0,09 brauchen, koennen sich das teilen -- jeder rueckt 0,045,
+ und beide bleiben, was sie waren.
+
+ Umgesetzt als wachsender Spielraum: Erst darf jeder Ton 0,02 von
+ seiner Farbe abweichen, dann 0,03, dann 0,04 ... Es wird immer die
+ KLEINSTE Abweichung genommen, mit der es aufgeht. Wer gar nicht
+ abweichen muss, bleibt unberuehrt.
+
+ ---------------------------------------------------------------------
+ ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs
+
+ Keine Zahl wird hier abgeschrieben -- genau dafuer gibt es die
+ Datei. Und die FESTGELEGTEN Toene stehen ebenfalls dort: Sie werden
+ nie bewegt, auch nicht um einen Bildpunkt.
node tools/kachelton-entzerren.mjs rechnen und zeigen
node tools/kachelton-entzerren.mjs schreiben start.css aendern
+
+ Umgebung: ABSTAND=0.09 (Ziel), SCHRITT=3 (Rasterweite des Suchfelds)
===================================================================== */
-import { readFileSync, writeFileSync, mkdtempSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { readFileSync, writeFileSync } from "node:fs";
import {
GRUND, MINDEST_ABSTAND, MINDEST_BUNTHEIT, HOECHST_BUNTHEIT,
MINDEST_KONTRAST, FESTGELEGT,
@@ -40,251 +60,239 @@ const DATEI = "workspace/assets/css/start.css";
/* Etwas Luft ueber jeder Schwelle -- gerundet wird auf ganze
Bildpunkte, und eine Farbe, die genau auf der Grenze steht, faellt
beim naechsten Runden darunter. */
-const ZIEL_ABSTAND = MINDEST_ABSTAND + 0.004;
+const ZIEL_ABSTAND = Number(process.env.ABSTAND || MINDEST_ABSTAND) + 0.0005;
const ZIEL_KONTRAST = MINDEST_KONTRAST + 0.15;
const ZIEL_BUNT = MINDEST_BUNTHEIT + 0.004;
+const SCHRITT = Number(process.env.SCHRITT || 3);
+/* ---------- Farbrechnung ---------------------------------------------- */
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const srgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
-function oklab(h) {
- const [r, g, b] = srgb(h).map(lin);
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
+function labVon(r, g, b) {
+ const rr = lin(r / 255), gg = lin(g / 255), bb = lin(b / 255);
+ const l = Math.cbrt(0.4122214708 * rr + 0.5363325363 * gg + 0.0514459929 * bb);
+ const m = Math.cbrt(0.2119034982 * rr + 0.6806995451 * gg + 0.1073969566 * bb);
+ const s = Math.cbrt(0.0883024619 * rr + 0.2817188376 * gg + 0.6299787005 * bb);
return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s];
}
-const leuchte = (h) => { const [r, g, b] = srgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
-const kontrast = (h) => (leuchte(h) + 0.05) / (leuchte(GRUND) + 0.05);
-const bunt = (h) => { const [, a, b] = oklab(h); return Math.hypot(a, b); };
-const abst = (a, b) => { const x = oklab(a), y = oklab(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
+const labHex = (h) => labVon(...[1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)));
+const leuchteVon = (r, g, b) => 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255);
+const kontrastVon = (y) => (y + 0.05) / (leuchteVon(...[1, 3, 5].map((i) => parseInt(GRUND.slice(i, i + 2), 16))) + 0.05);
+const buntVon = (L) => Math.hypot(L[1], L[2]);
+const d2 = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
const hexVon = (r, g, b) => "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
-/* ---------- Die Farben aus der Datei --------------------------------- */
+/* ---------- Die Farben aus der Datei ----------------------------------- */
const css = readFileSync(DATEI, "utf8");
const TOENE = [...css.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)]
.map((m) => ({ n: Number(m[1]), alt: m[2].toLowerCase(), hex: m[2].toLowerCase() }));
if (TOENE.length < 20) { console.error("Muster passt nicht mehr"); process.exit(3); }
+for (const t of TOENE) t.altLab = labHex(t.alt);
-/* ---------- Wer darf sich nicht bewegen, wer darf blass sein --------- */
const FEST = new Map(FESTGELEGT.filter((f) => f.hex).map((f) => [f.ton, f.hex.toLowerCase()]));
const BLASS_ERLAUBT = new Map(FESTGELEGT.filter((f) => f.blassErlaubt).map((f) => [f.ton, f.blassErlaubt]));
for (const t of TOENE) {
if (FEST.has(t.n) && t.hex !== FEST.get(t.n)) {
- console.log(` Ton ${t.n} steht auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- wird zurueckgesetzt`);
+ console.log(` Ton ${t.n} stand auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- zurueckgesetzt`);
t.hex = FEST.get(t.n);
}
+ t.lab = labHex(t.hex);
}
-/* ---------- WELCHE TOENE WERDEN UEBERHAUPT BENUTZT? ------------------
+/* ---------- WELCHE TOENE WERDEN BENUTZT? -------------------------------
- Die Buntheitsgrenze gilt nur fuer BENUTZTE Toene -- so steht es in
- pruef-kachelfarben, und das ist der Grund, aus dem die Reparatur
- ueberhaupt aufgeht: Mit Buntheit >= 0,12 fuer ALLE 45 ist ein
- Mindestabstand von 0,09 rechnerisch unmoeglich (gemessene Decke:
- 0,0853). Die zwoelf unbenutzten duerfen blasser sein und nehmen
- damit Druck aus dem engen Blau-Tuerkis-Bereich.
+ ALLE FUENFUNDVIERZIG. Und das ist der Befund, an dem der zweite
+ Anlauf gescheitert ist.
- Gefragt wird das Programm selbst, nicht eine Liste. */
-process.env.WORKSPACE_DB = process.env.WORKSPACE_DB
- || join(mkdtempSync(join(tmpdir(), "toene-")), "workspace.db");
-const w = await import("../server/workspace.js");
+ Wer das PROGRAMM fragt (bereicheFuer() je Rolle), bekommt 33. Die
+ uebrigen zwoelf sehen dann aus wie freie Reserve -- sie duerfen
+ blasser werden und weiter wandern. Sie sind keine Reserve: Fuer die
+ fuenf Rollen des Agenturhauses gibt bereicheFuer() `null` zurueck,
+ und das heisst „nimm die Liste aus dem Browser". Die steht in
+ workspace/assets/js/bereiche.js, und dort stehen die Toene 1 bis 22
+ und 44 -- genau die zwoelf.
+
+ Gemessen: Ton 1 traegt „Dashboard", Ton 2 „Zahlen", Ton 21
+ „Scout-Pipeline", Ton 22 „Team-Lage". Alles Kacheln, die jeden Tag
+ jemand ansieht.
+
+ Gezaehlt wird deshalb ueber dieselben Quellen, die auch
+ pruef-kachel-universum liest. Die Frage lautet nicht „wem gibt der
+ Server eine Kachel", sondern „steht diese Nummer irgendwo an einer
+ Kachel". */
+const QUELLEN = ["../server/workspace.js", "../workspace/assets/js/bereiche.js",
+ "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js",
+ "../workspace/assets/js/befinden.js"];
const BENUTZT = new Set();
-for (const r of ["admin", "hand", "linke", "modi", "gast", "manager", "scout", "creator", "spicy"]) {
- for (const haus of ["crew", "agentur"]) {
- let k = [];
- try { k = [...(w.bereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || []),
- ...(w.zusatzBereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || [])]; }
- catch { continue; }
- for (const kachel of k) if (kachel?.ton != null) BENUTZT.add(Number(kachel.ton));
+for (const q of QUELLEN) {
+ for (const m of readFileSync(new URL(q, import.meta.url), "utf8").matchAll(/ton:\s*(\d+)/g)) {
+ BENUTZT.add(Number(m[1]));
}
}
if (BENUTZT.size < 20) { console.error(`nur ${BENUTZT.size} benutzte Toene gefunden`); process.exit(5); }
-console.log(`${BENUTZT.size} von ${TOENE.length} Toenen werden benutzt: ${[...BENUTZT].sort((a, b) => a - b).join(", ")}`);
-/* Auch die unbenutzten sollen so bunt bleiben, wie es noch passt --
- sonst steht beim naechsten Gebrauch ein blasser Ton bereit und die
- Pruefung wird rot, obwohl niemand etwas falsch gemacht hat. */
-const UNBENUTZT_BUNT = Number(process.env.UNBUNT || 0.045);
-const buntNoetig = (n) => (BENUTZT.has(n) && !BLASS_ERLAUBT.has(n) ? ZIEL_BUNT
- : Math.min(UNBENUTZT_BUNT, bunt(TOENE.find((t) => t.n === n).alt)));
+console.log(`${BENUTZT.size} von ${TOENE.length} Toenen werden benutzt`);
-/* Der verlangte Mindestabstand kann je Ton hoeher sein: Wer blass sein
- darf, muss das beim Abstand ausgleichen (steht so in
- pruef-kachelfarben und ist dort begruendet). */
-const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.004);
+/* Wer blass sein darf, muss das beim Abstand ausgleichen -- so steht es
+ mit Messung in kachelton-regeln.mjs. */
+const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.0005);
+const BLASS_BIS = new Map(FESTGELEGT.filter((f) => f.blassBis).map((f) => [f.ton, f.blassBis]));
+const buntFuer = (n) => (BLASS_BIS.has(n) ? BLASS_BIS.get(n) + 0.002 : ZIEL_BUNT);
-/* ---------- Alle erlaubten Farben ------------------------------------ */
-const ALLE = [];
-for (let r = 0; r <= 255; r += 2) {
- for (let g = 0; g <= 255; g += 2) {
- for (let b = 0; b <= 255; b += 2) {
- const h = hexVon(r, g, b);
- if (kontrast(h) < ZIEL_KONTRAST) continue;
- const c = bunt(h);
+/* ---------- Das Suchfeld ----------------------------------------------- */
+const KANDIDATEN = [];
+for (let r = 0; r <= 255; r += SCHRITT) {
+ for (let g = 0; g <= 255; g += SCHRITT) {
+ for (let b = 0; b <= 255; b += SCHRITT) {
+ const rr = Math.min(255, r), gg = Math.min(255, g), bb = Math.min(255, b);
+ if (kontrastVon(leuchteVon(rr, gg, bb)) < ZIEL_KONTRAST) continue;
+ const L = labVon(rr, gg, bb);
+ const c = buntVon(L);
if (c > HOECHST_BUNTHEIT) continue;
- ALLE.push({ hex: h, bunt: c });
+ KANDIDATEN.push({ hex: hexVon(rr, gg, bb), L, bunt: c });
}
}
}
-const kandidatenFuer = (n) => ALLE.filter((x) => x.bunt >= buntNoetig(n)).map((x) => x.hex);
-console.log(`${ALLE.length} erlaubte Farben (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, `
- + `Buntheit bis ${HOECHST_BUNTHEIT}; benutzte Toene ab ${ZIEL_BUNT.toFixed(3)})`);
+console.log(`${KANDIDATEN.length} Farben im Suchfeld (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, `
+ + `Buntheit bis ${HOECHST_BUNTHEIT}, Raster ${SCHRITT})`);
-/* ---------- Was steht zu eng? ---------------------------------------- */
-const engstes = () => {
- let m = Infinity, wo = null;
+/* ---------- Messen ------------------------------------------------------ */
+function engstes() {
+ let fehl = 0, klein = Infinity, wo = null;
for (let i = 0; i < TOENE.length; i++) {
for (let j = i + 1; j < TOENE.length; j++) {
const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n));
- const d = abst(TOENE[i].hex, TOENE[j].hex);
- if (d < noetig && d - noetig < m) { m = d - noetig; wo = { i, j, d, noetig }; }
+ const d = Math.sqrt(d2(TOENE[i].lab, TOENE[j].lab));
+ if (d < noetig) fehl++;
+ if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; }
}
}
- return wo;
-};
+ return { fehl, klein, wo };
+}
+const zuBlass = () => TOENE.filter((t) =>
+ buntVon(t.lab) < (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT));
-const naechsterZu = (h, ausser) => {
+/** Wie gut steht dieser Ton gerade? (Abstand zum naechsten, in Einheiten
+ * dessen, was er braucht -- >= 1 heisst „in Ordnung".) */
+function guete(lab, selbst) {
let m = Infinity;
- for (const t of TOENE) { if (t === ausser) continue; m = Math.min(m, abst(h, t.hex)); }
- return m;
-};
-
-console.log("");
-let bewegt = 0;
-for (let runde = 0; runde < 200; runde++) {
- const p = engstes();
- if (!p) break;
- const a = TOENE[p.i], b = TOENE[p.j];
-
- /* Wer wird bewegt? Nicht der Festgelegte. Sonst der, fuer den die
- naechste erlaubte Stelle naeher an seinem eigenen Ton liegt. */
- const kandidatFuer = (t) => {
- if (FEST.has(t.n)) return null;
- let best = null, bestWeg = Infinity;
- const noetig = abstandFuer(t.n);
- for (const h of kandidatenFuer(t.n)) {
- const weg = abst(t.alt, h);
- if (weg >= bestWeg) continue;
- let ok = true;
- for (const u of TOENE) {
- if (u === t) continue;
- if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; }
- }
- if (ok) { best = h; bestWeg = weg; }
- }
- return best ? { hex: best, weg: bestWeg } : null;
- };
- const ka = kandidatFuer(a), kb = kandidatFuer(b);
- if (!ka && !kb) {
- console.error(`Fuer ${a.hex} (Ton ${a.n}) und ${b.hex} (Ton ${b.n}) gibt es keinen Platz.`);
- process.exit(4);
+ for (const u of TOENE) {
+ if (u === selbst) continue;
+ const noetig = Math.max(abstandFuer(selbst.n), abstandFuer(u.n));
+ const v = Math.sqrt(d2(lab, u.lab)) / noetig;
+ if (v < m) m = v;
}
- /* WER WEICHT AUS? ZUERST DER, DEN NIEMAND SIEHT.
-
- Gemessen: In JEDEM der acht zu engen Paare ist mindestens einer
- der beiden Toene gar nicht in Gebrauch -- 33 der 45 tragen eine
- Kachel, zwoelf stehen nur bereit. Wer den unbenutzten verschiebt,
- loest dasselbe Problem, ohne dass sich fuer irgendjemanden auch
- nur eine Kachel veraendert.
-
- Das ist mehr als Bequemlichkeit: Eine Kachel, die ihre Farbe
- wechselt, muss neu gelernt werden. Wer zwei Jahre lang „das
- Tuerkise" angesteuert hat, sucht danach. Diese Kosten entstehen
- hier schlicht nicht.
-
- Erst wenn beide benutzt sind (oder beide nicht), entscheidet der
- kuerzere Weg. */
- const frei = (t, k) => k && !BENUTZT.has(t.n);
- const nimm = frei(a, ka) && !frei(b, kb) ? { t: a, k: ka }
- : frei(b, kb) && !frei(a, ka) ? { t: b, k: kb }
- : !ka ? { t: b, k: kb } : !kb ? { t: a, k: ka }
- : (ka.weg <= kb.weg ? { t: a, k: ka } : { t: b, k: kb });
- console.log(` ${p.d.toFixed(4)} (noetig ${p.noetig.toFixed(3)}) Ton ${a.n} ${a.hex} <-> `
- + `Ton ${b.n} ${b.hex} -> Ton ${nimm.t.n} wandert nach ${nimm.k.hex} `
- + `(${nimm.k.weg.toFixed(3)})`);
- nimm.t.hex = nimm.k.hex;
- bewegt++;
+ return m;
}
-/* ---------- NACHZIEHEN -----------------------------------------------
+/* ---------- Der wachsende Spielraum ------------------------------------- */
+const anfang = engstes();
+console.log(`vorher: kleinster Abstand ${anfang.klein.toFixed(4)}, `
+ + `${anfang.fehl} Paare unter dem Soll, ${zuBlass().length} zu blass`);
+console.log("");
- Der Durchgang oben arbeitet sich vom engsten Paar nach vorn und
- entscheidet dabei immer nur mit dem, was GERADE dasteht. Wer
- frueh ausweichen muss, weicht deshalb manchmal weiter aus als
- noetig -- die Stelle, die ihm gepasst haette, wird erst zwei
- Schritte spaeter frei.
+/* Der Spielraum waechst in jeder Runde ein wenig. Er MUSS wachsen,
+ auch wenn sich gerade etwas bewegt hat: Sonst haengt ein Ton, der
+ mehr Platz braucht als die anderen, fuer immer fest, waehrend die
+ uebrigen noch hin und her rutschen -- gemessen blieben so vier zu
+ blasse Toene stehen, obwohl das Verfahren noch lange nicht am
+ Ende war. */
+let spielraum = 0.015;
+for (let runde = 0; runde < 300; runde++) {
+ const lage = engstes();
+ const blass = zuBlass();
+ if (!lage.fehl && !blass.length) break;
- Gemessen war das kein Schoenheitsfehler: Ton 10 (#ff6877, ein
- Korallrot) landete beim ersten Lauf auf #a86cb4, einem Violett --
- 0,178 entfernt, und niemand haette die Kachel wiedererkannt.
+ let bewegt = 0;
+ for (const t of TOENE) {
+ if (FEST.has(t.n)) continue;
+ const jetztGuete = Math.min(guete(t.lab, t), 1);
+ const jetztBunt = buntVon(t.lab);
+ const bunkOk = jetztBunt >= (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT);
+ if (jetztGuete >= 1 && bunkOk) continue; // dieser ist fertig
- Also am Ende noch einmal von vorn: Jeder bewegte Ton darf so weit
- zu seiner alten Farbe zurueck, wie die Bedingungen es zulassen.
- Das aendert am Mindestabstand nichts -- es wird nur geprueft, ob
- es NAEHER geht. */
+ const grenze2 = spielraum * spielraum;
+ const mindBunt = buntFuer(t.n);
+ /* IST DIE BUNTHEIT DAS PROBLEM, ZAEHLT DIE JETZIGE LAGE NICHT.
+
+ Hier stand `bestGuete = jetztGuete`. Fuer einen Ton, der weit
+ genug von allen wegliegt (Guete 1) und nur zu blass ist, konnte
+ damit KEIN Kandidat gewinnen -- kein Wert ist groesser als 1,
+ wenn bei 1 gedeckelt wird. Vier Toene blieben deshalb blass
+ stehen, obwohl das Suchfeld voller passender Farben war.
+
+ Mit -1 als Ausgangswert gewinnt jeder Kandidat, der ueberhaupt
+ bunt genug ist -- und unter denen wieder der naechste. */
+ let best = null, bestGuete = bunkOk ? jetztGuete : -1, bestWeg = 0;
+ for (const k of KANDIDATEN) {
+ if (k.bunt < mindBunt) continue;
+ const weg2 = d2(t.altLab, k.L);
+ if (weg2 > grenze2) continue;
+ /* GEDECKELT BEI 1. „Weiter weg als noetig" ist kein Gewinn --
+ ohne den Deckel sucht das Verfahren das Maximum und wandert
+ dafuer quer durch den Farbraum. Mit ihm gewinnt unter allen
+ Farben, die reichen, die NAECHSTE. */
+ const g = Math.min(guete(k.L, t), 1);
+ if (g > bestGuete + 1e-9 || (g > bestGuete - 1e-9 && best && weg2 < bestWeg)) {
+ best = k; bestGuete = g; bestWeg = weg2;
+ }
+ }
+ if (best && best.hex !== t.hex) {
+ t.hex = best.hex; t.lab = best.L; bewegt++;
+ }
+ }
+ spielraum += 0.0015;
+ if (spielraum > 0.26) { console.error("Spielraum ausgeschoepft -- es geht nicht auf."); break; }
+}
+
+/* ---------- Nachziehen: so weit zurueck wie moeglich --------------------- */
for (let runde = 0; runde < 12; runde++) {
let geruckt = false;
for (const t of TOENE) {
if (FEST.has(t.n) || t.hex === t.alt) continue;
- const jetzt = abst(t.alt, t.hex);
- const noetig = abstandFuer(t.n);
- let best = null, bestWeg = jetzt;
- for (const h of kandidatenFuer(t.n)) {
- const weg = abst(t.alt, h);
- if (weg >= bestWeg) continue;
- let ok = true;
- for (const u of TOENE) {
- if (u === t) continue;
- if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; }
- }
- if (ok) { best = h; bestWeg = weg; }
- }
- if (best) {
- console.log(` nachgezogen: Ton ${t.n} ${t.hex} -> ${best} `
- + `(${jetzt.toFixed(3)} -> ${bestWeg.toFixed(3)} vom alten Ton)`);
- t.hex = best; geruckt = true;
+ const jetztWeg = d2(t.altLab, t.lab);
+ const mindBunt = buntFuer(t.n);
+ let best = null, bestWeg = jetztWeg;
+ for (const k of KANDIDATEN) {
+ if (k.bunt < mindBunt) continue;
+ const weg2 = d2(t.altLab, k.L);
+ if (weg2 >= bestWeg) continue;
+ if (guete(k.L, t) < 1) continue;
+ best = k; bestWeg = weg2;
}
+ if (best) { t.hex = best.hex; t.lab = best.L; geruckt = true; }
}
if (!geruckt) break;
}
-/* ---------- Nachmessen ------------------------------------------------ */
-let klein = Infinity, wo = null, unterSoll = 0;
-for (let i = 0; i < TOENE.length; i++) {
- for (let j = i + 1; j < TOENE.length; j++) {
- const d = abst(TOENE[i].hex, TOENE[j].hex);
- const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n));
- if (d < noetig) unterSoll++;
- if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; }
- }
-}
+/* ---------- Ergebnis ----------------------------------------------------- */
+const ende = engstes();
+const blassEnde = zuBlass();
const geaendert = TOENE.filter((t) => t.hex !== t.alt);
-const zuBlass = TOENE.filter((t) => BENUTZT.has(t.n)
- && bunt(t.hex) < MINDEST_BUNTHEIT && !BLASS_ERLAUBT.has(t.n));
-const zuDunkel = TOENE.filter((t) => kontrast(t.hex) < MINDEST_KONTRAST);
const doppelt = TOENE.length - new Set(TOENE.map((t) => t.hex)).size;
-console.log("");
-console.log(`geaendert ${geaendert.length} von ${TOENE.length}`);
-for (const t of geaendert) {
+console.log(`geaendert ${geaendert.length} von ${TOENE.length}`);
+for (const t of geaendert.sort((a, b) => d2(b.altLab, b.lab) - d2(a.altLab, a.lab))) {
console.log(` Ton ${String(t.n).padStart(2)} ${t.alt} -> ${t.hex} `
- + `verschoben ${abst(t.alt, t.hex).toFixed(3)} Buntheit ${bunt(t.hex).toFixed(3)} `
- + `Kontrast ${kontrast(t.hex).toFixed(2)}:1`);
+ + `verschoben ${Math.sqrt(d2(t.altLab, t.lab)).toFixed(3)} `
+ + `Buntheit ${buntVon(t.lab).toFixed(3)} `
+ + `Kontrast ${kontrastVon(leuchteVon(...[1, 3, 5].map((i) => parseInt(t.hex.slice(i, i + 2), 16)))).toFixed(2)}:1`);
}
-console.log(`kleinster Abstand ${klein.toFixed(4)} (Ton ${wo[0].n} ${wo[0].hex} / Ton ${wo[1].n} ${wo[1].hex})`);
-console.log(`unter dem Soll ${unterSoll}`);
-console.log(`zu blass ${zuBlass.map((t) => `Ton ${t.n} ${bunt(t.hex).toFixed(3)}`).join(", ") || "keiner"}`);
-console.log(`zu dunkel ${zuDunkel.map((t) => `Ton ${t.n}`).join(", ") || "keiner"}`);
-console.log(`doppelt ${doppelt}`);
+console.log(`kleinster Abstand ${ende.klein.toFixed(4)} `
+ + `(Ton ${ende.wo[0].n} ${ende.wo[0].hex} / Ton ${ende.wo[1].n} ${ende.wo[1].hex})`);
+console.log(`unter dem Soll ${ende.fehl}`);
+console.log(`zu blass ${blassEnde.map((t) => `Ton ${t.n} ${buntVon(t.lab).toFixed(3)}`).join(", ") || "keiner"}`);
+console.log(`doppelt ${doppelt}`);
+console.log(`groesste Aenderung ${geaendert.length ? Math.max(...geaendert.map((t) => Math.sqrt(d2(t.altLab, t.lab)))).toFixed(3) : "0"}`);
for (const [n, h] of FEST) {
const t = TOENE.find((x) => x.n === n);
- console.log(`festgelegt Ton ${n} ${t.hex} ${t.hex === h ? "(unveraendert)" : "*** ABWEICHUNG ***"}`);
+ console.log(`festgelegt Ton ${n} ${t.hex} ${t.hex === h ? "(unveraendert)" : "*** ABWEICHUNG ***"}`);
}
if (process.argv[2] === "schreiben") {
- if (unterSoll || zuBlass.length || zuDunkel.length || doppelt
+ if (ende.fehl || blassEnde.length || doppelt
|| [...FEST].some(([n, h]) => TOENE.find((x) => x.n === n).hex !== h)) {
console.error("\nNICHT geschrieben -- das Ergebnis haelt die Bedingungen nicht.");
process.exit(1);
diff --git a/tools/kachelton-regeln.mjs b/tools/kachelton-regeln.mjs
index 4f0536af..4c985f2f 100644
--- a/tools/kachelton-regeln.mjs
+++ b/tools/kachelton-regeln.mjs
@@ -108,6 +108,70 @@ const FESTGELEGT = [
Haus ohnehin steht. Die Bedingung dazu steht unten bei der
Pruefung und ist echt: Faellt der Abstand unter 0,10, wird diese
Zeile rot. */
- { ton: 44, kachel: "Support", hex: "#a8d8ff", blassErlaubt: 0.10,
+ { ton: 44, kachel: "Support", hex: "#a8d8ff", blassErlaubt: 0.10, blassBis: 0.070,
warum: "Babyblau auf Wunsch -- blass, dafuer mit gemessenem Sicherheitsabstand" },
+
+ /* ==== TUERKIS GEHT NICHT BUNTER (26.09.2026) =====================
+
+ Ton 1 traegt „Dashboard" im Agenturhaus und liegt bei einer
+ Buntheit von 0,112 -- acht Tausendstel unter der Hausgrenze.
+ Aufgefallen ist das erst, als diese Pruefung endlich AUCH die
+ Toene des Agenturhauses ansah; vorher kannte sie nur 33 von 45.
+
+ DIE NAHELIEGENDE ANTWORT WAERE, IHN BUNTER ZU MACHEN. Gemessen
+ geht das nicht: Durchsucht man ALLE 16,7 Millionen sRGB-Farben
+ nach der naechstgelegenen, die gleichzeitig Buntheit >= 0,124,
+ Kontrast >= 4,65 und 0,09 Abstand zu allen 44 anderen haelt,
+ kommt #7a75c8 heraus -- ein Blauviolett, 0,113 entfernt. Aus
+ dem Tuerkis des Dashboards wuerde also Flieder.
+
+ Der Grund ist der Farbraum selbst: Tuerkis erreicht in sRGB nur
+ eine geringe Buntheit, und dieses schmale Band teilen sich
+ bereits die Toene 15, 16, 23, 26, 31, 32 und 43. Es ist voll.
+
+ ALSO EINE AUSNAHME MIT ZAHL, KEIN FREIBRIEF: `blassBis` sagt,
+ wie blass er hoechstens sein darf -- blasser als heute darf er
+ nicht werden. Und `blassErlaubt` fordert weiterhin den vollen
+ Abstand ein. Eine Kachel, die ihre Farbfamilie behaelt, ist die
+ bessere Antwort auf „richtig geil und speziell" als eine, die
+ acht Tausendstel bunter ist und niemand wiedererkennt. */
+ { ton: 1, kachel: "Dashboard", blassErlaubt: 0.09, blassBis: 0.108,
+ warum: "Tuerkis -- in diesem schmalen Band gibt es keine buntere Farbe mit Abstand" },
+
+ /* ==== DER SECHSUNDVIERZIGSTE TON (26.09.2026) ====================
+
+ Filipe: „fuer die modis, rechte und linke hand und dogfather
+ eine kachel ... sie soll: Notizen, heissen."
+
+ GESUCHT WAR BERNSTEIN -- die Farbe von Papier. Gemessen gibt es
+ sie nicht mehr: Durchsucht man alle sRGB-Farben im Bernsteinband
+ (Farbwinkel 55 bis 100 Grad) nach der, die am weitesten von
+ allen 45 vorhandenen wegliegt, kommt #d0ac48 heraus -- mit
+ 0,0799 Abstand, also UNTER der Hausgrenze von 0,09.
+
+ Und Platz zu schaffen hilft nicht: Setzt man diesen Bernstein
+ fest und laesst die Nachbarn ausweichen, geht es zwar auf --
+ aber ein warmer Ton muss dafuer das Band verlassen. Gemessen
+ wanderte Ton 45 (Orange) 0,212 weit ins Magenta; haelt man auch
+ den fest, trifft es Ton 40 mit 0,246. Das warme Band ist voll,
+ und jede neue Farbe darin kostet eine vorhandene Kachel ihre
+ Identitaet. Das ist ein schlechter Tausch.
+
+ DIE FREIEN LUECKEN, alle gemessen:
+
+ Gruen #9cde90 0,0917 <- genommen
+ Violett #ca50ac 0,0913
+ Blau #746afc 0,0890
+ Tuerkis #66e2bc 0,0821
+ Bernstein #d0ac48 0,0799
+
+ Gruen passt zur Sache besser, als es zunaechst klingt: Das
+ Notizblatt im Haus traegt seine Linien in dieser Farbe, und ein
+ linierter Block in Gruen ist Papier, seit es Papier gibt.
+
+ ER IST FESTGELEGT, weil die Kachel ihre eigene Oberflaeche hat
+ (siehe `papier` in start.css): Ein Farblauf, der ihn verschiebt,
+ wuerde Linien und Flaeche auseinanderlaufen lassen. */
+ { ton: 46, kachel: "Notizen", hex: "#9cde90",
+ warum: "die groesste freie Luecke der Palette -- Bernstein gibt es nicht mehr" },
];
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 9808121c..0057c649 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`. -->
-
-
+
+
-
+