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

  • DeviceCapabilities interface — the public shape of the snapshot.
  • DEVICE_CAPS — the exported snapshot object. Mutable inside this module, treated as read-only by callers. Battery fields and powerSaverLikely are updated by _syncBattery as the Battery API emits events.
  • initDeviceCapabilities() — async boot hook that resolves the Battery API and wires levelchange / chargingchange listeners. Idempotent in spirit (re-resolves the BatteryManager); safe to no-op on browsers without the API.
  • isMobile() — convenience accessor returning DEVICE_CAPS.isMobile.
  • getDeviceInfoForTelemetry() — compact Record<string, unknown> payload assembled from DEVICE_CAPS, intended for telemetry events.
  • _probeGPU() (private) — creates a 1×1 throwaway canvas, opens a WebGL context, reads WEBGL_debug_renderer_info for unmasked vendor and renderer, then calls WEBGL_lose_context.loseContext() to release GPU resources.
  • _battery (private) — retained reference to the resolved BatteryManager so 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); reads level and charging and subscribes to levelchange / chargingchange.
  • window.devicePixelRatio — native DPR at page load.
  • window.innerWidth / window.innerHeight — screen dimensions in CSS pixels; innerWidth < 900 is 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 extensions WEBGL_debug_renderer_info and WEBGL_lose_context.

PUSHES TO

  • Nothing directly. The module is a passive read surface — it exposes DEVICE_CAPS and 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 powerSaverLikely heuristic 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, and nativeDPR are captured once at module load.
  • Does not freeze DEVICE_CAPS with Object.freeze. Despite the “frozen snapshot” wording in the source docblock, the object is a plain mutable export — battery fields and powerSaverLikely are updated in place by _syncBattery.
  • Does not throw on missing APIs. Every probe is wrapped in a typeof guard, optional chaining, or try/catch. Unavailable fields surface as null or sensible defaults (0, 1, empty string, false).

Signals

  • DEVICE_CAPS.isMobile — true when window.innerWidth < 900 AND (touch points > 0 OR (pointer: coarse) matches). Read via isMobile().
  • DEVICE_CAPS.powerSaverLikely — heuristic: battery level < 0.2 AND not charging. Updated whenever Battery API fires levelchange or chargingchange. 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 the prefers-reduced-motion: reduce media query.
  • DEVICE_CAPS.gpuVendor / DEVICE_CAPS.gpuRenderer — strings from WEBGL_debug_renderer_info; null when WebGL or the extension is unavailable. A renderer string of Google SwiftShader indicates 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 without getBattery.
  • isMobile(): boolean — preferred replacement for inline window.innerWidth < 900 checks elsewhere in the codebase.
  • getDeviceInfoForTelemetry(): Record<string, unknown> — returns { ua, screen, dpr, cores, memGB, gpu, gpuVendor, mobile, touch, battery, powerSaver, reducedMotion }. The battery field is either { level: 0-100 integer, charging } or null. 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 because getBattery() is async and absent on Safari/iOS.
  • SSR/non-browser safe: every probe guards typeof window !== 'undefined' or typeof 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 in try/catch so 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 _battery so the BatteryManager and its event subscriptions are not garbage-collected. There is no teardown path.
  • DEVICE_CAPS is exported as a const binding, but the underlying object is mutated by _syncBattery. Callers must not assume snapshot immutability across frames for battery fields.
  • The GPU probe accepts both webgl and experimental-webgl contexts but does not attempt webgl2. WebGL2 capability is not part of this module’s contract.