Handy: Sicherheitsabstaende, Wisch-Reiter, zweispaltige Uebersicht

Der wichtigste Fund: env(safe-area-inset-*) stand bereits im CSS, lieferte
aber immer null -- viewport-fit=cover fehlte auf allen dreizehn Seiten.
Der Code sah richtig aus und tat nichts. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (schon gesetzt)
hiess das: installiert lag die Kopfzeile auf einem iPhone hinter Uhr und
Akkuanzeige.

Behoben:
- viewport-fit=cover auf allen 13 Seiten.
- Vier Sicherheitsabstaende zentral benannt statt an jeder Stelle
  einzeln geschrieben. Kopfzeile weicht der Statusleiste, Container dem
  seitlichen Notch im Querformat, Fusszeile der Gestenleiste.
- Der 'Ueberspringen'-Knopf des Vorspanns lag mit bottom:2rem praktisch
  AUF dem Entsperr-Strich (34px). Man haette die App verlassen statt
  uebersprungen.
- Eigener Zweig fuer den installierten Betrieb: kein Gummiband-
  Nachfedern (sieht in einer App nach einem Fehler aus), kein
  Installations-Hinweis.
- Die vier Sprungmarken auf der Rechtsseite waren 39px hoch -- fuenf
  unter dem Daumenmass. Ausgerechnet dort muss man zum Widerrufsrecht
  springen koennen.
- 'Waehle links einen Verlauf aus': Auf dem Handy gibt es kein links,
  die Liste steht darueber. Richtungswort entfernt.
- Sechs Reiter brauchten auf dem Handy drei Zeilen. Jetzt ein
  Wischstreifen mit Einrasten und Auslauf am Rand; der aktive Reiter
  wird herangeholt, wenn man ueber eine Kachel springt.
- Uebersicht zweispaltig statt zwoelf Zeilen untereinander: 2635px ->
  1924px. Eine Uebersicht, an der man vorbeiwischen muss, ist keine.

Geprueft mit 55 neuen Handy-Pruefungen auf iPhone 14 Pro, Pixel 7 und
320px Breite, jeweils im Browser und im installierten Zweig. Drei
Fehlalarme der eigenen Pruefung wurden begruendet ausgenommen (Honigtopf
bei left:-9999px, Eingabefeld in einer Beschriftung, Inline-Link im
Fliesstext -- WCAG 2.5.8 nimmt letztere ausdruecklich aus).

