@wcstack/notification
v1.31.0
Published
Declarative desktop-notification component for Web Components. Framework-agnostic Notifications API wrapper via wc-bindable-protocol, with Service Worker support.
Maintainers
Readme
@wcstack/notification
🤖 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/notification is a headless desktop-notification component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns the Notifications API into reactive state and a state-driven command — the same way @wcstack/geolocation turns the device's location into reactive state.
With @wcstack/state, <wcs-notify> can be bound directly through path contracts:
- command surface:
request,notify,close,closeAll - input surface:
notice(reactive show),mode,body,icon,badge,tag,lang,dir,require-interaction,silent,renotify,manual - output state surface:
permission,granted,denied,prompt,unsupported,error,errorInfo,clicked,closed,shown
@wcstack/notification follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
NotificationCore) handles permission, showing (constructor or Service Worker), and click relaying - Shell (
<wcs-notify>) connects that to DOM attributes, the reactivenoticeinput, and lifecycle - Binding Contract (
static wcBindable) declares observableproperties,inputs, andcommands
Why this exists — both directions in one tag
Most wcstack IO nodes lean one way: <wcs-permission> only watches (it has no commands, because the Permissions API has no request()); <wcs-speak>/<wcs-listen> split a duo across two tags. The Notifications API is different — it is genuinely bidirectional in a single API:
- show is a command (state → element):
notify(title, options). - click / close / show are events (element → state): the user interacting with the OS notification.
So <wcs-notify> is the first wcstack node where the command-token (show) and event-token (click) directions live together in one tag. And unlike <wcs-permission>, the Notifications API does have Notification.requestPermission(), so this node is self-contained: it both requests/monitors the permission and shows notifications.
Secure context required. The Notifications API only works in a secure context (HTTPS, or
localhost). Where it is absent,<wcs-notify>reportspermission = "unsupported"instead of throwing. Requesting permission and showing also typically require a user gesture — firingnotifyfrom a timer may show nothing.
Install
npm install @wcstack/notificationQuick Start
1. Ask, then show — from state
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/notification/auto"></script>
<wcs-state>
<script type="module">
export default {
$commandTokens: ["request", "notify"],
$eventTokens: ["opened"],
ask() { this.$command.request.emit(); },
send() { this.$command.notify.emit("New message", { body: "Tap to open", tag: "chat", data: { room: 7 } }); },
$on: {
opened: (state, event) => { console.log("clicked", event.detail); }, // { tag, data, action }
},
};
</script>
</wcs-state>
<wcs-notify data-wcs="
command.request: $command.request;
command.notify: $command.notify;
eventToken.clicked: opened
"></wcs-notify>
<button data-wcs="onclick: ask">Allow notifications</button>
<button data-wcs="onclick: send">New message</button>The positional args of notify.emit(title, options) pass straight through to notify(title, options) — the same argument-forwarding contract used by <wcs-speak>/<wcs-fetch>.
2. Reactive notice vs imperative notify
<!-- reactive: shows whenever the bound value *changes* (same-value guard). -->
<wcs-notify data-wcs="notice: statusMessage | debounce(1000)"></wcs-notify>notice is the declarative counterpart of notify: writing a changed value shows it; an identical write is suppressed. The imperative notify command fires every call (even the same text). Auto-firing a notification on every state change risks spam, so debounce the bound source and prefer a tag so the OS de-dups.
3. Permission as bindable state
<wcs-notify data-wcs="permission: notifyPerm; granted: canNotify"></wcs-notify>
<!-- one boolean, straight from the node -->
<div data-wcs="hidden: canNotify">Allow notifications to get alerts.</div>permission is "prompt" / "granted" / "denied" / "unsupported". The Notifications API's own "default" is normalized to "prompt", so this node shares the exact four-value surface of @wcstack/permission / @wcstack/geolocation.
4. Reading the click
<wcs-notify data-wcs="command.notify: $command.notify; eventToken.clicked: opened"></wcs-notify>clicked / closed / shown carry { tag, data, action }. tag identifies the notification (your options.tag, or a generated wcs-<n> when omitted); data is whatever you passed in options.data; action is the Service Worker action-button id (always "" for the constructor backend).
See examples/state-notification-chat for the full demo.
Service Worker / mobile
new Notification() works on desktop only. On Android Chrome it throws, and ServiceWorkerRegistration.showNotification() is required. <wcs-notify> picks the backend per mode:
| mode | Behavior |
| ------------- | --------------------------------------------------------------------------------- |
| auto (default) | Try the Notification constructor; on a TypeError (mobile), fall back to the SW. |
| constructor | Constructor only; a TypeError surfaces as an error (no fallback). |
| sw | Always ServiceWorkerRegistration.showNotification(). |
The SW's notificationclick fires inside your Service Worker, which this package cannot inject into. Import the one-line helper so clicks relay back to the page:
// your sw.js
import { wireNotificationClicks } from "@wcstack/notification/sw";
wireNotificationClicks();It relays each click over BroadcastChannel("wcs-notify") (primary) and clients.postMessage (fallback); NotificationCore on the page de-dups the two transports and emits wcs-notify:click.
Attributes / Inputs
| Attribute | Type | Default | Description |
| --------------------- | ------- | ------- | --------------------------------------------------------------------------- |
| mode | string | auto | Show backend: auto / sw / constructor. |
| body | string | "" | Notification body text. |
| icon | string | "" | Icon URL. |
| badge | string | "" | Badge URL (monochrome, mobile). |
| tag | string | "" | Notification tag (the OS replaces a notification with the same tag). |
| lang | string | "" | Language tag. |
| dir | string | "" | Text direction: auto / ltr / rtl. |
| require-interaction | boolean | false | Keep the notification visible until the user dismisses it. |
| silent | boolean | false | Suppress sound/vibration. |
| renotify | boolean | false | Re-alert when replacing a same-tag notification. |
| manual | boolean | false | Mute the reactive notice path (the notify command still works). |
notice is a reactive input (no attribute): writing a changed value shows a notification. Per-call notify(title, options) options win per-key over these attribute defaults.
Observable Properties (outputs)
| Property | Event | Description |
| ------------- | ------------------------------ | ----------------------------------------------------------------- |
| permission | wcs-notify:permission-change | "prompt" / "granted" / "denied" / "unsupported", live. |
| granted / denied / prompt / unsupported | wcs-notify:permission-change | Convenience booleans derived from permission. |
| error | wcs-notify:error | { error, message } on a failure (never-throw), else null. |
| errorInfo | wcs-notify:error-info-changed | Serializable failure taxonomy (WcsIoErrorInfo: stable code / phase / recoverable) on a failure, else null. Additive — derived from error, whose shape is unchanged. |
| clicked | wcs-notify:click | { tag, data, action } of the last click (event-token source). |
| closed | wcs-notify:close | { tag, data, action } of the last close. |
| shown | wcs-notify:show | { tag, data, action } of the last shown notification. |
Commands
| Command | Description |
| ------------ | --------------------------------------------------------------------------------- |
| request() | Notification.requestPermission(); resolves to the normalized permission state. |
| notify(title, options?) | Show a notification; returns its identifying tag. |
| close(tag) | Dismiss the notification(s) with tag. |
| closeAll() | Dismiss every notification this element has shown. |
CSS styling with :state()
<wcs-notify> reflects five 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 |
|-------|---------|
| granted | wcs-notify:permission-change fires with "granted" |
| denied | wcs-notify:permission-change fires with "denied" |
| prompt | wcs-notify:permission-change fires with "prompt" |
| unsupported | wcs-notify:permission-change fires with "unsupported" |
| error | wcs-notify:error fires with a non-null detail (cleared on null) |
granted / denied / prompt / unsupported are a mutually exclusive
group: each permission-change event sets exactly one of the four and
clears the other three in the same pass.
wcs-notify:state(denied) ~ .fallback { display: block; }
wcs-notify:state(unsupported) ~ .fallback { display: block; }
form:has(wcs-notify: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-notify> 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-notify: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.["granted"]). 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-granted/data-wcs-state-denied/data-wcs-state-prompt/data-wcs-state-unsupported/data-wcs-state-errorattributes on the element, so the Elements panel highlights them as they toggle:<wcs-notify debug-states></wcs-notify>
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.
Notes & limitations
- Notifications outlive the page. Disconnecting
<wcs-notify>(or callingdispose()on the Core) detaches its subscriptions but does not close open notifications — a notification is meant to persist past the page. Useclose/closeAllto dismiss. modeis fixed at connect time. The backend is chosen once, when the element connects (observe(mode)); there is noobservedAttributesentry formode. Changing themodeattribute on an already-connected element has no effect until you re-connect it (remove and re-insert).- The SW backend does not relay
close. The@wcstack/notification/swhelper wiresnotificationclickonly, notnotificationclose. So under theswbackendclosed/wcs-notify:closenever fires (there is no cross-worker relay for close).clickedstill works via the click relay;closedis a constructor-backend-only signal. - Push API is out of scope. This package wraps the Notifications API (local notifications). Server-initiated Push is a separate concern.
- Silent failure handling (zero-log). Consistent with wcstack's zero-dependency philosophy,
<wcs-notify>never logs or throws. A missing API →permission = "unsupported"; a not-granted permission or a show failure → theerrorproperty. Binderror/permissionto react. errorInfotaxonomy (additive). Alongsideerror,<wcs-notify>publishes a serializableerrorInfo(wcs-notify:error-info-changed) that classifies the same failure into a stableWcsIoErrorInfo(code/phase/recoverable), without changing theerrorshape. The Core's own stable error code maps straight through:unsupported→capability-missing(phaseprobe); a not-granted permission →not-allowedand a non-string title →invalid-argument(both phasestart); a show failure →show-failedand a missing Service Worker backend →no-service-worker(both phaseexecute); any unexpected code folds defensively tonotify-error(phaseexecute). All six arerecoverable: false.errorInfotransitions exactly whenerrordoes (cleared tonullon recovery); the sharedWcsIoErrorInfotype and theWCS_NOTIFY_ERROR_CODEconstants are exported.- SSR (
@wcstack/server). Declaresstatic hasConnectedCallbackPromise = trueand exposesconnectedCallbackPromise, so the server renderer waits for the connect-time permission probe before snapshotting.
Configuration
<wcs-notify> ships a click autoTrigger that is enabled by default. When enabled, the first <wcs-notify> to connect installs a single document-level click listener. A click on any element carrying the trigger attribute (data-notifytarget="<id>") calls notify() on the <wcs-notify> with that id:
<wcs-notify id="app-notify"></wcs-notify>
<button data-notifytarget="app-notify" data-notifybody="You have 1 new message">
Notify me
</button>- The title is
data-notifytitleif present, otherwise the trigger element's trimmedtextContent; the body is the optionaldata-notifybody. - Because it is a document-wide click listener installed by default, disable it if you do not use the shortcut. Configuration is applied through
bootstrapNotification(userConfig?)(which registers the element and applies the config in one call);getConfig()reads back the effective, deep-frozen config:
import { bootstrapNotification, getConfig } from "@wcstack/notification";
// Register <wcs-notify> and opt out of the document click listener:
bootstrapNotification({ autoTrigger: false });
// Or rename the trigger attribute:
bootstrapNotification({ triggerAttribute: "data-notify" });
getConfig(); // read the (deep-frozen) effective config| Config key | Type | Default | Description |
| ------------------ | --------- | ------------------- | ----------------------------------------------------------------- |
| autoTrigger | boolean | true | Install the document-level click listener for data-notifytarget. |
| triggerAttribute | string | data-notifytarget | Attribute a clickable element uses to target a <wcs-notify> by id. |
| tagNames.notify | string | wcs-notify | Custom-element tag name to register. |
Call setConfig() / bootstrapNotification() before the elements connect for the change to take effect.
Headless usage (NotificationCore)
The Core has no DOM dependency and can be used directly with bind() from @wc-bindable/core:
import { NotificationCore } from "@wcstack/notification";
const notify = new NotificationCore();
await notify.observe(); // start watching permission + click relays
await notify.request(); // ask the user
notify.addEventListener("wcs-notify:click", (e) => {
console.log((e as CustomEvent).detail); // { tag, data, action }
});
const tag = notify.notify("Hello", { body: "world", data: { room: 1 } });
// later:
notify.close(tag);
notify.dispose(); // detach subscriptions (open notifications stay)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.
License
MIT
