0041: Environment + system preference signals
Status: Proposed Date: 2026-05-28
Context
Eight pieces of always-queryable browser/OS state recur in apps so often that hand-rolled signal wrappers for them appear in nearly every Purity app started so far:
prefers-color-scheme,prefers-reduced-motion,prefers-contrast— accessibility-driven branches.navigator.onLine+online/offlineevents — offline indicators, retry-on-reconnect.screen.orientation— responsive layout, kiosk apps.navigator.language+languagechange— i18n.window.devicePixelRatio— image asset switching, canvas setup. Changes when the user zooms or drags a window across monitors.document.fullscreenElement+fullscreenchange— fullscreen UX, exit detection.
ADR 0040 shipped mediaSignal(query) which technically handles
three of these (prefers-* queries), but mediaSignal returns a
boolean — apps wrap it in compute(() => mq() ? 'dark' : 'light')
boilerplate at every call site. The other five aren't media-query
shaped at all.
This ADR ships eight named, typed signal constructors. Each is a thin wrapper that picks the canonical query / event and exposes a meaningfully-typed return value.
Decision
Add eight environment signals to @purityjs/core:
export function onlineSignal(): ComputedAccessor<boolean>;
export function prefersColorSchemeSignal(): ComputedAccessor<'light' | 'dark'>;
export function prefersReducedMotionSignal(): ComputedAccessor<boolean>;
export function prefersContrastSignal(): ComputedAccessor<
'no-preference' | 'more' | 'less' | 'custom'
>;
export function screenOrientationSignal(): ComputedAccessor<'portrait' | 'landscape'>;
export function localeSignal(): ComputedAccessor<string>;
export function devicePixelRatioSignal(): ComputedAccessor<number>;
export function fullscreenSignal(): ComputedAccessor<Element | null>;
All eight return ComputedAccessor (read-only). All eight are
lazy singletons — first call registers exactly one listener,
subsequent calls return the cached accessor. All eight return
constant accessors in an SSR context.
Layered on ADR 0040 where it fits
The three prefers-* signals compose on top of mediaSignal:
export function prefersReducedMotionSignal(): ComputedAccessor<boolean> {
if (isSSR()) return compute(() => false);
const mq = mediaSignal('(prefers-reduced-motion: reduce)');
return compute(() => mq());
}
This reuses mediaSignal's per-query cache — calling
prefersReducedMotionSignal() and
mediaSignal('(prefers-reduced-motion: reduce)') share one
underlying MediaQueryList listener.
prefersColorSchemeSignal is the same shape but maps the boolean
to a typed string. prefersContrastSignal reads three queries
and reduces to a discriminated value.
Direct platform-event signals
The other five wrap events directly:
onlineSignal—navigator.onLineinitial value, thenonline/offlinewindow events.screenOrientationSignal— readsscreen.orientation.type(or falls back toinnerWidth > innerHeightwhen the API is unavailable), listens toscreen.orientationchange. Maps the four spec values (portrait-primary, etc.) down to'portrait'/'landscape'.localeSignal—navigator.languageinitial value, listens to thelanguagechangewindow event.devicePixelRatioSignal— readswindow.devicePixelRatio, watches(resolution: ${currentDPR}dppx)viamatchMedia. When that media query stops matching, the signal re-reads DPR and re-binds against the new value. This is the documented way to observe DPR changes.fullscreenSignal—document.fullscreenElementinitial value, listens tofullscreenchangeon document.
SSR defaults
Each constant SSR accessor returns the spec's "most likely" default branch so isomorphic code reads naturally without guards:
| Signal | SSR value |
|---|---|
onlineSignal |
true |
prefersColorSchemeSignal |
'light' |
prefersReducedMotionSignal |
false |
prefersContrastSignal |
'no-preference' |
screenOrientationSignal |
'portrait' |
localeSignal |
'en' |
devicePixelRatioSignal |
1 |
fullscreenSignal |
null |
Apps that need a request-aware default (e.g. Accept-Language
header drives localeSignal's server value) wire that up
outside this ADR — the constants are floors, not contracts.
Explicit non-features
- No setter on any signal. These reflect platform state;
the platform owns the source of truth. Apps wanting to
control color scheme write the class onto
document.documentElementthemselves; the signal will reflect the eventual platform value. - No
setLocale()/ language-override helper. Out of scope. Apps that internationalise build their owncurrentLocalesignal seeded fromlocaleSignal()plus user preference. - No
'portrait-primary' | 'portrait-secondary' | …granularity fromscreenOrientationSignal. The four-state return is rarely useful; apps that need it usescreen.orientation.typedirectly. - No
forced-colorssignal.forced-colorsis a real preference (Windows high-contrast mode), but it shares semantics withprefersContrastSignalfor most consumers. Worth a follow-up if apps actually need to distinguish them. - No SSR header sniffing. This ADR doesn't read
Accept-Language/Sec-CH-Prefers-Color-Scheme/Sec-CH-Viewport-Widthfrom the request. Header-driven defaults are a routing / loader concern; the signals consume the result.
Consequences
Positive:
- Eight named primitives replace eight different per-app
helpers people are writing anyway. Discoverability
(named import) and tree-shaking (one file each) are wins
over "just use
mediaSignal." - Three of the eight (
prefers-*) compose on top ofmediaSignal— they pay the cache cost only once, even if user code also importsmediaSignaldirectly with the same query. - SSR-safe by construction. Each signal's SSR fallback is a
constant
compute()— isomorphic code doesn't needtypeof window !== 'undefined'guards. - ~600 bytes gzipped for all eight combined (estimate from comparable shapes).
Negative:
- API surface grows from 36 to 44. Mitigated by: (a) the grouping ("environment signal") makes the increase coherent, (b) tree-shaking means apps pay only for what they import, (c) all eight follow the same shape — there's nothing new to learn after the first one.
devicePixelRatioSignal's re-bind dance is the most complex code in this ADR — every DPR change creates a newMediaQueryList. Acceptable: DPR changes are rare (zoom, monitor drag), so listener churn is minimal.- The
screen.orientationAPI is Baseline-but-not-universal. Older iOS Safari fell back towindow.orientation(deprecated). The signal degrades to theinnerWidth > innerHeightheuristic in that case; documented.
Neutral:
- Tests cover SSR constants + jsdom-driven event dispatch
for each signal.
matchMediaandscreen.orientationneed lightweight test mocks (jsdom doesn't ship either). - No SSR pipeline changes. Each signal is client-only by side effect.
Alternatives considered
Ship only the mediaSignal recipes in ADR 0040's docs;
skip the named wrappers. Rejected: onlineSignal,
localeSignal, screenOrientationSignal, devicePixelRatioSignal,
and fullscreenSignal aren't media-query-shaped. And for the
three that are, the typed return ('light' | 'dark' vs
boolean) is meaningfully more ergonomic than every app
writing the same ternary.
Bundle into one environment() factory that returns an
object with all eight as properties. Rejected: hostile to
tree-shaking. Apps that import only onlineSignal would pay
for all eight.
Make each signal a StateAccessor so apps can mock the
value in tests. Rejected: confuses the platform-state
contract. Tests mock the underlying browser API (matchMedia,
fullscreen, etc.) directly — same as they mock observers in
ADR 0040.
Expose screen.orientation.type (four-state) and let apps
narrow themselves. Rejected: 95% of callers want
'portrait' | 'landscape'. The four-state can be read from
screen.orientation.type directly when needed.
Cache devicePixelRatioSignal per discrete DPR value
(separate signal per DPR). Rejected: there's no use case for
"signal for DPR=2 vs signal for DPR=3"; the relevant signal is
"what is the current DPR?" — one singleton suffices.