device-capabilities.ts
PURPOSE
Detects device hardware and runtime traits — CPU cores, memory, GPU vendor/renderer, screen size, DPR, touch input, battery state, and an inferred power-saver flag — and exposes them as a single frozen snapshot for telemetry, quality scaling, and UI branching. Probes browser APIs once at module load (synchronous fields) plus an awaited init pass for the async Battery API.
OWNS
DeviceCapabilitiesinterface — the public shape of the snapshot.DEVICE_CAPS— the exported snapshot object. Mutable inside this module, treated as read-only by callers. Battery fields andpowerSaverLikelyare updated by_syncBatteryas the Battery API emits events.initDeviceCapabilities()— async boot hook that resolves the Battery API and wireslevelchange/chargingchangelisteners. Idempotent in spirit (re-resolves the BatteryManager); safe to no-op on browsers without the API.isMobile()— convenience accessor returningDEVICE_CAPS.isMobile.getDeviceInfoForTelemetry()— compactRecord<string, unknown>payload assembled fromDEVICE_CAPS, intended for telemetry events._probeGPU()(private) — creates a 1×1 throwaway canvas, opens a WebGL context, readsWEBGL_debug_renderer_infofor unmasked vendor and renderer, then callsWEBGL_lose_context.loseContext()to release GPU resources._battery(private) — retained reference to the resolvedBatteryManagerso listeners stay attached.
READS FROM
navigator.hardwareConcurrency— CPU logical core count.navigator.deviceMemory— device memory in GB (Chrome/Android only; null elsewhere).navigator.maxTouchPoints— touch-point capability, also used for the mobile heuristic.navigator.userAgent— UA string.navigator.getBattery()— async BatteryManager (Chrome/Android only); readslevelandchargingand subscribes tolevelchange/chargingchange.window.devicePixelRatio— native DPR at page load.window.innerWidth/window.innerHeight— screen dimensions in CSS pixels;innerWidth < 900is part of the mobile heuristic.window.matchMedia('(pointer: coarse)')— secondary touch signal for the mobile heuristic.window.matchMedia('(prefers-reduced-motion: reduce)')— accessibility preference.document.createElement('canvas')and the WebGL extensionsWEBGL_debug_renderer_infoandWEBGL_lose_context.
PUSHES TO
- Nothing directly. The module is a passive read surface — it exposes
DEVICE_CAPSand helpers that callers pull from. Telemetry shipping, quality tier selection, and UI gating live in other modules that import this one.
DOES NOT
- Does not detect WebGL2 capability. The GPU probe opens a WebGL1 context (
webgl/experimental-webgl) only, and the snapshot has no WebGL2 field. Renderer-side capability checks (WebGL2 vs WebGL1, extensions, max texture size) belong to the rendering module, not here. - Does not parse the user agent. UA is captured as a raw string for telemetry; this module does not derive OS, browser, or device family from it.
- Does not measure FPS, frame time, or GPU throttling. The module docblock describes sustained FPS drops as a power-saver signal in principle, but the implemented
powerSaverLikelyheuristic only considers battery level and charging state. - Does not poll. After
initDeviceCapabilities()wires battery listeners, updates are event-driven (levelchange,chargingchange). - Does not respond to viewport resize or DPR changes.
screenW,screenH, andnativeDPRare captured once at module load. - Does not freeze
DEVICE_CAPSwithObject.freeze. Despite the “frozen snapshot” wording in the source docblock, the object is a plain mutable export — battery fields andpowerSaverLikelyare updated in place by_syncBattery. - Does not throw on missing APIs. Every probe is wrapped in a
typeofguard, optional chaining, ortry/catch. Unavailable fields surface asnullor sensible defaults (0, 1, empty string, false).
Signals
DEVICE_CAPS.isMobile— true whenwindow.innerWidth < 900AND (touch points > 0 OR(pointer: coarse)matches). Read viaisMobile().DEVICE_CAPS.powerSaverLikely— heuristic: battery level < 0.2 AND not charging. Updated whenever Battery API fireslevelchangeorchargingchange. False on devices without Battery API (Safari/iOS).DEVICE_CAPS.batteryLevel/DEVICE_CAPS.batteryCharging— live battery telemetry, null on browsers without the Battery API.DEVICE_CAPS.prefersReducedMotion— captured at module load from theprefers-reduced-motion: reducemedia query.DEVICE_CAPS.gpuVendor/DEVICE_CAPS.gpuRenderer— strings fromWEBGL_debug_renderer_info; null when WebGL or the extension is unavailable. A renderer string ofGoogle SwiftShaderindicates software rendering.DEVICE_CAPS.cpuCores,DEVICE_CAPS.memoryGB,DEVICE_CAPS.maxTouchPoints,DEVICE_CAPS.nativeDPR,DEVICE_CAPS.screenW,DEVICE_CAPS.screenH,DEVICE_CAPS.userAgent— captured once at module load.
Entry points
initDeviceCapabilities(): Promise<void>— call once at app boot, awaitable. Resolves the Battery API where available and attaches change listeners; resolves immediately on browsers withoutgetBattery.isMobile(): boolean— preferred replacement for inlinewindow.innerWidth < 900checks elsewhere in the codebase.getDeviceInfoForTelemetry(): Record<string, unknown>— returns{ ua, screen, dpr, cores, memGB, gpu, gpuVendor, mobile, touch, battery, powerSaver, reducedMotion }. Thebatteryfield is either{ level: 0-100 integer, charging }ornull. Intended payload shape for diagnostic events.DEVICE_CAPS: DeviceCapabilities— direct read access to the snapshot.
Pattern notes
- Sync-then-async split: synchronous fields populate at module evaluation so callers can read them before
initDeviceCapabilities()resolves. The Battery API is deferred becausegetBattery()is async and absent on Safari/iOS. - SSR/non-browser safe: every probe guards
typeof window !== 'undefined'ortypeof navigator !== 'undefined'. The module can be imported in a non-browser context without throwing. - GPU probe is one-shot and self-cleaning: the throwaway 1×1 canvas opens a WebGL context only long enough to read vendor and renderer, then calls
WEBGL_lose_context.loseContext()to free the GPU resource immediately. The full block is wrapped intry/catchso a probe failure cannot crash module load. - Mobile detection requires both signals (narrow viewport AND touch capability). A narrow desktop window with a mouse will not register as mobile; a tablet with a wide viewport will not either.
- Power-saver inference is intentionally minimal — the source docblock lists four candidate signals (battery, hardwareConcurrency, sustained FPS drops, reduced-motion) but the implementation only uses the battery signal. There is no direct browser API for OS-level low-power mode.
- Battery listeners stay bound for the page lifetime; the module retains
_batteryso the BatteryManager and its event subscriptions are not garbage-collected. There is no teardown path. DEVICE_CAPSis exported as aconstbinding, but the underlying object is mutated by_syncBattery. Callers must not assume snapshot immutability across frames for battery fields.- The GPU probe accepts both
webglandexperimental-webglcontexts but does not attemptwebgl2. WebGL2 capability is not part of this module’s contract.