@wcstack/screen-orientation
v3.0.0
Published
Declarative Screen Orientation component for Web Components. Framework-agnostic screen.orientation monitor + lock/unlock commands via wc-bindable-protocol.
Maintainers
Readme
@wcstack/screen-orientation
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@wcstack/screen-orientation is a headless Screen Orientation component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns screen.orientation into reactive state, and exposes lock()/unlock() as declarative commands.
With @wcstack/state, <wcs-screen-orientation> can be bound directly through path contracts:
- input surface: none —
screen.orientationis a single global with nothing to configure - output state surface:
type,angle,portrait,landscape,error,errorInfo
Why this exists — a monitor/command asymmetry unique in this batch
Unlike @wcstack/network (a pure monitor), this node is bidirectional: it monitors orientation and exposes lock()/unlock() commands. This produces a notable internal asymmetry:
- Monitoring needs no
_gengeneration guard. Subscribing toscreen.orientation'schangeevent is fully synchronous — there is no asynchronous probe whose stale resolution could race adispose()(same reasoning as@wcstack/network). lock()does need one. It is asynchronous and in-flight; a stalelock()resolving after a newerlock()/unlock()call must not clobber the state that call already established. This guard is entirely independent of the monitoring path.
lock()is best-effort. This is not a desktop-vs-mobile split: most current browsers, desktop and mobile alike, reject a plain-tablock()call unless the document is fullscreen or running as an installed PWA (Safari does not supportlock()at all, in any context). The rejection's error name varies by browser and cause —NotAllowedError(current spec, fullscreen pre-lock condition unmet),NotSupportedError(locking to that orientation unsupported), orSecurityError(older implementations) — so do not branch on a specific name. Never-throw: failures land inerror, not as a rejected promise from the caller's perspective.
Install
npm install @wcstack/screen-orientationQuick Start
1. Read live orientation
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/screen-orientation/auto"></script>
<wcs-state>
<script type="module">
export default {
portrait: true,
};
</script>
</wcs-state>
<wcs-screen-orientation data-wcs="portrait: portrait"></wcs-screen-orientation>
<template data-wcs="if: portrait|not">
<p>Please rotate your device to portrait.</p>
</template>One timing rule applies to this example: <wcs-screen-orientation> publishes its snapshot through wcs-orientation:change events, and the first snapshot fires synchronously at connect — before @wcstack/state has attached its binding listeners. The initial value still arrives, because every observable property on this node is output-only (declared in properties, absent from inputs): that makes the default binding authority element, so the binding reads the property directly when it attaches instead of waiting for an event it already missed (directional initial sync, on by default since v1.21.0). A device already in landscape at load time is reflected with no manual pull (see Notes & limitations).
2. Lock orientation on command
<wcs-screen-orientation data-wcs="command.lock: $command.lockLandscape; error: lockError"></wcs-screen-orientation>
<button data-wcs="onclick: lockLandscape">Lock to landscape</button>export default {
lockError: null,
$commandTokens: ["lockLandscape"],
lockLandscape() {
this.$command.lockLandscape.emit("landscape");
},
};In a plain tab like this, clicking the button will not actually lock anything: current browsers reject lock() (surfacing in lockError) unless the document is fullscreen or running as an installed PWA. To see the lock take hold, pair this with a fullscreen trigger — e.g. <wcs-fullscreen> — and call lock() after entering fullscreen (see Notes & limitations).
Observable Properties (outputs)
| Property | Event | Description |
| ----------- | ---------------------- | ------------ |
| type | wcs-orientation:change | screen.orientation.type (e.g. "portrait-primary"), or null when unsupported. |
| angle | wcs-orientation:change | screen.orientation.angle, or null when unsupported. |
| portrait | wcs-orientation:change | true when type starts with "portrait". |
| landscape | wcs-orientation:change | true when type starts with "landscape". |
| error | wcs-orientation:error | The last lock()/unlock() failure, or null. |
| errorInfo | wcs-orientation:error-info-changed | Serializable failure taxonomy (WcsIoErrorInfo: stable code / phase / recoverable) derived from error, or null. Additive — the error shape is unchanged. |
type/angle/portrait/landscape all derive from the single wcs-orientation:change event.
Commands
| Command | Async | Description |
| -------- | ----- | ------------ |
| lock | yes | Request a specific orientation lock (e.g. "landscape", "portrait-primary"). Value passed through verbatim — never-throw; an unrecognized string or unsupported environment surfaces via error. |
| unlock | no | Release a previously requested lock. Synchronous, mirroring the platform API. |
Attributes / Inputs
None. screen.orientation is a single global; there is nothing per-instance to configure.
Notes & limitations
- No secure-context requirement for monitoring (unlike
@wcstack/geolocation/@wcstack/permission). lock()needs a fullscreen or installed-PWA context — not a desktop-vs-mobile split. A plain-tab call typically rejects on both desktop and mobile (asNotAllowedError/NotSupportedError/SecurityErrordepending on browser and cause — do not branch on the name); Safari does not implementlock()at all. Design any UI around it being best-effort, and pair it with an explicit fullscreen entry point (e.g.@wcstack/fullscreen) when the lock actually needs to take hold.- The initial snapshot event misses bindings, but the value still arrives. The first
wcs-orientation:changefires synchronously duringconnectedCallback— before@wcstack/stateattaches its binding listeners (binding setup is deferred to a later microtask; seedocs/timing-and-firing-contract.md§4.1) — and events are not replayed to late subscribers. The value is not lost, because every observable property here is output-only (propertiesonly, neverinputs), which makes the default binding authorityelement: the binding reads the property directly when it attaches (directional initial sync, on by default since v1.21.0). Soportrait/landscape/type/angleare correct on first paint with no manual pull, and this holds for every monitor node. Only if you explicitly setenableDirectionalInitialSync: falsedo you need the older$connectedCallback+whenDefinedpull again. Seedocs/timing-and-firing-contract.md§7 for the full firing/generation contract (initial snapshot,lock()generation ordering,errordedup). errorInfotaxonomy (additive). Alongsideerror,<wcs-screen-orientation>publishes a serializableerrorInfo(wcs-orientation:error-info-changed— note thewcs-orientation:namespace, not the tag name) that classifies the samelock()/unlock()failure into a stableWcsIoErrorInfo(code/phase/recoverable), without changing theerrorshape. A missingscreen.orientation/ method (synthetic "unsupported") →capability-missing(phaseprobe); the plain-tab lock rejectionsNotAllowedError/NotSupportedError/SecurityErrorall fold to a singlenot-allowed(phaseexecute,recoverable: false— matching the "don't branch on the name" model above); anAbortError(superseded by a newerlock()) →aborted(phaseexecute,recoverable: true— a freshlock()may still succeed); anything else (e.g.InvalidStateError, a raw throw, a missing.name) →orientation-error(phaseexecute).errorInfotransitions exactly whenerrordoes (cleared tonullon recovery); the sharedWcsIoErrorInfotype and theWCS_SCREEN_ORIENTATION_ERROR_CODEconstants are exported.- SSR (
@wcstack/server). Declaresstatic hasConnectedCallbackPromise = true; since monitoring is synchronous,connectedCallbackPromisealways settles immediately.
CSS styling with :state()
<wcs-screen-orientation> reflects boolean output states onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| portrait | wcs-orientation:change fires with a type that starts with "portrait" |
| landscape | wcs-orientation:change fires with a type that starts with "landscape" |
| error | wcs-orientation:error fires with a non-null detail (cleared on null) |
portrait and landscape are mutually exclusive and both fall off when type
is null (unsupported environment). Note the event namespace is
wcs-orientation:, not the tag name wcs-screen-orientation; angle is not
reflected (continuous value, excluded by design — see
docs/custom-state-reflection-design.md §3.2).
wcs-screen-orientation:state(portrait) ~ .portrait-hint { display: block; }
wcs-screen-orientation:state(landscape) ~ .landscape-hint { display: block; }
form:has(wcs-screen-orientation:state(error)) .banner { display: block; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-screen-orientation> itself keeps working
normally (graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-screen-orientation:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["portrait"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes ontodata-wcs-state-portrait/data-wcs-state-landscape/data-wcs-state-errorattributes on the element, so the Elements panel highlights them as they toggle:<wcs-screen-orientation debug-states></wcs-screen-orientation>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attributes exist purely to make state changes visible while debugging with
DevTools open; they are not a supported styling hook.
Headless usage (ScreenOrientationCore)
import { ScreenOrientationCore } from "@wcstack/screen-orientation";
const core = new ScreenOrientationCore();
core.addEventListener("wcs-orientation:change", (e) => {
console.log((e as CustomEvent).detail); // { type, angle }
});
core.observe();
await core.lock("landscape");
console.log(core.error);
// later:
core.dispose();The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
Accessibility
WCAG 1.3.4 Orientation (AA): content must not restrict itself to a single display orientation unless that orientation is essential (a bank-check scanner, a piano keyboard). A user with the device mounted on a wheelchair cannot rotate it. This README is precise about when lock() fails — the same precision applies to when it should be called: lock only when the orientation is essential to the task, and give the user a visible way back (a control wired to the unlock command). Monitoring (type / angle / portrait / landscape) carries no such obligation — adapting your layout to the reported orientation is exactly what 1.3.4 wants.
License
MIT
