diff --git a/server/pruef-tippziele.mjs b/server/pruef-tippziele.mjs new file mode 100644 index 00000000..90240535 --- /dev/null +++ b/server/pruef-tippziele.mjs @@ -0,0 +1,265 @@ +/* ===================================================================== + pruef-tippziele.mjs — Was man antippen soll, muss man treffen können. + + Filipe, 19.09.2026: „jeder button jeder knopf jede kachel jeder text + einfach alles muss klappen weil die modis haben schon probleme." + + --------------------------------------------------------------------- + WARUM ES DIESE PRÜFUNG GIBT, OBWOHL ES DEN GROSSEN RUNDGANG GIBT + + `pruef-handy-teamdogi` misst das Richtige -- an echten Bildpunkten, + über 206 Seitenansichten. Nur dauert sie Minuten und gehört damit zu + den Läufen, die nicht nebenbei laufen. + + Diese hier braucht keinen Browser und keine Sekunde: Sie liest das + CSS und fragt, ob ein Bedienelement überhaupt eine Chance hat, 44 + Pixel groß zu sein. Das ist weniger, als die große kann -- aber es + läuft nach JEDER Änderung, und genau daran ist die alte Lösung + gescheitert. + + --------------------------------------------------------------------- + DIE EIGENTLICHE AUFGABE: EINE LISTE ABSCHAFFEN + + In `module.css` steht ein Block `@media (pointer: coarse)`, der ein + Dutzend Klassen auf 44 Pixel hebt. Diese Liste wird von Hand + gepflegt -- und ist damit genau die Falle, vor der die Hausregeln + warnen: Wer morgen einen Knopf mit `width: 34px` baut, trägt ihn + dort nicht nach. Er merkt es auch nicht. + + Gemessen am 20.09.2026: `.chat__weg` (34×34) stand nicht drin. Das + ist der Knopf, der ein Gespräch wegräumt -- und direkt daneben + sitzt der, der es FÜR ALLE auflöst. Zwei 34-Pixel-Ziele + nebeneinander, von denen eines unwiderruflich ist. + + Die Liste bleibt (CSS kann nicht rechnen). Aber sie wird ab jetzt + BEWACHT: Diese Prüfung leitet die Bedienelemente aus dem CSS ab und + verlangt für jedes eine Abdeckung. Eine Liste, die überwacht wird, + kann nicht mehr still veralten. + + --------------------------------------------------------------------- + WAS ALS BEDIENELEMENT ZÄHLT — UND WAS NICHT + + Ein Bedienelement ist, was man antippt. Ein Zeichen DARIN ist es + nicht: `.chat__weg svg` ist 17 Pixel groß und soll das auch sein -- + getroffen wird der Knopf darum herum. Ebenso `::before`, `::after` + und Innenteile wie `__punkt`, `__lupe`, `__pfeil`. + + Der erste Anlauf hat genau das nicht unterschieden und 21 Treffer + gemeldet, von denen 15 richtig waren. Eine Prüfung, die zu viel + meldet, wird abgeschaltet -- das ist kein besseres Ergebnis als + eine, die zu wenig meldet. + + AUFRUF: node server/pruef-tippziele.mjs + Läuft in unter einer Sekunde, ohne Server, ohne Browser. + ===================================================================== */ + +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const CSS = join(HIER, "..", "workspace", "assets", "css"); + +/** Was ein Daumen sicher trifft. Keine Hausmeinung, sondern die Zahl, + * die Apple, Google und die WCAG (2.5.8) gemeinsam nennen. */ +const ZIEL = 44; + +let ok = 0, fehl = 0; +const pruef = (name, bedingung, zusatz = "") => { + if (bedingung) { ok++; console.log(` OK ${name}`); } + else { fehl++; console.log(` FEHL ${name}${zusatz ? "\n " + zusatz : ""}`); } +}; + +/** Zerlegt CSS klammergenau. Ein Muster „bis zur nächsten }" greift bei + * jedem `@media` daneben. */ +function regeln(css, inMedia = null) { + const out = []; + let i = 0; const n = css.length; + while (i < n) { + if (css.startsWith("/*", i)) { const j = css.indexOf("*/", i + 2); i = j < 0 ? n : j + 2; continue; } + if (" \t\r\n".includes(css[i])) { i++; continue; } + const s = i; let j = i; + while (j < n && css[j] !== "{") { + if (css.startsWith("/*", j)) { const k = css.indexOf("*/", j + 2); j = k < 0 ? n : k + 2; continue; } + j++; + } + if (j >= n) break; + const kopf = css.slice(s, j).trim(); + let t = 0, k = j; + for (; k < n; k++) { + if (css.startsWith("/*", k)) { const m = css.indexOf("*/", k + 2); k = m < 0 ? n : m + 1; continue; } + if (css[k] === "{") t++; + else if (css[k] === "}") { t--; if (!t) { k++; break; } } + } + const koerper = css.slice(j + 1, k - 1); + if (kopf.startsWith("@")) { + /* In einen @media hineingehen -- und merken, unter welcher + Bedingung die Regeln darin stehen. */ + out.push(...regeln(koerper, inMedia ? `${inMedia} && ${kopf}` : kopf)); + } else { + out.push({ sel: kopf, koerper, media: inMedia }); + } + i = k; + } + return out; +} + +/** Ist dieser Selektor ein Ding, das man antippt? */ +function istBedienelement(sel) { + /* Nur der LETZTE Teil zählt: `.chat__weg svg` ist das Zeichen im + Knopf, nicht der Knopf. */ + const letzter = sel.split(",")[0].trim().split(/\s+|>/).filter(Boolean).pop() || ""; + if (/::(before|after|marker|backdrop)/.test(letzter)) return false; + /* Ein nacktes Element am Ende (svg, span, path) ist ein Innenteil. */ + if (/^(svg|path|span|i|b|em|circle|rect|line)\b/.test(letzter)) return false; + /* Innenteile tragen im Haus eigene Endungen. */ + if (/__(svg|zeichen|punkt|lupe|pfeil|gleis|funke|laden|zahl|text|name)\b/.test(letzter)) return false; + return /knopf|__weg\b|schalter|pille|chip|tag-knopf|toggle|schritt\b/i.test(letzter); +} + +/** Die kleinste feste Größe, die eine Regel festschreibt. */ +function festeGroesse(koerper) { + const zahl = (name) => { + const m = new RegExp(`(?:^|[;{\\s])${name}:\\s*(\\d+)px`).exec(koerper); + return m ? Number(m[1]) : null; + }; + return { h: zahl("height"), w: zahl("width") }; +} + +console.log("\n=== Tippziele: kann man jeden Knopf mit dem Daumen treffen? ===\n"); + +const dateien = readdirSync(CSS).filter((f) => f.endsWith(".css")); +const alle = []; +for (const f of dateien) { + for (const r of regeln(readFileSync(join(CSS, f), "utf8"))) alle.push({ ...r, datei: f }); +} +pruef("das CSS ließ sich zerlegen", alle.length > 500, `${alle.length} Regeln in ${dateien.length} Dateien`); + +/* ---- 1. Wer ist zu klein, ohne Rücksicht auf den Zeiger? ----------- */ +const zuKlein = []; +for (const r of alle) { + if (r.media) continue; /* gleich gesondert */ + if (!istBedienelement(r.sel)) continue; + const { h, w } = festeGroesse(r.koerper); + if ((h !== null && h < ZIEL) || (w !== null && w < ZIEL)) { + zuKlein.push({ ...r, h, w }); + } +} + +/* ---- 2. Wird es auf Fingergeräten angehoben? ----------------------- */ +/** Alle Selektoren, die in einem `pointer: coarse`-Block auf mindestens + * 44 gehoben werden. Abgeleitet, nicht abgeschrieben. */ +const gehoben = new Set(); +for (const r of alle) { + if (!r.media || !/pointer:\s*coarse/.test(r.media)) continue; + const minH = /min-height:\s*(\d+)px/.exec(r.koerper); + const minW = /min-width:\s*(\d+)px/.exec(r.koerper); + if (!minH && !minW) continue; + for (const teil of r.sel.split(",")) { + const t = teil.trim(); + if (!t) continue; + gehoben.add(t); + /* Auch die kurze Form merken, damit `.a .b` und `.b` sich finden. */ + const letzter = t.split(/\s+|>/).filter(Boolean).pop(); + if (letzter) gehoben.add(letzter); + } +} +pruef("es gibt überhaupt einen Block für Fingerbedienung", gehoben.size > 0, + `${gehoben.size} Selektoren werden angehoben`); + +/** Der zweite Weg: eine unsichtbare Trefferfläche um ein kleines + * Zeichen herum (`::after { inset: -10px }`). In dichten Zeilen ist + * das die richtige Lösung -- ein 44er-Knopf würde dort jede Zeile + * aufblähen. + * + * NACHGERECHNET, NICHT GEGLAUBT: Die Fläche zählt nur, wenn + * Grundgröße + 2 x Rand wirklich 44 erreicht. Sonst hätte man eine + * Regel, die gut aussieht und nichts löst. */ +const flaeche = new Map(); +for (const r of alle) { + if (!r.media || !/pointer:\s*coarse/.test(r.media)) continue; + const m = /inset:\s*-(\d+)px/.exec(r.koerper); + if (!m) continue; + for (const teil of r.sel.split(",")) { + const t = teil.trim(); + if (!/::(after|before)\s*$/.test(t)) continue; + const grund = t.replace(/::(after|before)\s*$/, "").trim(); + flaeche.set(grund, Math.max(flaeche.get(grund) || 0, Number(m[1]))); + const letzter = grund.split(/\s+|>/).filter(Boolean).pop(); + if (letzter) flaeche.set(letzter, Math.max(flaeche.get(letzter) || 0, Number(m[1]))); + } +} +/* KEINE PRUEFUNG DARAUF, DASS ES SIE GIBT. Am 20.09.2026 stand hier + `pruef("und es gibt den zweiten Weg", flaeche.size > 0)` -- und + genau dann fiel sie um, als die Trefferfläche wieder verschwand, + weil sich die schlichte Lösung als richtig erwies. Eine Prüfung, + die eine BAUWEISE erzwingt statt eines Ergebnisses, steht dem + Aufräumen im Weg. Sie wird anerkannt, wenn sie benutzt wird -- + verlangt wird sie nicht. */ + +const ungedeckt = []; +for (const r of zuKlein) { + const teile = r.sel.split(",").map((t) => t.trim()).filter(Boolean); + /* Gedeckt ist es nur, wenn JEDER Teil des Selektors gedeckt ist -- + sonst bliebe die Hälfte klein. */ + const alleGedeckt = teile.every((t) => { + const letzter = t.split(/\s+|>/).filter(Boolean).pop(); + if (gehoben.has(t) || (letzter && gehoben.has(letzter))) return true; + /* Trefferfläche: nachrechnen, nicht glauben. */ + const rand = flaeche.get(t) ?? (letzter ? flaeche.get(letzter) : 0) ?? 0; + if (!rand) return false; + const klein = Math.min(r.h ?? Infinity, r.w ?? Infinity); + return klein + 2 * rand >= ZIEL; + }); + if (!alleGedeckt) ungedeckt.push(r); +} + +/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Fände die Prüfung gar keine + Bedienelemente, wäre „keins ungedeckt" trivialerweise wahr. */ +pruef("jedes zu kleine Bedienelement wird auf Fingergeräten angehoben", + zuKlein.length > 0 && ungedeckt.length === 0, + `${zuKlein.length} fest kleiner als ${ZIEL}px, davon ungedeckt: ${ungedeckt.length}\n ` + + ungedeckt.map((r) => `${r.datei}: ${r.sel.replace(/\s+/g, " ").slice(0, 46)}` + + ` (${[r.h && r.h + "h", r.w && r.w + "w"].filter(Boolean).join(" ")})`).join("\n ")); + +/* ---- 3. Und der Block hebt wirklich auf 44, nicht auf 40 ----------- */ +const zuNiedrig = []; +for (const r of alle) { + if (!r.media || !/pointer:\s*coarse/.test(r.media)) continue; + for (const name of ["min-height", "min-width"]) { + const m = new RegExp(`${name}:\\s*(\\d+)px`).exec(r.koerper); + if (m && Number(m[1]) < ZIEL) zuNiedrig.push(`${r.sel.slice(0, 40)} ${name}: ${m[1]}px`); + } +} +pruef(`und angehoben wird auf mindestens ${ZIEL}px`, zuNiedrig.length === 0, + zuNiedrig.join("; ")); + +/* ---- DIE GEGENPROBEN ----------------------------------------------- */ +console.log("\n --- Gegenproben (jede MUSS anschlagen) ---"); + +pruef("Gegenprobe: ein Zeichen IM Knopf zählt nicht als Tippziel", + !istBedienelement(".chat__weg svg") && !istBedienelement(".zurueck-knopf::before")); +pruef("Gegenprobe: der Knopf selbst zählt schon", + istBedienelement(".chat__weg") && istBedienelement(".wahl2__knopf")); +pruef("Gegenprobe: eine feste Größe wird erkannt", + festeGroesse("width: 34px; height: 34px;").w === 34); +pruef("Gegenprobe: eine min-Größe ist KEINE feste Größe", + festeGroesse("min-width: 34px;").w === null, + "sonst gälte ein Knopf, der nach unten begrenzt ist, als zu klein"); +pruef("Gegenprobe: eine zu kleine Trefferfläche rettet nichts", + (() => { + /* 24 + 2x5 = 34 -- das reicht nicht, und die Prüfung muss das + merken. Sonst wäre jede Fläche ein Freibrief. */ + const klein = 24, rand = 5; + return klein + 2 * rand < ZIEL; + })()); +pruef("Gegenprobe: eine ausreichende schon", + 24 + 2 * 10 >= ZIEL); + +pruef("Gegenprobe: @media wird betreten, nicht überlesen", + regeln("@media (pointer: coarse) { .x { min-height: 44px } }")[0]?.media + ?.includes("coarse")); + +console.log(`\n ${ok} bestanden, ${fehl} fehlgeschlagen`); +console.log(` (${alle.length} CSS-Regeln, ${zuKlein.length} feste Tippziele unter ${ZIEL}px)\n`); +process.exit(fehl ? 1 : 0); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 113be0f8..4c81e8b9 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +