diff --git a/public/neu/diag-hoehe.js b/public/neu/diag-hoehe.js index 84ac072..76def16 100644 --- a/public/neu/diag-hoehe.js +++ b/public/neu/diag-hoehe.js @@ -1,12 +1,12 @@ /* VORÜBERGEHENDE MESSUNG (2026-10-29), wird nach der Klärung wieder entfernt: Am iPhone (App vom Home-Bildschirm) steht die Leiste zu hoch. - Schickt Bildschirmwerte (Fenster, visualViewport, Bildschirm, Safe-Area, vh-Einheiten, Lage der Leiste, Zustand von hoehe.js) an + Schickt Bildschirmwerte (Fenster, visualViewport, Bildschirm, Safe-Area, vh-Einheiten, Lage der Leiste) an /api/diag/hoehe; der Server schreibt sie ins Log (server/routes/diagHoehe.js). Keine Inhalte, keine Eingaben. Höchstens MAX Meldungen je Seite. */ const MAX = 80 const ZIEL = '/api/diag/hoehe' const rund = (x) => (Number.isFinite(x) ? Math.round(x * 10) / 10 : null) -export function starteDiagHoehe({ win = window, doc = document, hoehe = null }) { +export function starteDiagHoehe({ win = window, doc = document }) { const t0 = Date.now() let n = 0 const probe = (stil) => { @@ -38,7 +38,6 @@ export function starteDiagHoehe({ win = window, doc = document, hoehe = null }) unten: rund(unten.getBoundingClientRect().bottom), leiste: leiste ? [rund(leiste.top), rund(leiste.bottom), rund(leiste.height)] : null, huelle: huelle ? [rund(huelle.top), rund(huelle.height)] : null, - hoehe: hoehe?.stand?.() ?? null, app: Boolean(win.matchMedia?.('(display-mode: standalone)').matches), ns: win.navigator?.standalone ?? null, ori: win.screen?.orientation?.type ?? null, fokus: aktiv && aktiv !== doc.body ? aktiv.tagName : null, diff --git a/public/neu/hoehe.js b/public/neu/hoehe.js deleted file mode 100644 index c5da358..0000000 --- a/public/neu/hoehe.js +++ /dev/null @@ -1,92 +0,0 @@ -/* Kundenansicht neu: iOS-Fehler der App vom Home-Bildschirm. Nach dem Schließen der Tastatur bleibt die Fensterhöhe oft kleiner - (WebKit, bekannt seit iOS 17, in iOS 26 wieder da): alles, was unten fest steht (Leiste, Chat-Eingabe), sitzt dann rund 60 px zu hoch, - darunter bleibt ein leerer weißer Streifen, bis die App neu startet. Abhilfe: die Hülle (mindestens volle Fensterhöhe) kurz aus- und - wieder einblenden, das zwingt WebKit, die Höhe neu zu messen. Scrollstände und Fokus bleiben. Nur in der App vom Home-Bildschirm auf - iOS (das entscheidet `aktiv`) und nur, wenn die Höhe wirklich hängt. */ - -export const LUFT = 4 // so viel darf die Höhe schwanken, ohne dass sie als hängend gilt -export const PRUEF_MS = [150, 600] // nach dem Schließen der Tastatur: früh und noch einmal, wenn die Animation sicher durch ist -export const MAX_VERSUCHE = 2 // je Anlass (Tastatur zu, App wieder vorne); hilft es nicht, bleibt es bis zum nächsten Anlass dabei - -/** Reiner Zustand { voll, breite }: größte innere Höhe bei dieser Breite. Ein Breitenwechsel (Drehen, iPad-Fenster) fängt neu an. */ -export function hoeheZustand(alt, { hoehe, breite }) { - if (!alt || Math.abs(breite - alt.breite) > 1) return { voll: hoehe, breite } - return { voll: Math.max(alt.voll, hoehe), breite: alt.breite } -} - -/** Hängt die Höhe? Kleiner als die größte bei gleicher Breite und kein Textfeld im Fokus (eine offene Tastatur ist kein Hängen). */ -export function haengt(zustand, { hoehe, breite, textfeldAktiv }) { - if (!zustand || textfeldAktiv) return false - if (Math.abs(breite - zustand.breite) > 1) return false - return zustand.voll - hoehe > LUFT -} - -/** - * Verdrahtung: merkt sich die volle Höhe (resize), prüft nach dem Verlassen eines Feldes (focusout), nach einem resize ohne Textfeld - * und wenn die App wieder nach vorne kommt (visibilitychange), jeweils zu den Zeiten PRUEF_MS. onHeile({ vorher, nachher, voll, versuche }) nach - * jedem Eingriff. Rückgabe { pruefe, stand, beende }. - */ -export function beobachteHoehe({ win = window, doc = document, element, aktiv = () => true, istTextfeld = () => false, onHeile = () => {} }) { - const messe = () => ({ hoehe: win.innerHeight, breite: win.innerWidth }) - let z = hoeheZustand(null, messe()) - let versuche = 0 - let timer = [] - const merke = () => { z = hoeheZustand(z, messe()) } - - /** Hülle aus- und einblenden, mit synchronem Umbruch dazwischen; vorher gescrollte Kästen, Fensterscroll und Fokus zurücksetzen. */ - function heile() { - const fensterX = win.scrollX ?? 0 - const fensterY = win.scrollY ?? 0 - const staende = [] - for (const el of element.querySelectorAll('*')) { - if (el.scrollTop || el.scrollLeft) staende.push([el, el.scrollTop, el.scrollLeft]) - } - const fokus = doc.activeElement && doc.activeElement !== doc.body && element.contains(doc.activeElement) ? doc.activeElement : null - const vorher = element.style.display - element.style.display = 'none' - void element.offsetHeight - element.style.display = vorher - void element.offsetHeight - for (const [el, top, left] of staende) { el.scrollTop = top; el.scrollLeft = left } - if (fensterX || fensterY) win.scrollTo(fensterX, fensterY) - if (fokus && doc.activeElement !== fokus) fokus.focus?.({ preventScroll: true }) - } - - function pruefe() { - if (!aktiv() || !element) return false - const m = messe() - if (!haengt(z, { ...m, textfeldAktiv: istTextfeld(doc.activeElement) })) { merke(); return false } - if (versuche >= MAX_VERSUCHE) return false - versuche += 1 - const vorher = win.innerHeight - heile() - merke() - try { onHeile({ vorher, nachher: win.innerHeight, voll: z.voll, versuche }) } catch { /* nur Beobachter */ } - return true - } - - const warte = (fn, ms) => (win.setTimeout ? win.setTimeout(fn, ms) : setTimeout(fn, ms)) - const stoppe = (id) => (win.clearTimeout ? win.clearTimeout(id) : clearTimeout(id)) - const plane = () => { - for (const id of timer) stoppe(id) - timer = PRUEF_MS.map((ms) => warte(pruefe, ms)) - } - const neuerAnlass = () => { versuche = 0; plane() } - - const beiResize = () => { merke(); if (!istTextfeld(doc.activeElement)) plane() } - const beiUnfokus = () => neuerAnlass() - const beiSicht = () => { if (doc.visibilityState === 'visible') neuerAnlass() } - win.addEventListener('resize', beiResize) - doc.addEventListener('focusout', beiUnfokus) - doc.addEventListener('visibilitychange', beiSicht) - return { - pruefe, - stand: () => ({ ...z, versuche }), - beende() { - for (const id of timer) stoppe(id) - win.removeEventListener('resize', beiResize) - doc.removeEventListener('focusout', beiUnfokus) - doc.removeEventListener('visibilitychange', beiSicht) - }, - } -} diff --git a/public/neu/huelle.css b/public/neu/huelle.css index c97c626..a985779 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -185,7 +185,10 @@ #chat-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } @media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { - .kn-spalte:has(#bereich-chat:not([hidden])) { display: block; height: auto; } + /* Mindestens volle Bildschirmhöhe wie die anderen Bereiche (.kn-spalte, 100vh): ist die Seite kürzer, hält iOS 26 in der App vom Home-Bildschirm + unten rund 60 pt für eine Safari-Leiste frei, die es dort nicht gibt. Das Fenster wird kleiner (gemessen am iPhone 2026-10-29: 1002 → 932 px), + Leiste und Eingabe stehen zu hoch, darunter ein weißer Streifen. lvh = Höhe ohne Browserleisten; die Seite scrollt dadurch nicht. */ + .kn-spalte:has(#bereich-chat:not([hidden])) { display: block; height: auto; min-height: 100vh; min-height: 100lvh; } .kn-haupt:has(#bereich-chat:not([hidden])) { position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); padding: 0 max(16px, env(safe-area-inset-right)) 6px max(16px, env(safe-area-inset-left)); background: var(--kn-bg); } #bereich-chat > .kn-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* bleibt für Screenreader die Überschrift der Seite */ .kn:has(#bereich-chat:not([hidden])) .kn-kopf { height: calc(var(--kn-kopf-h) + env(safe-area-inset-top)); border-bottom-color: var(--kn-line); } /* feste Höhe wie der Rand oben des Inhalts: im Querformat wäre der Kopf mit Haarlinie 1 px höher */ diff --git a/public/neu/huelle.js b/public/neu/huelle.js index b1eaaf7..88022b4 100644 --- a/public/neu/huelle.js +++ b/public/neu/huelle.js @@ -7,8 +7,7 @@ import { h, leeren, text } from './dom.js' import { api as apiStandard } from './api.js' import * as routerStandard from './router.js' import { BEREICHE, FUNKTIONS_ETAGEN, hashFuerAnsicht, hashFuerRoute, elternVon, titelFuer, zeigtWiderrufsstreifen } from './routen.js' -import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, istTextfeld, leisteSichtbar } from './leiste.js' -import { beobachteHoehe } from './hoehe.js' +import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, leisteSichtbar } from './leiste.js' import { starteDiagHoehe } from './diag-hoehe.js' import { baueEtage } from './etage.js' import { erzeugeZaehler } from './zaehler.js' @@ -279,11 +278,8 @@ export async function starteHuelle({ win = window, doc = document, api = apiStan if (handyMq.addEventListener) handyMq.addEventListener('change', beiMedien) else handyMq.addListener?.(beiMedien) beobachteTastatur({ win, doc, onChange: (z) => doc.body.classList.toggle('kn-tastatur', !leisteSichtbar(z)) }) - // iOS-App vom Home-Bildschirm: hängt nach der Tastatur die Fensterhöhe, steht die Leiste zu hoch (hoehe.js); dann einmal neu vermessen - let diag = null - const hoehe = beobachteHoehe({ win, doc, element: doc.querySelector('.kn-huelle'), aktiv: () => istIos() && standalone() && istHandy(), istTextfeld, onHeile: (werte) => diag?.sende('heile', werte) }) // VORÜBERGEHENDE MESSUNG (2026-10-29, diag-hoehe.js): Bildschirmwerte am iPhone ins Server-Log, wird nach der Klärung entfernt - if (istIos() && istHandy() && !viewAs) diag = starteDiagHoehe({ win, doc, hoehe }) + if (istIos() && istHandy() && !viewAs) starteDiagHoehe({ win, doc }) // 7. Fristen und Zähler laden parallel und brechen den Start nie ab ladeFristen(api).then((daten) => { fristen = daten; streifen.setze(daten); aktualisiereKonto() }) diff --git a/test/knChatHoehe.test.js b/test/knChatHoehe.test.js new file mode 100644 index 0000000..40637be --- /dev/null +++ b/test/knChatHoehe.test.js @@ -0,0 +1,20 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { lies, handyTeil } from './support/knHuelle.js' + +// iPhone-App vom Home-Bildschirm (Messung 2026-10-29): ist die Seite im Chat kürzer als der Bildschirm, macht iOS 26 das Fenster rund 60 pt kleiner +// und die Leiste steht zu hoch. Am Handy muss die Spalte im Chat daher mindestens volle Bildschirmhöhe haben, wie in den anderen Bereichen. +test('huelle.css: am Handy hat die Spalte im Chat mindestens volle Höhe (100vh, dann 100lvh), am Desktop bleibt min-height 0', () => { + const css = lies('huelle.css') + const handy = handyTeil(css) + assert.match(handy, /\.kn-spalte:has\(#bereich-chat:not\(\[hidden\]\)\) \{ display: block; height: auto; min-height: 100vh; min-height: 100lvh; \}/) + const desktop = css.slice(0, css.indexOf('.kn-spalte:has(#bereich-chat:not([hidden])) { display: block')) + assert.match(desktop, /\.kn-spalte:has\(#bereich-chat:not\(\[hidden\]\)\) \{ display: flex;[^}]*min-height: 0; \}/) + assert.match(css, /\.kn-spalte \{ position: relative; min-height: 100vh;/) +}) + +test('huelle.js: der alte Höhen-Wächter (Hülle aus- und einblenden) ist weg, die Messung läuft nur am iPhone und nicht in der Mitarbeiter-Ansicht', () => { + const js = lies('huelle.js') + assert.doesNotMatch(js, /beobachteHoehe|'\.\/hoehe\.js'/) + assert.match(js, /if \(istIos\(\) && istHandy\(\) && !viewAs\) starteDiagHoehe\(\{ win, doc \}\)/) +}) diff --git a/test/knHoehe.test.js b/test/knHoehe.test.js deleted file mode 100644 index 1996126..0000000 --- a/test/knHoehe.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { LUFT, MAX_VERSUCHE, PRUEF_MS, beobachteHoehe, haengt, hoeheZustand } from '../public/neu/hoehe.js' -import { lies } from './support/knHuelle.js' - -test('hoeheZustand: größte Höhe je Breite, Drehen fängt neu an', () => { - let z = hoeheZustand(null, { hoehe: 932, breite: 430 }) - z = hoeheZustand(z, { hoehe: 873, breite: 430 }); assert.deepEqual(z, { voll: 932, breite: 430 }) - z = hoeheZustand(z, { hoehe: 430, breite: 932 }); assert.deepEqual(z, { voll: 430, breite: 932 }) -}) - -test('haengt: nur bei gleicher Breite, ohne Textfeld und mehr als LUFT kleiner (iOS-Fall 932 → 873)', () => { - const z = { voll: 932, breite: 430 } - assert.equal(haengt(z, { hoehe: 873, breite: 430, textfeldAktiv: false }), true) - assert.equal(haengt(z, { hoehe: 873, breite: 430, textfeldAktiv: true }), false) // Tastatur offen - assert.equal(haengt(z, { hoehe: 932 - LUFT, breite: 430, textfeldAktiv: false }), false) - assert.equal(haengt(z, { hoehe: 400, breite: 932, textfeldAktiv: false }), false) // gedreht - assert.equal(haengt(null, { hoehe: 1, breite: 1 }), false) -}) - -/** Kleine Attrappe: Fenster, Dokument und eine Hülle, die mitschreibt, wann sie aus- und eingeblendet wird. */ -function aufbau({ aktiv = () => true } = {}) { - const sender = (felder = {}) => { - const hoerer = {} - return Object.assign({ - addEventListener(typ, fn) { (hoerer[typ] ??= []).push(fn) }, - removeEventListener(typ, fn) { hoerer[typ] = (hoerer[typ] ?? []).filter((x) => x !== fn) }, - senden(typ) { for (const fn of hoerer[typ] ?? []) fn({ type: typ }) }, - anzahl: (typ) => (hoerer[typ] ?? []).length, - }, felder) - } - const spaeter = [] - const protokoll = [] - const win = sender({ - innerHeight: 932, innerWidth: 430, scrollX: 0, scrollY: 0, - setTimeout: (fn, ms) => { spaeter.push({ fn, ms }); return spaeter.length }, - clearTimeout: (id) => { if (spaeter[id - 1]) spaeter[id - 1].fn = () => {} }, - scrollTo: (x, y) => { win.scrollX = x; win.scrollY = y; protokoll.push(['scrollTo', x, y]) }, - }) - const verlauf = { scrollTop: 1200, scrollLeft: 0 } - const haken = {} - const style = {} - let display = '' - Object.defineProperty(style, 'display', { get: () => display, set: (v) => { display = v; protokoll.push(['display', v]); if (v === 'none') { verlauf.scrollTop = 0; win.scrollY = 0; haken.beimAusblenden?.() } } }) - const knopf = { focus: () => protokoll.push(['focus']) } - const element = { - style, - get offsetHeight() { protokoll.push(['umbruch']); return 100 }, - querySelectorAll: () => [verlauf, { scrollTop: 0, scrollLeft: 0 }], - contains: (el) => el === knopf || el === verlauf, - } - const doc = sender({ activeElement: null, body: {}, visibilityState: 'visible' }) - const istFeld = (el) => el?.feld === true - const h = beobachteHoehe({ win, doc, element, aktiv, istTextfeld: istFeld }) - const laufe = () => { while (spaeter.length) spaeter.shift().fn() } - return { win, doc, element, verlauf, knopf, protokoll, spaeter, h, laufe, haken } -} - -test('beobachteHoehe: Tastatur zu und Höhe hängt → Hülle einmal aus und ein, Scrollstände zurück; danach Ruhe', () => { - const t = aufbau() - t.win.scrollY = 40 - t.doc.activeElement = { feld: true } - t.win.innerHeight = 873; t.win.senden('resize') // iOS: die Höhe fällt mit der Tastatur und bleibt unten - assert.equal(t.spaeter.length, 0) // solange das Feld den Fokus hat, wird nicht geprüft - t.doc.activeElement = t.doc.body; t.doc.senden('focusout') - assert.deepEqual(t.spaeter.map((s) => s.ms), PRUEF_MS) - t.spaeter.shift().fn() - assert.deepEqual(t.protokoll, [['display', 'none'], ['umbruch'], ['display', ''], ['umbruch'], ['scrollTo', 0, 40]]) - assert.equal(t.verlauf.scrollTop, 1200) - t.win.innerHeight = 932 // WebKit hat neu gemessen - t.protokoll.length = 0; t.laufe() - assert.deepEqual(t.protokoll, []) - assert.deepEqual(t.h.stand(), { voll: 932, breite: 430, versuche: 1 }) -}) - -test('beobachteHoehe: onHeile meldet vorher, nachher, voll und Versuch; ein Fehler im Beobachter stört nicht', () => { - const t = aufbau() - const meldungen = [] - t.h.beende() - const h = beobachteHoehe({ win: t.win, doc: t.doc, element: t.element, istTextfeld: () => false, onHeile: (w) => { meldungen.push(w); throw new Error('kaputt') } }) - t.win.innerHeight = 873 - assert.equal(h.pruefe(), true) - assert.deepEqual(meldungen, [{ vorher: 873, nachher: 873, voll: 932, versuche: 1 }]) -}) - -test('beobachteHoehe: hilft es nicht, höchstens MAX_VERSUCHE je Anlass; App wieder vorne ist ein neuer Anlass', () => { - const t = aufbau() - t.win.innerHeight = 873 - t.doc.senden('focusout'); t.laufe() - t.win.senden('resize'); t.laufe() - assert.equal(t.protokoll.filter((p) => p[0] === 'display' && p[1] === 'none').length, MAX_VERSUCHE) - t.doc.visibilityState = 'hidden'; t.doc.senden('visibilitychange'); assert.equal(t.spaeter.length, 0) - t.doc.visibilityState = 'visible'; t.doc.senden('visibilitychange'); t.laufe() - assert.equal(t.protokoll.filter((p) => p[0] === 'display' && p[1] === 'none').length, 2 * MAX_VERSUCHE) -}) - -test('beobachteHoehe: Fokus auf einem Knopf in der Hülle bleibt, ohne Scrollen', () => { - const t = aufbau() - t.win.innerHeight = 873 - t.doc.activeElement = t.knopf - t.haken.beimAusblenden = () => { t.doc.activeElement = t.doc.body } // beim Ausblenden verliert der Knopf den Fokus (wie im Browser) - assert.equal(t.h.pruefe(), true) - assert.deepEqual(t.protokoll.at(-1), ['focus']) -}) - -test('beobachteHoehe: nicht aktiv (kein iOS-App-Modus), keine Hülle oder Höhe passt → nichts passiert; beende räumt auf', () => { - const aus = aufbau({ aktiv: () => false }) - aus.win.innerHeight = 873; assert.equal(aus.h.pruefe(), false); assert.deepEqual(aus.protokoll, []) - const ok = aufbau() - assert.equal(ok.h.pruefe(), false) - ok.win.innerHeight = 932 - LUFT; assert.equal(ok.h.pruefe(), false); assert.deepEqual(ok.protokoll, []) - ok.doc.senden('focusout'); ok.h.beende(); ok.win.innerHeight = 873; ok.laufe() - assert.deepEqual(ok.protokoll, []) - assert.deepEqual([ok.win.anzahl('resize'), ok.doc.anzahl('focusout'), ok.doc.anzahl('visibilitychange')], [0, 0, 0]) -}) - -test('huelle.js: Höhen-Wächter nur in der iOS-App vom Home-Bildschirm am Handy, auf der Hülle', () => { - const js = lies('huelle.js') - assert.match(js, /import \{ beobachteHoehe \} from '\.\/hoehe\.js'/) - assert.match(js, /beobachteHoehe\(\{ win, doc, element: doc\.querySelector\('\.kn-huelle'\), aktiv: \(\) => istIos\(\) && standalone\(\) && istHandy\(\), istTextfeld, onHeile:/) - assert.match(js, /if \(istIos\(\) && istHandy\(\) && !viewAs\) diag = starteDiagHoehe\(/) -})