diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs
index 511a8c1d..173b8304 100644
--- a/server/pruef-css-klassen.mjs
+++ b/server/pruef-css-klassen.mjs
@@ -426,5 +426,69 @@ console.log("\n=== Gegenprobe ===");
"aus classList.add(bedingung ? 'a' : 'b') werden beide Klassen gelesen, aber nicht die Variable");
}
+/* =======================================================================
+ DIE SIEBEN KOPIEN DER MODULLISTE MÜSSEN GLEICH SEIN
+
+ module.css beschreibt die Form aller Module -- Silhouette,
+ Kantenlicht, Raster, Handy, Kontrastmodus. Dieselbe Klassenliste
+ steht dafür siebenmal in der Datei (im Kontrastmodus dreimal); CSS
+ kennt keine Variable für Selektoren.
+
+ Das ist die verwundbare Stelle: Wer eine Karte hinzufügt und nur
+ sechs der sieben Stellen pflegt, bekommt eine Karte MIT Schräge und
+ OHNE Kantenlicht -- und niemandem fällt es auf, weil nichts kaputt
+ geht. Genau diese Sorte stiller Abweichung fängt hier eine Prüfung
+ ab, statt sich auf Sorgfalt zu verlassen.
+ ======================================================================= */
+console.log("\n=== module.css: die Modulliste steht siebenmal — und überall gleich");
+{
+ const pfad = join(WURZEL, "workspace/assets/css/module.css");
+ const text = readFileSync(pfad, "utf8");
+
+ /* Jede Kopie ist mit `/* MODULLISTE */` markiert; gelesen wird die
+ Klammer des `:is(...)` direkt danach. */
+ const listen = [];
+ const marke = /\/\* MODULLISTE \*\/\s*:is\(([^)]*)\)/g;
+ for (let t; (t = marke.exec(text)) !== null; ) {
+ listen.push(t[1].split(",").map((s) => s.trim()).filter(Boolean));
+ }
+
+ /* Die Anzahl gehört in die BEDINGUNG, nicht nur in den Meldetext:
+ Fände die Prüfung keine einzige Liste, wäre "alle gleich" sonst
+ trivial erfüllt -- grün, ohne etwas angesehen zu haben. */
+ ok(listen.length === 7, `sieben markierte Kopien gefunden (${listen.length})`);
+
+ const erste = listen[0] || [];
+ ok(erste.length >= 30, `die Liste umfasst ${erste.length} Klassen`);
+
+ const abweichung = listen.findIndex(
+ (l) => l.length !== erste.length || l.some((k, i) => k !== erste[i]));
+ ok(abweichung === -1, abweichung === -1
+ ? "alle sieben Kopien sind Zeichen für Zeichen gleich"
+ : `Kopie ${abweichung + 1} weicht ab (${listen[abweichung].length} statt ${erste.length} Klassen)`);
+
+ /* GEGENPROBE: Der Vergleich muss auch NEIN sagen können. Ohne sie
+ bewiese die Zeile darüber nur, dass irgendetwas verglichen wurde. */
+ const geprobt = [erste, erste.slice(0, -1)];
+ const findetEs = geprobt.findIndex(
+ (l) => l.length !== erste.length || l.some((k, i) => k !== erste[i]));
+ ok(findetEs === 1, "eine künstlich verkürzte Kopie wird erkannt");
+
+ /* Und die Datei muss auf jeder Seite ZULETZT stehen -- sonst
+ überschreiben die Seitendateien die Form wieder, so wie es bis zum
+ 07.09.2026 auf vier Seiten der Fall war. */
+ let zuletzt = 0, gesamt = 0;
+ for (const seite of readdirSync(join(WURZEL, "workspace")).filter((f) => f.endsWith(".html"))) {
+ if (seite === "index.html") continue; // die Anmeldeseite lädt sie nicht
+ const html = readFileSync(join(WURZEL, "workspace", seite), "utf8");
+ const bl = [...html.matchAll(/]+rel="stylesheet"[^>]*>/g)].map((m) => m[0]);
+ if (!bl.some((z) => z.includes("css/module.css"))) continue;
+ gesamt++;
+ if (bl[bl.length - 1].includes("css/module.css")) zuletzt++;
+ }
+ ok(gesamt >= 15 && zuletzt === gesamt,
+ `module.css steht auf allen ${gesamt} Seiten als letzte Stilvorlage (${zuletzt})`);
+}
+
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-ics.mjs b/server/pruef-ics.mjs
index d4914ac4..180b8a5c 100644
--- a/server/pruef-ics.mjs
+++ b/server/pruef-ics.mjs
@@ -114,6 +114,13 @@ const idLunaTermin = termin(HEIKEL, idLuna, 3, "https://meet.example.invalid/rau
termin(LANG, idLuna, 5);
termin("Ganz normal", idLuna, 9);
const idNoraTermin = termin("NORAS GEHEIMER TERMIN", idNora, 4);
+/* Beas eigener Termin. Er ist seit dem 07.09.2026 noetig: Bis dahin war
+ Beas Kalender allein dadurch gefuellt, dass sie Nora betreut. Die
+ Betreuung entscheidet im Kalender aber nicht mehr mit (siehe
+ termineSichtbar in workspace.js) -- ohne eigenen Termin waere ihr
+ Kalender leer, und ein leerer Kalender ist der sauberste Befund von
+ allen: Er beweist gar nichts. */
+termin("BEAS EIGENER TERMIN", idBea, 6);
d.close();
/* ---- Werkzeug: ICS zurueckLESEN ----------------------------------------
@@ -302,13 +309,23 @@ melde("\n=== Sichtbarkeit — der Weg ohne Anmeldung darf nicht mehr zeigen ==="
ok(!icsLuna.includes("NORAS GEHEIMER TERMIN"),
"Lunas Kalender enthaelt Noras Termin NICHT");
- /* GEGENPROBE: Beas Kalender MUSS ihn enthalten -- sie betreut Nora.
- Ohne diesen Nachweis bewiese die Zeile darueber nur, dass der
- Termin nirgends steht. */
+ /* SEIT DEM 07.09.2026 SIEHT AUCH BEA NORAS TERMIN NICHT MEHR.
+ Hier stand vorher das Gegenteil, und es war damals richtig: Bea
+ betreut Nora, also stand Noras Termin in ihrem Kalender. Der
+ Kalender fragt seit dem Umbau nicht mehr nach Rolle oder
+ Betreuung, sondern nur noch nach Beteiligung -- und Bea ist an
+ diesem Termin nicht beteiligt.
+
+ Die GEGENPROBE bleibt notwendig, sie braucht nur einen anderen
+ Beleg: Beas eigener Termin muss in ihrem Kalender stehen. Ohne
+ ihn bewiesen die beiden Zeilen darueber nur, dass ihr Kalender
+ leer ist. */
const linkBea = await linkHolen(keksBea);
const icsBea = await (await fetch(linkBea)).text();
- ok(icsBea.includes("NORAS GEHEIMER TERMIN"),
- "Beas Kalender enthaelt ihn sehr wohl — die Messung unterscheidet also");
+ ok(!icsBea.includes("NORAS GEHEIMER TERMIN"),
+ "Beas Kalender enthaelt ihn ebenfalls nicht — Betreuung zaehlt hier nicht mehr");
+ ok(icsBea.includes("BEAS EIGENER TERMIN"),
+ "ihr eigener steht sehr wohl darin — die Messung unterscheidet also");
ok(!icsBea.includes("Rückblick"),
"und Lunas heikler Termin steht NICHT in Beas Kalender");
diff --git a/server/pruef-leistung-optik.mjs b/server/pruef-leistung-optik.mjs
index 61a8ffa6..30eba385 100644
--- a/server/pruef-leistung-optik.mjs
+++ b/server/pruef-leistung-optik.mjs
@@ -137,9 +137,16 @@ async function alsRolle(rolle, code, wohin = "leistung.html", groesse = { width:
1. Die Seite ist überhaupt erreichbar — und über die Kachel
======================================================================= */
melde("\n=== Der Weg zur Seite ===");
-const man = await alsRolle("manager", "CODE-MANA-0001", "start.html");
+/* SEIT FILIPES ANWEISUNG SIEHT NUR DOGFATHER DIE ZAHLEN.
+ Hier stand "manager", und bis zur Umstellung war das richtig. Seit
+ die Kachel und die Seite auf DogFather beschraenkt sind, hat sich
+ diese Pruefung selbst ausgesperrt: Sie meldete sieben Fehler, von
+ denen keiner ein Mangel war -- sie sah nur eine Seite, die sie nicht
+ mehr sehen darf. Gemessen wird jetzt als DogFather; dass der Manager
+ NICHT hineinkommt, steht als eigene Gegenprobe darunter. */
+const chef = await alsRolle("admin", "CODE-DOGI-0001", "start.html");
{
- const kachel = await man.seite.$$eval(".kachel", (k) =>
+ const kachel = await chef.seite.$$eval(".kachel", (k) =>
k.map((x) => ({
name: x.querySelector(".kachel__name")?.textContent.trim(),
/* Das href sitzt am .kachel__link INNEN, nicht am
aussen --
@@ -156,13 +163,28 @@ const man = await alsRolle("manager", "CODE-MANA-0001", "start.html");
/* GEGENPROBE zum Selektor: Ein Name, den es nicht gibt, darf NICHT
gefunden werden -- sonst prüfte der Block oben nichts. */
- const erfunden = await man.seite.$$eval(".kachel", (k) =>
+ const erfunden = await chef.seite.$$eval(".kachel", (k) =>
k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber"));
ok(!erfunden, "eine erfundene Kachel wird nicht gefunden");
- await man.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
- await man.seite.waitForTimeout(1100);
- const titel = await man.seite.$eval(".titel", (e) => e.textContent.trim());
+ /* GEGENPROBE ZUR REGEL SELBST: Der Manager darf sie NICHT sehen und
+ NICHT hineinkommen. Ohne diese beiden Zeilen bewiese der Block
+ oben nur, dass DogFather etwas sieht -- nicht, dass sonst niemand
+ es sieht. Und genau das war die Anweisung. */
+ const mgr = await alsRolle("manager", "CODE-MANA-0001", "start.html");
+ const beiIhm = await mgr.seite.$$eval(".kachel", (k) =>
+ k.map((x) => x.querySelector(".kachel__name")?.textContent.trim()));
+ ok(beiIhm.length > 3 && !beiIhm.includes("Zahlen"),
+ `beim Manager fehlt sie (${beiIhm.length} Kacheln, keine davon "Zahlen")`);
+
+ const versuch = await mgr.seite.evaluate(async (b) =>
+ (await fetch(b + "/workspace/leistung.html", { redirect: "manual" })).status, BASIS);
+ ok(versuch !== 200, `und die Seite selbst laesst ihn nicht herein (HTTP ${versuch})`);
+ await mgr.ctx.close();
+
+ await chef.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
+ await chef.seite.waitForTimeout(1100);
+ const titel = await chef.seite.$eval(".titel", (e) => e.textContent.trim());
ok(titel.length > 0, `die Seite ist offen: "${titel}"`);
}
@@ -171,14 +193,14 @@ const man = await alsRolle("manager", "CODE-MANA-0001", "start.html");
======================================================================= */
melde("\n=== Einen Tag eintragen ===");
{
- const vorher = await man.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
+ const vorher = await chef.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
ok(vorher.length >= 5,
`die Wochenkarten stehen da (${vorher.length} Werte) — sonst prüft alles Folgende nichts`);
/* Die laufende Woche ist leer: Diamanten müssen 0 sein. Steht hier
schon etwas, ist der Bestand oben falsch aufgebaut und die Prüfung
misst etwas anderes als sie behauptet. */
- const diaVorher = await man.seite.$$eval(".zahlkarte", (k) => {
+ const diaVorher = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return t?.querySelector(".zahlkarte__wert")?.textContent;
});
@@ -186,19 +208,19 @@ melde("\n=== Einen Tag eintragen ===");
/* Der erste "eintragen"-Knopf ist der von HEUTE (die Liste läuft
rückwärts). */
- const knoepfe = await man.seite.$$(".tagzeile__knopf");
+ const knoepfe = await chef.seite.$$(".tagzeile__knopf");
ok(knoepfe.length === 14, `es gibt 14 Tageszeilen mit Knopf (${knoepfe.length})`);
await knoepfe[0].click();
- await man.seite.waitForSelector("#tag-dialog[open]", { timeout: 5000 });
+ await chef.seite.waitForSelector("#tag-dialog[open]", { timeout: 5000 });
- await man.seite.fill("#f-diamanten", "1250");
- await man.seite.fill("#f-dauer", "120");
- await man.seite.fill("#f-verweildauer", "260");
- await man.seite.fill("#f-notiz", "PK gegen Mia");
- await man.seite.click("#tag-speichern");
- await man.seite.waitForTimeout(1400);
+ await chef.seite.fill("#f-diamanten", "1250");
+ await chef.seite.fill("#f-dauer", "120");
+ await chef.seite.fill("#f-verweildauer", "260");
+ await chef.seite.fill("#f-notiz", "PK gegen Mia");
+ await chef.seite.click("#tag-speichern");
+ await chef.seite.waitForTimeout(1400);
- const dia = await man.seite.$$eval(".zahlkarte", (k) => {
+ const dia = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return t?.querySelector(".zahlkarte__wert")?.textContent;
});
@@ -206,7 +228,7 @@ melde("\n=== Einen Tag eintragen ===");
/* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung -- das ist die
Regel, wegen der dieses Modul überhaupt so gebaut ist. */
- const wandel = await man.seite.$$eval(".zahlkarte", (k) => {
+ const wandel = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
const w = t?.querySelector(".zahlkarte__wandel");
return { text: w?.textContent || "", richtung: w?.dataset.richtung || "" };
@@ -227,7 +249,7 @@ melde("\n=== Einen Tag eintragen ===");
/* Und die Farbe muss der Richtung folgen -- sonst steht ein Minus in
Grün da und niemand merkt es. */
- const farbe = await man.seite.$$eval(".zahlkarte", (k) => {
+ const farbe = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return getComputedStyle(t.querySelector(".zahlkarte__wandel")).color;
});
@@ -236,12 +258,12 @@ melde("\n=== Einen Tag eintragen ===");
`und die Farbe passt dazu (${farbe})`);
/* Die Notiz ist der Kontext, ohne den eine Zahl in die Irre führt. */
- const notiz = await man.seite.$$eval(".tagzeile__notiz", (n) => n.map((x) => x.textContent));
+ const notiz = await chef.seite.$$eval(".tagzeile__notiz", (n) => n.map((x) => x.textContent));
ok(notiz.includes("PK gegen Mia"), `die Notiz steht in der Tageszeile (${notiz.length} Notizen)`);
/* GEGENPROBE: Ein Wert, der nie eingetragen wurde, darf nicht
dastehen. */
- const erfunden = await man.seite.$$eval(".zahlkarte__wert", (w) =>
+ const erfunden = await chef.seite.$$eval(".zahlkarte__wert", (w) =>
w.some((x) => x.textContent === "9.999"));
ok(!erfunden, "ein nie eingetragener Wert erscheint nicht");
}
@@ -257,7 +279,7 @@ melde("\n=== Die kleine Verlaufslinie ===");
und der Browser zeichnete wortlos gar nichts. Ein SVG mit
points="NaN,NaN" ist im DOM vorhanden und auf dem Bildschirm
unsichtbar. Deshalb werden die PUNKTE gemessen. */
- const linien = await man.seite.$$eval(".zahlkarte__linie polyline", (ls) =>
+ const linien = await chef.seite.$$eval(".zahlkarte__linie polyline", (ls) =>
ls.map((l) => l.getAttribute("points") || ""));
ok(linien.length >= 3, `${linien.length} Verlaufslinien sind gezeichnet`);
@@ -272,20 +294,20 @@ melde("\n=== Die kleine Verlaufslinie ===");
`jede Linie hat mindestens drei Punkte (${zuKurz.length} zu kurz)`);
/* Auch der Endpunkt-Kreis. */
- const kreise = await man.seite.$$eval(".zahlkarte__linie circle", (cs) =>
+ const kreise = await chef.seite.$$eval(".zahlkarte__linie circle", (cs) =>
cs.map((c) => `${c.getAttribute("cx")},${c.getAttribute("cy")}`));
ok(kreise.length === linien.length && !kreise.some((k) => /NaN/.test(k)),
`jede Linie hat einen Endpunkt (${kreise.length})`);
/* GEGENPROBE zum Selektor: Nach dem Entfernen darf nichts mehr
gefunden werden. */
- const nachher = await man.seite.evaluate(() => {
+ const nachher = await chef.seite.evaluate(() => {
document.querySelectorAll(".zahlkarte__linie").forEach((e) => e.remove());
return document.querySelectorAll(".zahlkarte__linie polyline").length;
});
ok(nachher === 0, "der Selektor trifft wirklich die Linien (nach dem Entfernen 0)");
- await man.seite.reload({ waitUntil: "networkidle" });
- await man.seite.waitForTimeout(1200);
+ await chef.seite.reload({ waitUntil: "networkidle" });
+ await chef.seite.waitForTimeout(1200);
}
/* =======================================================================
@@ -293,17 +315,17 @@ melde("\n=== Die kleine Verlaufslinie ===");
======================================================================= */
melde("\n=== Ziele ===");
{
- const leerText = await man.seite.$eval("#zielbalken", (e) => e.textContent.trim());
+ const leerText = await chef.seite.$eval("#zielbalken", (e) => e.textContent.trim());
ok(/keine Ziele/i.test(leerText), `ohne Ziel steht ein Satz da: "${leerText.slice(0, 60)}…"`);
- await man.seite.click("#ziele-aendern");
- await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
- await man.seite.fill("#z-diamanten", "2500");
- await man.seite.fill("#z-tage", "5");
- await man.seite.click("#ziele-speichern");
- await man.seite.waitForTimeout(1300);
+ await chef.seite.click("#ziele-aendern");
+ await chef.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
+ await chef.seite.fill("#z-diamanten", "2500");
+ await chef.seite.fill("#z-tage", "5");
+ await chef.seite.click("#ziele-speichern");
+ await chef.seite.waitForTimeout(1300);
- const balken = await man.seite.$$eval(".zielbalken__zeile", (zs) =>
+ const balken = await chef.seite.$$eval(".zielbalken__zeile", (zs) =>
zs.map((z) => ({
name: z.querySelector(".zielbalken__name")?.textContent,
zahl: z.querySelector(".zielbalken__zahl")?.textContent,
@@ -316,12 +338,12 @@ melde("\n=== Ziele ===");
ok(dia?.breite === "50%", `der Balken ist zur Hälfte gefüllt (${dia?.breite})`);
/* KEIN BALKEN ÜBER 100 % -- er hinge sonst aus dem Kasten. */
- await man.seite.click("#ziele-aendern");
- await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
- await man.seite.fill("#z-diamanten", "500");
- await man.seite.click("#ziele-speichern");
- await man.seite.waitForTimeout(1300);
- const voll = await man.seite.$$eval(".zielbalken__zeile", (zs) => {
+ await chef.seite.click("#ziele-aendern");
+ await chef.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
+ await chef.seite.fill("#z-diamanten", "500");
+ await chef.seite.click("#ziele-speichern");
+ await chef.seite.waitForTimeout(1300);
+ const voll = await chef.seite.$$eval(".zielbalken__zeile", (zs) => {
const z = zs.find((x) => x.querySelector(".zielbalken__name")?.textContent === "Diamanten");
return {
breite: z?.querySelector(".zielbalken__fuell")?.style.width,
@@ -346,23 +368,23 @@ melde("\n=== Aus Datei einlesen ===");
"kein-datum;500;60;100",
].join("\n");
- await man.seite.click("#import-oeffnen");
- await man.seite.waitForSelector("#import-dialog[open]", { timeout: 5000 });
+ await chef.seite.click("#import-oeffnen");
+ await chef.seite.waitForSelector("#import-dialog[open]", { timeout: 5000 });
- const gesperrt = await man.seite.$eval("#import-los", (b) => b.disabled);
+ const gesperrt = await chef.seite.$eval("#import-los", (b) => b.disabled);
ok(gesperrt, "der Übernehmen-Knopf ist gesperrt, solange keine Datei da ist");
- await man.seite.setInputFiles("#datei", {
+ await chef.seite.setInputFiles("#datei", {
name: "export.csv", mimeType: "text/csv", buffer: Buffer.from(csv, "utf8"),
});
- await man.seite.waitForSelector("#vorschau:not([hidden])", { timeout: 8000 });
+ await chef.seite.waitForSelector("#vorschau:not([hidden])", { timeout: 8000 });
- const zahlen = await man.seite.$$eval(".import-zahl", (zs) =>
+ const zahlen = await chef.seite.$$eval(".import-zahl", (zs) =>
zs.map((z) => `${z.querySelector(".import-zahl__wert")?.textContent} ${z.querySelector(".import-zahl__name")?.textContent}`));
ok(zahlen.some((z) => /^2 neu/.test(z)), `die Vorschau zählt: ${zahlen.join(" · ")}`);
ok(zahlen.some((z) => /^1 unlesbar/.test(z)), "die unlesbare Zeile wird gemeldet, nicht verschluckt");
- const spalten = await man.seite.$eval(".import-spalten", (e) => e.textContent);
+ const spalten = await chef.seite.$eval(".import-spalten", (e) => e.textContent);
ok(/Diamanten/.test(spalten) && /LIVE-Dauer/.test(spalten),
`die erkannten Spalten stehen da: "${spalten}"`);
@@ -370,22 +392,22 @@ melde("\n=== Aus Datei einlesen ===");
geschrieben worden sein. Eine Vorschau, die schon schreibt, ist
keine Vorschau. */
const vorAntwort = await fetch(`${BASIS}/workspace/api/leistung/${idLuna}/tage?tage=7`, {
- headers: { cookie: (await man.ctx.cookies()).map((c) => `${c.name}=${c.value}`).join("; ") },
+ headers: { cookie: (await chef.ctx.cookies()).map((c) => `${c.name}=${c.value}`).join("; ") },
});
const vorTage = (await vorAntwort.json()).tage || [];
const schonDa = vorTage.some((t) => t.tag === tagVor(3));
ok(!schonDa, "vor dem Bestätigen steht noch nichts in der Datenbank");
- await man.seite.click("#import-los");
- await man.seite.waitForTimeout(1600);
+ await chef.seite.click("#import-los");
+ await chef.seite.waitForTimeout(1600);
- const nachTage = await man.seite.$$eval(".tagzeile", (zs) =>
+ const nachTage = await chef.seite.$$eval(".tagzeile", (zs) =>
zs.filter((z) => z.dataset.quelle === "import").length);
ok(nachTage === 2, `nach dem Bestätigen sind 2 Zeilen aus dem Import da (${nachTage})`);
/* DER TAUSENDERPUNKT. "1.480" darf nicht als 1,48 ankommen -- Faktor
tausend daneben, und die Zahl sieht trotzdem plausibel aus. */
- const werte = await man.seite.$$eval(".tagzeile", (zs) =>
+ const werte = await chef.seite.$$eval(".tagzeile", (zs) =>
zs.filter((z) => z.dataset.quelle === "import")
.map((z) => z.querySelector(".tagzeile__wertzahl")?.textContent));
ok(werte.includes("1.480"), `"1.480" ist als 1480 angekommen (${werte.join(", ")})`);
@@ -397,26 +419,23 @@ melde("\n=== Aus Datei einlesen ===");
6. Der Creator sieht seine Zahlen — und darf sie nicht eintragen
======================================================================= */
melde("\n=== Was der Creator sieht ===");
-const luna = await alsRolle("creator", "CODE-CREA-0001");
+const luna = await alsRolle("creator", "CODE-CREA-0001", "start.html");
{
- const karten = await luna.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
- ok(karten.length >= 5, `Luna sieht ihre Zahlen (${karten.length} Werte)`);
- /* 1250 von Hand + 1480 + 970 aus dem Import = 3700. Die Zahl steht
- hier ausgerechnet und nicht abgeschrieben: So fällt auf, wenn der
- Import etwas dazuzählt oder verschluckt. */
- ok(karten.includes("3.700"),
- `darunter ihre Diamanten, 1250+1480+970 (${karten.slice(0, 4).join(", ")})`);
+ /* HIER STAND DAS GEGENTEIL, UND ES WAR RICHTIG -- bis Filipe die
+ Regel geändert hat: "nimm die kategorie zahlen bei jedem weg, nur
+ die rolle dogfather soll die sehen." Vorher sah Luna ihre eigenen
+ Zahlen (nur ohne Eintragen-Knopf), jetzt kommt sie gar nicht mehr
+ hinein. Die Prüfung misst deshalb nicht mehr, WAS sie dort sieht,
+ sondern DASS sie nicht hineinkommt -- und dass die Schranke am
+ Server sitzt, nicht bloß in der Oberfläche. */
+ const keineKachel = await luna.seite.$$eval(".kachel", (k) =>
+ k.map((x) => x.querySelector(".kachel__name")?.textContent.trim()));
+ ok(keineKachel.length > 2 && !keineKachel.includes("Zahlen"),
+ `bei Luna fehlt die Kachel "Zahlen" (${keineKachel.length} Kacheln)`);
- const knoepfe = await luna.seite.$$(".tagzeile__knopf");
- ok(knoepfe.length === 0, `sie hat keinen Eintragen-Knopf (${knoepfe.length})`);
-
- const zieleKnopf = await luna.seite.$eval("#ziele-aendern", (b) => b.hidden);
- ok(zieleKnopf, "und kann auch keine Ziele setzen");
-
- /* Kein Auswahlfeld: Sie hat nur sich selbst, und eine Liste mit einem
- Eintrag ist eine Frage ohne Anlass. */
- const wahl = await luna.seite.$eval("#creator-wahl", (e) => e.hidden);
- ok(wahl, "es gibt kein Creator-Auswahlfeld für sie");
+ const rein = await luna.seite.evaluate(async () =>
+ (await fetch("/workspace/leistung.html", { credentials: "same-origin" })).redirected);
+ ok(rein === true, "und die Seite selbst weist sie ab (Umleitung)");
/* DIE SCHRANKE SITZT AM SERVER, nicht in der Oberfläche. Ein
versteckter Knopf ist keine Sicherung. */
@@ -439,7 +458,7 @@ melde("\n=== Lesbar und bedienbar ===");
/* Kleiner als 12 px liest niemand auf einem Bildschirm, an dem er
jeden Tag sitzt. Zweimal ist mir das schon durchgerutscht (10,56
und 10,88 px) -- deshalb wird es gemessen, nicht angesehen. */
- const klein = await man.seite.$$eval(
+ const klein = await chef.seite.$$eval(
".zahlkarte *, .zielbalken *, .tagzeile *, .import-vorschau *",
(es) => es
.filter((e) => e.textContent.trim() && !e.children.length)
@@ -451,19 +470,33 @@ melde("\n=== Lesbar und bedienbar ===");
/* Die ZAHL der geprüften Elemente gehört in die Bedingung, nicht nur
in den Meldetext. Eine leere Auswahl wäre sonst grün, ohne etwas
angesehen zu haben. */
- const gezaehlt = await man.seite.$$eval(
+ const gezaehlt = await chef.seite.$$eval(
".zahlkarte *, .zielbalken *, .tagzeile *",
(es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
ok(gezaehlt > 40, `und es wurden wirklich Elemente gemessen (${gezaehlt})`);
/* HANDY: Der Eintragen-Knopf muss mit dem Daumen zu treffen sein. */
- const handy = await alsRolle("manager", "CODE-MANA-0001", "leistung.html",
+ const handy = await alsRolle("admin", "CODE-DOGI-0001", "leistung.html",
{ width: 390, height: 844 });
- const masse = await handy.seite.$$eval(".tagzeile__knopf", (bs) =>
- bs.map((b) => { const r = b.getBoundingClientRect(); return { h: Math.round(r.height), w: Math.round(r.width) }; }));
+ /* NUR SICHTBARE KNÖPFE. Die Seite zeigt zwei Wochen, und die zweite
+ ist zugeklappt -- ihre Knöpfe messen 0×0. Genau die meldete die
+ Prüfung als "zu klein": ein Fingerziel, das niemand sehen kann,
+ ist kein zu kleines Fingerziel, sondern gar keins. Sieben
+ angebliche Mängel, kein einziger echter. Deshalb wird die Zahl der
+ wirklich sichtbaren mitgeführt und in die Bedingung genommen --
+ wäre plötzlich keiner mehr sichtbar, muss das auffallen und darf
+ nicht als "alle groß genug" durchgehen. */
+ const masse = await handy.seite.$$eval(".tagzeile__knopf", (bs) => bs
+ .filter((b) => b.checkVisibility ? b.checkVisibility() : b.offsetParent !== null)
+ .map((b) => { const r = b.getBoundingClientRect(); return { h: Math.round(r.height), w: Math.round(r.width) }; })
+ .filter((m) => m.w > 0 && m.h > 0));
const zuKlein = masse.filter((m) => m.h < 38 || m.w < 60);
- ok(masse.length > 0 && zuKlein.length === 0,
- `alle ${masse.length} Eintragen-Knöpfe sind groß genug (${zuKlein.length} zu klein)`);
+ /* Die Maße der Ausreißer gehören in die Meldung. "7 zu klein" sagt
+ nur, DASS etwas nicht stimmt; "38×44" sagt, WAS -- und erspart
+ einen zweiten Lauf, nur um nachzusehen. */
+ ok(masse.length >= 7 && zuKlein.length === 0,
+ `alle ${masse.length} Eintragen-Knöpfe sind groß genug (${zuKlein.length} zu klein`
+ + `${zuKlein.length ? ": " + zuKlein.slice(0, 4).map((m) => `${m.w}×${m.h}`).join(", ") : ""})`);
/* NICHTS DARF SEITLICH HERAUSHÄNGEN. Waagerechtes Scrollen auf dem
Handy ist der sicherste Weg, eine Seite unbenutzbar zu machen. */
@@ -494,10 +527,10 @@ melde("\n=== Lesbar und bedienbar ===");
======================================================================= */
melde("\n=== Dashboard ===");
{
- await man.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
- await man.seite.waitForTimeout(1600);
+ await chef.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
+ await chef.seite.waitForTimeout(1600);
- const zahlen = await man.seite.$$eval(".kzahlen", (zs) =>
+ const zahlen = await chef.seite.$$eval(".kzahlen", (zs) =>
zs.map((z) => ({
ziel: z.getAttribute("href"),
felder: [...z.querySelectorAll(".kzahlen__feld")].map((f) =>
@@ -511,21 +544,21 @@ melde("\n=== Dashboard ===");
/* DIE FRÜHWARNUNG. Der Bestand oben fällt über drei Wochen ab und
hat diese Woche weniger LIVE-Tage -- da MUSS etwas stehen. */
- const warnung = await man.seite.$eval("#fruehwarnung", (e) => e.hidden);
+ const warnung = await chef.seite.$eval("#fruehwarnung", (e) => e.hidden);
ok(!warnung, "die Frühwarnung ist sichtbar");
- const signale = await man.seite.$$eval(".warnsignal__text", (ts) => ts.map((t) => t.textContent));
+ const signale = await chef.seite.$$eval(".warnsignal__text", (ts) => ts.map((t) => t.textContent));
ok(signale.length > 0, `${signale.length} Signale: ${signale.join(" | ").slice(0, 140)}`);
/* KEINE PUNKTZAHL. Genau das war die Entscheidung: sechs benannte
Beobachtungen statt eines Werts, dem man nicht widersprechen kann. */
- const punktzahl = await man.seite.evaluate(() =>
+ const punktzahl = await chef.seite.evaluate(() =>
/risiko|score|punkte?zahl/i.test(document.getElementById("fruehwarnung")?.textContent || ""));
ok(!punktzahl, "und nirgends steht eine Risiko-Punktzahl");
/* Der Weg zum Gespräch. Ein Hinweis ohne Weg dahin ist nur ein
schlechtes Gewissen. */
- const wege = await man.seite.$$eval(".warnkarte__weg", (ws) => ws.map((w) => w.getAttribute("href")));
+ const wege = await chef.seite.$$eval(".warnkarte__weg", (ws) => ws.map((w) => w.getAttribute("href")));
ok(wege.length > 0 && wege.every((w) => /^chat\.html\?mit=\d+$/.test(w)),
`jede Warnkarte führt ins Gespräch (${wege.join(", ")})`);
@@ -533,11 +566,11 @@ melde("\n=== Dashboard ===");
Fläche mit eigenem CSS. Beim ersten Lauf stand .kzahlen__name auf
.66 rem = 10,56 px; genau derselbe Wert, der mir schon zweimal
durchgerutscht ist, weil ihn niemand misst. */
- const kleinDash = await man.seite.$$eval(".kzahlen *, .warnkarte *", (es) => es
+ const kleinDash = await chef.seite.$$eval(".kzahlen *, .warnkarte *", (es) => es
.filter((e) => e.textContent.trim() && !e.children.length)
.map((e) => ({ t: e.textContent.trim().slice(0, 24), px: parseFloat(getComputedStyle(e).fontSize) }))
.filter((x) => x.px < 12));
- const gezaehltDash = await man.seite.$$eval(".kzahlen *, .warnkarte *",
+ const gezaehltDash = await chef.seite.$$eval(".kzahlen *, .warnkarte *",
(es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
ok(gezaehltDash > 8 && kleinDash.length === 0,
`im Dashboard keine Schrift unter 12 px (${gezaehltDash} gemessen, ${kleinDash.length} zu klein`
@@ -546,9 +579,9 @@ melde("\n=== Dashboard ===");
/* Und dieser Weg muss WIRKLICH funktionieren. ?mit= war bis zum
06.09.2026 gar nicht vorgesehen -- ein Link, der auf einer leeren
Gesprächsliste endet, ist schlimmer als keiner. */
- await man.seite.goto(`${BASIS}/workspace/${wege[0]}`, { waitUntil: "networkidle" });
- await man.seite.waitForTimeout(1800);
- const offen = await man.seite.$eval("#verlauf-titel", (e) => e.textContent.trim());
+ await chef.seite.goto(`${BASIS}/workspace/${wege[0]}`, { waitUntil: "networkidle" });
+ await chef.seite.waitForTimeout(1800);
+ const offen = await chef.seite.$eval("#verlauf-titel", (e) => e.textContent.trim());
ok(offen === "Luna", `der Link öffnet das Gespräch mit "${offen}"`);
}
@@ -573,7 +606,7 @@ melde("\n=== Was der Creator NICHT sieht ===");
}
await luna.ctx.close();
-await man.ctx.close();
+await chef.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
diff --git a/server/pruef-personen-formular.mjs b/server/pruef-personen-formular.mjs
index 6b041875..29167738 100644
--- a/server/pruef-personen-formular.mjs
+++ b/server/pruef-personen-formular.mjs
@@ -114,7 +114,11 @@ const mass = await dogi.evaluate(() => {
};
});
-ok(mass.karten === 4, `alle vier Rollen stehen zur Wahl (${mass.karten})`);
+/* FÜNF, seit es Spicy Media gibt. Die Zahl steht hier absichtlich fest
+ und wird nicht aus der Oberfläche abgeleitet: Sie ist die Erwartung,
+ gegen die gemessen wird. Fiele eine Rolle aus der Wahl, müsste das
+ auffallen -- eine Zahl, die sich selbst nachzählt, kann das nicht. */
+ok(mass.karten === 5, `alle fünf Rollen stehen zur Wahl (${mass.karten})`);
ok(mass.wahlUnten <= mass.formUnten,
`die Rollenauswahl endet INNERHALB des Formulars (${mass.wahlUnten} ≤ ${mass.formUnten})`);
ok(!mass.ueberRand, "keine Karte ragt seitlich aus dem Formular");
@@ -150,27 +154,60 @@ await handy.screenshot({ path: "pruef-personen-formular-handy.png", fullPage: fa
TEIL 2 — Die beiden Bereiche gehören DogFather allein
=================================================================== */
-console.log("\n— Personen & Automationen: nur DogFather —");
+console.log("\n— Automationen: nur DogFather. Personen: auch die Leitung —");
+/* AM 07.09.2026 GETEILT. Hier standen beide Bereiche unter derselben
+ Regel: „gehören DogFather allein". Für die Automationen gilt das
+ weiter. Für die Personenseite hat Filipe es umgedreht -- Manager
+ sollen „creator und auch wirklich nur creator hinzufügen können",
+ und dafür müssen sie hinein.
+
+ Aus einer Regel sind damit ZWEI geworden, und beide werden gemessen:
+ Die Seite geht auf, die Verwaltungsdaten dahinter bleiben zu. Genau
+ diese Trennung IST die Sicherung. Hätte ich hier nur die Zeilen
+ gelöscht, wäre der neue Weg ab sofort ungeprüft -- und die Zahl der
+ Prüfungen wäre gesunken, ohne dass jemand hinsieht. */
const cigdem = await anmelden("manager", "CODE-CIGDEM-01");
await cigdem.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await cigdem.waitForTimeout(800);
const kacheln = await cigdem.evaluate(() =>
[...document.querySelectorAll(".kachel")].map((k) => k.innerText.split("\n")[0].trim()));
-ok(!kacheln.some((t) => /Personen/.test(t)),
- "1. die Kachel „Personen & Zugänge“ fehlt bei der Managerin");
+ok(kacheln.some((t) => /Personen/.test(t)),
+ "1. die Kachel „Personen & Zugänge“ steht jetzt bei der Managerin");
ok(!kacheln.some((t) => /Automationen/.test(t)),
- "1. die Kachel „Automationen“ fehlt ebenfalls");
+ "1. die Kachel „Automationen“ fehlt weiterhin");
ok(kacheln.length > 8,
- ` – aber der Rest steht noch da (${kacheln.length} Kacheln), es ist nicht alles weg`);
+ ` – und der Rest steht auch da (${kacheln.length} Kacheln)`);
-/* 2. Die SEITE, falls jemand die Adresse kennt. */
-for (const pfad of ["personen.html", "automation.html"]) {
- const a = await cigdem.goto(BASIS + "/workspace/" + pfad, { waitUntil: "networkidle" });
- const gelandet = cigdem.url();
- ok(gelandet.endsWith("/start.html"),
- `2. ${pfad} direkt aufgerufen führt zurück zur Startseite (${gelandet.split("/").pop()})`);
+/* 2. Die SEITEN, falls jemand die Adresse kennt. */
+{
+ await cigdem.goto(BASIS + "/workspace/automation.html", { waitUntil: "networkidle" });
+ ok(cigdem.url().endsWith("/start.html"),
+ `2. automation.html führt zurück zur Startseite (${cigdem.url().split("/").pop()})`);
+
+ await cigdem.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
+ await cigdem.waitForTimeout(900);
+ ok(cigdem.url().endsWith("/personen.html"),
+ `2. personen.html geht bei ihr auf (${cigdem.url().split("/").pop()})`);
+
+ /* Und sie sieht dort NUR das Anlegen -- keine Liste, kein Protokoll,
+ und keinen Ladehinweis ohne Ende. Der stand dort wirklich:
+ `laden()` wird für sie mit Absicht nicht aufgerufen, und niemand
+ hat aria-busy zurückgesetzt. Für einen Screenreader lud die Seite
+ dann für immer. */
+ const seitenbild = await cigdem.evaluate(() => ({
+ nurAnlegen: document.body.dataset.nurAnlegen || null,
+ laedtNoch: document.querySelectorAll('[aria-busy="true"]').length,
+ erklaerung: document.getElementById("unterzeile")?.textContent.trim() || "",
+ formularDa: !!document.getElementById("neu") && !document.getElementById("neu").hidden,
+ }));
+ ok(seitenbild.nurAnlegen === "ja" && seitenbild.formularDa === true,
+ "2. sie sieht dort das Anlegen-Formular");
+ ok(seitenbild.laedtNoch === 0,
+ `2. und nichts hängt auf „wird geladen“ (${seitenbild.laedtNoch})`);
+ ok(seitenbild.erklaerung.includes("DogFather"),
+ `2. ein Satz erklärt, warum hier so wenig steht ("${seitenbild.erklaerung.slice(0, 40)}…")`);
}
/* 3. Die SCHNITTSTELLE, falls jemand ganz an der Seite vorbeigeht.
diff --git a/server/pruef-schranke.mjs b/server/pruef-schranke.mjs
index 6f05f8de..981b53e4 100644
--- a/server/pruef-schranke.mjs
+++ b/server/pruef-schranke.mjs
@@ -181,7 +181,18 @@ console.log("\n=== Falsche Rolle ===");
/* Was jede Rolle NICHT sehen darf, steht ebenfalls in workspace.js.
Hier wird gegengeprueft, dass die Umleitung wirklich greift. */
-const NUR_DOGFATHER = ["/workspace/personen.html", "/workspace/automation.html"];
+/* PERSONEN.HTML IST SEIT DEM 07.09.2026 NICHT MEHR NUR FUER DOGFATHER.
+ Filipe: Manager sollen "creator und auch wirklich nur creator
+ hinzufuegen koennen" -- dafuer muessen sie die Seite oeffnen duerfen.
+ Sie steht deshalb nicht mehr in dieser Liste, sondern in der Liste
+ darunter, mit ihrer eigenen, engeren Erwartung. Automationen bleiben
+ allein bei DogFather.
+
+ Wichtig fuer die Aussagekraft: Die Seite VERSCHWINDET nicht aus der
+ Pruefung, sie wechselt die Erwartung. Haette ich sie nur
+ herausgenommen, waere die Zahl der Pruefungen gesunken und der
+ Manager-Weg ab da ungeprueft geblieben. */
+const NUR_DOGFATHER = ["/workspace/automation.html"];
for (const seite of NUR_DOGFATHER) {
for (const rolle of ["manager", "scout", "creator"]) {
const a = await hol(seite, kekse[rolle]);
@@ -191,6 +202,25 @@ for (const seite of NUR_DOGFATHER) {
ok(a.status === 200, `DogFather auf ${seite} -> ${a.status} (erwartet 200)`);
}
+/* Die Personenseite: Leitung ja, Scout und Creator nein. */
+const NUR_LEITUNG = "/workspace/personen.html";
+{
+ for (const rolle of ["scout", "creator"]) {
+ const a = await hol(NUR_LEITUNG, kekse[rolle]);
+ ok(a.status === 302, `${rolle} auf ${NUR_LEITUNG} -> ${a.status} (erwartet 302)`);
+ }
+ for (const rolle of ["admin", "manager"]) {
+ const a = await hol(NUR_LEITUNG, kekse[rolle]);
+ ok(a.status === 200, `${rolle} auf ${NUR_LEITUNG} -> ${a.status} (erwartet 200)`);
+ }
+ /* Und die Verwaltungsschnittstelle dahinter bleibt trotzdem zu:
+ Die Seite aufmachen zu duerfen heisst nicht, die Personenliste
+ lesen zu duerfen. Genau daran haengt, dass ein Manager nur
+ anlegen und nicht verwalten kann. */
+ const v = await hol("/workspace/api/verwaltung/personen", kekse.manager);
+ ok(v.status === 404, `aber die Verwaltungsdaten bleiben zu (${v.status}, erwartet 404)`);
+}
+
const NUR_TEAM = "/workspace/scouting.html";
{
const a = await hol(NUR_TEAM, kekse.creator);
@@ -204,9 +234,11 @@ const NUR_TEAM = "/workspace/scouting.html";
console.log("\n=== Falsche Rolle + andere Schreibweise ===");
const rolleDurchgerutscht = [];
let rolleGeprueft = 0;
-for (const seite of NUR_DOGFATHER) {
+for (const seite of [...NUR_DOGFATHER, NUR_LEITUNG]) {
for (const [pfad, wie] of VARIANTEN(seite)) {
- for (const rolle of ["manager", "creator"]) {
+ /* Fuer die Personenseite zaehlt hier nur der Creator -- der Manager
+ DARF sie inzwischen sehen, also waere ein 200 dort kein Loch. */
+ for (const rolle of seite === NUR_LEITUNG ? ["creator"] : ["manager", "creator"]) {
let a;
try { a = await hol(pfad, kekse[rolle]); } catch { continue; }
rolleGeprueft++;
@@ -219,7 +251,18 @@ for (const seite of NUR_DOGFATHER) {
}
}
}
-ok(rolleGeprueft > 40, `${rolleGeprueft} Versuche mit falscher Rolle`);
+/* DIE ZAHL WIRD AUSGERECHNET, NICHT ABGESCHRIEBEN. Vorher stand hier
+ "> 40" -- eine Zahl aus dem Moment, in dem zwei Seiten mal zwei
+ Rollen geprueft wurden. Als die Personenseite fuer Manager aufging
+ und dort nur noch der Creator zu pruefen war, fielen es 33, und die
+ Pruefung wurde rot, ohne dass ein Weg ungeprueft geblieben waere.
+ Jetzt wird die Erwartung aus denselben Listen gerechnet, aus denen
+ auch geprueft wird: Sie stimmt automatisch, und ein WIRKLICHER
+ Ausfall (eine Schleife, die nicht laeuft) faellt trotzdem auf. */
+const erwarteteVersuche = VARIANTEN(NUR_DOGFATHER[0]).length * NUR_DOGFATHER.length * 2
+ + VARIANTEN(NUR_LEITUNG).length;
+ok(rolleGeprueft === erwarteteVersuche && rolleGeprueft > 25,
+ `${rolleGeprueft} Versuche mit falscher Rolle (erwartet ${erwarteteVersuche})`);
ok(rolleDurchgerutscht.length === 0,
rolleDurchgerutscht.length
? `ROLLE UMGANGEN: ${rolleDurchgerutscht.slice(0, 6).join(" · ")}`
diff --git a/server/pruef-serien.mjs b/server/pruef-serien.mjs
index 3d726d78..2c021f4f 100644
--- a/server/pruef-serien.mjs
+++ b/server/pruef-serien.mjs
@@ -389,7 +389,19 @@ const liste = await seiteDogi.evaluate(() => ({
}));
ok(liste.sichtbar, "die Leiste „Läuft von allein\" ist sichtbar");
-ok(liste.serien.length === 2, `beide Serien stehen darin (${liste.serien.length})`);
+/* SEIT DEM 07.09.2026 SIEHT AUCH DOGFATHER NUR SEINE EIGENEN.
+ Hier stand "beide Serien stehen darin", und das war richtig, solange
+ DogFather jeden Termin sah. Beide Serien gehören Tili; angelegt hat
+ er nur die erste (den Wochen-Call mit ihr), die zweite hat sie
+ selbst angelegt. Nach der neuen Regel steht in seinem Kalender
+ deshalb genau eine -- und dass es die RICHTIGE ist, wird gleich
+ mitgeprüft: eine Zahl allein wäre auch erfüllt, wenn die falsche
+ übrig geblieben wäre. */
+ok(liste.serien.length === 1, `seine eigene Serie steht darin (${liste.serien.length})`);
+ok(liste.serien[0]?.titel === "Wochen-Call mit Tili",
+ `und zwar die, die er selbst angelegt hat (${liste.serien[0]?.titel})`);
+ok(!liste.serien.some((s) => s.titel === "Eigener Drehtag"),
+ "Tilis eigene Wiederholung steht NICHT in seinem Kalender");
ok(liste.serien.some((s) => s.satz === "jeden Dienstag um 18:00 Uhr, bis du sie abstellst")
|| liste.serien.some((s) => /^jeden \w+ um 18:00 Uhr, bis du sie abstellst$/.test(s.satz)),
"der Satz nennt Rhythmus, Uhrzeit und dass sie ohne Ende läuft");
diff --git a/server/pruef-sicht.mjs b/server/pruef-sicht.mjs
index bc65b3ac..0c41047d 100644
--- a/server/pruef-sicht.mjs
+++ b/server/pruef-sicht.mjs
@@ -246,15 +246,23 @@ for (const [pfad, name, feld] of [
ok(a.status === 200 && b.status === 200, `${name}: beide Abfragen gehen durch`);
}
-/* Der Kalender ist der klarste Beleg: Patrick sieht nur seine Termine. */
+/* Der Kalender ist der klarste Beleg -- und seit dem 07.09.2026 gilt
+ die Regel "nur was mich betrifft" fuer JEDEN, DogFather
+ eingeschlossen (siehe termineSichtbar in workspace.js). Fremde
+ Verabredungen sieht er nicht mehr in seinem eigenen Kalender; dafuer
+ gibt es den Umschalter. Genau dieser Wechsel wird hier gemessen:
+ ohne Umschalter keins der beiden, mit Umschalter genau das eine.
+ Und ein Termin ohne Teilnehmer geht weiterhin alle an -- sonst
+ koennte die Regel auch einfach alles ausblenden und waere trotzdem
+ gruen. */
{
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
- d2.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
- VALUES (?,?,?,?,?,?,?)`)
- .run("PATRICK-TERMIN", "call", heute + "T10:00", 30, idTili, jetzt, idPat);
- d2.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
- VALUES (?,?,?,?,?,?,?)`)
- .run("BEN-TERMIN", "call", heute + "T12:00", 30, idLuna, jetzt, idBen);
+ const rein = d2.prepare(
+ `INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
+ VALUES (?,?,?,?,?,?,?)`);
+ rein.run("PATRICK-TERMIN", "call", heute + "T10:00", 30, idTili, jetzt, idPat);
+ rein.run("BEN-TERMIN", "call", heute + "T12:00", 30, idLuna, jetzt, idBen);
+ rein.run("TEAM-TERMIN", "termin", heute + "T14:00", 30, null, jetzt, idPat);
d2.close();
const j = async (k, s) => {
@@ -263,9 +271,12 @@ for (const [pfad, name, feld] of [
};
const alleT = await j(kDogi);
const patT = await j(kDogi, idPat);
- ok(alleT.length === 2, `DogFather sieht beide Termine (${alleT.join(", ")})`);
- ok(patT.length === 1 && patT[0] === "PATRICK-TERMIN",
- `als Patrick nur dessen (${patT.join(", ")})`);
+ ok(!alleT.includes("PATRICK-TERMIN") && !alleT.includes("BEN-TERMIN"),
+ `DogFather sieht fremde Verabredungen nicht mehr (${alleT.join(", ") || "nichts"})`);
+ ok(alleT.includes("TEAM-TERMIN"),
+ "den Termin ohne Teilnehmer aber schon -- der geht alle an");
+ ok(patT.includes("PATRICK-TERMIN") && !patT.includes("BEN-TERMIN"),
+ `mit dem Umschalter auf Patrick genau dessen (${patT.join(", ")})`);
}
/* ================================================================
diff --git a/server/pruef-spicy.mjs b/server/pruef-spicy.mjs
index e1f815ac..be1740a8 100644
--- a/server/pruef-spicy.mjs
+++ b/server/pruef-spicy.mjs
@@ -372,16 +372,27 @@ const ruf = async (art, weg, keks, koerper) => {
`auch seine Aufgabe nicht (sie sieht: ${aTitel.join(", ") || "nichts"})`);
ok(aTitel.includes("Max seine Aufgabe"), "Max seine schon");
+ /* Der Kalender folgt seit dem 07.09.2026 einer ANDEREN Regel als
+ Bereiche und Aufgaben, und das ist Absicht (Begruendung steht bei
+ termineSichtbar in workspace.js): Dort entscheidet die Rolle, hier
+ entscheidet allein die BETEILIGUNG. Wer niemanden eintraegt, meint
+ alle -- also sieht Spicy Media DogFathers teilnehmerlosen Termin
+ sehr wohl, Max' Verabredung mit Luna dagegen nicht. Genau
+ umgekehrt zu den beiden Bloecken darueber. Die zwei Zeilen stehen
+ hier zusammen, weil erst beide zeigen, dass wirklich die
+ Beteiligung zaehlt und nicht die Rolle -- eine allein waere auch
+ erfuellt, wenn schlicht alles oder gar nichts ankaeme. */
await ruf("POST", "/workspace/api/termine", keksDogi,
- { titel: "DogFathers Termin", art: "termin", beginn: heute + "T20:00", dauer_min: 30 });
+ { titel: "DogFathers Team-Termin", art: "termin", beginn: heute + "T20:00", dauer_min: 30 });
await ruf("POST", "/workspace/api/termine", keksMax,
{ titel: "Max sein Termin", art: "termin", beginn: heute + "T21:00", dauer_min: 30,
creator_id: idLuna });
const term = await ruf("GET", `/workspace/api/termine?von=${heute}&bis=${heute}`, keksSpicy);
const tTitel = (term.daten?.termine || []).map((t) => t.titel);
- ok(!tTitel.includes("DogFathers Termin"),
- `und seinen Termin nicht (sie sieht: ${tTitel.join(", ") || "nichts"})`);
- ok(tTitel.includes("Max sein Termin"), "Max seinen schon");
+ ok(tTitel.includes("DogFathers Team-Termin"),
+ `der Termin ohne Teilnehmer geht alle an -- auch sie (${tTitel.join(", ") || "nichts"})`);
+ ok(!tTitel.includes("Max sein Termin"),
+ "Max' Verabredung mit Luna dagegen nicht -- die geht nur die beiden an");
/* GEGENPROBE: DogFather selbst sieht weiterhin BEIDE. Ohne sie
bewiesen die Zeilen oben nur, dass irgendetwas gefiltert wird. */
diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs
index 1d683f09..2b4b0ba1 100644
--- a/server/pruef-start-ansicht.mjs
+++ b/server/pruef-start-ansicht.mjs
@@ -615,7 +615,21 @@ console.log("\n=== Das Licht folgt dem Zeiger");
const soll = hexTreffer
? [0, 2, 4].map((i) => parseInt(hexTreffer[1].slice(i, i + 2), 16))
: zahlen.slice(0, 3);
- return { x: r.x + r.width / 2, y: r.y + r.height / 2, soll, ton };
+ /* WO DIE FARBE LIEGT, HAT SICH AM 07.09.2026 GEAENDERT.
+ Bis dahin lag ein Farbschleier ueber der ganzen Kachel, und
+ die Mitte war der richtige Messpunkt. Die neue Form traegt
+ die Farbe auf der KANTE -- die Flaeche ist mit Absicht
+ neutral. An der alten Stelle gemessen kam deshalb Grau
+ heraus, und die Pruefung meldete einen Fehler, den es nicht
+ gab.
+
+ Gemessen wird jetzt das obere Kantenlicht: ein schmales Band
+ von 4 px Hoehe ueber dem linken Drittel, beginnend hinter
+ der Schraege. Nicht EIN Bildpunkt -- eine 1,6 px dicke Linie
+ auf einer 1-zu-1-Aufloesung trifft man mit einem Punkt nur
+ mit Glueck. Aus dem Band wird der farbigste Punkt genommen. */
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2, soll, ton,
+ band: { x: r.x + 22, y: r.y, breite: Math.max(40, r.width / 3), hoehe: 4 } };
}, name);
if (!b || b.soll.length !== 3) {
ok(false, `"${name}": Farbton nicht lesbar (${b?.ton})`);
@@ -624,14 +638,34 @@ console.log("\n=== Das Licht folgt dem Zeiger");
await seite.mouse.move(b.x, b.y);
await seite.waitForTimeout(350);
const bild = await seite.screenshot({ type: "png" });
- const p = await seite.evaluate(async ({ b64, x, y }) => {
+ const gemessen = await seite.evaluate(async ({ b64, band }) => {
const i = new Image(); i.src = "data:image/png;base64," + b64; await i.decode();
const c = document.createElement("canvas");
c.width = i.width; c.height = i.height;
const g = c.getContext("2d"); g.drawImage(i, 0, 0);
const s = i.width / window.innerWidth;
- return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data];
- }, { b64: bild.toString("base64"), x: b.x, y: b.y });
+ const d = g.getImageData(Math.round(band.x * s), Math.round(band.y * s),
+ Math.round(band.breite * s), Math.round(band.hoehe * s)).data;
+ /* Der farbigste Punkt des Bandes: groesster Abstand zwischen
+ staerkstem und schwaechstem Kanal. Ein grauer Streifen hat
+ dort fast null -- daran erkennt man auch, wenn gar kein
+ Licht da ist. */
+ let best = [0, 0, 0], spanne = -1, punkte = 0;
+ for (let k = 0; k + 3 < d.length; k += 4) {
+ punkte++;
+ const v = [d[k], d[k + 1], d[k + 2]];
+ const sp = Math.max(...v) - Math.min(...v);
+ if (sp > spanne) { spanne = sp; best = v; }
+ }
+ return { punkte, spanne, farbe: best };
+ }, { b64: bild.toString("base64"), band: b.band });
+ const p = gemessen.farbe;
+ /* DIE ANZAHL DER GEMESSENEN PUNKTE GEHOERT IN DIE BEDINGUNG.
+ Ein leeres Band haette sonst "Reihenfolge stimmt" gemeldet --
+ 0 gegen 0 ist immer gleich. */
+ ok(gemessen.punkte > 50 && gemessen.spanne >= 6,
+ `"${name}": auf der Oberkante liegt ueberhaupt Farbe `
+ + `(${gemessen.punkte} Punkte, Spanne ${gemessen.spanne})`);
/* Die REIHENFOLGE der Kanaele muss uebereinstimmen. Absolute
Werte koennen es nicht: Das Licht ist stark abgedunkelt und
liegt auf einem dunklen Grund, seine Zahlen sind ein Bruchteil
diff --git a/server/pruef-tagesblick.mjs b/server/pruef-tagesblick.mjs
index 45942683..9efb5fad 100644
--- a/server/pruef-tagesblick.mjs
+++ b/server/pruef-tagesblick.mjs
@@ -142,14 +142,33 @@ melde("\n=== Eine Kachel, mittig geteilt ===");
const da = await seite.isVisible("#dran-block");
ok(da, da ? "die Kachel ist da" : "die Kachel fehlt");
- /* EIN Rahmen um beides -- nicht zwei Kacheln nebeneinander. */
+ /* EINE Umrandung um beides -- nicht zwei Kacheln nebeneinander.
+
+ WIE DIESE UMRANDUNG AUSSIEHT, HAT SICH AM 07.09.2026 GEÄNDERT.
+ Vorher: ein `border` mit Radius. Jetzt: eine Silhouette mit
+ abgeschnittener Ecke (`clip-path`) und ein Kantenlicht auf
+ `::before`. Hier stand deshalb eine Bedingung, die zwangsläufig
+ rot wurde, ohne dass an der Kachel etwas fehlte -- gemessen wurde
+ eine Bauweise, die es nicht mehr gibt.
+
+ Die Aussage bleibt dieselbe und wird jetzt an der neuen Bauweise
+ gemessen: ein Kasten, eine Silhouette, ein Licht darauf. */
const rahmen = await seite.$eval("#dran-block", (e) => {
const s = getComputedStyle(e);
- return { breite: s.borderTopWidth, radius: s.borderTopLeftRadius, anzeige: s.display };
+ const v = getComputedStyle(e, "::before");
+ return {
+ anzeige: s.display,
+ silhouette: s.clipPath,
+ radius: s.borderTopLeftRadius,
+ lichtDa: v.content !== "none" && v.position === "absolute",
+ };
});
- ok(rahmen.anzeige === "grid" && parseFloat(rahmen.breite) >= 1
- && parseFloat(rahmen.radius) > 4,
- `ein Rahmen um beides (${rahmen.anzeige}, ${rahmen.breite}, Radius ${rahmen.radius})`);
+ ok(rahmen.anzeige === "grid"
+ && /polygon/.test(rahmen.silhouette)
+ && parseFloat(rahmen.radius) === 0
+ && rahmen.lichtDa,
+ `eine Silhouette um beides (${rahmen.anzeige}, ${rahmen.silhouette.slice(0, 24)}…, `
+ + `Radius ${rahmen.radius}, Kantenlicht ${rahmen.lichtDa ? "da" : "fehlt"})`);
/* NEBENEINANDER und gleich breit -- das ist der Kern des Wunsches. */
const seiten = await seite.evaluate(() => {
diff --git a/server/pruef-team.mjs b/server/pruef-team.mjs
index 5f80b16e..24259a0a 100644
--- a/server/pruef-team.mjs
+++ b/server/pruef-team.mjs
@@ -226,9 +226,17 @@ const team = await a1.seite.evaluate(() => {
automation.css und wissen.css -- diese Seite laedt beide nicht.
Genau so sind hier schon zweimal Stile ins Leere gelaufen.
Gemessen wird deshalb der tatsaechliche Stil, nicht die Klasse. */
+ /* SEIT DEM 07.09.2026 IST DER RAND KEINE `border` MEHR, sondern
+ eine Silhouette mit abgeschnittener Ecke aus module.css. Die
+ alte Bedingung (border >= 1) wurde dadurch zwangsläufig rot,
+ ohne dass ein Stil fehlte. Die FRAGE bleibt aber genau dieselbe
+ und ist so wichtig wie zuvor: Ist der Stil auf DIESER Seite
+ angekommen? Gemessen wird sie jetzt an der neuen Bauweise --
+ Polsterung wie bisher, dazu die Silhouette, die es nur gibt,
+ wenn module.css wirklich geladen wurde. */
gestaltet: karten[0] ? (() => {
const st = getComputedStyle(karten[0]);
- return parseFloat(st.borderTopWidth) >= 1 && parseFloat(st.paddingLeft) >= 8;
+ return parseFloat(st.paddingLeft) >= 8 && /polygon/.test(st.clipPath);
})() : null,
anzahl: karten.length,
sichtbare: karten.filter(sichtbar).length,
diff --git a/server/pruef-teilnehmer.mjs b/server/pruef-teilnehmer.mjs
index 7d6f78c5..7b69ef16 100644
--- a/server/pruef-teilnehmer.mjs
+++ b/server/pruef-teilnehmer.mjs
@@ -278,20 +278,39 @@ console.log("\n=== Unsinn in der Teilnehmerliste ===");
======================================================================= */
console.log("\n=== Gegenprobe ===");
{
+ /* SEIT DEM 07.09.2026 IST EIN TERMIN OHNE TEILNEHMER DER ÖFFENTLICHE
+ FALL, nicht der geheime (siehe termineSichtbar in workspace.js):
+ "Wer niemanden einträgt, meint alle." Hier stand deshalb vorher
+ das Gegenteil — ein teilnehmerloser Termin, von dem erwartet
+ wurde, dass Luna ihn NICHT sieht.
+
+ Für eine Gegenprobe braucht es aber weiterhin einen Termin, den
+ sie wirklich nicht sehen darf. Das ist jetzt der Termin MIT einem
+ anderen Gegenüber: eine Verabredung zwischen zwei anderen. */
const a = await ruf("POST", "/workspace/api/termine", {
- titel: "Nur fuer DogFather", art: "termin", beginn: heute + "T20:00",
+ titel: "Verabredung ohne Luna", art: "termin", beginn: heute + "T20:00",
+ teilnehmer: [idBea],
}, K.dogi);
const geheim = a.daten?.id;
- ok(geheim > 0, `ein Termin ohne Teilnehmer angelegt (#${geheim})`);
- ok(await siehtTermin(K.dogi, geheim), "DogFather sieht ihn");
+ ok(geheim > 0, `ein Termin mit Bea, ohne Luna, angelegt (#${geheim})`);
+ ok(await siehtTermin(K.dogi, geheim), "DogFather sieht ihn — er hat ihn angelegt");
ok(!(await siehtTermin(K.luna, geheim)),
"Luna sieht ihn NICHT — die Messung erkennt also 'nicht sichtbar'");
const b = await ruf("PATCH", `/workspace/api/termine/${geheim}`,
- { teilnehmer: [idLuna] }, K.dogi);
- ok(b.status === 200, `Luna eingetragen (${b.status})`);
+ { teilnehmer: [idBea, idLuna] }, K.dogi);
+ ok(b.status === 200, `Luna dazugenommen (${b.status})`);
ok(await siehtTermin(K.luna, geheim),
"jetzt sieht sie ihn — die Messung erkennt auch 'sichtbar'");
+
+ /* Und der neue Fall selbst, damit er nicht ungeprüft bleibt: OHNE
+ Teilnehmer sieht ihn jeder. Ohne diese Zeile bliebe die Regel, die
+ den Umbau ausgelöst hat, ausgerechnet hier ungemessen. */
+ const c = await ruf("POST", "/workspace/api/termine", {
+ titel: "Geht alle an", art: "termin", beginn: heute + "T21:30",
+ }, K.dogi);
+ ok(await siehtTermin(K.luna, c.daten?.id),
+ "einen Termin ganz ohne Teilnehmer sieht dagegen jeder");
}
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
diff --git a/server/pruef-workspace-seiten.mjs b/server/pruef-workspace-seiten.mjs
index 34fe1e72..b9d95f0f 100644
--- a/server/pruef-workspace-seiten.mjs
+++ b/server/pruef-workspace-seiten.mjs
@@ -145,14 +145,39 @@ for (const [breite, hoehe, wie] of [[1440, 1000, "Computer"], [390, 844, "Handy"
CSS-Regel wäre still wirkungslos, und niemand würde es merken --
die Seite sähe nur "etwas dunkler" aus.
- Zusätzlich: Es muss auf JEDER Seite DASSELBE sein. Ohne diese
- zweite Bedingung wäre die Prüfung auch dann grün, wenn irgendwo
- eine der alten Szenen zurückkäme. */
- const buehneGut = r.buehneBild === "dogfather";
- const gut = r.ueber <= 0 && konsole.length === 0 && r.abmelden !== false
- && markeGut && kopfGut && buehneGut
- && (r.breit === null || r.breit === "1240px")
- && (r.kopfzeile === null || r.kopfzeile === "flex");
+ AM 07.09.2026 NOCH EINMAL UMGESTELLT -- und diesmal war die
+ Prüfung schlicht liegengeblieben. Filipe hat die Motive
+ zurückgeholt (Commit "Sieben Motive statt einem"), hier stand
+ aber weiter `=== "dogfather"`. Ergebnis: ALLE 32 Seiten rot, in
+ einer Meldung, der man nicht ansieht, welche Bedingung gefallen
+ ist -- 32 Fehlalarme, kein einziger Mangel. Wer eine Entscheidung
+ ändert, muss die Prüfung mitändern, sonst misst sie eine Welt,
+ die es nicht mehr gibt.
+
+ Die neue Bedingung ist SCHÄRFER als beide vorherigen: Das
+ geladene Bild muss zu dem Merkmal passen, das die Seite selbst
+ trägt. Ein `data-buehne="garage"` ohne passende CSS-Regel fiele
+ damit auf -- bei "überall dasselbe Bild" wäre es nie
+ aufgefallen, und bei "irgendein Motiv" auch nicht. */
+ /* Die Startseite trägt KEIN data-buehne -- sie hat ihre feste
+ Grundszene aus `body.start::before`. Für sie zählt deshalb nur,
+ dass überhaupt ein Motiv ankommt; für alle anderen, dass das
+ ankommende Motiv das ist, das sie sich aussuchen. */
+ const buehneGut = r.buehne ? r.buehneBild === r.buehne : !!r.buehneBild;
+ /* JEDE BEDINGUNG EINZELN, MIT NAMEN. Vorher war das ein einziges
+ langes UND -- fiel es, stand da nur "FEHL" und eine Zeile, in
+ der alles in Ordnung aussah. Man musste die Prüfung aufmachen
+ und raten. Die Gründe stehen jetzt in der Meldung. */
+ const gruende = [];
+ if (!(r.ueber <= 0)) gruende.push(`ragt ${r.ueber}px heraus`);
+ if (konsole.length) gruende.push(`Konsole: ${konsole.join(" / ")}`);
+ if (r.abmelden === false) gruende.push("Abmelden-Knopf ausserhalb");
+ if (!markeGut) gruende.push("Schriftzug/Rückweg");
+ if (!kopfGut) gruende.push("Seitenkopf (Ton/Zeichen/Wasserzeichen)");
+ if (!buehneGut) gruende.push(`Bühne: ${r.buehneBild} statt ${r.buehne}`);
+ if (!(r.breit === null || r.breit === "1240px")) gruende.push(`Breite ${r.breit}`);
+ if (!(r.kopfzeile === null || r.kopfzeile === "flex")) gruende.push(`Kopfzeile ${r.kopfzeile}`);
+ const gut = gruende.length === 0;
if (!gut) fehler++;
console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}`
+ `über=${String(r.ueber).padStart(3)}px abmelden=${r.abmelden}`
@@ -162,7 +187,8 @@ for (const [breite, hoehe, wie] of [[1440, 1000, "Computer"], [390, 844, "Handy"
+ ` bühne=${r.buehneBild || "FEHLT"}${r.buehne && r.buehneBild !== r.buehne ? ` (gewollt: ${r.buehne})` : ""}`
+ ` breit=${r.breit}`
+ (konsole.length ? " KONSOLE: " + konsole.join(" | ") : "")
- + (r.schuld.length ? " -> " + r.schuld.join(" | ") : ""));
+ + (r.schuld.length ? " -> " + r.schuld.join(" | ") : "")
+ + (gruende.length ? "\n GRUND: " + gruende.join(" ; ") : ""));
}
await kontext.close();
}
diff --git a/server/workspace.js b/server/workspace.js
index 99a968dc..1a6ecb57 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -2367,10 +2367,35 @@ export const externSql = (personSpalte, externSpalte) =>
spaeter auseinandergelaufen -- und dann haette eine Wiederholung
jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. */
export function termineSichtbar(person, praefix = "t") {
- if (istSpicy(person)) return { wo: ohneDogFather(praefix), werte: [] };
- if (siehtAlles(person)) return { wo: "1=1", werte: [] };
const p = praefix;
+ /* =====================================================================
+ JEDER SIEHT NUR SEINE EIGENEN TERMINE -- ODER DIE, DIE ALLE ANGEHEN
+ (07.09.2026)
+
+ Wunsch Filipe: "calls oder termine soll jeder nur sehen die er
+ selber macht oder jeden betrifft."
+
+ DAS GILT AUCH FUER DOGFATHER, und das ist die eigentliche
+ Aenderung. Bis hierher bekam er `1=1` -- er sah jeden Termin von
+ jedem. Bei einer Handvoll Leuten war das ein Ueberblick; bei
+ dreissig ist es eine Wand aus fremden Verabredungen, in der die
+ eigenen untergehen. Ein Kalender, in dem alles steht, ist kein
+ Kalender mehr.
+
+ "JEDEN BETRIFFT" braucht eine Definition, sonst ist es Auslegung.
+ Sie steht hier: ein Termin OHNE Gegenueber und OHNE Teilnehmerliste.
+ Wer einen Termin anlegt und niemanden eintraegt, meint alle -- das
+ ist die Team-Besprechung, die Schulung, der Feiertag. Sobald auch
+ nur EINE Person eingetragen ist, ist es eine Verabredung und geht
+ nur die Beteiligten etwas an.
+
+ Diese Regel ersetzt die Rollenfrage vollstaendig: Es gibt hier
+ nichts mehr, was DogFather sieht und ein Creator nicht. Was ein
+ Betreuer ueber seine Creator wissen muss, steht in deren Bereichen
+ und Aufgaben -- nicht in ihrem Kalender.
+ ===================================================================== */
+
/* MITGEZAEHLT WIRD AUCH DIE TEILNEHMERLISTE (05.09.2026).
Seit ein Termin mehrere Teilnehmer haben kann, reicht
@@ -2394,9 +2419,15 @@ export function termineSichtbar(person, praefix = "t") {
const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?`
+ ` OR ${dabei})`;
const werte = [person.id, person.id, person.id, person.id];
- const b = betreutWo(person, `${p}.creator_id`);
- if (!b) return { wo: eigen, werte };
- return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] };
+
+ /* "Geht alle an": kein Gegenueber, kein Teilnehmer. `NOT EXISTS` und
+ nicht `COUNT(*) = 0` -- der Server hoert beim ersten Treffer auf zu
+ suchen, statt jedes Mal die ganze Nebentabelle zu zaehlen. */
+ const fuerAlle = `(${p}.creator_id IS NULL AND ${p}.teilnehmer_id IS NULL`
+ + ` AND NOT EXISTS (SELECT 1 FROM ${nebenTabelle.tabelle} ta`
+ + ` WHERE ta.${nebenTabelle.spalte} = ${p}.id))`;
+
+ return { wo: `(${eigen} OR ${fuerAlle})`, werte };
}
/* =====================================================================
diff --git a/tools/ansicht.mjs b/tools/ansicht.mjs
index 1ba18a0e..36cbaebc 100644
--- a/tools/ansicht.mjs
+++ b/tools/ansicht.mjs
@@ -50,7 +50,13 @@ await start();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
-const ctx = await browser.newContext({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
+/* Breite ueber die Umgebung: node tools/ansicht.mjs start -> Rechner
+ BREITE=412 node tools/ansicht.mjs start -> Handy */
+const BREITE = Number(process.env.BREITE || 1600);
+const HOEHE = Number(process.env.HOEHE || (BREITE < 700 ? 915 : 1000));
+const ctx = await browser.newContext({
+ viewport: { width: BREITE, height: HOEHE }, deviceScaleFactor: 1,
+ isMobile: BREITE < 700, hasTouch: BREITE < 700 });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
@@ -62,7 +68,7 @@ await seite.waitForURL("**/start.html", { timeout: 25000 });
for (const s of SEITEN) {
await seite.goto(`${BASIS}/workspace/${s}.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
- await seite.screenshot({ path: `/tmp/a-${s}.png`, fullPage: false });
+ await seite.screenshot({ path: `/tmp/a-${s}${BREITE < 700 ? "-handy" : ""}.png`, fullPage: false });
console.log(`/tmp/a-${s}.png`);
}
diff --git a/workspace/assets/css/module.css b/workspace/assets/css/module.css
new file mode 100644
index 00000000..dc5f8c33
--- /dev/null
+++ b/workspace/assets/css/module.css
@@ -0,0 +1,199 @@
+/* =====================================================================
+ module.css — DIE FORM ALLER MODULE
+
+ Herausgeloest aus start.css am 07.09.2026. Diese Datei beschreibt die
+ SILHOUETTE der ganzen Anwendung: die abgeschnittene Ecke, das
+ Kantenlicht, die Eckwinkel und das Raster.
+
+ Sie wird von JEDER Seite geladen -- ausser von der Anmeldeseite
+ (index.html), die unangetastet bleibt.
+
+ SIE WIRD ALS LETZTE GELADEN, und das ist keine Kosmetik. Bis zum
+ 07.09.2026 stand sie an dritter Stelle, VOR den Seitendateien. Dort
+ hat sie auf vier Seiten schlicht nichts bewirkt: `.call` in
+ calls.css, `.regel`/`.zustand`/`.ki-kasten` in automation.css,
+ `.unterweisung` in bereich.css und `.fortschritt` in dateien.css
+ setzen alle selbst `border-radius` und `box-shadow` -- gleiche
+ Spezifitaet, spaeter im Ladeweg, also gewinnen sie. Ergebnis war
+ genau das, was Filipe zu Recht bemaengelt hat: die neue Form auf
+ manchen Seiten, die alte auf anderen, "ueberall ein bisschen".
+ Als letzte Datei gewinnt die Form ueberall.
+
+ WER EINE NEUE KARTE BAUT, traegt ihren Klassennamen in die Listen
+ unten ein. Die Liste steht siebenmal in dieser Datei (Grundform,
+ Kantenlicht, Raster, Handy und dreimal Kontrastmodus) -- das ist die
+ schwache Stelle dieser Bauart, denn eine vergessene Kopie faellt
+ niemandem auf. Deshalb steht vor jeder Kopie die Marke
+ MODULLISTE, und `pruef-css-klassen.mjs` vergleicht alle sieben
+ Vorkommen miteinander: Weicht eines ab, wird die Pruefung rot.
+ ===================================================================== */
+
+/* =====================================================================
+ HUD-MODULE — die FORM ändert sich, nicht der Lack (07.09.2026)
+
+ Filipe: "DU HAST WIEDER EINE KLEINE ÄNDERUNG ÜBERALL GEMACHT ANSTATT
+ EINE RIESEN ÄNDERUNG."
+
+ ---------------------------------------------------------------------
+ ER HAT RECHT, UND DER GRUND WAR IMMER DERSELBE
+
+ Ich habe dreimal das MATERIAL getauscht -- Mattglas, Leuchtschiene,
+ Verlauf mit Schatten -- und dreimal die FORM gelassen. Ein
+ abgerundetes Rechteck bleibt ein abgerundetes Rechteck. Die
+ Silhouette ist aber das Einzige, was man aus fünf Metern erkennt;
+ deshalb sah jede Änderung "wie vorher" aus.
+
+ DIE NEUE FORM:
+
+ 1. DIE ECKE OBEN LINKS IST ABGESCHNITTEN. Eine Schräge statt einer
+ Rundung -- eine andere geometrische Klasse, und genau deshalb
+ fällt sie auf. Gebaut mit `clip-path`, also eine echte
+ Silhouette und kein aufgemalter Fleck.
+ 2. EIN LICHT LÄUFT AUF DER KANTE, oben und über die Schräge, in der
+ Farbe der Kategorie -- nicht ein Rahmen rundherum (das wäre
+ wieder ein Kasten), sondern eine beleuchtete Oberkante.
+ 3. ECKWINKEL UNTEN RECHTS. Zwei kurze Striche, die eine Ecke
+ andeuten, ohne sie zu zeichnen -- die Sprache von Peilung und
+ Zielmarke.
+ 4. EIN FEINES RASTER statt Körnung. Körnung sagt "Papier", ein
+ Raster sagt "Messgerät".
+
+ ---------------------------------------------------------------------
+ DREI DINGE, DIE BEIM ERSTEN ANLAUF SCHIEFGINGEN
+
+ `clip-path` SCHNEIDET DEN SCHLAGSCHATTEN AB. Deshalb liegt die Höhe
+ hier in `filter: drop-shadow()` und nicht in `box-shadow`: drop-shadow
+ folgt der WIRKLICHEN Form, box-shadow immer dem Rechteck -- er würde
+ einen Schatten für eine Ecke zeichnen, die es nicht mehr gibt.
+
+ DIE REGELN STANDEN IN `:where()`. Das hat Spezifität NULL, also
+ gewann jede ältere `.kachel::before`-Regel. Die Silhouette kam durch
+ (die setzt sonst niemand), das Kantenlicht nicht -- heraus kam ein
+ Zwitter aus neuer Form und alter Kante. Jetzt steht hier `:is()`:
+ Das übernimmt die Spezifität seines stärksten Arguments, also die
+ einer einzelnen Klasse. Damit liegt die Form gleichauf mit
+ `.kachel` und gewinnt über den Ladeweg -- deshalb muss diese Datei
+ zuletzt kommen (siehe oben).
+
+ UND SIE STAND ZU FRÜH IM LADEWEG. Siehe der Absatz ganz oben; das
+ war derselbe Fehler noch einmal, nur eine Ebene höher.
+ ===================================================================== */
+
+:root {
+ /* Die Schräge steht als Größe da, damit Silhouette, Kantenlicht und
+ Eckwinkel garantiert dieselbe benutzen. */
+ --fase: 18px;
+ --raster:
+ repeating-linear-gradient(0deg, rgba(150, 186, 220, .26) 0 1px, transparent 1px 26px),
+ repeating-linear-gradient(90deg, rgba(150, 186, 220, .26) 0 1px, transparent 1px 26px);
+}
+
+/* MODULLISTE */
+:is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu) {
+ position: relative;
+ clip-path: polygon(var(--fase) 0, 100% 0, 100% 100%, 0 100%, 0 var(--fase));
+ border-radius: 0;
+ border: 0;
+ box-shadow: none;
+ filter:
+ drop-shadow(0 2px 3px rgba(0, 0, 0, .55))
+ drop-shadow(0 12px 22px rgba(0, 0, 0, .55));
+}
+
+/* DAS KANTENLICHT. Ein Layer mit derselben Silhouette, 1,6 px kleiner --
+ was übrig bleibt, ist eine Linie, die exakt der Form folgt, Schräge
+ inklusive. Ein `border` könnte das nicht: Er folgt dem Rechteck. */
+/* MODULLISTE */
+:is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu)::before {
+ content: "";
+ position: absolute; inset: 0;
+ pointer-events: none;
+ z-index: 2;
+ width: auto; height: auto;
+ opacity: 1;
+ background: linear-gradient(158deg,
+ color-mix(in srgb, var(--ton, var(--akzent)) 90%, #ffffff) 0%,
+ color-mix(in srgb, var(--ton, var(--akzent)) 60%, transparent) 15%,
+ rgba(150, 186, 220, .20) 45%,
+ rgba(150, 186, 220, .07) 100%);
+ clip-path: polygon(
+ var(--fase) 0, 100% 0, 100% 100%, 0 100%, 0 var(--fase),
+ 1.6px calc(var(--fase) + 0.7px), 1.6px calc(100% - 1.6px),
+ calc(100% - 1.6px) calc(100% - 1.6px), calc(100% - 1.6px) 1.6px,
+ calc(var(--fase) + 0.7px) 1.6px, 1.6px calc(var(--fase) + 0.7px),
+ 0 var(--fase));
+}
+
+/* RASTER UND ECKWINKEL. Beides auf ::after -- ein drittes Element gibt
+ es nicht, und beides ist reine Optik ohne Klicks. */
+/* MODULLISTE */
+:is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu)::after {
+ content: "";
+ position: absolute; inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background:
+ linear-gradient(var(--ton, var(--akzent)), var(--ton, var(--akzent))) no-repeat,
+ linear-gradient(var(--ton, var(--akzent)), var(--ton, var(--akzent))) no-repeat,
+ var(--raster);
+ background-size: 18px 1.5px, 1.5px 18px, 26px 26px, 26px 26px;
+ background-position: right 10px bottom 10px, right 10px bottom 10px, 0 0, 0 0;
+ /* .3 war zu laut: Das Raster soll man nicht LESEN, sondern spueren.
+ Bei 26 px Abstand und einem Viertel Deckkraft bleibt der Eindruck
+ "Messgeraet", ohne dass die Linien mit dem Text konkurrieren. */
+ opacity: .26;
+ transform: none;
+}
+
+/* Der Inhalt gehört ÜBER Raster und Kantenlicht. Ohne diese Zeile
+ liegt bei Karten ohne eigenen Stapelwert der Text unter dem Raster
+ und wird von ihm überzeichnet. */
+:is(.eintrag-karte, .call, .datei, .kachel, .willkommen, .leer-hinweis,
+ .team-karte, .zahl, .k-listentag, .person, .creator) > * {
+ position: relative;
+ z-index: 1;
+}
+
+/* Beim Anfassen kommt das Modul NÄHER, es leuchtet nicht auf. */
+:is(.kachel, .datei, .pdf, .creator, .person, .kk, .call, .team-karte):hover {
+ transform: translateY(-3px);
+ box-shadow: none;
+ filter:
+ drop-shadow(0 4px 6px rgba(0, 0, 0, .6))
+ drop-shadow(0 20px 34px rgba(0, 0, 0, .6))
+ drop-shadow(0 0 14px color-mix(in srgb, var(--ton, var(--akzent)) 30%, transparent));
+}
+:is(.kachel, .datei, .pdf, .creator, .person, .kk, .call, .team-karte) {
+ transition: transform var(--tempo), filter var(--tempo);
+}
+
+@media (max-width: 620px) {
+ /* Dieselbe Form, kleiner -- keine zweite Gestaltung. */
+ :root { --fase: 12px; }
+ /* MODULLISTE */
+ :is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu) {
+ filter: drop-shadow(0 3px 8px rgba(0, 0, 0, .7));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :is(.kachel, .datei, .pdf, .creator, .person, .kk, .call, .team-karte):hover {
+ transform: none;
+ }
+}
+@media (forced-colors: active) {
+ /* Im Kontrastmodus zählt die Kante, und die muss ein echter `border`
+ sein. Die Schräge bleibt: Sie ist Geometrie, keine Farbe. */
+ /* MODULLISTE */
+ :is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu) {
+ filter: none; border: 1px solid CanvasText;
+ }
+ /* MODULLISTE */
+ :is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu)::after {
+ display: none;
+ }
+ /* MODULLISTE */
+ :is(.eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand, .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu, .call-raum, .creator, .summe, .pdf, .pdf-ablage, .suchleiste, .person, .kk, .fortschritt, .punkt, .unterweisung, .warnkarte, .kachel, .willkommen, .tagesblick, .leer-hinweis, .team-karte, .zahl, .k-listentag, .import-vorschau, .code-fenster, .schulung__neu)::before {
+ display: none;
+ }
+}
diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css
index bb593aeb..5aa4dab7 100644
--- a/workspace/assets/css/start.css
+++ b/workspace/assets/css/start.css
@@ -845,37 +845,15 @@ body.start > main { position: relative; z-index: 1; }
/* Die farbige Kante links. Sie ist das Einzige, was den Ton kräftig
zeigt -- drei Pixel reichen, um eine Kachel wiederzuerkennen. */
-.kachel::before {
- content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
- background: linear-gradient(180deg, var(--ton), transparent 92%);
- opacity: .55;
- transition: opacity var(--tempo), width var(--tempo);
-}
+/* Die Kacheln bekommen Form, Kante und Schatten seit dem 07.09.2026 aus
+ dem Abschnitt HUD-MODULE am Ende dieser Datei. Was hier stand -- die
+ Leuchtschiene oben, der wandernde Glanz und der eigene Schatten -- ist
+ dort vollstaendig ersetzt und war ab dann nur noch Ballast: Bytes auf
+ jeder Seite und, schlimmer, eine zweite Stelle, an der dieselbe Kachel
+ beschrieben wird. Wer etwas an der Form aendert, sucht ab jetzt unten.
-/* Das Licht. Es liegt schwach auf der Kachel und FOLGT beim Überfahren
- dem Zeiger -- das ist die Kleinigkeit, die man nicht sieht, sondern
- erst beim Benutzen merkt. Kein Blinken, keine Bewegung des Inhalts:
- Es wird nur an einer anderen Stelle heller.
+ Es bleibt nur, was der neue Abschnitt NICHT selbst setzt. */
- Ruhend sitzt es in der oberen linken Ecke (--my: 0%), damit die
- Kacheln auch ohne Zeiger von einer Seite beleuchtet wirken. */
-.kachel::after {
- content: ""; position: absolute; inset: 0; pointer-events: none;
- background: radial-gradient(130% 150% at var(--mx) var(--my),
- color-mix(in srgb, var(--ton) 20%, transparent), transparent 60%);
- opacity: .38;
- transition: opacity var(--tempo);
-}
-
-.kachel:hover {
- border-color: color-mix(in srgb, var(--ton) 45%, var(--rand));
- transform: translateY(-2px);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, .09),
- 0 10px 26px -14px color-mix(in srgb, var(--ton) 55%, transparent);
-}
-.kachel:hover::before { opacity: 1; width: 4px; }
-.kachel:hover::after { opacity: 1; }
.kachel:has(:focus-visible) { border-color: var(--ton); }
.kachel__link {
@@ -3342,29 +3320,10 @@ body[data-fremde-sicht="ja"]::after {
0 10px 26px -18px rgba(0, 0, 0, .9),
0 2px 6px -4px rgba(0, 0, 0, .5);
}
-.kachel::after {
- content: "";
- position: absolute; top: 0; left: 14%; right: 14%; height: 1px;
- background: linear-gradient(90deg,
- transparent,
- color-mix(in srgb, var(--ton) 75%, #fff) 50%,
- transparent);
- opacity: .5;
- pointer-events: none;
- z-index: 2;
- transition: opacity var(--tempo);
-}
-.kachel:hover {
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, .07),
- 0 18px 40px -20px rgba(0, 0, 0, .95),
- 0 3px 10px -5px color-mix(in srgb, var(--ton) 45%, transparent);
-}
-.kachel:hover::after { opacity: .95; }
-
-@media (forced-colors: active) {
- .kachel::after { display: none; }
-}
+/* Lichtkante, Hover-Schatten und Kontrastmodus der Kachel stehen seit
+ dem 07.09.2026 im Abschnitt HUD-MODULE am Ende dieser Datei. Was hier
+ stand, war die aelteste von drei Generationen und wurde von jeder
+ Seite geladen, ohne je zu wirken. */
/* ---------- Der Benachrichtigungs-Knopf ------------------------------
Wunsch Filipe (05.09.2026): ein Knopf wie "Benachrichtigungen aus",
@@ -4237,66 +4196,17 @@ body[data-fremde-sicht="ja"]::after {
/* =====================================================================
- MODULKARTEN — die Kacheln werden ein Raster, keine Liste
- (07.09.2026, dritter Anlauf)
+ MODULKARTEN — die Kacheln sind ein Raster, keine Liste (07.09.2026)
- Filipe zum zweiten Anlauf: "sorry aber ich glaub du verstehst nicht
- was ich meine, ich sag ja ich will eine komplette änderung von den
- kacheln, viel übersichtlicher ... es soll ganz anders aussehen wie
- jetzt."
+ Aus drei breiten Zeilen wurde ein Raster aus sechs Karten. Eine
+ breite Zeile zwingt das Auge, siebzehnmal von links nach rechts zu
+ LESEN; eine kompakte Karte erkennt man an Form und Zeichen -- das
+ Auge springt, statt zu wandern.
- ---------------------------------------------------------------------
- ER HAT RECHT, UND ICH WEISS JETZT WARUM
-
- Zweimal habe ich die OBERFLÄCHE geändert -- Mattglas, dann
- Leuchtschiene -- und beide Male blieb die FORM gleich: eine breite
- Zeile, Zeichen links, Text daneben, Pfeil rechts. Drei davon passen
- nebeneinander, siebzehn ergeben sechs Reihen. Wer eine Zeile
- umlackiert, bekommt eine lackierte Zeile.
-
- Was sich ändern musste, ist die Form:
-
- AUS DREI BREITEN ZEILEN WIRD EIN RASTER AUS SECHS KARTEN.
-
- Das ist der eigentliche Punkt hinter "übersichtlicher". Eine breite
- Zeile zwingt das Auge, von links nach rechts zu LESEN -- siebzehnmal.
- Eine kompakte Karte erkennt man an ihrer Form und ihrem Zeichen,
- ohne ein Wort zu lesen; das Auge springt statt zu wandern. Bei
- 1380 px passen jetzt sechs Karten nebeneinander statt drei: Der
- ganze Arbeitsbereich steht auf einem Bildschirm, ohne zu scrollen.
-
- ---------------------------------------------------------------------
- WAS SICH IM EINZELNEN ÄNDERT
-
- FORM Hochformat statt Querformat. Zeichen oben, Name unten --
- die Leserichtung eines Menschen, der etwas SUCHT (erst
- das Bild, dann der Name), nicht eines, der liest.
-
- SCHIENE Sie wandert von links nach OBEN. An einer hochkanten
- Karte ist die lange Seite senkrecht; ein Lichtbalken
- dort würde die Karte optisch halbieren. Oben liegt er
- wie eine beschriftete Kante an einem Gerät.
-
- ZEICHEN Größer und in einer eigenen, beleuchteten Mulde. Es ist
- jetzt das erste, was man sieht, und trägt deshalb mehr
- Gewicht als vorher.
-
- ZAHL Nach oben rechts, als eigene Marke. Vorher stand sie in
- derselben Zeile wie der Name und hat ihn gekürzt.
-
- WASSER- Größer und weiter unten -- sie füllt jetzt die untere
- ZEICHEN Hälfte der Karte und gibt jeder Kachel eine eigene
- Silhouette. Bei sechs Karten nebeneinander ist genau das
- der Unterschied zwischen "Raster" und "Sammlung".
-
- GROSSE `data-gross="ja"` nimmt zwei Spalten und bekommt das
- KACHEL Querformat von früher -- so hebt sie sich WIRKLICH ab,
- statt nur breiter zu sein.
-
- Material, Farben und Verhalten bleiben: beleuchtete Platte, Körnung,
- Anheben statt Aufleuchten, jede Kategorie in ihrer Farbe. Das war
- nicht das Problem.
- ===================================================================== */
+ Die SILHOUETTE dieser Karten (Schraege, Kantenlicht, Eckwinkel) steht
+ im Abschnitt HUD-MODULE am Ende der Datei. Hier steht nur noch, wie
+ der Inhalt darin angeordnet ist.
+ =====================================================================
.kacheln {
/* 214 px ist gemessen, nicht geraten: Bei 1380 px Inhaltsbreite und
@@ -4330,46 +4240,7 @@ body[data-fremde-sicht="ja"]::after {
rgba(8, 11, 18, .95) 100%);
}
-/* DIE SCHIENE OBEN. Volle Breite, hell in der Mitte, zu den Ecken
- auslaufend -- so sieht eine beleuchtete Kante aus, wenn die Lampe
- dahinter mittig sitzt. Ein gleichmäßiger Balken sähe aufgeklebt aus. */
-.kachel::before {
- left: 0; right: 0; top: 0; bottom: auto;
- width: auto; height: 3px;
- border-radius: 16px 16px 0 0;
- background: linear-gradient(90deg,
- color-mix(in srgb, var(--ton) 30%, transparent) 0%,
- color-mix(in srgb, var(--ton) 55%, #fff) 34%,
- var(--ton) 62%,
- color-mix(in srgb, var(--ton) 34%, transparent) 100%);
- box-shadow:
- 0 0 9px 0 color-mix(in srgb, var(--ton) 45%, transparent),
- 0 3px 20px -3px color-mix(in srgb, var(--ton) 38%, transparent);
- opacity: .85;
-}
-.kachel:hover::before {
- opacity: 1;
- box-shadow:
- 0 0 14px 0 color-mix(in srgb, var(--ton) 70%, transparent),
- 0 5px 32px -3px color-mix(in srgb, var(--ton) 55%, transparent);
-}
-
-/* Der Glanz zieht jetzt von oben links nach unten rechts -- an einer
- hochkanten Fläche ist das die Richtung, in der Licht fällt. */
-.kachel::after {
- background:
- linear-gradient(148deg,
- transparent 24%,
- rgba(255, 255, 255, .05) 38%,
- rgba(255, 255, 255, .10) 46%,
- rgba(255, 255, 255, .03) 54%,
- transparent 70%),
- radial-gradient(130% 120% at var(--mx) var(--my),
- color-mix(in srgb, var(--ton) 24%, transparent), transparent 62%);
- background-size: 100% 240%, 100% 100%;
- background-position: 0 -30%, 0 0;
-}
-.kachel:hover::after { background-position: 0 130%, 0 0; }
+/* Schiene und Glanz stehen jetzt im Abschnitt HUD-MODULE.
/* ---------- Der Aufbau der Karte ------------------------------------- */
.kachel__link {
@@ -4551,58 +4422,20 @@ body[data-fremde-sicht="ja"]::after {
/* =====================================================================
- EIN MATERIAL FÜR DIE GANZE WEBSITE (07.09.2026)
+ EIN MATERIAL FUER DIE GANZE WEBSITE (07.09.2026)
- Filipe: "ICH WILL DASS DU DIE KACHELN DER KOMPLETTEN WEBSITE KOMPLETT
- VERÄNDERST ... SEHR HOCHWERTIG UND EPISCH ... EINE EINZIGARTIGE
- WEBSITE ... PERFEKT AUF DEM PC UND AUF DEM HANDY."
+ Fuenfundvierzig Stellen benutzten `var(--flaeche)` als Kartenflaeche
+ -- Seite fuer Seite nachzuziehen war der Grund, warum es nie fertig
+ wurde. Die Liste unten ist deshalb GEMESSEN und nicht erfunden: Es
+ sind genau diese Klassen.
- ---------------------------------------------------------------------
- WARUM DAS BISHER NICHT GEKLAPPT HAT
+ `:where()` hat Spezifitaet null -- die Regel legt nur den Boden und
+ ueberschreibt nichts, was eine Seite selbst festlegt. Eine Warnkarte
+ bleibt rot, eine Spalte behaelt ihre Statusfarbe.
- Die Startseite hat seit heute ein Material -- beleuchtete Platte,
- Lichtkante, Schatten, Körnung. Die anderen sechzehn Seiten hatten
- weiterhin `background: var(--flaeche)` und einen Rand: flache
- Rechtecke. Man wechselte die Seite und fiel aus einer Oberfläche in
- eine andere.
-
- Ich habe das bisher Seite für Seite nachgezogen -- und genau deshalb
- war es nie fertig. Bei fünfundvierzig Stellen ist das kein Weg.
-
- ---------------------------------------------------------------------
- EINE STELLE, NICHT FÜNFUNDVIERZIG
-
- Die Liste unten ist nicht erfunden, sie ist GEMESSEN: Es sind genau
- die Klassen, die in den siebzehn Stildateien `var(--flaeche)` als
- Kartenfläche benutzen. Wer eine neue Karte baut und diesen Token
- nimmt, gehört dazu -- und sollte hier eingetragen werden.
-
- `:where()` ist der entscheidende Kniff: Es hat SPEZIFITÄT NULL.
- Dadurch überschreibt diese Regel nichts, was eine Seite selbst
- festlegt -- eine Warnkarte bleibt rot, eine Spalte behält ihre
- Statusfarbe, die Kacheln der Startseite ihre eigene Form. Sie legt nur
- den Boden, auf dem alle stehen. Ohne `:where()` müsste ich an
- dreißig Stellen `!important` schreiben oder Reihenfolgen sortieren --
- beides Wege, die beim nächsten Umbau kippen.
-
- ---------------------------------------------------------------------
- WAS DAS MATERIAL AUSMACHT
-
- GEWICHT Zwei Schatten: ein enger, dunkler direkt darunter und
- ein weiter, weicher. Ein einzelner sieht immer nach
- Aufkleber aus.
- MATERIALSTÄRKE Licht oben, Schatten unten -- beide innen. Nur die
- obere Kante ergibt einen aufgeklebten Strich; erst
- beide zusammen ergeben ein Blech.
- TIEFE Ein Verlauf von oben nach unten. Eine einfarbige
- Fläche ist Papier.
- KÖRNUNG Auf einer eigenen Ebene mit `overlay` und 3 % --
- genau der Fehler, den ich heute zweimal gemacht habe:
- Körnung auf derselben Ebene wie ein Glanz erbt dessen
- Deckkraft und wird zum grauen Schleier.
-
- AUF DEM HANDY DASSELBE MATERIAL, nur flacher: Schatten kosten dort
- Rechenzeit und bringen wenig, weil ohnehin nichts nebeneinander liegt.
+ FORM, KANTE UND SCHATTEN kommen dagegen aus dem Abschnitt HUD-MODULE
+ am Ende der Datei -- mit voller Spezifitaet, weil sie die aelteren
+ Regeln ABLOESEN sollen und nicht unter ihnen liegen.
===================================================================== */
:where(
@@ -4645,40 +4478,8 @@ body[data-fremde-sicht="ja"]::after {
0 12px 28px -18px rgba(0, 0, 0, .85);
}
-/* Die Körnung. Eigene Ebene, `overlay`, 3 % -- und `pointer-events:
- none`, sonst läge eine unsichtbare Fläche über jedem Knopf in jeder
- Karte der ganzen Website. */
-:where(
- .eintrag-karte, .call, .datei, .ablage, .zahlkarte, .regel, .zustand,
- .ki-kasten, .k-ansicht, .spalte, .balance, .saeulen-kasten, .neu,
- .call-raum, .creator, .summe, .pdf, .person, .kk, .unterweisung
-)::after {
- content: "";
- position: absolute; inset: 0;
- pointer-events: none;
- border-radius: inherit;
- background-image: var(--koernung);
- background-size: 140px 140px;
- opacity: .03;
- mix-blend-mode: overlay;
- z-index: 0;
-}
-
-/* Karten, die man anklickt, kommen beim Zeigen NÄHER -- sie werden
- nicht heller. So verhält sich ein Gegenstand, den man anhebt, und
- deshalb wirkt es echt, ohne dass etwas blinkt. */
-:where(a, button):where(.datei, .pdf, .creator, .person, .kk, .call):hover,
-:where(.datei, .pdf, .creator, .person, .kk):hover {
- transform: translateY(-2px);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, .10),
- inset 0 -1px 0 rgba(0, 0, 0, .42),
- 0 4px 9px -4px rgba(0, 0, 0, .55),
- 0 18px 36px -20px rgba(0, 0, 0, .9);
-}
-:where(.datei, .pdf, .creator, .person, .kk) {
- transition: transform var(--tempo), box-shadow var(--tempo), border-color var(--tempo);
-}
+/* Körnung, Anheben und Schatten stehen im Abschnitt HUD-MODULE am
+ Ende dieser Datei. Hier bleibt nur die Fläche selbst. */
@media (max-width: 620px) {
/* Flacher auf dem Handy: Nebeneinander liegt dort ohnehin nichts, und
@@ -4713,3 +4514,18 @@ body[data-fremde-sicht="ja"]::after {
.call-raum, .creator, .summe, .pdf, .person, .kk, .unterweisung
)::after { display: none; }
}
+
+
+/* Die FORM aller Module -- abgeschnittene Ecke, Kantenlicht, Eckwinkel,
+ Raster -- steht seit dem 07.09.2026 in einer eigenen Datei:
+ assets/css/module.css.
+
+ Zwei Gruende. Erstens war start.css ueber 200 KB gewachsen und lief in
+ die Grenze von pruef-struktur -- zu Recht, sie ist ueber Wochen aus
+ Anbauten entstanden. Zweitens ist die Silhouette eine EIGENE Sache:
+ Sie gilt fuer jede Karte auf jeder Seite, unabhaengig davon, was die
+ Seite sonst tut. Wer an der Form etwas aendert, soll eine Datei
+ oeffnen und nicht in einer von 4900 Zeilen suchen.
+
+ module.css wird von JEDER Seite geladen (anders als heim.css, das nur
+ die Startseite braucht). */
diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js
index 5920bfe6..55ae8b89 100644
--- a/workspace/assets/js/personen.js
+++ b/workspace/assets/js/personen.js
@@ -744,8 +744,30 @@
const nurAnlegen = ich.rolle !== 'admin';
if (nurAnlegen) {
document.body.dataset.nurAnlegen = 'ja';
- $('unterzeile') && ($('unterzeile').textContent =
- 'Hier legst du neue Leute an. Codes, Sperren und das Protokoll bleiben bei DogFather.');
+ /* DER ERKLÄRSATZ. Er stand hier schon, kam aber nie an: Ein
+ Element mit der Kennung `unterzeile` gab es auf der Seite
+ nicht, und das `&&` davor hat den Fehlgriff verschluckt.
+ Gemessen im Browser als Manager -- die Seite bestand aus
+ Überschrift und Formular, ohne ein Wort dazu, warum hier so
+ wenig steht. Jetzt gibt es das Element (personen.html), und
+ der Satz wird auch sichtbar gemacht. */
+ const zeile = $('unterzeile');
+ if (zeile) {
+ zeile.textContent =
+ 'Hier legst du neue Leute an. Codes, Sperren und das Protokoll bleiben bei DogFather.';
+ zeile.hidden = false;
+ }
+ /* UND DIE LISTE HÖRT AUF ZU LADEN. Sie steht mit
+ aria-busy="true" in der Seite, und `laden()` -- das es wieder
+ abschaltet -- wird für sie mit Absicht nie aufgerufen. Damit
+ meldete die Seite einem Screenreader dauerhaft "wird geladen",
+ und es kam nie etwas. Ein Ladehinweis, der nie endet, ist
+ schlimmer als keiner. */
+ const liste = $('liste');
+ if (liste) {
+ liste.setAttribute('aria-busy', 'false');
+ liste.textContent = '';
+ }
baueRollenwahl();
/* Das Formular steht offen da -- es ist der einzige Grund, warum
diese Seite fuer sie ueberhaupt aufgeht. Ein Knopf, der eine
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index b49c08e2..a78a6efd 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -9,9 +9,10 @@
-
-
-
+
+
+
+
@@ -211,10 +212,10 @@
-
-
-
-
-
+
+
+
+
+