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;