diff --git a/pruef-buehne.png b/pruef-buehne.png index 7df4d02d..8cbe579e 100644 Binary files a/pruef-buehne.png and b/pruef-buehne.png differ diff --git a/pruef-content-computer.png b/pruef-content-computer.png index dc808851..4d052261 100644 Binary files a/pruef-content-computer.png and b/pruef-content-computer.png differ diff --git a/pruef-content-creator.png b/pruef-content-creator.png index 00b3d3a0..45b73a75 100644 Binary files a/pruef-content-creator.png and b/pruef-content-creator.png differ diff --git a/pruef-content-handy.png b/pruef-content-handy.png index b3472c0a..62fbda17 100644 Binary files a/pruef-content-handy.png and b/pruef-content-handy.png differ diff --git a/pruef-handy.png b/pruef-handy.png new file mode 100644 index 00000000..05c7546b Binary files /dev/null and b/pruef-handy.png differ diff --git a/pruef-kalender-computer.png b/pruef-kalender-computer.png index 0fe2a3ac..16d48593 100644 Binary files a/pruef-kalender-computer.png and b/pruef-kalender-computer.png differ diff --git a/pruef-kalender-handy.png b/pruef-kalender-handy.png index f2077f4e..d082b86a 100644 Binary files a/pruef-kalender-handy.png and b/pruef-kalender-handy.png differ diff --git a/pruef-personen.png b/pruef-personen.png index 08cb4b2c..ab530fdf 100644 Binary files a/pruef-personen.png and b/pruef-personen.png differ diff --git a/pruef-protokoll-auf.png b/pruef-protokoll-auf.png index 935c4f25..d35f4ac6 100644 Binary files a/pruef-protokoll-auf.png and b/pruef-protokoll-auf.png differ diff --git a/pruef-protokoll-zu.png b/pruef-protokoll-zu.png index 5be6068b..aa0463f6 100644 Binary files a/pruef-protokoll-zu.png and b/pruef-protokoll-zu.png differ diff --git a/pruef-start-computer.png b/pruef-start-computer.png index ef37233c..8c0bb464 100644 Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ diff --git a/pruef-start-creator.png b/pruef-start-creator.png index 2da7e652..15a0d815 100644 Binary files a/pruef-start-creator.png and b/pruef-start-creator.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png index d8b853b4..df96c331 100644 Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ diff --git a/pruef-uebersicht-computer.png b/pruef-uebersicht-computer.png index 80f03190..f0758489 100644 Binary files a/pruef-uebersicht-computer.png and b/pruef-uebersicht-computer.png differ diff --git a/pruef-uebersicht-creator.png b/pruef-uebersicht-creator.png index 134c3c99..11bb93f1 100644 Binary files a/pruef-uebersicht-creator.png and b/pruef-uebersicht-creator.png differ diff --git a/pruef-uebersicht-handy.png b/pruef-uebersicht-handy.png index a90444c5..05fb3b87 100644 Binary files a/pruef-uebersicht-handy.png and b/pruef-uebersicht-handy.png differ diff --git a/pruef-zustand.png b/pruef-zustand.png index 6bbd97d3..94b18940 100644 Binary files a/pruef-zustand.png and b/pruef-zustand.png differ diff --git a/server/pruef-handy.mjs b/server/pruef-handy.mjs new file mode 100644 index 00000000..9f31fb56 --- /dev/null +++ b/server/pruef-handy.mjs @@ -0,0 +1,303 @@ +/* =================================================================== + Die Handy-Pruefung. + + Auf einem Telefon geht anderes kaputt als am Rechner, und man sieht + es beim Entwickeln nie: Ein Knopf steht 3 px aus dem Bild, eine + Ueberschrift ist auf halbem Wort abgeschnitten, ein Feld ist so + schmal, dass man es nicht trifft. Alles davon faellt erst auf, wenn + jemand das Geraet in der Hand hat. + + Geprueft wird auf DREI echten Geraetegroessen, jede Seite einzeln: + + 1. Nichts steht ueber waagerechtes Scrollen ist auf einem Handy + der schlimmste Fehler -- die Seite ruckelt + und man verliert den Rand + 2. Kein Text abgeschnitten gemessen wird die tatsaechliche + Textbreite gegen die verfuegbare, nicht + "sieht ganz aus" + 3. Beruehrziele gross genug 24x24 CSS-Pixel ist das Mindestmass + (WCAG 2.2, Kriterium 2.5.8). Darunter + trifft man mit dem Daumen daneben + 4. Nichts ueberlappt zwei Bedienelemente duerfen sich nicht + ueberdecken -- man traefe das falsche + 5. Keine Fehler in der Konsole + =================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-handy-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4198"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4198"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 400_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} + +/* Echte Daten statt leerer Seiten: Eine leere Liste kann nicht + ueberlaufen. Geprueft wird mit LANGEN Namen und vollen Listen -- + genau dort bricht es. */ +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick-Maximilian", "scout", "CODE-SCOU-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); + +const LANG = "Ein sehr langer Eintrag ohne Leerzeichenumbruch fuer die Handypruefung"; +for (const [status, frist] of [["offen", gestern], ["offen", heute], ["arbeit", null], + ["review", null], ["erledigt", null]]) { + d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist, + prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,'hoch',?,?)`) + .run(LANG, status, idTili, idTili, frist, jetzt, idDogi); +} +for (const [art, stunde] of [["call", "10:00"], ["termin", "14:30"], ["review", "18:00"]]) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, + erledigt, erstellt, erstellt_von) VALUES (?,?,?,60,?,?,0,?,?)`) + .run(LANG, art, heute + "T" + stunde, idTili, idTili, jetzt, idDogi); +} +d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, + creator_id, erstellt, erstellt_von) + VALUES ('technik','problem',?,?,'hoch','offen',?,?,?)`) + .run(LANG, heute, idTili, jetzt, idDogi); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +const SEITEN = [ + "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "personen.html", + "profil.html", "wissen.html", "dateien.html", "calls.html", "startcheck.html", + "report.html", "scouting.html", "automation.html", "content.html", "bereich.html?b=live", +]; + +/* Drei echte Geraete, nicht "irgendwas Schmales": + 320 iPhone SE (1. Gen) -- das schmalste noch benutzte Geraet + 390 iPhone 14/15, das haeufigste Mass + 412 Pixel 8 / Galaxy S24, typisches Android */ +const GERAETE = [[320, 568, "sehr schmal"], [390, 844, "iPhone"], [412, 915, "Android"]]; + +const messen = () => ({ + /* 1. Waagerechtes Scrollen. Der schlimmste Fehler auf einem Handy. */ + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + schuld: [...document.querySelectorAll("body *")] + .filter((e) => { + const r = e.getBoundingClientRect(); + /* Nur was wirklich sichtbar ist. Ein Wasserzeichen hinter + overflow:hidden meldet ein Rechteck, das nie zu sehen war -- + das hat frueher schon einmal falschen Alarm ausgeloest. */ + if (r.width < 1 || r.height < 1) return false; + const st = getComputedStyle(e); + if (st.visibility === "hidden" || st.display === "none") return false; + /* Steckt es in einem Vorfahren mit overflow:hidden? Dann ist es + abgeschnitten und steht nicht wirklich ueber. */ + for (let v = e.parentElement; v; v = v.parentElement) { + const vs = getComputedStyle(v); + if (vs.overflow !== "visible" || vs.overflowX !== "visible") return false; + } + return r.right > window.innerWidth + 1; + }) + .map((e) => `${e.tagName}.${String(e.className).slice(0, 26)}(+${Math.round(e.getBoundingClientRect().right - window.innerWidth)}px)`) + .slice(0, 4), + + /* 2. Abgeschnittener Text. Gemessen wird die tatsaechliche Breite + gegen die verfuegbare -- nicht "sieht ganz aus". Elemente, die + ausdruecklich kuerzen duerfen (ellipsis), sind ausgenommen: Dort + ist das Kuerzen die Absicht und der volle Text steht woanders. */ + abgeschnitten: [...document.querySelectorAll("h1, h2, h3, p, span, a, label, button, li, td, th")] + .filter((e) => { + if (e.children.length) return false; + const t = e.textContent.trim(); + if (t.length < 3) return false; + const st = getComputedStyle(e); + if (st.display === "none" || st.visibility === "hidden") return false; + if (st.textOverflow === "ellipsis") return false; + if (st.overflow === "hidden" && st.whiteSpace === "nowrap") return false; + return e.scrollWidth > e.clientWidth + 2; + }) + .map((e) => `"${e.textContent.trim().slice(0, 22)}" (${e.scrollWidth}>${e.clientWidth})`) + .slice(0, 5), + + /* 3. Beruehrziele. 24x24 CSS-Pixel ist das Mindestmass nach WCAG 2.2 + (2.5.8). Gemessen wird das Ziel selbst ODER sein umschliessender + Bereich -- ein 16 px grosses Symbol in einem 44 px grossen Knopf + ist in Ordnung. */ + klein: [...document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")] + .filter((e) => { + const r = e.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return false; + const st = getComputedStyle(e); + if (st.display === "none" || st.visibility === "hidden") return false; + return r.width < 24 || r.height < 24; + }) + .map((e) => { + const r = e.getBoundingClientRect(); + return `${e.tagName}"${(e.textContent || e.getAttribute("aria-label") || "").trim().slice(0, 14)}" ` + + `${Math.round(r.width)}x${Math.round(r.height)}`; + }) + .slice(0, 5), + + /* 4. Ueberlappende Bedienelemente. Zwei Knoepfe, die sich decken, + heissen: Man trifft den falschen. Geprueft wird der Mittelpunkt -- + liegt dort ein ANDERES Bedienelement, ist das Ziel verdeckt. */ + verdeckt: [...document.querySelectorAll("button, a[href], input, select")] + .filter((e) => { + const r = e.getBoundingClientRect(); + if (r.width < 8 || r.height < 8) return false; + if (r.top < 0 || r.bottom > window.innerHeight) return false; + const st = getComputedStyle(e); + if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false; + const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return oben && oben !== e && !e.contains(oben) && !oben.contains(e); + }) + .map((e) => `${e.tagName}"${(e.textContent || "").trim().slice(0, 16)}"`) + .slice(0, 3), + + /* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die + einzige Stelle, ueber die man wieder wegkommt. */ + kopfDrin: (() => { + const a = document.querySelector(".abmelden")?.getBoundingClientRect(); + return a ? (a.right <= window.innerWidth + 1 && a.left >= -1) : null; + })(), + /* 6. Schriftgroesse: unter 12 px liest niemand auf einem Handy. */ + winzig: [...document.querySelectorAll("p, span, li, a, label, small")] + .filter((e) => e.children.length === 0 && e.textContent.trim().length > 3 + && parseFloat(getComputedStyle(e).fontSize) < 11.5) + .map((e) => `"${e.textContent.trim().slice(0, 18)}" ${getComputedStyle(e).fontSize}`) + .slice(0, 3), +}); + +for (const [breite, hoehe, wie] of GERAETE) { + console.log(`\n=== ${wie} (${breite}x${hoehe})`); + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + + /* Auch die Anmeldeseite selbst wird geprueft -- sie ist die erste, + die jemand auf dem Handy sieht. */ + { + await seite.waitForTimeout(500); + const r = await seite.evaluate(messen); + const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length && !r.verdeckt.length; + ok(gut, `Anmeldung über=${r.ueber}px abgeschnitten=${r.abgeschnitten.length} ` + + `klein=${r.klein.length} verdeckt=${r.verdeckt.length}` + + (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "") + + (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "") + + (r.klein.length ? `\n zu klein: ${r.klein.join(", ")}` : "") + + (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")); + } + + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + + for (const s of SEITEN) { + const konsole = []; + const auf = (m) => { if (m.type() === "error") konsole.push(m.text()); }; + const err = (e) => konsole.push("pageerror: " + e.message); + seite.on("console", auf); + seite.on("pageerror", err); + + await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + const r = await seite.evaluate(messen); + + const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length + && !r.verdeckt.length && !r.winzig.length + && r.kopfDrin !== false && !konsole.length; + + console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}` + + `über=${String(r.ueber).padStart(3)}px` + + (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "") + + (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "") + + (r.klein.length ? `\n Berührziel zu klein: ${r.klein.join(", ")}` : "") + + (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "") + + (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "") + + (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "") + + (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "")); + if (!gut) fehler++; + + seite.off("console", auf); + seite.off("pageerror", err); + } + + if (breite === 390) { + await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + await seite.screenshot({ path: "pruef-handy.png", fullPage: true }); + } + await kontext.close(); +} + +/* --------------------------------------------------------------------- + Bedienbarkeit mit dem Finger: Auf einem Geraet ohne feinen Zeiger darf + das Licht gar nicht erst eingebaut werden -- es kostete Arbeit fuer + einen Effekt, den dort niemand sehen kann. + --------------------------------------------------------------------- */ +console.log("\n=== Kein Licht ohne Zeiger"); +{ + const kontext = await browser.newContext({ + viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.waitForTimeout(800); + /* Eine Kachel antippen und wieder loslassen -- danach darf nichts + leuchten bleiben. */ + await seite.tap(".kachel >> nth=1").catch(() => {}); + await seite.waitForTimeout(400); + const r = await seite.evaluate(() => ({ + lichter: document.querySelectorAll(".licht").length, + an: document.querySelectorAll('[data-licht="an"]').length, + })); + ok(r.lichter === 0, `auf dem Handy wird gar kein Licht eingebaut (${r.lichter})`); + ok(r.an === 0, `und keines bleibt an (${r.an})`); + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs index 953d05a5..acda2c99 100644 --- a/server/pruef-start-ansicht.mjs +++ b/server/pruef-start-ansicht.mjs @@ -449,6 +449,129 @@ console.log("\n=== Das Licht folgt dem Zeiger"); await seite.mouse.move(5, 5); await seite.waitForTimeout(400); } + + /* --- Bleibt das Licht haengen? ----------------------------------------- + Beanstandet am 01.09.2026: "teilweise bleibt das Licht haengen". + Es gab dafuer vier Wege, und jeder einzelne wird hier durchgespielt. + Alle vier haben dasselbe Merkmal: Der Zeiger ist weg, aber die Karte + leuchtet weiter. */ + { + const anzahlAn = () => seite.evaluate(() => + document.querySelectorAll('[data-licht="an"]').length); + + /* 1. Ueber die Luecke zwischen zwei Kacheln hinaus. */ + const k1 = await seite.locator(".kachel").nth(1).boundingBox(); + await seite.mouse.move(k1.x + k1.width / 2, k1.y + k1.height / 2); + await seite.waitForTimeout(300); + ok(await anzahlAn() === 1, `beim Ueberfahren leuchtet genau eine Kachel (${await anzahlAn()})`); + await seite.mouse.move(k1.x + k1.width / 2, k1.y - 30); // in die Luecke darueber + await seite.waitForTimeout(350); + ok(await anzahlAn() === 0, `in der Luecke geht es aus (${await anzahlAn()})`); + + /* 2. Direkt von einer Kachel auf die naechste -- es darf nie mehr + als eine gleichzeitig leuchten. */ + const k2 = await seite.locator(".kachel").nth(2).boundingBox(); + await seite.mouse.move(k1.x + k1.width / 2, k1.y + k1.height / 2); + await seite.waitForTimeout(250); + await seite.mouse.move(k2.x + k2.width / 2, k2.y + k2.height / 2); + await seite.waitForTimeout(300); + ok(await anzahlAn() === 1, `beim Wechsel leuchtet weiterhin nur eine (${await anzahlAn()})`); + + /* 3. Gescrollt, ohne die Maus zu bewegen. Die Karte wandert unter + dem stehenden Zeiger weg -- es kommt KEIN Zeigerereignis. Das war + der haeufigste Fall. */ + await seite.mouse.wheel(0, 600); + await seite.waitForTimeout(450); + ok(await anzahlAn() === 0, `nach dem Scrollen ist es aus (${await anzahlAn()})`); + /* WIEDER HOCH. Ohne das lagen die Kacheln fuer alle folgenden + Pruefungen 600 px weiter oben -- die naechste Pruefung bewegte den + Zeiger dann ins Leere und meldete einen Fehler, den es nicht gab. */ + await seite.mouse.wheel(0, -600); + await seite.waitForTimeout(400); + + /* 4. Der Wettlauf: schnell hinein und sofort wieder hinaus. Der + angemeldete Bildaufbau lief frueher NACH dem Verlassen und + schaltete das Licht wieder an -- so blieb es haengen. */ + await seite.mouse.move(0, 0); + await seite.waitForTimeout(200); + const k3 = await seite.locator(".kachel").nth(0).boundingBox(); + if (k3) { + await seite.mouse.move(k3.x + 20, k3.y + 20); + await seite.mouse.move(k3.x - 60, k3.y - 60); // im selben Wimpernschlag wieder raus + await seite.waitForTimeout(500); + ok(await anzahlAn() === 0, `hinein und sofort wieder hinaus: aus (${await anzahlAn()})`); + } + } + + /* --- Die Farbe des Lichts gehoert zur Kachel --------------------------- + Beanstandet: Es leuchtete in der Farbe der SEITE statt in der der + Kachel. Geprueft wird, dass zwei verschieden gefaerbte Kacheln auch + verschieden gefaerbtes Licht bekommen. */ + { + const farben = []; + for (const nr of [1, 2, 3]) { + const b = await seite.locator(".kachel").nth(nr).boundingBox(); + if (!b) continue; + await seite.mouse.move(b.x + b.width / 2, b.y + b.height / 2); + await seite.waitForTimeout(300); + farben.push(await seite.evaluate((n) => { + const k = document.querySelectorAll(".kachel")[n]; + const l = k.querySelector(".licht"); + return { + ton: getComputedStyle(k).getPropertyValue("--ton").trim(), + licht: l ? getComputedStyle(l, "::before").backgroundImage.slice(0, 90) : null, + }; + }, nr)); + } + const toene = new Set(farben.map((f) => f.ton)); + ok(toene.size === farben.length, + `jede Kachel hat ihren eigenen Ton (${[...toene].join(", ")})`); + /* Die Farbe im Verlauf NICHT als Text vergleichen: Der Browser + rechnet color-mix() aus und schreibt das Ergebnis je nach Fassung + als rgb(), color(srgb ...) oder oklab() zurueck. Ein + Zeichenkettenvergleich schlaegt dann fehl, obwohl die Farbe + stimmt -- genau das ist beim ersten Lauf passiert. + + Verglichen werden stattdessen die Lichter UNTEREINANDER: Drei + verschieden gefaerbte Kacheln muessen drei verschiedene Verlaeufe + ergeben. Kaeme die Farbe weiterhin von der SEITE (der Fehler, den + Filipe gesehen hat), waeren alle drei gleich. */ + const lichter = new Set(farben.map((f) => f.licht)); + ok(lichter.size === farben.length, + `und das Licht ist je Kachel ein anderes (${lichter.size} von ${farben.length})`); + + /* Zusaetzlich an echten Bildpunkten: Das Licht der Aufgaben-Kachel + (orange) muss mehr Rot als Blau haben, das der Kalender-Kachel + (tuerkis) mehr Blau als Rot. Das kann keine Zeichenkette + vortaeuschen. */ + for (const [name, pruefung] of [["Aufgaben", (p) => p[0] > p[2]], + ["Kalender", (p) => p[2] > p[0]]]) { + const b = await seite.evaluate((n) => { + const k = [...document.querySelectorAll(".kachel")] + .find((x) => x.querySelector(".kachel__name")?.textContent === n); + if (!k) return null; + const r = k.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, name); + if (!b) continue; + await seite.mouse.move(b.x, b.y); + await seite.waitForTimeout(350); + const bild = await seite.screenshot({ type: "png" }); + const p = await seite.evaluate(async ({ b64, x, y }) => { + const i = new Image(); i.src = "data:image/png;base64," + b64; await i.decode(); + const c = document.createElement("canvas"); + c.width = i.width; c.height = i.height; + const g = c.getContext("2d"); g.drawImage(i, 0, 0); + const s = i.width / window.innerWidth; + return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data]; + }, { b64: bild.toString("base64"), x: b.x, y: b.y }); + ok(pruefung(p), `das Licht auf "${name}" hat die richtige Farbe ` + + `(rot ${p[0]}, grün ${p[1]}, blau ${p[2]})`); + } + + await seite.mouse.move(2, 2); + await seite.waitForTimeout(300); + } ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`); await seite.mouse.move(kasten.x + kasten.width * 0.2, kasten.y + kasten.height * 0.3); diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index f12dacb2..0b833ebf 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -119,10 +119,13 @@ font-size: .74rem; color: var(--text-still); letter-spacing: normal; } -.spalte[data-status="offen"] { --sfarbe: #6f89a8; } -.spalte[data-status="arbeit"] { --sfarbe: #3fbdf5; } -.spalte[data-status="review"] { --sfarbe: #e0a33c; } -.spalte[data-status="erledigt"] { --sfarbe: #4dab77; } +/* --ton mitgesetzt: Das Licht unter dem Zeiger nimmt seine Farbe von + dort. Ohne diese Zeile leuchtete die gruene Erledigt-Spalte orange + (der Farbton der SEITE), was aussah wie ein Fehler und einer war. */ +.spalte[data-status="offen"] { --sfarbe: #6f89a8; --ton: #6f89a8; } +.spalte[data-status="arbeit"] { --sfarbe: #3fbdf5; --ton: #3fbdf5; } +.spalte[data-status="review"] { --sfarbe: #e0a33c; --ton: #e0a33c; } +.spalte[data-status="erledigt"] { --sfarbe: #4dab77; --ton: #4dab77; } .spalte__punkt { background: var(--sfarbe); box-shadow: 0 0 9px color-mix(in srgb, var(--sfarbe) 70%, transparent); diff --git a/workspace/assets/css/automation.css b/workspace/assets/css/automation.css index 7bc8441f..9a8f043b 100644 --- a/workspace/assets/css/automation.css +++ b/workspace/assets/css/automation.css @@ -44,7 +44,7 @@ .ki-kasten__daten { display: grid; gap: 7px; margin: 18px 0 0; padding: 16px 0 0; list-style: none; border-top: 1px solid var(--rand); - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); } .ki-kasten__daten li { display: flex; gap: 10px; font-size: .83rem; color: var(--text-leise); } .ki-kasten__schild { @@ -83,7 +83,7 @@ .zustand__daten { display: grid; gap: 7px; margin: 18px 0 0; padding: 16px 0 0; list-style: none; border-top: 1px solid var(--rand); - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); } .zustand__daten li { display: flex; gap: 10px; font-size: .83rem; color: var(--text-leise); } .zustand__daten .ki-kasten__schild { min-width: 128px; } @@ -125,7 +125,7 @@ .regeln { display: grid; gap: 10px; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); } .regel { padding: 15px 17px; diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index 422b4108..ffd53a04 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -70,7 +70,7 @@ } .k-anlassreihe { display: grid; gap: 9px; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); margin-top: 10px; } diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css index bda89e76..ad7d59ce 100644 --- a/workspace/assets/css/personen.css +++ b/workspace/assets/css/personen.css @@ -111,7 +111,7 @@ .rollenwahl-block { grid-column: 1 / -1; } .rollenwahl { display: grid; gap: 8px; margin-top: 2px; - grid-template-columns: repeat(auto-fit, minmax(232px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(232px, 100%), 1fr)); } .rollenwahl__knopf { diff --git a/workspace/assets/css/profil.css b/workspace/assets/css/profil.css index eb22847e..92f2a560 100644 --- a/workspace/assets/css/profil.css +++ b/workspace/assets/css/profil.css @@ -38,7 +38,7 @@ .gruppe__raster { display: grid; gap: 14px; margin-top: 16px; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); } /* Die interne Notiz hebt sich deutlich ab, damit im Gespräch mit dem diff --git a/workspace/assets/css/report.css b/workspace/assets/css/report.css index 3bc48f46..61f9a9f3 100644 --- a/workspace/assets/css/report.css +++ b/workspace/assets/css/report.css @@ -19,7 +19,7 @@ .kacheln { display: grid; gap: 10px; - grid-template-columns: repeat(auto-fit, minmax(158px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(158px, 100%), 1fr)); } .kachel { diff --git a/workspace/assets/css/scouting.css b/workspace/assets/css/scouting.css index f8b9e975..f2bfa9cc 100644 --- a/workspace/assets/css/scouting.css +++ b/workspace/assets/css/scouting.css @@ -132,7 +132,7 @@ } .kk__raster { display: grid; gap: 12px; margin-bottom: 14px; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); } .kk__raster input, .kk__raster select { width: 100%; padding: 9px 11px; diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 80139bd9..a7fda168 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -511,7 +511,7 @@ body.start > main { position: relative; z-index: 1; } .zahlen { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; - grid-template-columns: repeat(auto-fit, minmax(122px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(122px, 100%), 1fr)); } .zahl { @@ -1556,3 +1556,182 @@ body.start #protokoll { @media (forced-colors: active) { .licht { display: none; } } + +/* ===================================================================== + HANDY — was die Geraetepruefung gefunden hat (01.09.2026) + + Geprueft wurde auf 320, 390 und 412 px, auf allen fuenfzehn Seiten. + Drei Arten von Fehlern kamen heraus, und alle drei sind am Rechner + unsichtbar: + + 1. Raster, die nicht schrumpfen konnten. minmax(280px, 1fr) ist ein + MINDESTmass -- passt es nicht, laeuft die Seite ueber statt + umzubrechen. Behoben an zwoelf Stellen mit min(280px, 100%). + 2. Beruehrziele unter 24 px. Auswahlfelder waren 18 bis 22 px hoch; + mit dem Daumen trifft man daneben. WCAG 2.2 nennt 24x24 als + Mindestmass (Kriterium 2.5.8), und das ist knapp bemessen. + 3. Schrift unter 11,5 px. Auf einem Telefon ist das keine + Beschriftung mehr, sondern ein Muster. + ===================================================================== */ + +@media (max-width: 760px) { + /* --- Beruehrziele --------------------------------------------------- + Auswahlfelder, Eingaben und kleine Knoepfe bekommen ein Mindestmass. + 44 px ist die Empfehlung von Apple und Google und deutlich ueber der + Norm -- der Daumen ist nun einmal breiter als ein Mauszeiger. */ + select, input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), + textarea, button, .schritt, .knopf-still, .abmelden { + min-height: 44px; + } + select, input:not([type="checkbox"]):not([type="radio"]):not([type="file"]) { + /* 16 px verhindert nebenbei, dass iOS beim Antippen hineinzoomt -- + jeder kleinere Wert loest das aus, und danach steht die Seite + schief. */ + font-size: 16px; + padding: 10px 12px; + } + /* Kleine Umschalter dürfen niedriger sein, brauchen aber Breite. */ + .filterreihe__knopf, .k-chip, .k-ansicht, .marke-tag, .tag-knopf { + min-height: 38px; + } + + /* --- Schriftgroessen ------------------------------------------------ + Alles unter 11,5 px hochgezogen. Die Rollenmarke in der Kopfleiste + lag bei 10,56 px, die Bereichszeile bei 10,88 px, die Uhrzeiten im + Kalender bei 9,6 px. */ + .wer__rolle, .kopf-zeile .marke, .willkommen .marke, + .feldschild, .marke, .gruppe__unter, .kachel__unter { + font-size: .75rem; + } + .k-pille, .k-anlass { font-size: .7rem; } + .k-tag__heute, .k-anlasskarte__rest small, + .k-anlasskarte__rest, .zahl__schild, .karte__zeile { + font-size: .72rem; + } + small, .leise { font-size: .78rem; } + + /* --- Kalenderpillen ------------------------------------------------- + Sie waren 15 px hoch -- als Ziel zu klein und als Text zu eng. Auf + dem Handy bekommen sie Luft; dass dann weniger in eine Zelle passt, + ist richtig so: Der volle Tag steht im Tagesdialog. */ + .k-pille, .k-anlass { + min-height: 26px; + display: flex; align-items: center; + padding: 3px 6px; + } + .k-tag { min-height: 92px; } +} + +/* Auf sehr schmalen Geraeten (320 px, iPhone SE) wird zusaetzlich alles + Mehrspaltige einspaltig. Zwei Spalten auf 288 px Inhaltsbreite sind + keine zwei Spalten mehr, sondern zwei Streifen. */ +@media (max-width: 380px) { + .neu__raster, .gruppe__raster, .zahlen, .kacheln, + .feld-block__punkte, .zustand__daten, .ki-kasten__daten, .regeln { + grid-template-columns: 1fr !important; + } + .inhalt { padding-left: 13px; padding-right: 13px; } + .kopfleiste { padding-left: 13px; padding-right: 13px; } +} + +/* --- Feste Mindestbreiten aufheben ----------------------------------- + Zwoelf Stellen im Stil setzen eine feste Mindestbreite (Auswahlfelder + 170 bis 240 px, Beschriftungsspalten 74 bis 128 px). Am Rechner ist + das richtig -- ein Auswahlfeld, das mit seinem Inhalt schrumpft, + springt bei jeder Aenderung in der Breite. + + Auf einem 320-px-Geraet bleiben nach Polsterung 294 px uebrig. Ein + Feld mit 240 px Mindestbreite plus Beschriftung passt da nicht mehr, + und die Seite laesst sich waagerecht schieben. Genau das waren die + letzten 24 und 30 px auf startcheck.html und automation.html. + + Auf dem Handy nehmen die Felder deshalb die volle Breite -- was + ohnehin die bessere Bedienung ist. */ +@media (max-width: 760px) { + .steuerung select, .steuerung input, + .wahl2, .wahl2 select, + .betreuung__wahl, .filterreihe select, .kopf-knoepfe select, + select, input[type="search"], input[type="text"] { + min-width: 0; + max-width: 100%; + } + .steuerung, .kopf-zeile, .kopf-knoepfe { flex-wrap: wrap; } + .steuerung > *, .kopf-knoepfe > * { min-width: 0; } + + /* Beschriftungsspalten in Datenlisten: nebeneinander wird zu + untereinander. 128 px Beschriftung plus Wert gehen auf 294 px nicht + mehr nebeneinander, ohne dass eines von beiden abbricht. */ + .ki-kasten__schild, .zustand__daten .ki-kasten__schild { + min-width: 0; + } + .ki-kasten__daten li, .zustand__daten li { + flex-direction: column; gap: 2px; + } + + /* Alles im Inhalt bleibt in seinem Kasten. Die Zeile ist die letzte + Sicherung: Sie faengt auch das ab, was kuenftig dazukommt, ohne dass + jemand daran denkt. */ + .inhalt img, .inhalt table, .inhalt pre, .inhalt input, + .inhalt select, .inhalt textarea { max-width: 100%; } +} + + +/* --- Mindestschriftgroesse auf dem Handy ----------------------------- + + Achtundvierzig Regeln im Stil setzen eine Schrift unter 11,7 px -- + Marken, Zaehler, Uhrzeiten, Spaltenkoepfe. Am Rechner ist das + angenehm: kleine Beschriftungen treten zurueck und lassen dem Inhalt + den Vortritt. + + Auf einem Telefon ist dieselbe Groesse kein Text mehr, sondern ein + Muster. Die Geraetepruefung hat sie einzeln aufgelistet -- 9,6 px bei + den Uhrzeiten im Kalender, 10,56 px bei der Rollenmarke. + + Gesucht wurden sie nicht von Hand (das waere ein Ratespiel gewesen + und haette die Haelfte uebersehen), sondern durch Durchsuchen der + Stildateien nach font-size unter 0,73rem. Alle Fundstellen stehen + hier und werden auf 0,75rem = 12 px angehoben. + + Wer kuenftig eine neue kleine Marke baut, faellt der Handypruefung + auf und landet ebenfalls hier. + + JEDER Selektor traegt body.start davor. Ohne das griff die Regel + nicht: start.css wird VOR den Seitenstilen geladen, und bei gleicher + Staerke gewinnt die spaetere Regel -- kalender.css setzte die Pille + also weiterhin auf 0,6rem. Der erste Anlauf sah im Quelltext richtig + aus und aenderte nichts. */ +@media (max-width: 760px) { + body.start .balance__zahl, body.start .betreuung__schild, body.start .datei__schild, + body.start .filtergruppe__schild, body.start .fortschritt__schild, body.start .fortschritt__warn, + body.start .gruppe__zahl, body.start .k-anlass, body.start .k-anlasskarte__rest, + body.start .k-anlasskarte__rest small, body.start .k-kw, body.start .k-listentag__kw, + body.start .k-listentag__marke, body.start .k-pille, body.start .k-spaltenkopf, + body.start .k-strahl__kw, body.start .k-tag__heute, body.start .k-tag__monat, + body.start .k-wochentag__name, body.start .k-zeile__art, body.start .kachel__name, + body.start .karte__zeile, body.start .katwahl__weltname, body.start .ki-kasten__schild, + body.start .kk__schild, body.start .kopfleiste .marke, body.start .marke, + body.start .marke-art, body.start .marke-fehlt, body.start .marke-fertig, + body.start .marke-geraet, body.start .marke-neu, body.start .marke-punkt, + body.start .marke-rolle, body.start .marke-saeule, body.start .marke-status, + body.start .marke-still, body.start .marke-stufe, body.start .marke-tag, + body.start .marke-wichtig, body.start .naechstes__schild, body.start .rueck__wann, + body.start .spalte__zahl, body.start .suche-gruppe__titel, body.start .summe__name, + body.start .tu-knopf, body.start .wahl2__gruppe, body.start .wahl__rolle, + body.start .wer__rolle, body.start .wert__name, body.start .willkommen__marker, + body.start .zahl__schild + { + font-size: .75rem; + } +} + +/* Der Verweis auf den naechsten Termin im Dashboard war 24 px hoch -- + genau an der Grenze und damit darunter, sobald der Browser rundet. + Ein Ziel, das man mit dem Daumen nur knapp trifft, ist kein Ziel. */ +@media (max-width: 760px) { + body.start .naechstes__wert, + body.start a.naechstes__wert { + display: inline-flex; align-items: center; + min-height: 32px; + padding: 4px 2px; + } +} diff --git a/workspace/assets/css/startcheck.css b/workspace/assets/css/startcheck.css index 9e25ba70..5e657ce9 100644 --- a/workspace/assets/css/startcheck.css +++ b/workspace/assets/css/startcheck.css @@ -136,9 +136,17 @@ @media (max-width: 620px) { .punkt__kopf { flex-direction: column; } - .stufen { width: 100%; } - .stufe-knopf { flex: 1; } + /* flex: none MUSS hier aufgehoben werden. Die Regel oben verbietet den + Stufen jedes Schrumpfen -- gedacht, damit sie am Rechner nicht + zusammengequetscht werden. Auf 294 px Inhaltsbreite passten die drei + Knoepfe dann aber nicht mehr nebeneinander und die Seite liess sich + waagerecht schieben (gemessen: 331 px Inhalt in 294 px Kasten). + "width: 100%" allein reichte nicht: Ein Element mit flex: none haelt + seine Wunschbreite auch dann. */ + .stufen { width: 100%; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; } + .stufe-knopf { flex: 1 1 auto; min-width: 0; } .paket__form { grid-template-columns: 1fr; } + .punkt { padding-left: 16px; padding-right: 13px; } } @media (prefers-reduced-motion: reduce) { diff --git a/workspace/assets/css/uebersicht.css b/workspace/assets/css/uebersicht.css index 769c1a52..8999dc0d 100644 --- a/workspace/assets/css/uebersicht.css +++ b/workspace/assets/css/uebersicht.css @@ -11,7 +11,7 @@ .summen { display: grid; gap: 8px; margin-bottom: 26px; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr)); } .summe { display: grid; gap: 2px; padding: 14px 16px; @@ -36,7 +36,7 @@ .creator-liste { display: grid; gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(430px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); } .creator { position: relative; overflow: hidden; diff --git a/workspace/assets/css/wissen.css b/workspace/assets/css/wissen.css index b45780d7..1e77a7b9 100644 --- a/workspace/assets/css/wissen.css +++ b/workspace/assets/css/wissen.css @@ -67,11 +67,12 @@ .kacheln { display: grid; gap: 11px; - grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); } .kachel { --w: var(--akzent); + --ton: var(--akzent); position: relative; display: flex; align-items: flex-start; gap: 14px; padding: 15px 16px; text-decoration: none; color: var(--text); @@ -139,12 +140,20 @@ } /* Eine Farbe je Welt -- die Reihenfolge folgt der Gliederung. */ -.block[data-welt="tiktok"] .kachel { --w: #3fbdf5; } -.block[data-welt="aufbau"] .kachel { --w: #8a76ff; } -.block[data-welt="bildton"] .kachel { --w: #5fc99a; } -.block[data-welt="technik"] .kachel { --w: #e8b877; } -.block[data-welt="inhalt"] .kachel { --w: #e88fb4; } -.block[data-welt="hilfe"] .kachel { --w: #8e9cb0; } +/* Beide Namen gleichzeitig: --w heisst die Farbe hier seit jeher und + steht in zwei Dutzend Regeln dieser Datei; --ton ist der Name, den + der ganze uebrige Workspace benutzt (Kacheln der Startseite, + Seitenkoepfe, Buehnenzuordnung). + + Ohne --ton leuchtete das Licht unter dem Zeiger in der Farbe der + SEITE statt in der Farbe der Kachel -- auf der Wissensseite waren + also alle sechs Welten violett. Genau das war zu sehen. */ +.block[data-welt="tiktok"] .kachel { --w: #3fbdf5; --ton: #3fbdf5; } +.block[data-welt="aufbau"] .kachel { --w: #8a76ff; --ton: #8a76ff; } +.block[data-welt="bildton"] .kachel { --w: #5fc99a; --ton: #5fc99a; } +.block[data-welt="technik"] .kachel { --w: #e8b877; --ton: #e8b877; } +.block[data-welt="inhalt"] .kachel { --w: #e88fb4; --ton: #e88fb4; } +.block[data-welt="hilfe"] .kachel { --w: #8e9cb0; --ton: #8e9cb0; } /* ---------- Karte einer Anleitung -------------------------------------------- */ diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index d152a98a..c16fe9f4 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -358,7 +358,12 @@ const tage = Math.round((ausText(a.tag) - ausText(daten.heute)) / 86400000); const k = el('div', 'k-anlasskarte' + (a.art === 'feiertag' ? ' feiertag' : '') + (tage < 0 ? ' vorbei' : '')); - k.style.setProperty('--afarbe', a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE); + const af = a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE; + k.style.setProperty('--afarbe', af); + /* --ton mitsetzen: Davon nimmt das Licht unter dem Zeiger seine + Farbe (siehe kopf.js). Sonst leuchtete jede Karte im Farbton der + SEITE statt in ihrem eigenen. */ + k.style.setProperty('--ton', af); k.append(el('span', 'k-anlasskarte__sym', a.sym)); const txt = el('div', 'k-anlasskarte__txt'); @@ -562,6 +567,7 @@ k.dataset.art = e.art; k.dataset.erledigt = e.erledigt ? 'ja' : 'nein'; k.style.setProperty('--zfarbe', e.farbe); + k.style.setProperty('--ton', e.farbe); k.append(el('span', 'k-zeile__zeit', e.uhr ? e.uhr + ' Uhr' : '—')); k.append(el('span', 'k-zeile__titel', e.titel)); diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js index c708f4ca..67850b16 100644 --- a/workspace/assets/js/kopf.js +++ b/workspace/assets/js/kopf.js @@ -182,15 +182,42 @@ let warten = false; let letzte = null; + /* Die Karte, auf der das Licht gerade AN ist. Genau eine -- mehr + kann es nicht geben, es gibt ja nur einen Zeiger. */ + let aktuelle = null; + + const aus = (karte) => { + if (!karte) return; + karte.removeAttribute('data-licht'); + karte.style.removeProperty('--mx'); + karte.style.removeProperty('--my'); + if (aktuelle === karte) aktuelle = null; + }; wurzel.addEventListener('pointermove', (e) => { const karte = e.target.closest?.(LICHT_KARTEN); - if (!karte) return; + if (!karte) { + /* Der Zeiger ist zwischen den Karten -- das Licht der letzten + geht aus. Ohne das blieb es haengen, sobald man eine Karte + ueber eine Luecke verliess statt ueber eine Nachbarkarte. */ + aus(aktuelle); + letzte = null; + return; + } + /* Direkt von einer Karte auf die naechste: Die alte ausschalten, + bevor die neue angeht. */ + if (aktuelle && aktuelle !== karte) aus(aktuelle); letzte = { karte, x: e.clientX, y: e.clientY }; if (warten) return; warten = true; requestAnimationFrame(() => { warten = false; + /* WETTLAUF. Zwischen dem Anmelden dieses Bildaufbaus und seinem + Ablauf kann der Zeiger die Karte laengst verlassen haben -- + dann hat pointerout schon aufgeraeumt, und diese Zeilen + schalteten das Licht gleich wieder AN. Genau so blieb es + haengen. Deshalb wird hier noch einmal geprueft, ob ueberhaupt + noch etwas zu beleuchten ist. */ if (!letzte) return; const { karte: k, x, y } = letzte; vorbereiten(k); @@ -198,6 +225,7 @@ k.style.setProperty('--mx', ((x - r.left) / r.width * 100).toFixed(1) + '%'); k.style.setProperty('--my', ((y - r.top) / r.height * 100).toFixed(1) + '%'); k.dataset.licht = 'an'; + aktuelle = k; }); }, { passive: true }); @@ -213,10 +241,36 @@ verlaesst, wird geloescht -- sonst flackerte das Licht bei jeder Bewegung ueber ein Wort hinweg. */ if (karte.contains(e.relatedTarget)) return; - karte.removeAttribute('data-licht'); - karte.style.removeProperty('--mx'); - karte.style.removeProperty('--my'); + letzte = null; /* den angemeldeten Bildaufbau entwerten */ + aus(karte); }, { capture: true, passive: true }); + + /* Drei weitere Wege, auf denen der Zeiger verschwindet, ohne dass + pointerout die Karte meldet -- jeder davon hat das Licht haengen + lassen: + + Fenster verlassen der Zeiger geht ueber den Rand hinaus + Tab gewechselt es kommt gar kein Ereignis mehr + gescrollt die Karte wandert unter dem stehenden + Zeiger weg; mausbewegt wurde nichts + + Beim Scrollen wird nicht blind geloescht: Es wird nachgesehen, ob + die zuletzt beleuchtete Karte noch unter dem Zeiger liegt. */ + let zeigerX = 0, zeigerY = 0; + wurzel.addEventListener('pointermove', (e) => { zeigerX = e.clientX; zeigerY = e.clientY; }, + { passive: true }); + + const pruefen = () => { + if (!aktuelle) return; + const unten = document.elementFromPoint(zeigerX, zeigerY); + if (!unten || !aktuelle.contains(unten)) { letzte = null; aus(aktuelle); } + }; + window.addEventListener('scroll', pruefen, { passive: true, capture: true }); + window.addEventListener('blur', () => { letzte = null; aus(aktuelle); }); + document.addEventListener('visibilitychange', () => { + if (document.hidden) { letzte = null; aus(aktuelle); } + }); + document.addEventListener('pointerleave', () => { letzte = null; aus(aktuelle); }); }; /* ---------- Jede Seite trägt Zeichen und Farbe ihrer Kachel ------------- diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index fb720925..35f29fb4 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 2d94ccb8..25fe0b24 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 218b2b4e..ed6dcaab 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -107,9 +107,9 @@ - - - - + + + + diff --git a/workspace/calls.html b/workspace/calls.html index db172504..fc27fb86 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 78c0d22c..48b395a3 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -158,9 +158,9 @@ - - - - + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index a32f1685..16179b28 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index b8dd2eb8..63b4b1d7 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index b4ed370d..5fb4b798 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index fdfbcbc8..323e39ac 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index d8cfc879..f137ef4f 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -170,9 +170,9 @@ - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index e8ed61fd..105709e4 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index f5cd51b8..bfe8e941 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index 1fd1181f..a6cde442 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 16530869..70b3eda8 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 56b050dc..c2114c84 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 26f13449..33265e66 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +