PURPOSE

Aggressive multi-layer diagnostic for the “game gets stuck zoomed in” PWA bug. Samples every zoom-relevant value once per second and on key DOM events. When any layer drifts from a captured baseline, it pushes a single Sentry event with the full state snapshot and forces an on-screen overlay open. Also exposes a nuclear recovery routine that resets every zoom layer it knows about so the user can be unstuck regardless of where the bug actually lives.

OWNS

  • Module-local mutable state: _baseline (post-settle snapshot), _warnedOnce (per-run Sentry rate-limit flag), _overlayEl / _overlayVisible (DOM overlay handle + visibility), _timerId (1Hz setInterval handle), _lastSnapshot (most recent sample for on-toggle redraw).
  • The ZoomSnapshot interface (28 fields spanning viewport, document, root, canvas, engine camera, body scroll, screen/OS).
  • A single fixed-position DOM overlay element #zoom-watcher-overlay (lower-left, monospace, green when stable / red when drifting).

READS FROM

  • window.visualViewport (.scale, .width, .height, .offsetLeft, .offsetTop).
  • window.innerWidth, innerHeight, devicePixelRatio, scrollX, scrollY.
  • document.documentElement (.clientWidth, .clientHeight).
  • document.getElementById('root')getBoundingClientRect() and getComputedStyle().transform.
  • document.querySelector('canvas').width, .height, .style.width, .style.height, getComputedStyle().transform.
  • screen.orientation.type, screen.width, screen.height.
  • camera.zoom, camera.targetZoom and game.phase from ../core.
  • navigator.userAgent and window.matchMedia('(display-mode: standalone)') for Sentry context tagging.

PUSHES TO

  • Sentry (dynamic import('@sentry/browser')):
    • Sentry.captureMessage with tag bug=pwa_zoom_drift (level warning) on first detected drift per run; extras include drift, baseline, current, userAgent, isPWA.
    • Sentry.captureMessage with tag bug=pwa_zoom_manual_dump via dumpZoomStateToSentry; extras include reason, baseline, current, userAgent, isPWA.
    • Sentry.addBreadcrumb (category zoom-watcher, level info) on every DOM-event sample, carrying vvScale, cameraZoom, innerW, canvasCssW.
  • The DOM: appends #zoom-watcher-overlay to document.body and rewrites its textContent / colors on every update.
  • camera.zoom and camera.targetZoom (reset to 1 in resetAllZoomLayers).
  • The meta[name="viewport"] element — temporarily swaps to an unlocked content string for one frame, then restores the locked width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover.
  • window.scrollTo(0, 0), inline style.transform clearing on #root and the canvas, and a synthetic window.dispatchEvent(new Event('resize')) to force canvas resize.

DOES NOT

  • Does not write to disk, IndexedDB, localStorage, Supabase, or any in-game persistent state.
  • Does not console.log diagnostics for the user — telemetry is Sentry-only and the visible signal is the on-screen overlay.
  • Does not modify game logic, scene state, input, or render output beyond the camera zoom reset in resetAllZoomLayers.
  • Does not throw — Sentry import failures are swallowed; the overlay update path continues regardless.
  • Does not auto-recover on drift detection; resetAllZoomLayers is only invoked by the caller (the RECOVER button).
  • Does not retry Sentry sends: drift reports are rate-limited to once per run (_warnedOnce); the manual dump path is unlimited.
  • Does not remove or re-create its overlay or interval after startZoomWatcher runs once — a second call is a no-op (guarded by _timerId !== null).

Signals

Drift is detected by _detectDrift against the captured baseline. A drift exists if any of the following hold, and the returned string lists every triggering condition pipe-separated:

  • visualViewport.scale differs from 1 by more than 0.01.
  • window.scrollX or scrollY is non-zero (body got pushed).
  • #root has a non-none transform that differs from baseline.
  • Canvas computed transform is non-none and differs from baseline.
  • Canvas buffer-to-CSS-width ratio drifts from baseline by more than 0.1 (buffer/CSS misalignment).
  • devicePixelRatio changes by more than 0.05 from baseline.
  • Canvas CSS width exceeds innerWidth by more than 2px (visible zoom-in).
  • camera.zoom > 1.5 (engine-level zoom suspect).
  • documentElement.clientWidth exceeds innerWidth by more than 4px (layout overflow).

Entry points

  • startZoomWatcher() — idempotent (guarded by _timerId). Builds the overlay (hidden), schedules baseline capture at +500ms, starts a setInterval at 1000ms, and attaches listeners to window (resize, orientationchange, focus, blur), document (visibilitychange, gesturestart, gesturechange, gestureend), and visualViewport (resize, scroll) when present.
  • toggleZoomOverlay(): boolean — toggles overlay display, redraws from the last snapshot, and returns the new visibility.
  • dumpZoomStateToSentry(reason: string) — takes a fresh snapshot and sends a Sentry message tagged pwa_zoom_manual_dump regardless of drift; intended for an on-demand RECOVER button.
  • resetAllZoomLayers(): string — runs the six-step recovery: viewport-meta unlock-then-relock across a requestAnimationFrame, scrollTo(0,0), clear #root and canvas inline transform, reset camera.zoom and camera.targetZoom to 1, dispatch a synthetic resize, then re-baseline and clear _warnedOnce. Returns a one-line before-to-after diagnostic string covering vvScale, cameraZoom, and canvasCssW.

Pattern notes

  • Baseline is captured 500ms after startZoomWatcher to let the initial layout / URL-bar settle, not at t=0.
  • Drift thresholds are deliberately lenient (1px rounding, <=100px vertical URL-bar slack absorbed, 1% scale noise, 5% DPR noise) so URL-bar show/hide and ordinary rounding don’t trigger reports.
  • Sentry is import()-ed lazily inside every reporting call so this module never blocks the engine bundle on Sentry availability and gracefully no-ops when the dynamic import fails.
  • The overlay element uses pointer-events:none so it never intercepts game input, and a high z-index:99999 plus text-shadow so it remains legible over any in-game rendering.
  • On drift detection the overlay is force-shown (_overlayVisible = true), enabling later screenshots to capture the live values without the user toggling anything.
  • Sentry drift reports are rate-limited to one per run via _warnedOnce; the flag is cleared by resetAllZoomLayers so a post-recovery drift can still be reported.
  • The viewport-meta recovery deliberately transitions from unlocked to locked across a requestAnimationFrame boundary — a plain wipe-and-restore was observed to be a no-op on stuck Android visual viewports (see v5.113.3 post-mortem).
  • All zoom-relevant DOM/engine values are sampled in a single _takeSnapshot() call so the snapshot is internally consistent; per-event handlers share the same snapshot path used by the 1Hz timer.
  • The module imports camera and game from ../core and treats them as nullable (camera?.zoom ?? 1, game?.phase ?? 'unknown'), so the watcher can run before the engine is fully constructed.