From 17bce304ebcdde200598f5425a517736f1909709 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:21:13 +0000 Subject: [PATCH] Kundenansicht neu: Leiste im Chat am iPhone wieder ganz unten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Messung am iPhone (iOS 26.2, App vom Home-Bildschirm) zeigt die Ursache: Auf der Startseite ist das Fenster 1002 px hoch. Im Chat schrumpft die Spalte auf den Kopf (alles andere steht fest), die Seite wird kürzer als der Bildschirm, und iOS hält unten rund 60 pt für eine Safari-Leiste frei, die es in der App nicht gibt: 932 px, Leiste und Eingabe stehen zu hoch, darunter ein weißer Streifen. Mit der Tastatur hat das nichts zu tun. Am Handy hat die Spalte im Chat jetzt wie in den anderen Bereichen mindestens volle Bildschirmhöhe (100vh, dann 100lvh). Ohne Browserleisten scrollt die Seite dadurch nicht; in Chromium sind alle Chat-Bilder am Handy und Desktop pixelgleich zu main. Der Höhen-Wächter aus 0fa8fdb (Hülle aus- und einblenden) hat nicht geholfen und ist wieder raus. Die Messung bleibt für die Kontrolle am iPhone und wird danach entfernt. Co-Authored-By: Claude Opus 5.5 --- public/neu/diag-hoehe.js | 5 +- public/neu/hoehe.js | 92 ----------------------------- public/neu/huelle.css | 5 +- public/neu/huelle.js | 8 +-- test/knChatHoehe.test.js | 20 +++++++ test/knHoehe.test.js | 122 --------------------------------------- 6 files changed, 28 insertions(+), 224 deletions(-) delete mode 100644 public/neu/hoehe.js create mode 100644 test/knChatHoehe.test.js delete mode 100644 test/knHoehe.test.js 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\(/) -})