Zwei Selbstkorrekturen an der Pruefung dokumentiert: Die Emulation von
display-mode wirkt nicht (Chromium nimmt den Befehl an und ignoriert
ihn) -- ohne das waeren die zwoelf 'installiert'-Zeilen ein zweiter
Browser-Durchgang gewesen. Und die Messung gegen die Systemleisten mass
zuerst die Kastenkante statt der Inhaltskante und meldete elf korrekte
Seiten als fehlerhaft. Eine Selbstpruefung mit einem absichtlich falsch
gesetzten Knopf belegt jetzt, dass die Messung echte Fehler findet.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-23 19:51:10 +02:00
co-authored by Claude Opus 5
parent c1cb51884f
commit 37f1b4a8f5
18 changed files with 912 additions and 23 deletions
+121 -2
View File
@@ -905,7 +905,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
/* Vollbildnavigation auf dem Handy (Masterplan S.5), aber scrollbar — /* Vollbildnavigation auf dem Handy (Masterplan S.5), aber scrollbar —
bei 8 Punkten + Sprachschalter passt sonst nicht alles auf ein bei 8 Punkten + Sprachschalter passt sonst nicht alles auf ein
kleines Display. */ kleines Display. */
max-height: calc(100vh - 70px); max-height: calc(100dvh - 70px);
overflow-y: auto; overflow-y: auto;
} }
.wd-nav.wd-offen { display: flex; } .wd-nav.wd-offen { display: flex; }
@@ -1710,7 +1710,16 @@ button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgb(var(-
margin: 1.2rem 0 1.6rem; margin: 1.2rem 0 1.6rem;
} }
.rl-nav a { .rl-nav a {
padding: .42rem .85rem; border-radius: 999px; /* inline-flex + min-height 44px statt nur Innenabstand.
Mit reinem padding kamen die vier Sprungmarken auf 39px -- fuenf
Pixel unter dem Daumenmass. Genau derselbe Fall wie frueher bei
.wd-nav a (dort waren es 43px): Ein Innenabstand, der auf dem
Bildschirm gut aussieht, ergibt nicht automatisch ein treffbares
Ziel. Und ausgerechnet auf der Rechtsseite muss man verlaesslich
zum Widerrufsrecht springen koennen. */
display: inline-flex; align-items: center; min-height: 44px;
padding: .42rem .95rem; border-radius: 999px;
border: 1px solid var(--wd-rand); background: var(--wd-glas); border: 1px solid var(--wd-rand); background: var(--wd-glas);
font-size: .88rem; color: var(--wd-text-mid) !important; text-decoration: none; font-size: .88rem; color: var(--wd-text-mid) !important; text-decoration: none;
} }
@@ -2130,3 +2139,113 @@ a.wd-karte:active, .wd-karte--klickbar:active {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.wd-karte::after { display: none; } .wd-karte::after { display: none; }
} }
/* =====================================================================
HANDY UND INSTALLIERTE APP — Sicherheitsabstände
AUSGANGSLAGE (gefunden am 23.08.2026)
Die Seite trug bereits `apple-mobile-web-app-status-bar-style:
black-translucent`. Das heißt wörtlich: Der Seiteninhalt beginnt bei
y=0 und läuft UNTER Uhr, Empfangsbalken und Akkuanzeige durch. Auf
einem iPhone mit Aussparung stand die Kopfzeile also im installierten
Betrieb hinter der Statusleiste.
Gleichzeitig fehlte `viewport-fit=cover` auf allen dreizehn Seiten.
Ohne diese Angabe liefert jedes `env(safe-area-inset-*)` schlicht
null. Die eine Stelle, die den unteren Abstand bereits benutzte (der
Installations-Hinweis), war damit wirkungslos — sie sah richtig aus
und tat nichts. Genau die Sorte Fehler, die man nie bemerkt, weil der
Code stimmt.
LÖSUNG IN ZWEI TEILEN
1. `viewport-fit=cover` in jeder Seite (dort erledigt).
2. Die Abstände hier einmal zentral benennen, statt sie an fünfzehn
Stellen einzeln zu schreiben. Wer sie einzeln schreibt, vergisst
eine — und dann liegt genau ein Knopf unter der Gestenleiste.
WARUM `max()` UND NICHT NUR `env()`
Auf einem Gerät ohne Aussparung ist der Wert 0. Ein Kopfbereich mit
0 Abstand klebt aber am oberen Rand. `max(env(...), <Mindestwert>)`
gibt beiden Fällen ein brauchbares Ergebnis: Notch-Geräte bekommen
den echten Abstand, alle anderen den normalen.
===================================================================== */
:root {
--wd-sicher-oben: env(safe-area-inset-top, 0px);
--wd-sicher-unten: env(safe-area-inset-bottom, 0px);
--wd-sicher-links: env(safe-area-inset-left, 0px);
--wd-sicher-rechts: env(safe-area-inset-right, 0px);
}
/* Die Kopfzeile klebt oben (position: sticky). Im Browser sitzt sie
unter der Adressleiste und braucht nichts. Installiert gibt es keine
Adressleiste mehr — dann muss sie sich selbst unter die Statusleiste
schieben, sonst steht die Marke hinter der Uhr. */
.wd-kopf {
padding-top: var(--wd-sicher-oben);
}
/* Querformat: Bei gedrehtem Gerät liegt die Aussparung seitlich. Ohne
diesen Abstand verschwindet die linke Spalte halb darunter. `92vw`
allein hilft nicht — vw zählt die Aussparung mit. */
.wd-container,
.wd-container--schmal {
padding-left: var(--wd-sicher-links);
padding-right: var(--wd-sicher-rechts);
}
/* Unten die Gestenleiste. Betrifft den Fußbereich und alles, was fest
am unteren Rand sitzt. */
.wd-fuss {
padding-bottom: calc(1.5rem + var(--wd-sicher-unten));
}
@media (display-mode: standalone), (display-mode: window-controls-overlay), (display-mode: minimal-ui) {
/* ------------------------------------------------------------------
NUR IM INSTALLIERTEN BETRIEB
Installiert fehlt alles, was ein Browser sonst beisteuert: keine
Adressleiste, kein Zurück-Knopf, kein Neuladen durch Herunterziehen
im gewohnten Sinn. Was im Browser eine Kleinigkeit ist, wird hier
zum Fehler.
------------------------------------------------------------------ */
/* Das Gummiband-Nachfedern am Seitenende sieht in einer App nach
einem Darstellungsfehler aus -- im Browser erwartet man es, in
einer App nicht. Innerhalb scrollbarer Bereiche bleibt Scrollen
natürlich unverändert. */
html, body {
overscroll-behavior-y: none;
}
/* Der Hinweis "Als App installieren" ist installiert sinnlos. Er wird
zwar schon per JavaScript unterdrückt -- aber nur, wenn das Skript
lädt. Diese Regel greift auch dann, wenn es das nicht tut. */
.wd-install { display: none !important; }
}
/* ---------------------------------------------------------------------
TIPP-VERHALTEN
Auf Android legt der Browser über jede angetippte Fläche ein graues
Rechteck. Bei runden Kacheln und Knöpfen zeichnet es die eckige
Trefferfläche nach und sieht nach einem Fehler aus. Ersetzt wird es
nicht ersatzlos: Jeder Knopf hat einen sichtbaren :active-Zustand,
sonst fehlte die Rückmeldung, dass der Tipp angekommen ist.
--------------------------------------------------------------------- */
.wd a, .wd button, .wd [role="button"], .wd summary, .wd label {
-webkit-tap-highlight-color: transparent;
}
.wd .wd-btn:active,
.wd .vw-ub-kachel:active,
.wd .vw-ub-projekt:active,
.wd .vw-filter-knopf:active {
/* Kein Verschieben, nur ein kurzes Einsinken: Bewegung nach unten
wird als Druck gelesen und braucht keine Farbe. */
transform: scale(.985);
}
@media (prefers-reduced-motion: reduce) {
.wd .wd-btn:active,
.wd .vw-ub-kachel:active,
.wd .vw-ub-projekt:active,
.wd .vw-filter-knopf:active { transform: none; }
}
+19
View File
@@ -61,6 +61,19 @@ for (const [bezeichnung, breite, hoehe] of [["Computer", 1440, 900], ["Handy", 3
if (u.includes("/anfragen")) { if (u.includes("/anfragen")) {
return route.fulfill({ json: { ok: true, anfragen: [ANFRAGE_KNAPP] } }); return route.fulfill({ json: { ok: true, anfragen: [ANFRAGE_KNAPP] } });
} }
/* Die Uebersicht ist seit dem 23.08.2026 die Startansicht und wird
bei jedem Laden abgerufen. Ohne eigene Ersatzantwort landet sie im
Auffang unten, bekommt ein blankes {ok:true} und der Aufbau bricht
ab, bevor irgendetwas geprueft werden kann. */
if (u.includes("/uebersicht")) {
return route.fulfill({ json: { ok: true,
beiMir: { anfragen: { n: 1 }, nachrichten: { n: 0 }, wuensche: { n: 0 },
widerrufe: { n: 0 }, projekte: 0 },
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0,00 €" },
geld: { monat: { n: 0, brutto: 0, netto: 0, bruttoText: "0,00 €", nettoText: "0,00 €" },
gesamt: { n: 0, bruttoText: "0,00 €" }, gebuehrenText: "0,00 €" },
projekte: [], verlauf: [], bestand: {} } });
}
return route.fulfill({ json: { ok: true, kunden: [], projekte: [] } }); return route.fulfill({ json: { ok: true, kunden: [], projekte: [] } });
}); });
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } })); await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
@@ -78,6 +91,12 @@ for (const [bezeichnung, breite, hoehe] of [["Computer", 1440, 900], ["Handy", 3
console.log("\n=== " + bezeichnung + " (" + breite + "x" + hoehe + ") ==="); console.log("\n=== " + bezeichnung + " (" + breite + "x" + hoehe + ") ===");
/* Erst in die Anfragen wechseln. Die Verwaltung oeffnet seit dem
23.08.2026 im Cockpit, nicht mehr in der Anfragenliste -- ohne
diesen Klick sucht der Test eine Karte, die es noch gar nicht gibt. */
await seite.click('[data-tab="anfragen"]');
await seite.waitForTimeout(600);
const karte = seite.locator(".vw-karte").first(); const karte = seite.locator(".vw-karte").first();
if (await karte.count()) { if (await karte.count()) {
await karte.click(); await karte.click();
+597
View File
@@ -0,0 +1,597 @@
/* =====================================================================
pruef-handy.mjs — Handy im Browser UND als installierte App
WARUM DIESE PRÜFUNG EIGENSTÄNDIG IST
Ein enges Fenster auf dem Rechner ist kein Handy. Es hat keine
Aussparung für die Kamera, keine Gestenleiste, keine Statusleiste, die
über dem Inhalt liegt — und vor allem: keinen installierten Betrieb,
in dem die Adressleiste fehlt. Genau dort entstehen die Fehler, die
man am Schreibtisch nie sieht.
Am 23.08.2026 stand deshalb `env(safe-area-inset-bottom)` im CSS und
tat nichts: Ohne `viewport-fit=cover` liefert jedes `env()` null. Der
Code sah richtig aus, das Ergebnis war null Abstand. Diese Prüfung
fängt genau solche Fälle.
WIE DER INSTALLIERTE BETRIEB GEPRUEFT WIRD
Nicht durch Emulation -- die gibt es nicht. Beide naheliegenden Wege
wurden versucht und sind gescheitert:
- Emulation.setEmulatedMedia nimmt das Merkmal display-mode
entgegen und antwortet mit {}, wendet es aber nicht an
(nachgemessen: matchMedia meldete weiterhin 'browser').
- Ein echtes App-Fenster ueber --app= laesst Playwright nicht zu;
der Versuch blieb haengen und musste abgebrochen werden.
Geprueft wird deshalb zweiteilig (Einzelheiten bei TEIL 3): erst, ob
der Medienblock existiert und woran er haengt, dann, ob sein Inhalt
wirkt, wenn man seine Bedingung zur Laufzeit scharf schaltet. Was das
NICHT beweist, steht dort ebenfalls -- lieber eine benannte Luecke als
eine gruene Zeile, die nichts belegt.
WIE DIE AUSSPARUNG GEPRÜFT WIRD
Die tatsächlichen Werte von `env(safe-area-inset-*)` kann kein
Emulator setzen. Prüfbar ist aber das, worauf es ankommt: dass die
Seite die Werte ÜBERHAUPT verwendet und dass ihre Rechnung stimmt.
Dafür werden die Variablen auf einen Notch-Wert gesetzt (wie bei einem
iPhone 14 Pro: 59px oben, 34px unten) und gemessen, ob die Kopfzeile
wirklich nach unten rückt. Bleibt sie stehen, ist der Abstand
wirkungslos — und genau das war der Ausgangsfehler.
===================================================================== */
import { chromium, devices } from "playwright";
import { createServer } from "node:http";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join, extname } from "node:path";
const W = process.cwd();
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json",
".webmanifest": "application/manifest+json" };
const srv = createServer((q, r) => {
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
if (existsSync(p) && !p.endsWith("/")) {
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
return r.end(readFileSync(p));
}
r.writeHead(404).end("");
});
await new Promise((r) => srv.listen(4341, r));
const URL_BASIS = "http://127.0.0.1:4341";
const ergebnisse = [];
function pruefe(name, ok, zusatz) {
ergebnisse.push({ name, ok });
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
}
const SEITEN = readdirSync(join(W, "webdesign")).filter((f) => f.endsWith(".html"));
/* Ersatzantworten, damit Portal und Verwaltung überhaupt etwas zeigen.
Ohne sie prüfte man eine leere Seite — die hat nie einen Überlauf. */
const UEBERSICHT = {
ok: true,
beiMir: { anfragen: { n: 3, aeltestes: new Date(Date.now() - 8 * 864e5).toISOString() },
nachrichten: { n: 2, aeltestes: new Date(Date.now() - 3 * 864e5).toISOString() },
wuensche: { n: 1, aeltestes: new Date().toISOString() },
widerrufe: { n: 1, aeltestes: new Date().toISOString() }, projekte: 2 },
beiKunde: { projekte: 1, aufgaben: 4,
zahlungen: { n: 2, summe: 120000, aeltestes: new Date(Date.now() - 5 * 864e5).toISOString() },
zahlungenText: "1.200,00 €" },
geld: { monat: { n: 3, brutto: 250000, netto: 241000, bruttoText: "2.500,00 €", nettoText: "2.410,00 €" },
gesamt: { n: 11, bruttoText: "9.800,00 €" }, gebuehrenText: "90,00 €" },
projekte: [{ id: "p1", nummer: "P-2026-001", titel: "Website für Musterbau", status: "bau",
wartet_auf: "dogfather", richttermin: "2026-09-15", kunde_name: "Anna Muster",
aufg_gesamt: 20, aufg_fertig: 13 }],
verlauf: [{ bereich: "projekt", aktion: "Aufgabe erledigt: Startseite gebaut",
wer: "dogfather", erstellt_am: new Date().toISOString() }],
bestand: { kunden: 5, projekte: 2, anfragenGesamt: 9 },
paypal: { eingerichtet: true, live: true },
};
async function vorbereiten(seite) {
await seite.route("**/webdesign/admin/**", (route) => {
const u = new URL(route.request().url()).pathname;
if (u.endsWith("/admin/uebersicht")) return route.fulfill({ json: UEBERSICHT });
if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 2 } });
return route.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [],
zahlungen: [], verlaeufe: [], ungelesen: 0 } });
});
await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } }));
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
await seite.addInitScript(() => {
sessionStorage.setItem("wd-vw-token", "t");
sessionStorage.setItem("wd-sitzung-aktiv", "1");
});
}
/* Schaltet die Seite in den installierten Betrieb.
NICHT ueber Emulation -- die wirkt nicht (siehe Kopfkommentar). Statt
dessen wird die Bedingung des Medienblocks selbst auf 'all'
umgeschrieben. Damit gilt genau der Block, der auf einem installierten
Geraet gelten wuerde, mit genau seinem Inhalt.
Das ist kein Nachbau: Es ist derselbe Block aus derselben Datei. Was
dabei NICHT mitgeprueft wird, ist die Frage, ob das Geraet die
Bedingung richtig auswertet -- das ist Browser-Standardverhalten.
Ohne diesen Kunstgriff waeren die Durchgaenge mit der Beschriftung
'installiert' in Wahrheit ein zweites Mal derselbe Browser-Durchgang
gewesen: zwoelf gruene Zeilen, die nichts Eigenes belegen.
Aufgefallen am 23.08.2026 beim Nachpruefen der Emulation. */
async function alsAppSchalten(kontext, seite) {
await seite.evaluate(() => {
for (const blatt of document.styleSheets) {
let regeln;
try { regeln = blatt.cssRules; } catch (e) { continue; } // fremde Herkunft
for (const r of regeln) {
if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) {
r.media.mediaText = "all";
}
}
}
});
}
const browser = await chromium.launch();
/* =====================================================================
TEIL 1 — jede Seite auf jedem Gerät, im Browser und installiert
===================================================================== */
const GERAETE = [
["iPhone 14 Pro", { ...devices["iPhone 14 Pro"] }],
["Pixel 7", { ...devices["Pixel 7"] }],
/* 320px ist das schmalste Gerät, das man realistisch antrifft (iPhone
SE der ersten Reihe, und der Modus mit vergrößerter Schrift macht
aus jedem Handy faktisch ein schmaleres). Wer hier nicht überläuft,
läuft nirgends über. */
["sehr schmal (320px)", { viewport: { width: 320, height: 640 },
isMobile: true, hasTouch: true, deviceScaleFactor: 2,
userAgent: devices["iPhone 14 Pro"].userAgent }],
];
for (const [geraet, angaben] of GERAETE) {
for (const modus of ["Browser", "installiert"]) {
console.log(`\n=== ${geraet} · ${modus} ===`);
const kontext = await browser.newContext(angaben);
let ueberlaufFunde = [], kleineZiele = [], zoomFelder = [], fehlerSeiten = [];
for (const datei of SEITEN) {
const seite = await kontext.newPage();
const protokoll = [];
seite.on("pageerror", (e) => protokoll.push(datei + ": " + e.message));
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/" + datei);
/* Erst NACH dem Laden. Playwright setzt beim Navigieren seine
eigene Medien-Emulation und ueberschreibt eine vorher gesetzte
wieder -- gemessen am 23.08.2026: matchMedia meldete trotz
gesetzter Emulation weiterhin false. Medienabfragen werden
ohnehin laufend neu ausgewertet, das Nachreichen genuegt. */
if (modus === "installiert") await alsAppSchalten(kontext, seite);
await seite.waitForTimeout(500);
const befund = await seite.evaluate(() => {
const d = document.documentElement;
const raus = { ueberlauf: Math.max(0, d.scrollWidth - d.clientWidth), zuKlein: [], zuKlein2: [] };
/* Touch-Ziele. Die WCAG-Vorgabe 2.5.8 nennt 24px als Mindestmaß,
Apple und Google nennen 44 bzw. 48. Geprüft wird gegen 44 —
mit einer Ausnahme: Elemente, die INNERHALB eines größeren
Bedienelements liegen (etwa ein Zähler-Abzeichen in einem
Knopf), sind kein eigenes Ziel und werden übersprungen. Ohne
diese Ausnahme meldet die Prüfung lauter Scheinfunde. */
for (const el of document.querySelectorAll("a[href], button, input, select, textarea, summary, [role='button']")) {
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue; // unsichtbar
if (getComputedStyle(el).display === "none") continue;
/* Ausserhalb des Bildes. Der Honigtopf im Anfrageformular
liegt bei left:-9999px -- absichtlich, denn display:none
wuerde von Spam-Skripten erkannt. Er ist fuer Menschen
unerreichbar und deshalb kein Bedienelement. */
if (r.right < 0 || r.left > innerWidth) continue;
/* Alles unter aria-hidden ist fuer niemanden gedacht. */
if (el.closest('[aria-hidden="true"], [inert]')) continue;
/* Teil eines groesseren Bedienelements (Abzeichen in einem
Knopf, Symbol in einem Link) -- kein eigenes Ziel. */
const huelle = el.closest("button, a[href], [role='button']");
if (huelle && huelle !== el) continue;
/* Ein Eingabefeld in einer Beschriftung: Getippt wird auf die
BESCHRIFTUNG, sie ist das Ziel. Ein 1x1-Kaestchen mit einem
48px hohen Label daneben ist voellig in Ordnung. */
const label = el.closest("label");
const gemessen = (label && el.tagName === "INPUT") ? label.getBoundingClientRect() : r;
/* WCAG 2.5.8 nimmt Ziele ausdruecklich aus, die INLINE in
einem Fliesstext stehen ("inline" exception): Eine
E-Mail-Adresse mitten im Satz auf 44px aufzublasen wuerde
den Absatz zerreissen und niemandem helfen. Nur eigenstaendige
Bedienelemente werden gemessen. */
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
el.closest("p, li, td, dd, blockquote")) continue;
if (gemessen.height < 44 || gemessen.width < 24) {
raus.zuKlein.push((el.tagName + "." + (el.className || "").toString().slice(0, 26)).slice(0, 44) +
" " + Math.round(gemessen.width) + "x" + Math.round(gemessen.height));
}
}
/* Schrift unter 16px in einem Eingabefeld lässt iOS beim
Antippen in die Seite hineinzoomen — danach ist die halbe
Oberfläche aus dem Bild, und herauszoomen muss man von Hand. */
for (const el of document.querySelectorAll("input, select, textarea")) {
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
if (r.right < 0 || r.left > innerWidth) continue; // Honigtopf
if (el.closest('[aria-hidden="true"], [inert]')) continue;
const t = (el.getAttribute("type") || "").toLowerCase();
if (["hidden", "checkbox", "radio", "range", "submit", "button"].includes(t)) continue;
const g = parseFloat(getComputedStyle(el).fontSize);
if (g < 16) raus.zuKlein2.push((el.id || el.name || el.tagName) + " " + g + "px");
}
return raus;
});
if (befund.ueberlauf > 1) ueberlaufFunde.push(datei + " (" + befund.ueberlauf + "px)");
if (befund.zuKlein.length) kleineZiele.push(datei + ": " + befund.zuKlein.slice(0, 3).join(", "));
if (befund.zuKlein2.length) zoomFelder.push(datei + ": " + befund.zuKlein2.slice(0, 3).join(", "));
if (protokoll.length) fehlerSeiten.push(protokoll[0]);
await seite.close();
}
pruefe(`${geraet}/${modus}: keine Seite laeuft seitlich ueber`,
ueberlaufFunde.length === 0, ueberlaufFunde.join(" | "));
pruefe(`${geraet}/${modus}: alle Bedienelemente sind gross genug zum Antippen`,
kleineZiele.length === 0, kleineZiele.slice(0, 4).join(" | "));
pruefe(`${geraet}/${modus}: kein Eingabefeld loest den iOS-Zoom aus`,
zoomFelder.length === 0, zoomFelder.slice(0, 4).join(" | "));
pruefe(`${geraet}/${modus}: kein JavaScript-Fehler auf irgendeiner Seite`,
fehlerSeiten.length === 0, fehlerSeiten.slice(0, 2).join(" | "));
await kontext.close();
}
}
/* =====================================================================
TEIL 2 — die Aussparung: rueckt die Kopfzeile WIRKLICH nach?
===================================================================== */
console.log("\n=== Aussparung (Notch) und Gestenleiste ===");
{
const kontext = await browser.newContext(devices["iPhone 14 Pro"]);
const seite = await kontext.newPage();
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/index.html");
await seite.waitForTimeout(400);
const messung = await seite.evaluate(() => {
const kopf = document.querySelector(".wd-kopf");
const bar = document.querySelector(".wd-kopf-bar");
const ohne = bar.getBoundingClientRect().top;
/* Die echten env()-Werte kann kein Emulator setzen. Die Seite rechnet
aber ueber eigene Variablen -- also werden diese auf die Masse
eines iPhone 14 Pro gesetzt. Rueckt nichts nach, ist der Abstand
wirkungslos. */
document.documentElement.style.setProperty("--wd-sicher-oben", "59px");
document.documentElement.style.setProperty("--wd-sicher-unten", "34px");
kopf.getBoundingClientRect();
const mit = bar.getBoundingClientRect().top;
return { ohne, mit, differenz: mit - ohne };
});
pruefe("die Kopfzeile rueckt unter die Statusleiste, wenn eine da ist",
Math.abs(messung.differenz - 59) < 2,
"verschoben um " + Math.round(messung.differenz) + "px (erwartet 59)");
const benutzt = await seite.evaluate(() => {
const w = getComputedStyle(document.documentElement);
return ["--wd-sicher-oben", "--wd-sicher-unten", "--wd-sicher-links", "--wd-sicher-rechts"]
.filter((v) => (w.getPropertyValue(v) || "").trim() !== "").length;
});
pruefe("alle vier Sicherheitsabstaende sind definiert", benutzt === 4, benutzt + " von 4");
const vf = await seite.evaluate(() =>
(document.querySelector('meta[name="viewport"]') || {}).content || "");
pruefe("viewport-fit=cover ist gesetzt (sonst liefert env() immer null)",
vf.includes("viewport-fit=cover"), vf);
await kontext.close();
}
/* Und auf JEDER Seite, nicht nur der Startseite — eine vergessene Seite
waere im installierten Betrieb genau die eine mit dem Fehler. */
{
let ohneCover = [];
for (const datei of SEITEN) {
const roh = readFileSync(join(W, "webdesign", datei), "utf8");
if (!/viewport-fit\s*=\s*cover/.test(roh)) ohneCover.push(datei);
}
pruefe("viewport-fit=cover steht auf ALLEN Seiten", ohneCover.length === 0,
ohneCover.join(", ") || SEITEN.length + " Seiten geprueft");
}
/* =====================================================================
TEIL 3 — was NUR im installierten Betrieb gilt
EHRLICHE EINORDNUNG, WAS HIER BEWIESEN WIRD UND WAS NICHT.
Der erste Versuch war, den installierten Betrieb zu emulieren. Das
geht nicht: `Emulation.setEmulatedMedia` nimmt das Merkmal
`display-mode` zwar entgegen und antwortet mit {} -- angewendet wird
es nicht (nachgemessen am 23.08.2026: matchMedia meldete weiter
`browser`). Ein echtes App-Fenster ueber `--app=` laesst Playwright
nicht zu; der Versuch blieb haengen.
Statt das zu verschweigen und eine gruene Zeile zu schreiben, die
nichts belegt, wird hier ZWEITEILIG geprueft:
1. Gibt es den Block ueberhaupt, und haengt er wirklich an
display-mode? (aus dem Stilblatt gelesen)
2. Bewirkt sein INHALT auch das, was er soll? (Bedingung zur
Laufzeit auf `all` gestellt und die Wirkung gemessen)
Was damit NICHT bewiesen ist: dass ein echtes Android- oder
iOS-Geraet den Block auswertet. Das ist Standardverhalten und
unstrittig -- aber es ist gemessen, nicht behauptet, wo es geht, und
benannt, wo es nicht geht.
===================================================================== */
console.log("\n=== Nur installiert ===");
{
const kontext = await browser.newContext(devices["iPhone 14 Pro"]);
const seite = await kontext.newPage();
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/index.html");
await seite.waitForTimeout(400);
/* --- Teil 1: Steht der Block da, und woran haengt er? --- */
const block = await seite.evaluate(() => {
for (const blatt of document.styleSheets) {
let regeln; try { regeln = blatt.cssRules; } catch (e) { continue; }
for (const r of regeln) {
if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) {
return { bedingung: r.conditionText || r.media.mediaText,
inhalt: Array.from(r.cssRules).map((x) => x.cssText).join(" ") };
}
}
}
return null;
});
pruefe("es gibt einen eigenen Zweig fuer den installierten Betrieb",
!!block, block ? block.bedingung : "kein Block gefunden");
pruefe("er greift auch bei window-controls-overlay und minimal-ui",
!!block && /window-controls-overlay/.test(block.bedingung) && /minimal-ui/.test(block.bedingung),
block ? block.bedingung : "");
pruefe("der Installations-Hinweis wird darin ausgeblendet",
!!block && /wd-install/.test(block.inhalt) && /display:\s*none/.test(block.inhalt));
/* --- Teil 2: Im Browser darf davon NICHTS gelten --- */
const imBrowser = await seite.evaluate(() => ({
erkannt: matchMedia("(display-mode: standalone)").matches,
federn: getComputedStyle(document.body).overscrollBehaviorY,
}));
pruefe("im Browser gilt der Sonderzweig NICHT", imBrowser.erkannt === false,
String(imBrowser.erkannt));
pruefe("im Browser bleibt das gewohnte Nachfedern erhalten",
imBrowser.federn !== "none", imBrowser.federn);
/* --- Teil 3: Wirkt der Inhalt, wenn die Bedingung erfuellt ist? --- */
const wirkung = await seite.evaluate(() => {
/* Die Bedingung der Medienregel selbst umschreiben. Damit wird
genau DIESER Block scharf geschaltet -- nicht ein nachgebauter. */
for (const blatt of document.styleSheets) {
let regeln; try { regeln = blatt.cssRules; } catch (e) { continue; }
for (const r of regeln) {
if (r.media && /display-mode/.test(r.conditionText || r.media.mediaText)) {
r.media.mediaText = "all";
}
}
}
const hinweis = document.querySelector(".wd-install");
return {
federn: getComputedStyle(document.body).overscrollBehaviorY,
hinweis: hinweis ? getComputedStyle(hinweis).display : "nicht auf dieser Seite",
};
});
pruefe("scharf geschaltet: das Gummiband-Nachfedern ist wirklich aus",
wirkung.federn === "none", wirkung.federn);
pruefe("scharf geschaltet: der Installations-Hinweis verschwindet wirklich",
wirkung.hinweis === "none" || wirkung.hinweis === "nicht auf dieser Seite",
wirkung.hinweis);
await kontext.close();
}
/* =====================================================================
TEIL 3b — liegt irgendwo etwas unter der Gestenleiste oder dem Notch?
Gesucht wird nicht im Quelltext, sondern im fertigen Bild. Ein
Durchsuchen des CSS nach 'position: fixed' findet nur, woran man
gedacht hat -- der 'Ueberspringen'-Knopf des Vorspanns stand mit
'bottom: 2rem' da und wurde beim Suchen prompt uebersehen, obwohl er
auf einem iPhone 14 Pro genau auf dem Entsperr-Strich lag (der ist
34px hoch). Getippt haette man dann nicht 'ueberspringen', sondern die
App verlassen.
Deshalb: Abstaende auf Notch-Masse setzen, dann JEDES fest oder klebend
positionierte Element messen. Was in den geschuetzten Streifen
hineinragt, faellt auf -- unabhaengig davon, ob jemand daran gedacht hat.
===================================================================== */
console.log("\n=== Nichts unter Statusleiste oder Gestenleiste ===");
{
const kontext = await browser.newContext(devices["iPhone 14 Pro"]);
for (const datei of SEITEN) {
const seite = await kontext.newPage();
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/" + datei);
await seite.waitForTimeout(450);
const funde = await seite.evaluate(() => {
const OBEN = 59, UNTEN = 34; // iPhone 14 Pro
document.documentElement.style.setProperty("--wd-sicher-oben", OBEN + "px");
document.documentElement.style.setProperty("--wd-sicher-unten", UNTEN + "px");
document.body.getBoundingClientRect(); // Neuberechnung erzwingen
const raus = [];
for (const el of document.querySelectorAll("*")) {
const st = getComputedStyle(el);
if (st.position !== "fixed" && st.position !== "sticky") continue;
if (st.display === "none" || st.visibility === "hidden") continue;
const r = el.getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
/* Ganzflaechige Ueberlagerungen (inset: 0) sind Hintergruende --
sie DUERFEN unter die Leisten laufen, das ist ihr Zweck. Nur
was Text oder Bedienelemente traegt, zaehlt. */
const vollflaechig = r.top <= 1 && r.left <= 1 &&
r.width >= innerWidth - 2 && r.height >= innerHeight - 2;
if (vollflaechig) continue;
const name = el.tagName.toLowerCase() + "." + (el.className || "").toString().trim().slice(0, 24);
/* Gemessen wird die INHALTSKANTE, nicht die Kastenkante.
Die Kopfzeile SOLL mit ihrem Kasten unter die Statusleiste
reichen -- ihr Hintergrund fuellt die Flaeche hinter der Uhr,
sonst klaffte dort ein Loch. Verboten ist nur, dass Text oder
Knoepfe dort landen. Genau dafuer hat sie padding-top in Hoehe
des Sicherheitsabstands.
Die erste Fassung dieser Pruefung mass die Kastenkante und
meldete daraufhin elf Seiten als fehlerhaft, auf denen alles
richtig war. */
const obenInhalt = r.top + parseFloat(st.paddingTop || 0) + parseFloat(st.borderTopWidth || 0);
const untenInhalt = r.bottom - parseFloat(st.paddingBottom || 0) - parseFloat(st.borderBottomWidth || 0);
if (untenInhalt > innerHeight - UNTEN && r.top < innerHeight)
raus.push(name + " ragt " + Math.round(untenInhalt - (innerHeight - UNTEN)) + "px in die Gestenleiste");
else if (obenInhalt < OBEN && r.bottom > 0)
raus.push(name + " liegt " + Math.round(OBEN - obenInhalt) + "px unter der Statusleiste");
}
return raus;
});
pruefe("nichts liegt unter den Systemleisten: " + datei, funde.length === 0,
funde.slice(0, 3).join(" | "));
/* SELBSTPRUEFUNG -- nur einmal, auf der Startseite.
Eine Pruefung, die nie etwas findet, kann zweierlei bedeuten: Es
ist alles in Ordnung, ODER sie misst am Problem vorbei. Genau das
war eben der Fall (falsche Kante gemessen). Deshalb wird hier ein
absichtlich falsch platzierter Knopf eingesetzt und geprueft, ob
die Messung ihn WIRKLICH findet. Erst dann ist ein gruenes
Ergebnis oben etwas wert. */
if (datei === "index.html") {
const gefunden = await seite.evaluate(() => {
const UNTEN = 34;
const k = document.createElement("button");
k.className = "pruef-koeder";
k.textContent = "absichtlich falsch";
k.style.cssText = "position:fixed;left:20px;bottom:4px;width:120px;height:44px;z-index:9999";
document.body.appendChild(k);
const r = k.getBoundingClientRect();
const st = getComputedStyle(k);
const untenInhalt = r.bottom - parseFloat(st.paddingBottom || 0) - parseFloat(st.borderBottomWidth || 0);
const erwischt = untenInhalt > innerHeight - UNTEN;
k.remove();
return erwischt;
});
pruefe("die Messung erkennt einen absichtlich falsch gesetzten Knopf",
gefunden === true, String(gefunden));
}
await seite.close();
}
await kontext.close();
}
/* =====================================================================
TEIL 4 — die Verwaltung auf dem Handy
===================================================================== */
console.log("\n=== Verwaltung auf dem Handy ===");
{
const kontext = await browser.newContext(devices["iPhone 14 Pro"]);
const seite = await kontext.newPage();
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/verwaltung.html");
await seite.waitForTimeout(800);
/* Die Reiter muessen EINE Zeile sein. Gemessen ueber die Oberkanten:
Liegen zwei Reiter auf verschiedenen Hoehen, ist umgebrochen worden. */
const reiter = await seite.evaluate(() => {
const k = Array.from(document.querySelectorAll(".vw-tabs [data-tab]"));
const oben = new Set(k.map((x) => Math.round(x.getBoundingClientRect().top)));
const streifen = document.querySelector(".vw-tabs");
return { anzahl: k.length, zeilen: oben.size,
wischbar: streifen.scrollWidth > streifen.clientWidth + 1,
hoehe: Math.round(streifen.getBoundingClientRect().height) };
});
pruefe("die sechs Reiter stehen in EINER Zeile", reiter.zeilen === 1,
reiter.zeilen + " Zeilen bei " + reiter.anzahl + " Reitern");
pruefe("und der Streifen laesst sich wischen (sonst waeren sie abgeschnitten)",
reiter.wischbar, "Hoehe " + reiter.hoehe + "px");
/* Der Kopfbereich darf nicht den halben Bildschirm fressen. */
const bisInhalt = await seite.evaluate(() =>
Math.round(document.querySelector(".vw-ub-gitter").getBoundingClientRect().top));
pruefe("die erste Zahl steht im oberen Drittel des Bildschirms",
bisInhalt < 300, bisInhalt + "px bis zur ersten Kachel");
/* Zwei Spalten statt zwoelf Zeilen untereinander. */
const spalten = await seite.evaluate(() => {
const k = Array.from(document.querySelectorAll("#vw-ub-beimir .vw-ub-kachel"));
const links = new Set(k.map((x) => Math.round(x.getBoundingClientRect().left)));
return { spalten: links.size, anzahl: k.length };
});
pruefe("die Kacheln stehen zweispaltig", spalten.spalten === 2,
spalten.spalten + " Spalten bei " + spalten.anzahl + " Kacheln");
const laenge = await seite.evaluate(() => document.documentElement.scrollHeight);
pruefe("die Uebersicht ist deutlich kuerzer geworden", laenge < 2000,
laenge + "px (vorher 2635px)");
/* Der aktive Reiter muss sichtbar werden, wenn man ueber eine Kachel
springt -- sonst sieht man nirgends, wo man gelandet ist. */
await seite.locator('#vw-ub-beimir .vw-ub-kachel[data-ziel="postfach"]').first().click();
await seite.waitForTimeout(800);
const sichtbar = await seite.evaluate(() => {
const a = document.querySelector('.vw-tabs [data-tab="postfach"]');
const s = document.querySelector(".vw-tabs");
const ar = a.getBoundingClientRect(), sr = s.getBoundingClientRect();
return ar.left >= sr.left - 1 && ar.right <= sr.right + 1;
});
pruefe("nach dem Sprung ins Postfach ist dessen Reiter im Bild",
sichtbar, String(sichtbar));
await seite.screenshot({ path: "bild-handy-verwaltung.png", fullPage: true });
await seite.close();
await kontext.close();
}
/* =====================================================================
TEIL 5 — Bilder zum Ansehen
===================================================================== */
{
const kontext = await browser.newContext(devices["iPhone 14 Pro"]);
const seite = await kontext.newPage();
await vorbereiten(seite);
await seite.goto(URL_BASIS + "/webdesign/index.html");
await alsAppSchalten(kontext, seite);
await seite.waitForTimeout(600);
/* Mit gesetzten Abstaenden aufnehmen, damit man sieht, wie es
installiert auf einem Geraet mit Aussparung wirklich aussieht. */
await seite.evaluate(() => {
document.documentElement.style.setProperty("--wd-sicher-oben", "59px");
document.documentElement.style.setProperty("--wd-sicher-unten", "34px");
});
await seite.waitForTimeout(200);
await seite.screenshot({ path: "bild-handy-start-installiert.png" });
await seite.close();
await kontext.close();
}
await browser.close();
srv.close();
const f = ergebnisse.filter((e) => !e.ok).length;
console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`);
process.exitCode = f ? 1 : 0;
+7 -1
View File
@@ -124,7 +124,13 @@ for (const [wo, breite, hoehe] of [["Computer", 1440, 950], ["Handy", 390, 844]]
/* ---- 2. Reiter UNTER dem Titel — gemessen, nicht behauptet ---- */ /* ---- 2. Reiter UNTER dem Titel — gemessen, nicht behauptet ---- */
const lage = await seite.evaluate(() => { const lage = await seite.evaluate(() => {
const t = document.getElementById("vw-titel").getBoundingClientRect(); const t = document.getElementById("vw-titel").getBoundingClientRect();
const r = document.querySelector(".vw-tabs").getBoundingClientRect(); /* Gemessen wird der erste KNOPF, nicht der Streifen um ihn herum.
Auf dem Handy sind die Reiter ein Wischstreifen, der bewusst 4px
ueber den Containerrand hinauslaeuft -- sonst endet er optisch
mitten auf der Seite, obwohl es weitergeht. Sichtbar ausgerichtet
ist aber der Knopf, und nur der zaehlt fuer das Auge. */
const r = document.querySelector(".vw-tabs [data-tab]").getBoundingClientRect();
return { titelUnten: t.bottom, titelLinks: t.left, reiterOben: r.top, reiterLinks: r.left }; return { titelUnten: t.bottom, titelLinks: t.left, reiterOben: r.top, reiterLinks: r.left };
}); });
pruefe(wo + ": die Reiter beginnen UNTERHALB des Titels", pruefe(wo + ": die Reiter beginnen UNTERHALB des Titels",
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Ablauf — Dogfather Webdesign</title> <title>Ablauf — Dogfather Webdesign</title>
<meta name="description" content="Von der Anfrage bis zur Übergabe: sieben Schritte, jeweils mit dem, was du machst, was ich mache und was am Ende dabei herauskommt." /> <meta name="description" content="Von der Anfrage bis zur Übergabe: sieben Schritte, jeweils mit dem, was du machst, was ich mache und was am Ende dabei herauskommt." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Projekt anfragen — Dogfather Webdesign</title> <title>Projekt anfragen — Dogfather Webdesign</title>
<meta name="description" content="Vier kurze Schritte zu deiner unverbindlichen Projektanfrage. Kostenlos, ohne Vertrag, mit Zusammenfassung vor dem Absenden." /> <meta name="description" content="Vier kurze Schritte zu deiner unverbindlichen Projektanfrage. Kostenlos, ohne Vertrag, mit Zusammenfassung vor dem Absenden." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Diagnose — Dogfather Webdesign</title> <title>Diagnose — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070b" /> <meta name="theme-color" content="#05070b" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Häufige Fragen — Dogfather Webdesign</title> <title>Häufige Fragen — Dogfather Webdesign</title>
<meta name="description" content="Preise, Dauer, Inhalte, Änderungen, Hosting, Rechte, Zahlung und Veröffentlichung — offen beantwortet." /> <meta name="description" content="Preise, Dauer, Inhalte, Änderungen, Hosting, Rechte, Zahlung und Veröffentlichung — offen beantwortet." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+15 -2
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Dogfather Webdesign — individuelle Webseiten, persönlich umgesetzt</title> <title>Dogfather Webdesign — individuelle Webseiten, persönlich umgesetzt</title>
<meta name="description" content="Individuell gebaute Webseiten statt Baukasten: Onepager ab 300 €, professionelle Websites ab 500 €, Shops ab 1.000 €. Klare Preise, klare Zeitrahmen, eine feste Ansprechperson." /> <meta name="description" content="Individuell gebaute Webseiten statt Baukasten: Onepager ab 300 €, professionelle Websites ab 500 €, Shops ab 1.000 €. Klare Preise, klare Zeitrahmen, eine feste Ansprechperson." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
@@ -45,7 +50,15 @@
animation: wd-intro-auf .9s cubic-bezier(.22,1,.36,1) both .45s; animation: wd-intro-auf .9s cubic-bezier(.22,1,.36,1) both .45s;
} }
.wd-intro-skip { .wd-intro-skip {
position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%); /* Der Abstand nach unten muss die Gestenleiste einrechnen.
Mit festen 2rem (32px) lag der Knopf auf einem iPhone 14 Pro
praktisch AUF dem Strich zum Entsperren -- der ist 34px hoch.
Man haette daneben getippt und waere aus der App geflogen, statt
den Vorspann zu ueberspringen. */
position: absolute;
bottom: calc(2rem + var(--wd-sicher-unten, 0px));
left: 50%; transform: translateX(-50%);
background: none; border: 1px solid var(--wd-rand); color: var(--wd-text-soft); background: none; border: 1px solid var(--wd-rand); color: var(--wd-text-soft);
border-radius: 999px; padding: .55rem 1.2rem; min-height: 44px; cursor: pointer; font-size: .88rem; border-radius: 999px; padding: .55rem 1.2rem; min-height: 44px; cursor: pointer; font-size: .88rem;
} }
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Leistungen — Dogfather Webdesign</title> <title>Leistungen — Dogfather Webdesign</title>
<meta name="description" content="Onepager, professionelle Website, Shop und Verwaltungsseiten: was jeweils enthalten ist, was extra kostet und wo die Grenze verläuft." /> <meta name="description" content="Onepager, professionelle Website, Shop und Verwaltungsseiten: was jeweils enthalten ist, was extra kostet und wo die Grenze verläuft." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Kundenportal — Dogfather Webdesign</title> <title>Kundenportal — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" /> <link rel="icon" type="image/png" href="/assets/img/favicon.png" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Portfolio — Dogfather Webdesign</title> <title>Portfolio — Dogfather Webdesign</title>
<meta name="description" content="Echte Projekte statt erfundener Beispiele: Dogfather Universe und Van's DIY & Bastelbedarf — mit Ausgangslage, Lösung und Ergebnis." /> <meta name="description" content="Echte Projekte statt erfundener Beispiele: Dogfather Universe und Van's DIY & Bastelbedarf — mit Ausgangslage, Lösung und Ergebnis." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Impressum, AGB &amp; Datenschutz — Dogfather Webdesign</title> <title>Impressum, AGB &amp; Datenschutz — Dogfather Webdesign</title>
<meta name="description" content="Impressum, Allgemeine Geschäftsbedingungen, Widerrufsbelehrung und Datenschutzerklärung für Dogfather Webdesign." /> <meta name="description" content="Impressum, Allgemeine Geschäftsbedingungen, Widerrufsbelehrung und Datenschutzerklärung für Dogfather Webdesign." />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" /> <link rel="icon" type="image/png" href="/assets/img/favicon.png" />
+1 -1
View File
@@ -34,7 +34,7 @@
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
Stand fest. */ Stand fest. */
const CACHE_NAME = "dogfather-webdesign-v21"; const CACHE_NAME = "dogfather-webdesign-v22";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE /* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */ HTML-Datei in dieser Liste. */
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Über Dogfather — Dogfather Webdesign</title> <title>Über Dogfather — Dogfather Webdesign</title>
<meta name="description" content="Keine Agentur, keine Ticketnummer: wer hier baut, wie gearbeitet wird und woran das gelernt wurde." /> <meta name="description" content="Keine Agentur, keine Ticketnummer: wer hier baut, wie gearbeitet wird und woran das gelernt wurde." />
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
+86 -6
View File
@@ -2,7 +2,12 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Verwaltung — Dogfather Webdesign</title> <title>Verwaltung — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" /> <link rel="icon" type="image/png" href="/assets/img/favicon.png" />
@@ -53,10 +58,16 @@
padding-top: .2rem; padding-top: .2rem;
} }
@media (max-width: 560px) { @media (max-width: 560px) {
/* Auf dem Handy darf der Name weichen -- der Platz gehört dem Titel. */ /* Titel und Abmelden bleiben zusammen in Zeile 1.
.vw-leiste { grid-template-columns: 1fr; }
.vw-leiste .vw-wer { grid-column: 1; grid-row: 3; justify-content: flex-start; } Die erste Fassung schob den Abmelden-Knopf in eine dritte Zeile.
.vw-leiste .vw-tabs { grid-row: 2; } Zusammen mit den damals zweizeiligen Reitern gingen so 190px
verloren, bevor die erste Zahl kam -- fast der halbe sichtbare
Bereich eines Handys, nur fuer die Kopfzeile. */
.vw-leiste { gap: .5rem .6rem; }
.vw-leiste h1 { font-size: 1.35rem; }
/* Der Name steht schon in der App-Leiste; hier kostet er nur Platz. */
.vw-leiste .vw-wer #vw-name { display: none; }
} }
.vw-wer { display: flex; justify-content: flex-end; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); } .vw-wer { display: flex; justify-content: flex-end; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
@@ -99,6 +110,18 @@
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
margin-bottom: .4rem; margin-bottom: .4rem;
} }
@media (max-width: 560px) {
/* Zwei Spalten statt einer.
Mit einer Kachel pro Zeile war die Uebersicht auf dem Handy
2635px lang -- man musste an allem vorbeiwischen, um das Geld zu
sehen, und der Sinn einer UEBERSICHT ist, dass man nicht wischen
muss. Zwei Spalten halbieren das. Die Schrift bleibt gleich
gross; verkleinert wuerde sie schwerer lesbar, und lesbar ist
hier wichtiger als kompakt. */
.vw-ub-gitter { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.vw-ub-kachel { padding: .8rem .75rem; }
}
.vw-ub-kachel { .vw-ub-kachel {
display: flex; flex-direction: column; align-items: flex-start; gap: .18rem; display: flex; flex-direction: column; align-items: flex-start; gap: .18rem;
@@ -512,6 +535,41 @@
.vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; } .vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; }
.vw-tabs { display: flex; flex-wrap: wrap; gap: .4rem; } .vw-tabs { display: flex; flex-wrap: wrap; gap: .4rem; }
@media (max-width: 700px) {
/* Sechs Reiter brauchen umgebrochen zwei bis drei Zeilen. Statt in
die Hoehe zu wachsen, wird daraus EIN Streifen zum Wischen.
overflow-x statt Umbruch, scroll-snap damit ein Reiter nie halb
abgeschnitten stehen bleibt, und ein Auslauf am rechten Rand
(die Maske unten), damit man SIEHT, dass es weitergeht -- ein
harter Schnitt sieht aus wie das Ende der Liste. */
.vw-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scroll-snap-type: x proximity;
scrollbar-width: none;
/* Der Streifen darf ueber den Containerrand hinauslaufen und
nimmt den Innenabstand selbst mit -- sonst endet er optisch
mitten auf der Seite. */
margin-inline: calc(var(--wd-sicher-links, 0px) * -1 - 4px)
calc(var(--wd-sicher-rechts, 0px) * -1 - 4px);
padding-inline: 4px;
/* scroll-padding zusaetzlich zu padding.
Ohne das richtet scroll-snap den ersten Reiter am SCROLLRAND
aus und schluckt den Innenabstand: Der Knopf sass dann bei 12px
statt bei 16px und stand damit vier Pixel links neben dem Titel
darueber -- eine Fehlstellung, die man sieht, aber schwer
benennen kann. scroll-padding verschiebt die Einrastkante nach
innen, sodass Titel und erster Reiter buendig sind. */
scroll-padding-inline: 4px;
padding-bottom: 2px;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 26px), transparent 100%);
}
.vw-tabs::-webkit-scrollbar { display: none; }
.vw-tabs > * { scroll-snap-align: start; flex: 0 0 auto; }
}
.vw-link-anzeige { .vw-link-anzeige {
padding: 1rem; border-radius: 14px; padding: 1rem; border-radius: 14px;
background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .35); background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .35);
@@ -972,8 +1030,17 @@
<div class="vw-pf"> <div class="vw-pf">
<div id="vw-pf-liste"><div class="vw-laedt">Wird geladen…</div></div> <div id="vw-pf-liste"><div class="vw-laedt">Wird geladen…</div></div>
<div id="vw-pf-verlauf" class="wd-karte"> <div id="vw-pf-verlauf" class="wd-karte">
<!-- Bewusst OHNE Richtungsangabe.
Vorher stand hier "Waehle links einen Verlauf aus".
Auf dem Handy gibt es kein links: Dort liegt die Liste
UEBER diesem Kasten, nicht daneben. Der Satz schickte
den Leser also an eine Stelle, an der nichts ist.
Richtungswoerter setzen ein Layout voraus, das es nur
auf einem breiten Bildschirm gibt -- und sie helfen
ohnehin niemandem, der die Seite vorgelesen bekommt. -->
<p class="wd-leise" style="text-align:center;padding:2rem 0"> <p class="wd-leise" style="text-align:center;padding:2rem 0">
Wähle links einen Verlauf aus. Tippe einen Verlauf an, um ihn hier zu lesen.
</p> </p>
</div> </div>
</div> </div>
@@ -1996,6 +2063,19 @@
document.querySelectorAll("[data-tab]").forEach(function (x) { document.querySelectorAll("[data-tab]").forEach(function (x) {
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab); x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
}); });
/* Auf dem Handy sind die Reiter ein Wischstreifen. Wer ueber eine
Kachel nach "Postfach" springt, landet sonst in einer Ansicht,
deren Reiter ausserhalb des Streifens liegt -- man sieht dann
nirgends, wo man ist. Deshalb den aktiven Reiter heranholen.
Nur waagerecht, damit die Seite nicht zusaetzlich springt. */
var aktiv = document.querySelector('[data-tab="' + aktiverTab + '"]');
if (aktiv && aktiv.scrollIntoView) {
try {
aktiv.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
} catch (e) {
aktiv.scrollIntoView(); // aeltere Browser kennen die Angaben nicht
}
}
/* Vorher war das eine Ja/Nein-Umschaltung zwischen zwei Bereichen. /* Vorher war das eine Ja/Nein-Umschaltung zwischen zwei Bereichen.
Mit vier Reitern geht das nicht mehr: Jede neue Ansicht haette Mit vier Reitern geht das nicht mehr: Jede neue Ansicht haette
eine weitere Zeile "hidden = ..." gebraucht, und beim naechsten eine weitere Zeile "hidden = ..." gebraucht, und beim naechsten
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="wr_titel"> <html lang="de" data-i18n-titel="wr_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Vertrag widerrufen — Dogfather Webdesign</title> <title>Vertrag widerrufen — Dogfather Webdesign</title>
<meta name="description" content="Elektronische Widerrufsfunktion nach § 356a BGB — Vertrag online widerrufen, mit sofortiger Eingangsbestätigung." /> <meta name="description" content="Elektronische Widerrufsfunktion nach § 356a BGB — Vertrag online widerrufen, mit sofortiger Eingangsbestätigung." />
<meta name="robots" content="noindex, follow" /> <meta name="robots" content="noindex, follow" />
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="de" data-i18n-titel="seiten_titel"> <html lang="de" data-i18n-titel="seiten_titel">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Zugang — Dogfather Webdesign</title> <title>Zugang — Dogfather Webdesign</title>
<!-- Doppelt abgesichert: server/webdesign-gate.js schickt zusätzlich einen <!-- Doppelt abgesichert: server/webdesign-gate.js schickt zusätzlich einen
X-Robots-Tag-Header. Der Header wirkt auch dann, wenn die Seite gar X-Robots-Tag-Header. Der Header wirkt auch dann, wenn die Seite gar