diff --git a/server/pruef-formulare.mjs b/server/pruef-formulare.mjs
new file mode 100644
index 00000000..2a81dee9
--- /dev/null
+++ b/server/pruef-formulare.mjs
@@ -0,0 +1,147 @@
+/* Prueft die Formularzeilen auf allen Seiten.
+
+ Beanstandet am 01.09.2026: "das sieht scheisse aus, so verzogen".
+ Gemeint war die Zeile im Kalender -- "Art" schmal, "Beginn" breit,
+ "Dauer (Minuten)" zweizeilig umgebrochen, alle Felder auf
+ verschiedenen Hoehen.
+
+ Ursache war eine Regel, die nur fuer Kinder mit der Klasse .feld galt.
+ Der Kalender benutzt schlichte
ohne Klasse; die fielen hindurch,
+ bekamen von zwoelf Rasterspalten je EINE und wurden nur so breit, wie
+ ihr Inhalt sie zwang.
+
+ Geprueft wird deshalb nicht "sieht gut aus", sondern messbar:
+ * alle Eingabefelder einer Zeile gleich hoch
+ * ihre Unterkanten auf einer Linie (sonst haengt eines tiefer)
+ * kein Feld absurd schmal
+ * keine Beschriftung mehrzeilig */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-form-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = "4203";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+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");
+await new Promise((r) => setTimeout(r, 700));
+const BASIS = "http://127.0.0.1:4203";
+setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.();
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const salt = randomBytes(16).toString("hex");
+const hash = scryptSync("CODE-DOGI-0001", 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
+d.close();
+
+const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await chromium.launch();
+
+/* Seite, Knopf zum Aufklappen des Formulars. */
+const FORMULARE = [
+ ["kalender.html", "#neu-oeffnen"],
+ ["aufgaben.html", "#neu-oeffnen"],
+ ["personen.html", "#neu-oeffnen"],
+ ["bereich.html?b=live", "#neu-oeffnen"],
+ ["scouting.html", "#neu-oeffnen"],
+];
+
+for (const [breite, wie] of [[1280, "Computer"], [390, "Handy"]]) {
+ console.log(`\n=== ${wie} (${breite}px)`);
+ const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 } });
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 15000 });
+
+ for (const [s, knopf] of FORMULARE) {
+ await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(700);
+ const da = await seite.locator(knopf).count();
+ if (!da) { console.log(` -- ${s}: kein Formularknopf`); continue; }
+ await seite.click(knopf).catch(() => {});
+ await seite.waitForTimeout(450);
+
+ const r = await seite.evaluate(() => {
+ const raster = document.querySelector(".neu__raster");
+ if (!raster) return null;
+ const felder = [...raster.children].filter((k) => k.offsetParent !== null);
+ const eingaben = felder
+ .map((f) => f.querySelector("input, select, textarea"))
+ .filter(Boolean)
+ .map((e) => e.getBoundingClientRect());
+ const schilder = felder
+ .map((f) => f.querySelector(".feld-schild"))
+ .filter(Boolean);
+ /* Mehrzeilige Beschriftung: tatsaechliche Hoehe gegen eine Zeile. */
+ const mehrzeilig = schilder.filter((s2) => {
+ const zeile = parseFloat(getComputedStyle(s2).lineHeight) || 16;
+ return s2.getBoundingClientRect().height > zeile * 1.6;
+ }).map((s2) => s2.textContent.trim());
+ return {
+ anzahl: eingaben.length,
+ hoehen: eingaben.map((e) => Math.round(e.height)),
+ /* Auf 3 px genau zusammengefasst: Unterkanten liegen durch
+ Teilpixel-Rundung selten exakt gleich, obwohl sie auf einer
+ Linie sitzen. Ein erster Anlauf meldete deshalb "3 Unterkanten"
+ bei drei Feldern derselben Reihe -- ein Messfehler, kein
+ Gestaltungsfehler. */
+ unterkanten: eingaben.map((e) => Math.round(e.bottom / 3) * 3),
+ breiten: eingaben.map((e) => Math.round(e.width)),
+ einzeln: felder.map((f) => { const e=f.querySelector("input, select, textarea"); if(!e) return null; const r2=e.getBoundingClientRect(); const fr=f.getBoundingClientRect(); return { id:e.id, tag:e.tagName, top:Math.round(r2.top), bottom:Math.round(r2.bottom), h:Math.round(r2.height), feldTop:Math.round(fr.top), feldH:Math.round(fr.height) }; }).filter(Boolean),
+ mehrzeilig,
+ };
+ });
+
+ if (!r || r.anzahl < 2) { console.log(` -- ${s}: kein Raster mit mehreren Feldern`); continue; }
+
+ const hoehenGleich = new Set(r.hoehen).size === 1;
+ /* Unterkanten auf einer Linie -- auf dem Handy stehen die Felder
+ untereinander, dort gilt das natuerlich nicht. */
+ const reihen = new Set(r.unterkanten).size;
+ const buendig = breite < 700 ? true : reihen <= Math.ceil(r.anzahl / 3);
+ const nichtWinzig = Math.min(...r.breiten) >= 90;
+
+ const gut = hoehenGleich && buendig && nichtWinzig && !r.mehrzeilig.length;
+ if (!gut) fehler++;
+ console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}`
+ + `${r.anzahl} Felder Höhen=${[...new Set(r.hoehen)].join("/")} `
+ + `Breiten=${Math.min(...r.breiten)}–${Math.max(...r.breiten)}`
+ + (hoehenGleich ? "" : " UNGLEICH HOCH")
+ + (buendig ? "" : ` NICHT BÜNDIG (Unterkanten ${r.unterkanten.join(",")})`)
+ + (nichtWinzig ? "" : " ZU SCHMAL")
+ + (r.mehrzeilig.length ? ` MEHRZEILIG: ${r.mehrzeilig.join(", ")}` : "")
+ + (gut ? "" : " | " + r.einzeln.map((e) => `${e.id}(${e.tag}) Feld:${e.feldTop}+${e.feldH} Eingabe:${e.top}..${e.bottom}`).join(" ")));
+ }
+ await kontext.close();
+}
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log(fehler ? `\n${fehler} Formular(e) fehlerhaft.` : "\nAlles in Ordnung.");
+process.exit(fehler ? 1 : 0);
diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css
index 0b833ebf..15d98c3c 100644
--- a/workspace/assets/css/aufgaben.css
+++ b/workspace/assets/css/aufgaben.css
@@ -24,7 +24,46 @@
grid-template-columns: repeat(12, 1fr);
}
/* Vorgabe: ein Drittel. Reicht fuer Auswahlfelder und Datumsangaben. */
-.neu__raster > .feld { grid-column: span 4; min-width: 0; }
+/* JEDES direkte Kind, nicht nur die mit der Klasse .feld.
+
+ Genau daran lag die "verzogene" Formularzeile im Kalender: Dort
+ stehen schlichte
ohne Klasse. Sie fielen durch diese Regel
+ hindurch, bekamen von den zwoelf Spalten je EINE und wurden nur so
+ breit, wie ihr Inhalt sie zwang. Ergebnis: "Art" schmal, "Beginn"
+ breit, "Dauer (Minuten)" zweizeilig umgebrochen, alles auf
+ verschiedenen Hoehen. Es sah nach einem Fehler aus und war einer.
+
+ :not() haelt die Sonderbreiten am Leben. */
+.neu__raster > *:not(.feld--breit):not(.feld--halb) { grid-column: span 4; min-width: 0; }
+
+/* Alle Felder gleich hoch und unten buendig -- unabhaengig davon, ob
+ die Beschriftung darueber ein- oder zweizeilig ist. Ohne das sitzt
+ ein Feld mit langer Beschriftung tiefer als seine Nachbarn. */
+.neu__raster { align-items: stretch; }
+/* Zwei Zeilen je Feld: Die Beschriftung oben DEHNT sich (1fr), das
+ Eingabefeld unten hat feste Hoehe. Dadurch sitzen alle Eingabefelder
+ auf exakt derselben Linie -- unabhaengig davon, ob die Beschriftung
+ darueber ein- oder zweizeilig ist.
+
+ Der Umweg ueber flex mit justify-content: flex-end reichte nicht: Ein
+