diff --git a/.gitignore b/.gitignore
index 029eebae..c686bb40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,9 @@ server/node_modules/
server/tmp-lokal.mjs
server/package-lock.json
bild-*.png
+
+# Aufnahmen der Handy-Pruefung (pruef-verwaltung-handy.mjs). Sie
+# entstehen bei jedem Lauf neu und sind Zwischenstand, kein Quelltext --
+# im Repo waeren sie nur Ballast und wuerden bei jedem Lauf als
+# Aenderung erscheinen.
+handy-*.png
diff --git a/assets/js/wd-core.js b/assets/js/wd-core.js
index 6526ee2d..d720d54f 100644
--- a/assets/js/wd-core.js
+++ b/assets/js/wd-core.js
@@ -724,13 +724,38 @@
return iphone || ipadNeu;
}
+ /* Das Symbol der App, die hier tatsaechlich installiert wird.
+
+ ⚠️ Vorher stand hier fest "/assets/img/wd-app-192.png". Seit die
+ Verwaltung eine eigene App mit eigenem Symbol ist (26.08.2026),
+ zeigte der Hinweis dort das FALSCHE Bild: Man las "Als App
+ installieren" neben dem schwarzen Husky und bekam anschliessend das
+ Eiskristall-Wappen auf den Startbildschirm.
+
+ Ein Hinweis, der etwas anderes zeigt als das, was passiert, ist
+ schlimmer als gar keiner -- man haelt ihn beim naechsten Mal fuer
+ unzuverlaessig.
+
+ Jetzt wird das Symbol aus dem Manifest gelesen, das die Seite
+ tatsaechlich einbindet. Damit stimmt es automatisch auch fuer jede
+ kuenftige App, ohne dass jemand daran denken muss. */
+ function installSymbol() {
+ try {
+ var v = document.querySelector('link[rel="manifest"]');
+ if (v && /verwaltung\.webmanifest/.test(v.getAttribute("href") || "")) {
+ return "/assets/img/wd-admin-192.jpg";
+ }
+ } catch (e) { /* dann eben das allgemeine */ }
+ return "/assets/img/wd-app-192.png";
+ }
+
function installBannerBauen(inhalt, mitKnopf) {
var box = document.createElement("div");
box.className = "wd-install";
box.setAttribute("role", "region");
box.innerHTML =
'
' +
- '

' +
+ '
 + ')
' +
"
" + t("install_titel") + "" + inhalt + "
" +
"
" +
'' +
@@ -838,7 +863,7 @@
Stand haben. Die Nummer wird zusammen mit CACHE_NAME in sw.js
hochgezaehlt -- beide gehoeren zusammen. */
navigator.serviceWorker
- .register("/webdesign/sw.js?v=59", { scope: "/webdesign/" })
+ .register("/webdesign/sw.js?v=60", { scope: "/webdesign/" })
.catch(function () { /* still schlucken: ohne SW funktioniert alles weiter */ });
});
}
diff --git a/pruef-verwaltung-handy.mjs b/pruef-verwaltung-handy.mjs
new file mode 100644
index 00000000..9436b4de
--- /dev/null
+++ b/pruef-verwaltung-handy.mjs
@@ -0,0 +1,363 @@
+/* =====================================================================
+ Die Verwaltung auf dem Handy — mit echten Inhalten.
+
+ WARUM MIT NACHGEBILDETEN DATEN
+
+ Die Verwaltung liegt hinter der Zugangswand, ein Zugangscode liegt
+ hier nicht vor. Eine leere Seite zu prüfen bringt aber nichts: Genau
+ die Fehler, um die es geht — überlaufende Zeilen, abgeschnittene
+ Knöpfe, zu enge Tippziele — entstehen erst, wenn richtige Inhalte
+ darin stehen. Ein leerer Kasten läuft nie über.
+
+ Deshalb werden die Datenabrufe abgefangen und mit Inhalten
+ beantwortet, wie sie im Betrieb vorkommen: lange Firmennamen, lange
+ E-Mail-Adressen, mehrstellige Beträge. Absichtlich am oberen Rand des
+ Realistischen, denn dort brechen Layouts.
+
+ WAS GEMESSEN WIRD
+
+ 1. Läuft etwas seitlich über? (der klassische Handy-Fehler)
+ 2. Ragt ein Element aus dem Bildschirm?
+ 3. Sind Tippziele groß genug? (WCAG 2.2: 24px Minimum,
+ Apple und Google empfehlen 44px)
+ 4. Überlappen sich Elemente?
+ 5. Ist Text lesbar groß?
+ ===================================================================== */
+import { chromium } from "playwright";
+import http from "http";
+import fs from "fs";
+import path from "path";
+
+const TYP = {
+ ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
+ ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp",
+ ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg",
+ ".webmanifest": "application/manifest+json",
+};
+
+const srv = http.createServer((q, r) => {
+ const p = path.join(process.cwd(), decodeURIComponent(q.url.split("?")[0]));
+ if (fs.existsSync(p) && !p.endsWith("/")) {
+ r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" });
+ return r.end(fs.readFileSync(p));
+ }
+ r.writeHead(404);
+ r.end("");
+});
+
+let ok = 0, fehl = 0;
+const funde = [];
+const pruefe = (name, gut, info) => {
+ if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
+ else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); funde.push(name + " — " + info); }
+};
+
+/* ---------------------------------------------------------------------
+ NACHGEBILDETE DATEN
+
+ Bewusst unbequem: lange Namen, lange Adressen, grosse Zahlen. Wer mit
+ "Max Muster" und "12,00" prueft, findet die Fehler nicht, die im
+ Betrieb auftreten.
+ --------------------------------------------------------------------- */
+const jetzt = new Date().toISOString();
+const vorTagen = (n) => new Date(Date.now() - n * 864e5).toISOString();
+
+const ANFRAGEN = [
+ { id: "a1", nummer: "A-2608-0012", name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG",
+ email: "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com",
+ paket: "shop", status: "neu", budget: "5.000–8.000 €", termin: "Q4",
+ sprache: "de", erstellt_am: vorTagen(9), kunde_id: null },
+ { id: "a2", nummer: "A-2608-0011", name: "Bäckerei Krumm", email: "info@baeckerei-krumm.example",
+ paket: "onepager", status: "rueckfrage", budget: "1.200 €", termin: "sofort",
+ sprache: "de", erstellt_am: vorTagen(2), kunde_id: null },
+ { id: "a3", nummer: "A-2608-0010", name: "Praxis Dr. med. Hohenstein-Wittelsbach",
+ email: "empfang@praxis-hohenstein-wittelsbach.example.de",
+ paket: "website", status: "angenommen", budget: "3.400 €", termin: "November",
+ sprache: "de", erstellt_am: vorTagen(21), kunde_id: "k1" },
+];
+
+const ANTWORTEN = {
+ "/webdesign/admin/uebersicht": {
+ ok: true,
+ beiMir: {
+ anfragen: { n: 3, aeltestes: vorTagen(9) },
+ nachrichten: { n: 7, aeltestes: vorTagen(4) },
+ wuensche: { n: 2, aeltestes: vorTagen(6) },
+ projekte: 4,
+ widerrufe: { n: 1, aeltestes: vorTagen(1) },
+ },
+ beiKunde: {
+ projekte: 3, aufgaben: 11,
+ zahlungen: { n: 2, aeltestes: vorTagen(31) },
+ zahlungenText: "4.870,00 €",
+ },
+ geld: {
+ monat: { n: 6, bruttoText: "7.412,50 €", nettoText: "7.148,22 €" },
+ gesamt: { n: 38, bruttoText: "48.930,00 €" },
+ gebuehrenText: "264,28 €",
+ },
+ projekte: [
+ { id: "p1", nummer: "P-2608-0004", titel: "Onlineshop für Bastelbedarf mit Rabattsystem",
+ kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG",
+ wartet_auf: "dogfather", aufg_gesamt: 24, aufg_fertig: 17 },
+ { id: "p2", nummer: "P-2608-0003", titel: "Einseiter Bäckerei",
+ kunde_name: "Bäckerei Krumm", wartet_auf: "kunde", aufg_gesamt: 12, aufg_fertig: 12 },
+ ],
+ },
+ "/webdesign/admin/anfragen": { ok: true, anfragen: ANFRAGEN },
+ "/webdesign/admin/kunden": { ok: true, kunden: [
+ { id: "k1", nummer: "K-2608-0003", name: "Praxis Dr. med. Hohenstein-Wittelsbach",
+ email: "empfang@praxis-hohenstein-wittelsbach.example.de", firma: "Praxisgemeinschaft",
+ projekte: 2, erstellt_am: vorTagen(40) },
+ { id: "k2", nummer: "K-2608-0002", name: "Bäckerei Krumm", email: "info@baeckerei-krumm.example",
+ firma: "", projekte: 1, erstellt_am: vorTagen(60) },
+ ] },
+ "/webdesign/admin/projekte": { ok: true, projekte: [
+ { id: "p1", nummer: "P-2608-0004", titel: "Onlineshop für Bastelbedarf mit Rabattsystem",
+ kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", status: "umsetzung",
+ wartet_auf: "dogfather", preis: 684000, aufg_gesamt: 24, aufg_fertig: 17,
+ liefertermin: vorTagen(-14), erstellt_am: vorTagen(30) },
+ { id: "p2", nummer: "P-2608-0003", titel: "Einseiter Bäckerei", kunde_name: "Bäckerei Krumm",
+ status: "abnahme", wartet_auf: "kunde", preis: 120000, aufg_gesamt: 12, aufg_fertig: 12,
+ liefertermin: vorTagen(-3), erstellt_am: vorTagen(45) },
+ ] },
+ "/webdesign/admin/zahlungen": { ok: true, zahlungen: [
+ { id: "z1", nummer: "R-2608-0009", kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG",
+ betrag: 342000, gebuehr: 12400, status: "offen", faellig_am: vorTagen(31),
+ erstellt_am: vorTagen(38), art: "anzahlung" },
+ { id: "z2", nummer: "R-2608-0008", kunde_name: "Bäckerei Krumm", betrag: 60000,
+ gebuehr: 2100, status: "bezahlt", faellig_am: vorTagen(10),
+ erstellt_am: vorTagen(20), art: "schluss" },
+ ] },
+ "/webdesign/admin/postfach": { ok: true, nachrichten: [
+ { id: "n1", von: "Immobilienverwaltung Sonnenhof GmbH & Co. KG",
+ email: "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com",
+ betreff: "Rückfrage zur Rechnung und zum weiteren Ablauf im vierten Quartal",
+ text: "Guten Tag, wir hätten noch eine Frage zur Aufteilung der Anzahlung.",
+ gelesen: 0, erstellt_am: vorTagen(4) },
+ ] },
+ "/webdesign/admin/postfach-zaehler": { ok: true, ungelesen: 7 },
+ "/webdesign/admin/einstellungen": { ok: true, werte: {} },
+ "/webdesign/admin/push/stand": { ok: true, eingerichtet: true, geraete: 1,
+ geraeteMitFehlern: 0, wirktMeldung: true, oeffentlicherSchluessel: "BTest", geraete_liste: [] },
+};
+
+function antwortFuer(url) {
+ const pfad = url.split("?")[0].replace(/^https?:\/\/[^/]+/, "");
+ if (ANTWORTEN[pfad]) return ANTWORTEN[pfad];
+ /* Einzelabrufe wie /anfragen/a1 */
+ if (/^\/webdesign\/admin\/anfragen\/[^/]+$/.test(pfad)) {
+ const a = ANFRAGEN[0];
+ return { ok: true, anfrage: { ...a, ziel: "Ein neuer Auftritt für unsere Hausverwaltung.",
+ zielgruppe: "Eigentümer und Mieter", funktionen: ["Kontaktformular", "Objektliste"],
+ details: {}, bemerkung: "" }, verlauf: [] };
+ }
+ return { ok: true };
+}
+
+/* --------------------------------------------------------------------- */
+srv.listen(4411, async () => {
+ const browser = await chromium.launch();
+
+ /* Ein echtes Handy-Format, kein schmales Fenster: Handys haben ein
+ anderes Verhältnis und einen anderen Zeigertyp. */
+ const kontext = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
+ "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ });
+ const seite = await kontext.newPage();
+
+ const skriptfehler = [];
+ /* Mit Aufrufverlauf, nicht nur mit der Meldung: "Cannot read
+ properties of undefined" sagt allein nichts darueber, WO es
+ passiert -- und danach sucht man in einer 4000-Zeilen-Datei. */
+ seite.on("pageerror", (e) =>
+ skriptfehler.push(String(e.stack || e).split(String.fromCharCode(10)).slice(0, 3).join(" <- ").slice(0, 320)));
+
+ await seite.route("**/webdesign/admin/**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(antwortFuer(route.request().url())),
+ });
+ });
+
+ await seite.goto("http://127.0.0.1:4411/webdesign/verwaltung.html");
+ await seite.waitForTimeout(1200);
+
+ /* An der Anmeldung vorbei — geprueft wird das Layout, nicht die
+ Zugangswand. */
+ await seite.evaluate(() => {
+ document.getElementById("vw-anmeldung").hidden = true;
+ document.getElementById("vw-bereich").hidden = false;
+ if (typeof window.vwBalkenSetzen === "function") window.vwBalkenSetzen();
+ });
+ await seite.waitForTimeout(600);
+
+ const BEREICHE = ["uebersicht", "anfragen", "projekte", "kunden", "zahlungen", "postfach"];
+
+ for (const bereich of BEREICHE) {
+ console.log(`\n===== ${bereich.toUpperCase()} =====`);
+
+ await seite.evaluate((b) => {
+ const knopf = document.querySelector('[data-tab="' + b + '"]');
+ if (knopf) knopf.click();
+ }, bereich);
+ await seite.waitForTimeout(1400);
+
+ /* 1. Seitlicher Ueberlauf */
+ const ueberlauf = await seite.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ pruefe(` kein seitlicher Ueberlauf`, ueberlauf <= 1, ueberlauf + "px");
+
+ /* 2. Welche Elemente ragen heraus? Die Ursache, nicht nur das
+ Symptom -- ohne sie sucht man den Schuldigen von Hand. */
+ const heraus = await seite.evaluate(() => {
+ const breite = document.documentElement.clientWidth;
+ const raus = [];
+
+ /* ⚠️ Was ABSICHTLICH hinausragt, ist kein Fehler.
+
+ Die erste Fassung meldete die Reiter als Fehler -- sie liegen
+ auf dem Handy in einem Wischstreifen, und dass dort etwas
+ ausserhalb des Bildschirms liegt, ist der ganze Sinn der Sache.
+ Ein Test, der das als Mangel meldet, verleitet dazu, einen
+ funktionierenden Streifen "zu reparieren".
+
+ Deshalb: Alles, was in einem seitlich scrollbaren Behaelter
+ liegt, wird uebersprungen -- dort GEHOERT es hin. */
+ const istInWischstreifen = (el) => {
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
+ const st = getComputedStyle(p);
+ if ((st.overflowX === "auto" || st.overflowX === "scroll") &&
+ p.scrollWidth > p.clientWidth + 1) return true;
+ }
+ return false;
+ };
+
+ for (const el of document.querySelectorAll("#vw-bereich *")) {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue;
+ if (istInWischstreifen(el)) continue;
+ if (r.right > breite + 1 || r.left < -1) {
+ raus.push({
+ was: el.tagName.toLowerCase() + (el.className && typeof el.className === "string"
+ ? "." + el.className.split(" ").filter(Boolean).slice(0, 2).join(".") : ""),
+ rechts: Math.round(r.right), breite: Math.round(r.width),
+ text: (el.textContent || "").trim().slice(0, 30),
+ });
+ }
+ }
+ /* Nur die aeussersten melden -- sonst steht jedes Kind seines
+ ueberlaufenden Elternteils mit in der Liste. */
+ return raus.slice(0, 6);
+ });
+ pruefe(` nichts ragt aus dem Bildschirm`, heraus.length === 0,
+ heraus.length ? heraus.map((h) => `${h.was} bis ${h.rechts}px ("${h.text}")`).join(" | ") : "sauber");
+
+ /* 3. Tippziele */
+ const klein = await seite.evaluate(() => {
+ const raus = [];
+ const auswahl = "#vw-bereich button, #vw-bereich a, #vw-bereich input, " +
+ "#vw-bereich select, #vw-bereich summary, #vw-bereich [role='button']";
+ for (const el of document.querySelectorAll(auswahl)) {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue;
+ const st = getComputedStyle(el);
+ if (st.visibility === "hidden" || st.display === "none") continue;
+
+ /* ⚠️ Ein Ankreuzfeld misst man an seinem Label, nicht an sich
+ selbst.
+
+ Die erste Fassung meldete eine Checkbox mit 1x1 Punkt als
+ Mangel. Sie ist absichtlich so klein: Sichtbar ist ein selbst
+ gestalteter Schalter, angetippt wird das Label — 315x162
+ Punkte, also weit über jedem Mindestmaß. Wer dem Test folgt
+ und die Checkbox vergrößert, macht eine funktionierende
+ Bedienung kaputt und bekommt obendrein ein doppeltes
+ Bedienelement. */
+ if (el.type === "checkbox" || el.type === "radio") {
+ const lab = document.querySelector('label[for="' + el.id + '"]') || el.closest("label");
+ if (lab) {
+ const lr = lab.getBoundingClientRect();
+ if (lr.height >= 24 && lr.width >= 24) continue;
+ }
+ }
+ /* WCAG 2.2 Erfolgskriterium 2.5.8 verlangt 24x24. Apple und
+ Google empfehlen 44. Gemeldet wird alles unter 44, gezaehlt
+ als Fehler nur unter 24. */
+ if (r.height < 44 || r.width < 44) {
+ raus.push({
+ was: el.tagName.toLowerCase() + (typeof el.className === "string" && el.className
+ ? "." + el.className.split(" ").filter(Boolean)[0] : ""),
+ b: Math.round(r.width), h: Math.round(r.height),
+ text: (el.textContent || el.value || "").trim().slice(0, 22),
+ hart: r.height < 24 || r.width < 24,
+ });
+ }
+ }
+ return raus;
+ });
+ const hart = klein.filter((k) => k.hart);
+ pruefe(` Tippziele erreichen 24px (Mindestmass)`, hart.length === 0,
+ hart.length ? hart.slice(0, 4).map((k) => `${k.was} ${k.b}x${k.h} ("${k.text}")`).join(" | ")
+ : klein.length ? klein.length + " unter 44px, aber alle ueber 24px" : "alle gross genug");
+
+ if (klein.length && !hart.length) {
+ console.log(" Hinweis (unter 44px, empfohlen):");
+ for (const k of klein.slice(0, 5)) {
+ console.log(` ${k.was} ${k.b}x${k.h}px "${k.text}"`);
+ }
+ }
+
+ /* 4. Schriftgroesse */
+ const winzig = await seite.evaluate(() => {
+ const raus = [];
+ for (const el of document.querySelectorAll("#vw-bereich *")) {
+ if (el.children.length || (el.textContent || "").trim().length <= 3) continue;
+
+ /* ⚠️ Nur SICHTBARE Schrift zaehlt.
+
+ Die erste Fassung meldete "Strg" und "Enter" mit 10,4px als
+ Mangel -- beide sind auf schmalen Bildschirmen laengst
+ ausgeblendet (display:none ab 620px, mit Begruendung im
+ Stilblatt: auf einem Handy gibt es keine Strg-Taste).
+
+ getComputedStyle liefert auch fuer verborgene Elemente eine
+ Schriftgroesse. Ohne diese Pruefung meldet der Test also
+ genau die Stellen, an denen bereits richtig gehandelt wurde --
+ und verleitet dazu, eine funktionierende Loesung "zu
+ reparieren". */
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue;
+ const st = getComputedStyle(el);
+ if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue;
+
+ const g = parseFloat(st.fontSize);
+ if (g && g < 11) raus.push({ g: Math.round(g * 10) / 10, t: el.textContent.trim().slice(0, 24) });
+ }
+ return raus.slice(0, 5);
+ });
+ pruefe(` Schrift ist nicht unter 11px`, winzig.length === 0,
+ winzig.length ? winzig.map((w) => `${w.g}px "${w.t}"`).join(" | ") : "lesbar");
+
+ await seite.screenshot({ path: `handy-${bereich}.png`, fullPage: false });
+ }
+
+ console.log("\n===== ALLGEMEIN =====");
+ pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || "keiner");
+
+ await browser.close();
+ srv.close();
+
+ console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
+ if (funde.length) {
+ console.log("\nZU BEHEBEN:");
+ funde.forEach((f, i) => console.log(` ${i + 1}. ${f}`));
+ }
+ process.exit(fehl ? 1 : 0);
+});
diff --git a/webdesign/sw.js b/webdesign/sw.js
index b7851d8a..c6b943c5 100644
--- a/webdesign/sw.js
+++ b/webdesign/sw.js
@@ -34,7 +34,7 @@
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
Stand fest. */
-const CACHE_NAME = "dogfather-webdesign-v59";
+const CACHE_NAME = "dogfather-webdesign-v60";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */
diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html
index d957c2b0..26fbe001 100644
--- a/webdesign/verwaltung.html
+++ b/webdesign/verwaltung.html
@@ -940,7 +940,23 @@
"wo bin ich", die zweite "wo kann ich hin". */
.vw-leiste {
display: grid;
- grid-template-columns: 1fr auto;
+ /* minmax(0, …) statt "1fr auto" (26.08.2026).
+
+ Rasterspalten schrumpfen von sich aus NICHT unter die Breite
+ ihres Inhalts — sie haben "min-width: auto". Auf einem 390px
+ breiten Gerät summierten sich die beiden Spalten dadurch auf
+ 372,7px, während die Leiste selbst nur 358px breit ist. Das
+ Raster war also breiter als sein eigener Behälter, und alles
+ darin schob sich nach rechts über den Bildschirmrand: die
+ Kopfzeile 8px, der Reiterstreifen 12px.
+
+ Sichtbar war das kaum — der Streifen ist ohnehin wischbar, und
+ rechts fehlte nur ein Stück Rand. Genau deshalb blieb es liegen.
+
+ Mit minmax(0, …) darf jede Spalte kleiner werden als ihr Inhalt.
+ Die Überschrift kürzt dann mit Auslassungspunkten, statt das
+ ganze Raster aufzublähen. */
+ grid-template-columns: minmax(0, 1fr) minmax(0, auto);
align-items: center;
gap: .6rem 1rem;
margin-bottom: 1.2rem;
@@ -948,12 +964,38 @@
.vw-leiste h1 {
margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem);
grid-column: 1; grid-row: 1;
+ /* Damit die Spalte oben tatsächlich schrumpfen darf, muss auch das
+ Kind es dürfen. Lange Titel wie "Projektanfragen" kürzen dann,
+ statt zu schieben. */
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
- .vw-leiste .vw-wer { grid-column: 2; grid-row: 1; }
+ .vw-leiste .vw-wer { grid-column: 2; grid-row: 1; min-width: 0; }
/* Die Reiter über die volle Breite darunter. */
.vw-leiste .vw-tabs {
grid-column: 1 / -1; grid-row: 2;
padding-top: .2rem;
+
+ /* ⚠️ min-width: 0 ist hier NICHT überflüssig (26.08.2026).
+
+ Ein Rasterkind bekommt von sich aus "min-width: auto" — es darf
+ also breiter werden als seine Spalte, wenn sein Inhalt das
+ verlangt. Genau das passierte: Sobald ein Reiter einen Zähler
+ trug ("Anfragen ③"), wuchs der Streifen auf volle Fensterbreite
+ und ragte, weil die Leiste links 12px Abstand hat, rechts 12px
+ über den Bildschirmrand hinaus.
+
+ Gemessen auf einem 390px breiten Gerät: In der Übersicht endete
+ der Streifen bei 378px, im Anfragen-Bereich bei 402px. Sichtbar
+ wurde es kaum — der Streifen ist ohnehin seitlich wischbar, also
+ fiel nur auf, dass der letzte Reiter schwerer zu erreichen war.
+ Ein Fehler, der sich als Eigenart tarnt.
+
+ Mit min-width: 0 darf er wieder schmaler werden als sein Inhalt —
+ und genau dann greift das seitliche Wischen, wofür es gedacht
+ ist. */
+ min-width: 0;
}
@media (max-width: 560px) {
/* Titel und Abmelden bleiben zusammen in Zeile 1.
@@ -2955,6 +2997,27 @@
Ersatzantwort im Test genau das ausloeste. Ein halber Server ist
ein realistischer Fall (alte Fassung noch nicht neu gestartet),
und dann sollen die uebrigen Reiter weiter benutzbar sein. */
+ /* ⚠️ Die Absicht daneben galt nur für drei von fünf Teilen
+ (behoben 26.08.2026).
+
+ Geprüft wurden beiMir, beiKunde und geld — die beiden Listen
+ darunter, projekte und verlauf, wurden ungeprüft mit .length
+ angefasst. Fehlte eine davon, brach die Übersicht mit
+ "Cannot read properties of undefined" ab, und zwar NACH dem
+ Aufbau der oberen Kacheln: Die halbe Seite stand schon da, der
+ Rest fehlte kommentarlos.
+
+ Genau der Fall, den der Kommentar unten beschreibt — ein halber
+ Server, dessen Antwort nicht alles mitbringt. Statt die ganze
+ Übersicht zu verweigern, wird jetzt aufgefüllt: Was da ist, wird
+ gezeigt; was fehlt, bleibt leer. Eine Liste, die es nicht gibt,
+ ist keine leere Liste — hier aber die einzig sinnvolle Anzeige.
+
+ Gefunden vom Handy-Test, weil dessen nachgebildete Antwort diese
+ beiden Felder nicht enthielt. */
+ if (!Array.isArray(d.projekte)) d.projekte = [];
+ if (!Array.isArray(d.verlauf)) d.verlauf = [];
+
if (!d.beiMir || !d.beiKunde || !d.geld) {
laedt.hidden = true;
inhalt.hidden = false;