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(1HzsetIntervalhandle),_lastSnapshot(most recent sample for on-toggle redraw). - The
ZoomSnapshotinterface (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()andgetComputedStyle().transform.document.querySelector('canvas')—.width,.height,.style.width,.style.height,getComputedStyle().transform.screen.orientation.type,screen.width,screen.height.camera.zoom,camera.targetZoomandgame.phasefrom../core.navigator.userAgentandwindow.matchMedia('(display-mode: standalone)')for Sentry context tagging.
PUSHES TO
- Sentry (dynamic
import('@sentry/browser')):Sentry.captureMessagewith tagbug=pwa_zoom_drift(levelwarning) on first detected drift per run; extras includedrift,baseline,current,userAgent,isPWA.Sentry.captureMessagewith tagbug=pwa_zoom_manual_dumpviadumpZoomStateToSentry; extras includereason,baseline,current,userAgent,isPWA.Sentry.addBreadcrumb(categoryzoom-watcher, levelinfo) on every DOM-event sample, carryingvvScale,cameraZoom,innerW,canvasCssW.
- The DOM: appends
#zoom-watcher-overlaytodocument.bodyand rewrites itstextContent/ colors on every update. camera.zoomandcamera.targetZoom(reset to1inresetAllZoomLayers).- The
meta[name="viewport"]element — temporarily swaps to an unlocked content string for one frame, then restores the lockedwidth=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover. window.scrollTo(0, 0), inlinestyle.transformclearing on#rootand the canvas, and a syntheticwindow.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;
resetAllZoomLayersis 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
startZoomWatcherruns 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.scalediffers from1by more than0.01.window.scrollXorscrollYis non-zero (body got pushed).#roothas a non-nonetransformthat differs from baseline.- Canvas computed
transformis non-noneand differs from baseline. - Canvas buffer-to-CSS-width ratio drifts from baseline by more than
0.1(buffer/CSS misalignment). devicePixelRatiochanges by more than0.05from baseline.- Canvas CSS width exceeds
innerWidthby more than2px(visible zoom-in). camera.zoom > 1.5(engine-level zoom suspect).documentElement.clientWidthexceedsinnerWidthby more than4px(layout overflow).
Entry points
startZoomWatcher()— idempotent (guarded by_timerId). Builds the overlay (hidden), schedules baseline capture at+500ms, starts asetIntervalat1000ms, and attaches listeners towindow(resize,orientationchange,focus,blur),document(visibilitychange,gesturestart,gesturechange,gestureend), andvisualViewport(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 taggedpwa_zoom_manual_dumpregardless of drift; intended for an on-demand RECOVER button.resetAllZoomLayers(): string— runs the six-step recovery: viewport-meta unlock-then-relock across arequestAnimationFrame,scrollTo(0,0), clear#rootand canvas inlinetransform, resetcamera.zoomandcamera.targetZoomto1, dispatch a syntheticresize, then re-baseline and clear_warnedOnce. Returns a one-line before-to-after diagnostic string coveringvvScale,cameraZoom, andcanvasCssW.
Pattern notes
- Baseline is captured
500msafterstartZoomWatcherto let the initial layout / URL-bar settle, not att=0. - Drift thresholds are deliberately lenient (1px rounding,
<=100pxvertical 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:noneso it never intercepts game input, and a highz-index:99999plustext-shadowso 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 byresetAllZoomLayersso a post-recovery drift can still be reported. - The viewport-meta recovery deliberately transitions from unlocked to locked across a
requestAnimationFrameboundary — 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
cameraandgamefrom../coreand treats them as nullable (camera?.zoom ?? 1,game?.phase ?? 'unknown'), so the watcher can run before the engine is fully constructed.