0042: Capability + permission signals
Status: Proposed Date: 2026-05-28
Context
ADR 0041 shipped the always-available environment signals (online, prefers-*, locale, orientation, DPR, fullscreen). What's left are the privacy-gated, often-async, browser-quirky APIs that expose capabilities — permissions, battery, network info, idle detection — that apps still want as signals but that don't fit a one-shot synchronous read:
- Permissions API (
navigator.permissions.query) — async query, mutation viaPermissionStatus.onchange. Universally supported, no gesture required. - Battery Status API (
navigator.getBattery) — async, exposes level / charging events. Removed from Firefox; still works in Chromium. - Network Information API (
navigator.connection) — effective type / RTT / save-data. Chrome / Edge only, but high-value for the apps that target those platforms. - Idle Detection API (
IdleDetector) — Chrome-only, requires a permission grant and a user gesture to start. The gesture requirement is the design driver for this ADR.
Apps wire these by hand, often badly: they leak watch IDs, miss
the change event, double-prompt for permissions, or pretend
the API is synchronous and ship a flicker.
Decision
Add four capability signals to @purityjs/core:
export function permissionSignal(
name: PermissionDescriptor['name'] | string,
): ComputedAccessor<PermissionState>;
export type BatteryInfo = {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
};
export function batterySignal(): ComputedAccessor<BatteryInfo | null>;
export type NetworkInformation = {
effectiveType: '4g' | '3g' | '2g' | 'slow-2g';
saveData: boolean;
downlink: number;
rtt: number;
type: string;
};
export function networkInformationSignal(): ComputedAccessor<NetworkInformation>;
export type IdleSignalState = {
user: 'active' | 'idle';
screen: 'locked' | 'unlocked';
};
export function idleSignal(detector: IdleDetectorLike): ComputedAccessor<IdleSignalState>;
Where IdleDetectorLike is the structural subset of the
IdleDetector interface we depend on (defined locally so the
package doesn't require @types/wicg-idle-detector).
All four return ComputedAccessor (read-only). All four return
inert constants in an SSR context.
permissionSignal
const camera = permissionSignal('camera');
when(
() => camera() === 'granted',
() => html`<live-camera />`,
);
- Server. Returns a constant
'prompt'. - Client. Caches per permission name (one query + one
listener per name across all callers). Initial value is
'prompt'; the asyncnavigator.permissions.query({ name })resolves into either'granted'/'denied'/'prompt'. The signal then listens forchangeon the returnedPermissionStatus. - Errors. If
query()rejects (invalid permission name on this browser), the signal stays at'prompt'and the rejection is logged viaconsole.error.
batterySignal
const battery = batterySignal();
when(
() => (battery()?.level ?? 1) < 0.2 && !battery()?.charging,
() => html`<low-power-mode />`,
);
- Server. Returns a constant
null. - Client. Lazy singleton. Initial value
null; the asyncnavigator.getBattery()resolves into the BatteryManager, which we copy into a plainBatteryInfoobject on everychargingchange/levelchange/chargingtimechange/dischargingtimechangeevent. - Unavailable browser. Permanent
null. Apps that need a Firefox-friendly path read the signal defensively (battery()?.level).
networkInformationSignal
const net = networkInformationSignal();
when(
() => net().saveData || net().effectiveType === 'slow-2g',
() => html`<lite-mode />`,
);
- Server. Returns a constant
{ effectiveType: '4g', saveData: false, downlink: 10, rtt: 50, type: 'unknown' }. - Client. Lazy singleton. Reads
navigator.connection(with vendor-prefixed fallbacks); copies the fields into a plain object on everychangeevent. - Unavailable browser. Returns the SSR-default constant permanently.
idleSignal
const detector = new IdleDetector();
const idle = idleSignal(detector);
button.addEventListener('click', async () => {
await IdleDetector.requestPermission(); // requires user gesture
await detector.start({ threshold: 60_000 });
});
when(
() => idle().user === 'idle',
() => html`<screen-saver />`,
);
- Server. Returns a constant
{ user: 'active', screen: 'unlocked' }. - Client. Wraps an existing
IdleDetectorinstance. Listens to the detector'schangeevent and mirrorsdetector.userState/detector.screenStateinto the signal. Initial value is read synchronously from the detector (so pre-started detectors converge immediately). - Why a detector instead of options.
IdleDetectorrequires a user-gesture-boundstart()call and an explicit permission grant. Both are app-flow concerns the framework can't anticipate. The signal layer is honest: you build and start the detector under your own gesture, then hand it in for the signal-shape wrapper.
Explicit non-features
- No
geolocationSignalin this ADR. The watch-vs-one-shot distinction, the gesture-prompt UX, and the rich error model (PERMISSION_DENIED/POSITION_UNAVAILABLE/TIMEOUT) make geolocation a worse fit forComputedAccessorthan for the existingresource()shape. Apps that need a one-shot read useresource(() => null, async (_, { signal }) => new Promise((res, rej) => navigator.geolocation.getCurrentPosition(res, rej))). Apps that need a watch build a smallstate()+watchPositionpairing. - No
permissionsSignal({ name, ... })for descriptor-shaped permissions (e.g.'midi'with{ sysex: true }). v1 takes a bare name; descriptors land in a follow-up if needed. - No automatic
IdleDetectorpermission request. Apps callIdleDetector.requestPermission()themselves under a user gesture. - No
idleSignalcleanup hook. The signal doesn'tabortthe detector — that's the caller's responsibility (they built and started it). - No bandwidth / data-usage tracking signal. The Network Information API exposes static estimates only; "actual bytes transferred" is request-scope and belongs at the resource / fetch layer.
Consequences
Positive:
- Four privacy-gated capabilities lifted into the signal model with honest async / unavailable-browser semantics.
permissionSignalcollapses ~25 LOC of imperative permission watching into one call.batterySignal/networkInformationSignalmake battery-saver / save-data branching a one-linewhen().idleSignal's "bring your own started detector" shape keeps the framework out of the gesture / permission UX while still giving callers the signal ergonomics they want.- All four are tree-shakable and SSR-safe.
Negative:
- The Battery / Network / Idle APIs are not Baseline. Apps that branch on them must handle the "unavailable" path (null or default constant). Documented; the SSR fallback is the same shape so isomorphic code reads naturally.
idleSignal's caller-managed-detector pattern is unusual inside Purity (most signals are zero-arg or one-arg primitives). Justified by the gesture requirement; the alternative is to bake gesture/permission UX into the framework, which is worse.- Locally-typed
IdleDetectorLikewill drift if the spec adds fields. Manageable — the signal only reads two fields.
Neutral:
- Tests cover SSR constants + mocked
navigator.permissions,navigator.getBattery,navigator.connection, and a hand- rolledIdleDetectorstub. None of these APIs ship in jsdom. - Bundle delta: ~700 bytes gzipped for all four combined (rough estimate).
Alternatives considered
Wrap IdleDetector construction + start inside the signal
factory so callers write idleSignal({ threshold: 60_000 }).
Rejected: the factory would need a .start() method on the
returned accessor to respect the user-gesture requirement,
which breaks the ComputedAccessor shape. Bring-your-own-
detector pushes the gesture concern to the call site, where it
belongs.
Async signal constructors that await navigator.permissions .query() / navigator.getBattery() before returning. Rejected:
breaks the synchronous "drop into a component body" idiom.
Initial-value-then-update is the same pattern the existing
resource() already uses.
Ship geolocationSignal here. Rejected for now (see
non-features). The design space is larger than the other four
combined and deserves its own ADR if it lands.
Cache batterySignal / networkInformationSignal instances
across pages (per origin, via sessionStorage). Rejected:
out-of-scope cleverness; the lazy-singleton pattern already
makes them effectively per-page singletons.
Type permissionSignal strictly to the
PermissionName union. Rejected: that union is incomplete in
lib.dom and varies per browser. Accepting string keeps the
runtime API honest; TypeScript users still get autocomplete via
the structural overlap with PermissionDescriptor['name'].