/** * WCAG 1.4.10 Reflow (Level AA) — shared detection for sidebar flyout, * compact filter toolbar, disabled column stickies, and single-column nav scroll. * * Triggers when any of: * - viewport width ≤ 320 CSS px (1.4.10 test width), * - browser zoom ≥ ~200% (`visualViewport.scale`), * - short viewport height (≤ 640px or ≤ 420px). * * **Windows display scaling (125% / 150%) is not browser zoom.** The OS shrinks * the logical CSS viewport and raises DPR; `visualViewport.scale` stays ~1. * Do **not** invent an OS-DPI layout mode — rely on CSS px / rem, wrap identity * labels (`SHELL_IDENTITY_MENU_SURFACE_CLASS`), and this reflow signal for the * WCAG 200% / 320px path. Mid-scale QA: Windows 100/125/150% × browser 100/150/200%. */ import { rafThrottle } from "./raf-throttle" /** WCAG 1.4.10 — content must reflow at 320 CSS px without horizontal scroll. */ export const REFLOW_MAX_WIDTH_PX = 320 export const REFLOW_MAX_WIDTH_MQ = `(max-width: ${REFLOW_MAX_WIDTH_PX}px)` export const REFLOW_MAX_HEIGHT_MQ = "(max-height: 640px)" export const COARSE_POINTER_MQ = "(pointer: coarse)" /** Browser zoom ≥ ~200%, narrow 1.4.10 width, or a genuinely short viewport. */ export function computeReflowViewport(): boolean { if (typeof window === "undefined") return false const vv = window.visualViewport const scale = vv?.scale ?? 1 const vvWidth = vv?.width const narrow = window.matchMedia(REFLOW_MAX_WIDTH_MQ).matches || window.innerWidth <= REFLOW_MAX_WIDTH_PX || (typeof vvWidth === "number" && vvWidth > 0 && vvWidth <= REFLOW_MAX_WIDTH_PX) const short = window.matchMedia(REFLOW_MAX_HEIGHT_MQ).matches const veryShort = window.innerHeight <= 420 return narrow || scale >= 1.99 || short || veryShort } export function computeCoarsePointer(): boolean { if (typeof window === "undefined") return false return window.matchMedia(COARSE_POINTER_MQ).matches } type Listener = () => void let reflowListeners: Set | null = null let reflowCached = false let reflowScheduled: ReturnType | null = null function ensureReflowInitialized() { if (reflowListeners) return reflowListeners = new Set() if (typeof window === "undefined") return reflowCached = computeReflowViewport() reflowScheduled = rafThrottle(() => { const next = computeReflowViewport() if (next === reflowCached) return reflowCached = next reflowListeners?.forEach(cb => cb()) }) const vv = window.visualViewport vv?.addEventListener("resize", reflowScheduled, { passive: true }) vv?.addEventListener("scroll", reflowScheduled, { passive: true }) window.addEventListener("resize", reflowScheduled, { passive: true }) window.matchMedia(REFLOW_MAX_HEIGHT_MQ).addEventListener("change", reflowScheduled) window.matchMedia(REFLOW_MAX_WIDTH_MQ).addEventListener("change", reflowScheduled) } export function subscribeReflowViewport(callback: Listener): () => void { ensureReflowInitialized() reflowListeners!.add(callback) return () => { reflowListeners!.delete(callback) } } export function getReflowViewportSnapshot(): boolean { ensureReflowInitialized() return reflowCached } export function getServerReflowViewportSnapshot(): boolean { return false } let coarseListeners: Set | null = null let coarseCached = false function ensureCoarseInitialized() { if (coarseListeners) return coarseListeners = new Set() if (typeof window === "undefined") return coarseCached = computeCoarsePointer() const mql = window.matchMedia(COARSE_POINTER_MQ) mql.addEventListener("change", () => { const next = computeCoarsePointer() if (next === coarseCached) return coarseCached = next coarseListeners?.forEach(cb => cb()) }) } export function subscribeCoarsePointer(callback: Listener): () => void { ensureCoarseInitialized() coarseListeners!.add(callback) return () => { coarseListeners!.delete(callback) } } export function getCoarsePointerSnapshot(): boolean { ensureCoarseInitialized() return coarseCached } export function getServerCoarsePointerSnapshot(): boolean { return false }