diff --git a/docs/plans/pwa-ios-zoom-preso-bordas-cortadas-impl.md b/docs/plans/pwa-ios-zoom-preso-bordas-cortadas-impl.md new file mode 100644 index 00000000..be634128 --- /dev/null +++ b/docs/plans/pwa-ios-zoom-preso-bordas-cortadas-impl.md @@ -0,0 +1,86 @@ +# Impl: PWA iOS: tela ampliada e com as bordas cortadas desde o launch (zoom preso) + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #500 +Intenção: docs/plans/pwa-ios-zoom-preso-bordas-cortadas.md +Appetite restante: ~0,5–1,5 dia eng (herdado; inclui verificação em aparelho) + +## Leitura da intenção + +- **Outcome:** ao abrir/retomar o PWA `/campanha` no iPhone, escala 100% e nenhuma borda cortada; após teclado, a tela volta sozinha; pinça continua funcionando; zero regressão em aba/desktop. +- **O que NÃO negociar:** pinça do usuário (anti-goal de acessibilidade); sem hack de tela cheia que esconda conteúdo; sem redesenho; bloqueio de escala só como fallback documentado (decisão do gate: A ou B, nunca C). +- **O que reavaliar:** a hipótese da intenção apontava "config de viewport / shell h-svh / statusBarStyle" como área provável. **Verificação no código (Next 15.4.11):** o meta viewport de hoje já é `width=device-width, initial-scale=1` **sem** `maximum-scale` nem `viewport-fit=cover` (default do App Router em `node_modules/next/dist/lib/metadata/default-metadata.js`; o layout exporta só `themeColor`), e o shell `h-svh`/`overflow-hidden` é coerente com o chrome que já trata safe-area. Não há política de escala que remover nem configuração que mude o sintoma: **não existe um "toggle" que conserte isso** — a causa é o visual viewport do standalone ficar com scale > 1 preso (família de bugs WebKit 237961 / regressões iOS 26), então a correção é **client-side: curar o estado preso**. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[Mount em (campaign) root layout] --> B[Engine lib/campaignIosViewportHeal] + B --> C[Gate standalone-iOS] + C --> D{scale > 1.02?} + D -->|sim + sem input focado + sem pinça recente| E[Heal: scrollTo 0,0] + E --> F[Re-measure nudge: html height] + F --> G[Retry <=3x 250ms] + G --> H[Debug global __campaignIosViewportHeal] + D -->|não| I[No-op] + C -->|não| I +``` + +**Opções consideradas:** A (heal client-side do visual viewport, standalone-iOS only) | B (`maximum-scale=1`/`user-scalable=no` seletivo no standalone) | C (`interactive-widget=resizes-content`) | D (depender do B183) | E (mexer em `viewport-fit`/statusBarStyle) | F (reload forçado) + +**Recomendação:** A — ataca o sintoma (scale preso) em todas as origens (launch, resume/bfcache, pós-teclado) com o menor conjunto de medidas, é standalone-only (zero risco em aba/desktop), mantém a pinça (guards) e é verificável no aparelho via debug global. É a técnica validada pela família de reports (cederhook 2026-07 — "heal via forced re-measure"; west-wind — scrollTo + 16px como trigger fix). + +**Rejeitadas:** + +- B — mata a pinça (anti-goal de produto); vira **fallback documentado, off por default** via constante única: se a verificação no aparelho mostrar que o heal não cura, um deploy com a flag ligada ativa o `maximum-scale` seletivo só no standalone (decisão de produto B do gate, tradeoff WCAG conhecido). +- C — muda a semântica de layout do teclado do app inteiro (reflow do shell `h-svh` quando o teclado abre); risco alto de regressão de layout, não mira o zoom-de-launch. +- D — o B183 elimina o _trigger_ de input (fonte < 16px), mas o sintoma do launch/resume é independente de input; o aceite do B182 inclui o launch. O B183 reduz a frequência; não substitui. +- E — o meta de hoje não tem `maximum-scale` nem `cover`; não há política a ajustar; mexer no chrome (cover + status bar) altera métricas verticais de todas as telas sem tocar a causa do zoom. +- F — reload brutal, perde estado de navegação; só para crash-loop de diagnóstico. + +### Componentes / mudanças + +- **`campaignIosViewportHeal`** (`src/lib/campaignIosViewportHeal.ts`): motor profundo, testável (jsdom). Detecta standalone-iOS (`navigator.standalone === true` OU `matchMedia('(display-mode: standalone)')` + UA iOS), escuta `load`/`pageshow` (incl. `persisted`/bfcache)/`visibilitychange→visible`/`focusout`/`visualViewport.resize`, decide heal (`visualViewport.scale > 1.02` && !editable focado && !pinça recente — `gesturestart/change/end` + touch ≥2, janela 800 ms renovada durante o gesto) e executa em camadas: `scrollTo(0,0)` → re-measure nudge (`html.style.height` = `innerHeight px`, restore — reflow genérico; **não** re-resolve `svh` sozinho, verificação em aparelho decide se precisa estender ao scroll container) → retry ≤3 × 250 ms com **orçamento por episódio** (launch/resume/bfcache/teclado/resize — um episódio não esfomeia o próximo). **Heal imediato no install** (load/pageshow podem ter ocorrido antes da hidratação do React). `history.scrollRestoration = 'manual'` no standalone (evita o iOS restaurar estado bfcache ampliado). Expõe `window.__campaignIosViewportHeal = { state, healNow() }` (verificação em aparelho sem UI). Constante `CAMPAIGN_IOS_HEAL_MAXIMUM_SCALE_FALLBACK = false` (fallback B, documentado; opção `maximumScaleFallback` no install). +- **`CampaignIosViewportHeal`** (`src/components/campaign/shell/CampaignIosViewportHeal.tsx`): componente client que monta o motor em `useEffect` e retorna `null` — precedente: `RegisterServiceWorker`. `lib/` é client-safe (precedente: `campaignLastActedMunicipality`), então o motor pode viver lá. +- **`src/app/(campaign)/layout.tsx`**: montar o componente como sibling de `RegisterServiceWorker` — cobre login + `(app)` inteiro. **Sem migration, sem Access, sem Consent.** +- **UI:** Impeccable A — nenhuma UI nova; o único "afetado" visual é o usuário não ver mais a tela ampliada. + +## Fases verificáveis + +1. **Motor + mount** — `src/lib/campaignIosViewportHeal.ts` + `CampaignIosViewportHeal.tsx` + layout. Tracer bullet: heal no launch. +2. **Testes de domínio** — `tests/unit/campaignIosViewportHeal.unit.spec.ts` (jsdom): gate standalone (não-standalone = no-op), heal no launch (scale > 1 → scrollTo + nudge), skip com editable focado, skip com pinça recente, heal pós-teclado, retry com teto, debug global, fallback off. +3. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` na entrega. Runbook de verificação em aparelho (checklist do debug global + B183) documentado no corpo do PR para o aceite de produto. + +## Rabbit holes / Não escopo (engenharia) + +- Polifill geral de teclado/keyboard-inset (fora de escopo da intenção). +- Caçar a família inteira de bugs WebKit standalone — curamos o sintoma do nosso app, com fallback documentado. +- Mudar `viewport-fit`/status bar/`interactive-widget` (opções rejeitadas E/C). +- Ajuste de fontes dos inputs (é o B183; pode ser executado em paralelo). + +## Riscos e mitigação + +- **Heal lutar contra pinça intencional do usuário** → guards: nunca heal com editable focado; janela de pinça renovada por `gesturestart/change/end` e touch ≥2 (cobre gestos longos); threshold 1.02 (zoom acidental de pinça costuma ser > 1.05). Após um zoom deliberado que termina, nenhum evento dispara novo heal — a pinça persiste. +- **`scrollTo(0,0)` pode não surtir no shell `overflow-hidden`** → segunda camada (re-measure nudge) + heal imediato no install; a verificação em aparelho decide — o debug global permite testar `healNow()` sem deploy. **Gatilho:** se no aparelho o heal não curar, estender `healDocument` para togglar o overflow do scroll container do shell antes de ligar o fallback B. +- **Regressão em aba/desktop** → gate standalone-iOS: no-op em qualquer outro contexto (o código nem roda `visualViewport` em não-standalone). +- **iOS 26 muda comportamento** → heal é conservador (só age com scale > 1.02) e idempotente; fallback B documentado como último recurso. + +## Verificação em aparelho (aceite de produto) + +A suíte unit prova a fiação (detecção, guards, retry), não a cura no WebKit. O aceite é visual, no iPhone com o PWA instalado (standalone): + +1. **Launch (cold open):** fechar o app, abrir → escala 100%, nenhuma borda cortada. Se abrir ampliado: `window.__campaignIosViewportHeal.healNow()` no console — se curar, o heal funcionou e o bug era o launch sem trigger (o immediate heal no install cobre). +2. **Resume (bfcache):** abrir outro app, voltar → sem zoom. +3. **Teclado:** tocar na omnibox de uma lista, digitar, fechar o teclado → tela volta sozinha. (O trigger de 16px é o B183 — neste item o heal cura o estado preso caso o auto-zoom ainda dispare.) +4. **Pinça:** dar zoom por pinça → persiste (não é resetado); `state.lastSkippedReason === 'recent-pinch'` enquanto durar o gesto. +5. **Aba/desktop:** `isIosSafariStandalone` false → módulo é no-op (`state.standaloneIos === false`); nenhuma mudança perceptível. + +Se 1–3 falharem no aparelho: flip da flag `maximumScaleFallback` (decisão B) ou extensão do `healDocument` ao scroll container (gatilho acima). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (launch, resume, pós-teclado; pinça preservada; aba/desktop intactos) +- [ ] Invariantes AGENTS/engineering-standards (sem server-only em lib, sem UI nova, sem schema) +- [ ] Testes de domínio: `tests/unit/campaignIosViewportHeal.unit.spec.ts` +- [ ] Gates verdes: `gate:fast`, `tsc`, `lint` 0 warnings, `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm build` diff --git a/src/app/(campaign)/layout.tsx b/src/app/(campaign)/layout.tsx index 9db82c9b..cfb07574 100644 --- a/src/app/(campaign)/layout.tsx +++ b/src/app/(campaign)/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next' import { Inter } from 'next/font/google' import React from 'react' +import { CampaignIosViewportHeal } from '@/components/campaign/shell/CampaignIosViewportHeal' import { RegisterServiceWorker } from '@/components/campaign/shell/RegisterServiceWorker' import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa' @@ -44,6 +45,7 @@ export default function CampanhaRootLayout({ children }: { children: React.React {children} + ) diff --git a/src/components/campaign/shell/CampaignIosViewportHeal.tsx b/src/components/campaign/shell/CampaignIosViewportHeal.tsx new file mode 100644 index 00000000..274a49c7 --- /dev/null +++ b/src/components/campaign/shell/CampaignIosViewportHeal.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useEffect } from 'react' + +import { installCampaignIosViewportHeal } from '@/lib/campaignIosViewportHeal' + +export const CampaignIosViewportHeal = () => { + useEffect(() => { + const teardown = installCampaignIosViewportHeal(window) + return () => { + teardown() + } + }, []) + + return null +} diff --git a/src/lib/campaignIosViewportHeal.ts b/src/lib/campaignIosViewportHeal.ts new file mode 100644 index 00000000..07b3dffd --- /dev/null +++ b/src/lib/campaignIosViewportHeal.ts @@ -0,0 +1,309 @@ +/** + * iOS standalone PWA viewport heal (B182). + * + * WebKit keeps the standalone visual viewport stuck at scale > 1 — at launch, + * on bfcache resume, or after the keyboard auto-zoom fails to restore + * (WebKit bug 237961 family / iOS 26 regressions). There is no viewport + * policy that prevents it (the meta is already `width=device-width, + * initial-scale=1`), so the fix is client-side: detect the stuck scale and + * nudge WebKit back to 100%. + * + * Fail-safe by design: everything gates on iOS standalone, no-ops when the + * scale is already 1, never heals while an editable is focused or a pinch is + * recent (user zoom is a product anti-goal), and never throws. + */ + +const CAMPAIGN_IOS_VIEWPORT_HEAL_MIN_SCALE = 1.02 +export const CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS = 3 +export const CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS = 250 +export const CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS = 800 +export const CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS = 350 + +/** + * Documented fallback (product decision B): if the on-device verification + * proves the heal cannot cure the stuck state on current iOS, flipping this + * to true enables a selective `maximum-scale=1` meta rewrite — standalone + * only, with the known WCAG tradeoff (pinch zoom dies in the PWA). + */ +const CAMPAIGN_IOS_VIEWPORT_HEAL_MAXIMUM_SCALE_FALLBACK = false + +export const CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL = '__campaignIosViewportHeal' + +type CampaignIosViewportHealState = { + mounted: boolean + standaloneIos: boolean + lastScale: number + healedCount: number + lastHealAt: number | null + lastHealReason: string | null + lastSkippedReason: string | null +} + +type CampaignIosViewportHealDebug = { + state: CampaignIosViewportHealState + healNow: (reason?: string) => boolean +} + +declare global { + interface Window { + [CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?: CampaignIosViewportHealDebug + } +} + +type HealReason = 'launch' | 'bfcache' | 'resume' | 'keyboard' | 'resize' | 'manual' + +const isEditableElement = (element: Element | null): boolean => { + if (!element) return false + const tag = element.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true + return (element as HTMLElement).isContentEditable === true +} + +export const isIosSafariStandalone = (win: Window): boolean => { + const nav = win.navigator + if ((nav as unknown as { standalone?: boolean }).standalone === true) return true + const isIos = + /iP(hone|ad|od)/.test(nav.userAgent) || (nav.platform === 'MacIntel' && nav.maxTouchPoints > 1) + if (!isIos) return false + try { + return win.matchMedia('(display-mode: standalone)').matches + } catch { + return false + } +} + +export const getVisualViewportScale = (win: Window): number => { + const visualViewport = win.visualViewport + if (!visualViewport || typeof visualViewport.scale !== 'number') return 1 + return visualViewport.scale +} + +export const shouldAttemptViewportHeal = (input: { + scale: number + editableFocused: boolean + recentPinch: boolean + minScale?: number +}): boolean => { + const minScale = input.minScale ?? CAMPAIGN_IOS_VIEWPORT_HEAL_MIN_SCALE + return input.scale > minScale && !input.editableFocused && !input.recentPinch +} + +type InstallContext = { + win: Window + state: CampaignIosViewportHealState + lastPinchAt: number + keyboardWasOpen: boolean + attemptsLeft: number + retryTimer: number | null + keyboardHealTimer: number | null + healNow: (reason: HealReason) => boolean +} + +const healDocument = (win: Window): void => { + try { + win.scrollTo(0, 0) + } catch { + // scrollTo can be a stub (jsdom) or absent in odd webviews — never throw. + } + try { + const docEl = win.document.documentElement + const previousHeight = docEl.style.height + docEl.style.height = `${win.innerHeight}px` + void docEl.offsetHeight + docEl.style.height = previousHeight + } catch { + // Re-measure nudge is best effort; scale may already be healed by scrollTo. + } +} + +const createInstallContext = (win: Window, standaloneIos: boolean): InstallContext => { + const state: CampaignIosViewportHealState = { + mounted: true, + standaloneIos, + lastScale: getVisualViewportScale(win), + healedCount: 0, + lastHealAt: null, + lastHealReason: null, + lastSkippedReason: null, + } + const context: InstallContext = { + win, + state, + lastPinchAt: 0, + keyboardWasOpen: false, + attemptsLeft: CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS, + retryTimer: null, + keyboardHealTimer: null, + healNow: () => false, + } + context.healNow = (reason: HealReason): boolean => { + if (!state.mounted) return false + state.lastScale = getVisualViewportScale(win) + const recentPinch = + Date.now() - context.lastPinchAt < CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS + const editableFocused = isEditableElement(win.document.activeElement) + if (!shouldAttemptViewportHeal({ scale: state.lastScale, editableFocused, recentPinch })) { + if (editableFocused) state.lastSkippedReason = 'editable-focused' + else if (recentPinch) state.lastSkippedReason = 'recent-pinch' + else state.lastSkippedReason = 'scale-ok' + return false + } + healDocument(win) + state.healedCount += 1 + state.lastHealAt = Date.now() + state.lastHealReason = reason + state.lastSkippedReason = null + return true + } + return context +} + +const scheduleRetry = (context: InstallContext, reason: HealReason): void => { + if (context.retryTimer !== null) return + context.retryTimer = context.win.setTimeout(() => { + context.retryTimer = null + if (context.attemptsLeft <= 0) return + context.attemptsLeft -= 1 + if (context.healNow(reason)) scheduleRetry(context, reason) + }, CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) +} + +/** + * Each episode (launch, resume, bfcache, keyboard close, resize) gets a fresh + * retry budget — a stuck episode may need several nudges, but one episode + * must never starve the next. + */ +const attemptEpisodeHeal = (context: InstallContext, reason: HealReason): void => { + context.attemptsLeft = CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS + if (context.healNow(reason)) scheduleRetry(context, reason) +} + +export type CampaignIosViewportHealInstallOptions = { + maximumScaleFallback?: boolean +} + +/** + * Wires the standalone iOS viewport heal; runs one launch heal immediately + * (load/pageshow may have fired before React hydration) and then keeps + * listening. Returns a teardown the React mount wires as the effect cleanup — + * the campaign root never unmounts in the running app, so the cleanup only + * fires in tests. + */ +export const installCampaignIosViewportHeal = ( + win: Window, + options: CampaignIosViewportHealInstallOptions = {}, +): (() => void) => { + const maximumScaleFallback = + options.maximumScaleFallback ?? CAMPAIGN_IOS_VIEWPORT_HEAL_MAXIMUM_SCALE_FALLBACK + const standaloneIos = isIosSafariStandalone(win) + const context = createInstallContext(win, standaloneIos) + const { state } = context + + const debug: CampaignIosViewportHealDebug = { + state, + healNow: () => context.healNow('manual'), + } + + if (!standaloneIos) { + win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] = debug + return () => { + state.mounted = false + delete win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + } + } + + if (maximumScaleFallback) { + const viewportMeta = win.document.querySelector('meta[name="viewport"]') + viewportMeta?.setAttribute( + 'content', + 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no', + ) + } + + try { + if (typeof win.history.scrollRestoration === 'string') { + win.history.scrollRestoration = 'manual' + } + } catch { + // Privacy modes can deny scrollRestoration writes. + } + + const markPinch = (): void => { + context.lastPinchAt = Date.now() + } + + const onTouchStart = (event: TouchEvent): void => { + if (event.touches && event.touches.length >= 2) markPinch() + } + + const onTouchMove = (event: TouchEvent): void => { + if (event.touches && event.touches.length >= 2) markPinch() + } + + const onFocusIn = (event: FocusEvent): void => { + if (isEditableElement(event.target as Element | null)) { + context.keyboardWasOpen = true + } + } + + const onFocusOut = (): void => { + if (!context.keyboardWasOpen) return + context.keyboardWasOpen = false + context.keyboardHealTimer = context.win.setTimeout( + () => attemptEpisodeHeal(context, 'keyboard'), + CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS, + ) + } + + const onVisualViewportResize = (): void => { + attemptEpisodeHeal(context, 'resize') + } + + const onVisibilityChange = (): void => { + if (win.document.visibilityState === 'visible') { + attemptEpisodeHeal(context, 'resume') + } + } + + const onPageshow = (event: PageTransitionEvent): void => { + attemptEpisodeHeal(context, event.persisted ? 'bfcache' : 'launch') + } + + const onLoad = (): void => { + attemptEpisodeHeal(context, 'launch') + } + + win.addEventListener('gesturestart', markPinch) + win.addEventListener('gesturechange', markPinch) + win.addEventListener('gestureend', markPinch) + win.addEventListener('touchstart', onTouchStart, { passive: true }) + win.addEventListener('touchmove', onTouchMove, { passive: true }) + win.document.addEventListener('focusin', onFocusIn, true) + win.document.addEventListener('focusout', onFocusOut, true) + win.visualViewport?.addEventListener('resize', onVisualViewportResize) + win.addEventListener('visibilitychange', onVisibilityChange) + win.addEventListener('pageshow', onPageshow) + win.addEventListener('load', onLoad) + + attemptEpisodeHeal(context, 'launch') + + win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] = debug + + return () => { + state.mounted = false + if (context.retryTimer !== null) win.clearTimeout(context.retryTimer) + if (context.keyboardHealTimer !== null) win.clearTimeout(context.keyboardHealTimer) + win.removeEventListener('gesturestart', markPinch) + win.removeEventListener('gesturechange', markPinch) + win.removeEventListener('gestureend', markPinch) + win.removeEventListener('touchstart', onTouchStart) + win.removeEventListener('touchmove', onTouchMove) + win.document.removeEventListener('focusin', onFocusIn, true) + win.document.removeEventListener('focusout', onFocusOut, true) + win.visualViewport?.removeEventListener('resize', onVisualViewportResize) + win.removeEventListener('visibilitychange', onVisibilityChange) + win.removeEventListener('pageshow', onPageshow) + win.removeEventListener('load', onLoad) + delete win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + } +} diff --git a/tests/unit/campaignIosViewportHeal.unit.spec.ts b/tests/unit/campaignIosViewportHeal.unit.spec.ts new file mode 100644 index 00000000..01dc4751 --- /dev/null +++ b/tests/unit/campaignIosViewportHeal.unit.spec.ts @@ -0,0 +1,510 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL, + CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS, + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS, + CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS, + CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS, + getVisualViewportScale, + installCampaignIosViewportHeal, + isIosSafariStandalone, + shouldAttemptViewportHeal, +} from '@/lib/campaignIosViewportHeal' + +const IOS_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' + +const MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' + +const stubNavigator = (partial: { + userAgent?: string + platform?: string + maxTouchPoints?: number + standalone?: boolean +}): void => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) jsdom', + platform: 'Linux', + maxTouchPoints: 0, + standalone: undefined, + ...partial, + }) +} + +const stubMatchMedia = (matches: boolean): void => { + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches })) +} + +const stubThrowingMatchMedia = (): void => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation(() => { + throw new Error('unavailable') + }), + ) +} + +const stubStandaloneProperty = (value: boolean): void => { + Object.defineProperty(window.navigator, 'standalone', { + configurable: true, + value, + }) +} + +const stubScrollRestoration = (value: string): void => { + Object.defineProperty(window.history, 'scrollRestoration', { + configurable: true, + writable: true, + value, + }) +} + +let viewportStub: { + scale: number + addEventListener: ReturnType + removeEventListener: ReturnType +} + +const setScale = (scale: number): void => { + viewportStub.scale = scale +} + +const clearVisualViewport = (): void => { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: undefined, + }) +} + +const setVisible = (visible: boolean): void => { + Object.defineProperty(window.document, 'visibilityState', { + configurable: true, + value: visible ? 'visible' : 'hidden', + }) +} + +const appendViewportMeta = (): HTMLMetaElement => { + const viewportMeta = document.createElement('meta') + viewportMeta.name = 'viewport' + viewportMeta.content = 'width=device-width, initial-scale=1' + document.head.append(viewportMeta) + return viewportMeta +} + +let teardown: (() => void) | null = null + +const installStandalone = ( + options?: Parameters[1], +): void => { + stubStandaloneProperty(true) + teardown = installCampaignIosViewportHeal(window, options) +} + +const installNonStandalone = (): void => { + teardown = installCampaignIosViewportHeal(window) +} + +const focusInput = (): HTMLInputElement => { + const input = document.createElement('input') + document.body.append(input) + input.focus() + return input +} + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + setVisible(true) + document.body.innerHTML = '' + document.head.innerHTML = '' + viewportStub = { + scale: 1, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: viewportStub, + }) + stubStandaloneProperty(false) +}) + +afterEach(() => { + teardown?.() + teardown = null + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + clearVisualViewport() + setVisible(true) +}) + +describe('isIosSafariStandalone', () => { + it('returns true when navigator.standalone is set (classic iOS PWA)', () => { + stubStandaloneProperty(true) + expect(isIosSafariStandalone(window)).toBe(true) + }) + + it('returns true for iOS UA + standalone display mode', () => { + stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' }) + stubMatchMedia(true) + expect(isIosSafariStandalone(window)).toBe(true) + }) + + it('returns false for iOS UA without standalone display mode (Safari tab)', () => { + stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' }) + stubMatchMedia(false) + expect(isIosSafariStandalone(window)).toBe(false) + }) + + it('returns false for a non-iOS UA even in standalone display mode', () => { + stubNavigator({ + userAgent: 'Mozilla/5.0 (Linux; Android 14) Mobile', + platform: 'Linux', + }) + stubMatchMedia(true) + expect(isIosSafariStandalone(window)).toBe(false) + }) + + it('returns true for iPadOS (MacIntel platform + touch) in standalone', () => { + stubNavigator({ userAgent: MAC_UA, platform: 'MacIntel', maxTouchPoints: 5 }) + stubMatchMedia(true) + expect(isIosSafariStandalone(window)).toBe(true) + }) + + it('returns false for a Mac without touch input', () => { + stubNavigator({ userAgent: MAC_UA, platform: 'MacIntel', maxTouchPoints: 0 }) + stubMatchMedia(true) + expect(isIosSafariStandalone(window)).toBe(false) + }) + + it('returns false when matchMedia throws', () => { + stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' }) + stubThrowingMatchMedia() + expect(isIosSafariStandalone(window)).toBe(false) + }) +}) + +describe('getVisualViewportScale', () => { + it('reads visualViewport.scale', () => { + setScale(1.3) + expect(getVisualViewportScale(window)).toBe(1.3) + }) + + it('returns 1 when the VisualViewport API is missing', () => { + clearVisualViewport() + expect(getVisualViewportScale(window)).toBe(1) + }) + + it('returns 1 when visualViewport.scale is not a number', () => { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { scale: undefined }, + }) + expect(getVisualViewportScale(window)).toBe(1) + }) +}) + +describe('shouldAttemptViewportHeal', () => { + it('heals when the scale is stuck and nothing blocks', () => { + expect( + shouldAttemptViewportHeal({ scale: 1.3, editableFocused: false, recentPinch: false }), + ).toBe(true) + }) + + it('does not heal at scale 1', () => { + expect( + shouldAttemptViewportHeal({ scale: 1, editableFocused: false, recentPinch: false }), + ).toBe(false) + }) + + it('does not heal while an editable is focused (keyboard open)', () => { + expect( + shouldAttemptViewportHeal({ scale: 1.3, editableFocused: true, recentPinch: false }), + ).toBe(false) + }) + + it('does not heal right after a pinch (user zoom)', () => { + expect( + shouldAttemptViewportHeal({ scale: 1.3, editableFocused: false, recentPinch: true }), + ).toBe(false) + }) + + it('honors an explicit minScale', () => { + expect( + shouldAttemptViewportHeal({ + scale: 1.01, + editableFocused: false, + recentPinch: false, + minScale: 1, + }), + ).toBe(true) + }) +}) + +describe('installCampaignIosViewportHeal — non-standalone contexts', () => { + it('installs a no-op debug surface without wiring any heal', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + installNonStandalone() + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.standaloneIos).toBe(false) + expect(debug?.state.mounted).toBe(true) + + setScale(1.4) + window.dispatchEvent(new Event('pageshow')) + expect(scrollToSpy).not.toHaveBeenCalled() + expect(debug?.state.healedCount).toBe(0) + }) + + it('keeps scrollRestoration untouched outside standalone', () => { + stubScrollRestoration('auto') + installNonStandalone() + expect(window.history.scrollRestoration).toBe('auto') + }) + + it('teardown removes the debug global', () => { + installNonStandalone() + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeDefined() + teardown?.() + teardown = null + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeUndefined() + }) +}) + +describe('installCampaignIosViewportHeal — standalone', () => { + it('opts out of history scroll restoration', () => { + stubScrollRestoration('auto') + installStandalone() + expect(window.history.scrollRestoration).toBe('manual') + }) + + it('heals immediately at install when already stuck (load/pageshow may predate hydration)', () => { + setScale(1.3) + installStandalone() + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(1) + expect(debug?.state.lastHealReason).toBe('launch') + }) + + it('heals on launch when the scale is stuck', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + installStandalone() + setScale(1.4) + window.dispatchEvent(new Event('pageshow')) + + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(1) + expect(debug?.state.lastHealReason).toBe('launch') + expect(scrollToSpy).toHaveBeenCalledWith(0, 0) + }) + + it('heals on the load event', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('load')) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('launch') + }) + + it('does nothing at scale 1', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + installStandalone() + window.dispatchEvent(new Event('pageshow')) + expect(scrollToSpy).not.toHaveBeenCalled() + }) + + it('heals on bfcache resume (pageshow persisted)', () => { + installStandalone() + setScale(1.2) + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('bfcache') + }) + + it('heals when the app returns to the foreground', () => { + installStandalone() + setScale(1.2) + setVisible(false) + window.dispatchEvent(new Event('visibilitychange')) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0) + + setVisible(true) + window.dispatchEvent(new Event('visibilitychange')) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('resume') + }) + + it('retries up to the attempt budget while the scale stays stuck, then stops', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(1) + + for (let i = 0; i < CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS; i += 1) { + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) + } + expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS) + expect(vi.getTimerCount()).toBe(1) + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) + expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS) + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops retrying once the scale heals', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(1) + + setScale(1) + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) + expect(debug?.state.healedCount).toBe(1) + expect(debug?.state.lastSkippedReason).toBe('scale-ok') + }) + + it('gives each episode a fresh retry budget', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + for (let i = 0; i < CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS; i += 1) { + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) + } + expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS) + + window.dispatchEvent(new Event('pageshow')) + expect(debug?.state.healedCount).toBe(2 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS) + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS) + expect(debug?.state.healedCount).toBe(3 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS) + }) + + it('skips the heal while an editable is focused', () => { + installStandalone() + focusInput() + setScale(1.3) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(0) + expect(debug?.state.lastSkippedReason).toBe('editable-focused') + }) + + it('skips the heal right after a pinch gesture', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('gesturestart')) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(0) + expect(debug?.state.lastSkippedReason).toBe('recent-pinch') + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS) + window.dispatchEvent(new Event('pageshow')) + expect(debug?.state.healedCount).toBe(1) + }) + + it('keeps the pinch guard fresh for the whole gesture', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('gesturestart')) + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS + 100) + window.dispatchEvent(new Event('gesturechange')) + window.dispatchEvent(new Event('pageshow')) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(0) + expect(debug?.state.lastSkippedReason).toBe('recent-pinch') + }) + + it('heals after the keyboard closes following an input interaction', () => { + installStandalone() + const input = focusInput() + setScale(1.3) + input.blur() + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0) + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS) + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + expect(debug?.state.healedCount).toBe(1) + expect(debug?.state.lastHealReason).toBe('keyboard') + }) + + it('does not schedule a keyboard heal when the focus never touched an editable', () => { + installStandalone() + setScale(1.3) + document.body.dispatchEvent(new Event('focusout')) + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0) + }) + + it('heals on visualViewport resize while nothing is focused', () => { + installStandalone() + const resizeListener = viewportStub.addEventListener.mock.calls.find( + ([name]) => name === 'resize', + )?.[1] + expect(resizeListener).toBeDefined() + + setScale(1.25) + resizeListener() + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('resize') + }) + + it('exposes a manual heal for on-device verification', () => { + installStandalone() + setScale(1.3) + const healed = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.healNow() + expect(healed).toBe(true) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('manual') + + setScale(1) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.healNow()).toBe(false) + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastSkippedReason).toBe( + 'scale-ok', + ) + }) + + it('teardown cancels pending retries and removes the debug global', () => { + installStandalone() + setScale(1.3) + window.dispatchEvent(new Event('pageshow')) + teardown?.() + teardown = null + expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeUndefined() + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS * 3) + expect(vi.getTimerCount()).toBe(0) + }) + + it('teardown cancels a pending keyboard heal timer', () => { + installStandalone() + const input = focusInput() + input.blur() + const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] + teardown?.() + teardown = null + + vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS) + expect(debug?.state.healedCount).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('applies the maximum-scale fallback only in standalone when opted in', () => { + const viewportMeta = appendViewportMeta() + installStandalone({ maximumScaleFallback: true }) + expect(viewportMeta.content).toContain('maximum-scale=1') + expect(viewportMeta.content).toContain('user-scalable=no') + }) + + it('keeps the viewport meta untouched by default (fallback off)', () => { + const viewportMeta = appendViewportMeta() + installStandalone() + expect(viewportMeta.content).toBe('width=device-width, initial-scale=1') + }) + + it('keeps the viewport meta untouched in non-standalone even when opted in', () => { + const viewportMeta = appendViewportMeta() + teardown = installCampaignIosViewportHeal(window, { maximumScaleFallback: true }) + expect(viewportMeta.content).toBe('width=device-width, initial-scale=1') + }) +})