Mercurial
changeset 252:7a7581f040e8
[ui] Add scoped notification component
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 04 Aug 2026 11:57:16 -0700 |
| parents | 117c4d53c9a4 |
| children | fdf3816959cb |
| files | .claude/skills/zenbu-design-system/SKILL.md design_system/NOTIFICATIONS.md design_system/README.md design_system/main.c design_system/src/components/index.js design_system/src/components/notifications.js design_system/src/index.html design_system/src/storybook.js design_system/src/styles/components.css design_system/src/styles/tokens.css design_system/test/storybook_test.js |
| diffstat | 11 files changed, 1284 insertions(+), 1 deletions(-) [+] |
line wrap: on
line diff
--- a/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 09:14:57 2026 -0700 +++ b/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 11:57:16 2026 -0700 @@ -20,6 +20,10 @@ - Wrap native interactive elements instead of recreating form/link semantics. - Do not add framework or runtime dependencies. - Keep application state outside components. +- Keep notification action tokens opaque and out of DOM attributes; use scoped, + non-composed `zen-notify` events and bounded queues. +- Keep `zen-notifications` behavior aligned with + `design_system/NOTIFICATIONS.md`. - Preserve keyboard, form, validity, label, and ARIA behavior. - Prefix custom elements and tokens with `zen-` / `--zen-`. - Keep selectors low-specificity so applications can override tokens.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/NOTIFICATIONS.md Tue Aug 04 11:57:16 2026 -0700 @@ -0,0 +1,97 @@ +# Zenbu UI notifications + +`zen-notifications` is a light-DOM, framework-free notification scope. Its +stack is fixed to the bottom-right by default and styled only with `--zen-*` +tokens. + +```html +<zen-notifications aria-label="Notifications"> + <main id="application"></main> +</zen-notifications> +``` + +## Notify + +Descendants dispatch a bubbling, non-composed event: + +```js +source.dispatchEvent(new CustomEvent("zen-notify", { + bubbles: true, + composed: false, + detail: { + version: 1, + id: "repository-removed", + tone: "success", + message: "Repository removed", + description: "The local checkout was preserved.", + announcement: "polite", + durationMs: 5000, + action: { + token: "opaque-action-token", + label: "Undo", + }, + }, +})); +``` + +Fields: + +| Field | Values | +| --- | --- | +| `version` | must be `1` | +| `id` | stable non-empty string | +| `tone` | `info`, `success`, `warning`, or `error` | +| `message` | required text | +| `description` | optional text | +| `announcement` | `none`, `polite`, or `assertive` | +| `durationMs` | 250–120000; defaults to 5000 | +| `persistent` | disables timeout when `true` | +| `action` | opaque token and visible label | + +The same ID updates and moves the existing record instead of creating a +duplicate. An ID is announced once during its current lifetime. + +## Dismiss and actions + +Dismiss through the public method or a scoped event: + +```js +notifications.dismiss("repository-removed"); + +source.dispatchEvent(new CustomEvent("zen-dismiss-notification", { + bubbles: true, + composed: false, + detail: { version: 1, id: "repository-removed" }, +})); +``` + +Action buttons dispatch `zen-notification-action` with +`{ version, id, token }`. The opaque token remains in private component state +and is never written into DOM attributes or source markup. + +Removal dispatches `zen-notification-removed` with `{ version, id, reason }`. + +## Bounds and timing + +- Three records are rendered. +- Twenty additional records may remain queued. +- Overflow evicts the oldest finite record; all-persistent queues reject new + records. +- Queued records do not count down. +- Finite timers pause while hovered, while focus remains inside, and while the + document is hidden. +- Disconnecting clears records, timers, announcements, and internal DOM. + +## Accessibility + +- Polite and assertive announcements use separate persistent live regions. +- Announcement priority is explicit and independent of tone. +- Announcement messages are serialized so bursts remain observable. +- Actions and dismissal use native buttons. +- New notifications never move focus. +- Keyed rendering preserves focused controls when unrelated records change. +- Forced-colors and reduced-motion modes are supported by component CSS. + +Events with `composed: true` are rejected. This keeps nested application scopes +isolated; adapters crossing a ShadowRoot must validate and redispatch a new +non-composed event deliberately.
--- a/design_system/README.md Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/README.md Tue Aug 04 11:57:16 2026 -0700 @@ -60,12 +60,49 @@ | `zen-card` | direct `article` or `section` | `interactive` | native descendant events | | `zen-alert` | message HTML | `tone`, `dismissible` | `zen-dismiss` | | `zen-field` | direct `label`, form control, help text | native control attributes | native input/change/invalid | +| `zen-notifications` | descendant event producers | `aria-label`, `dismiss-label` | `zen-notification-action`, `zen-notification-removed` | | `zen-stack` | any HTML | `direction`, `gap` | native descendant events | Do not put business state into the design-system components. They provide presentation, small accessibility wiring, and interaction affordances while applications retain data and workflow ownership. +### Notifications + +See [`NOTIFICATIONS.md`](NOTIFICATIONS.md) for the complete event contract, +queue semantics, actions, timing, and accessibility behavior. + +Place one scope around the part of the application that owns notifications: + +```html +<zen-notifications aria-label="Notifications"> + <main id="application"></main> +</zen-notifications> +``` + +Any descendant can dispatch a non-composed bubbling event: + +```js +source.dispatchEvent(new CustomEvent("zen-notify", { + bubbles: true, + composed: false, + detail: { + version: 1, + id: "deployment-complete", + tone: "success", + message: "Deployment complete", + announcement: "polite", + durationMs: 5000, + }, +})); +``` + +Use `zen-dismiss-notification` with `{ version: 1, id }` to dismiss by ID. +Actions contain only an opaque token and label; clicking dispatches +`zen-notification-action`. Tokens are held in private component state and never +written to DOM attributes. The component renders three records and retains up +to 20 additional queued records. + ## Tests ```bash
--- a/design_system/main.c Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/main.c Tue Aug 04 11:57:16 2026 -0700 @@ -76,6 +76,7 @@ Seobeo_Router_Register("GET", "/components/card", Get_Component_Catalog); Seobeo_Router_Register("GET", "/components/alert", Get_Component_Catalog); Seobeo_Router_Register("GET", "/components/field", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/notifications", Get_Component_Catalog); Seobeo_Router_Register("GET", "/components/stack", Get_Component_Catalog); const char *port = getenv("DESIGN_SYSTEM_PORT");
--- a/design_system/src/components/index.js Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/src/components/index.js Tue Aug 04 11:57:16 2026 -0700 @@ -2,5 +2,10 @@ export { ZenButton } from "./button.js"; export { ZenCard } from "./card.js"; export { ZenField } from "./field.js"; +export { + ZenNotifications, + validateNotification, + validateNotificationDismiss, +} from "./notifications.js"; export { ZenStack } from "./stack.js"; export { ZenStory } from "./story.js";
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/notifications.js Tue Aug 04 11:57:16 2026 -0700 @@ -0,0 +1,482 @@ +const MAX_VISIBLE = 3; +const MAX_QUEUED = 20; +const MAX_RECORDS = MAX_VISIBLE + MAX_QUEUED; +const DEFAULT_DURATION_MS = 5000; +const MIN_DURATION_MS = 250; +const MAX_DURATION_MS = 120000; +const TONES = new Set(["info", "success", "warning", "error"]); +const ANNOUNCEMENTS = new Set(["none", "polite", "assertive"]); + +function isShortString(value, maximum, allowEmpty = false) { + return typeof value === "string" && + value.length <= maximum && + (allowEmpty || value.trim().length > 0); +} + +export function validateNotification(value) { + if (!value || + value.version !== 1 || + !isShortString(value.id, 128) || + !TONES.has(value.tone) || + !isShortString(value.message, 500) || + !ANNOUNCEMENTS.has(value.announcement)) return null; + if (value.description !== undefined && + !isShortString(value.description, 1000, true)) return null; + if (value.persistent !== undefined && + typeof value.persistent !== "boolean") return null; + if (value.durationMs !== undefined && + (!Number.isFinite(value.durationMs) || + value.durationMs < MIN_DURATION_MS || + value.durationMs > MAX_DURATION_MS)) return null; + + let action; + if (value.action !== undefined) { + if (!value.action || + !isShortString(value.action.token, 256) || + !isShortString(value.action.label, 120)) return null; + action = Object.freeze({ + token: value.action.token, + label: value.action.label, + }); + } + + return Object.freeze({ + version: 1, + id: value.id, + tone: value.tone, + message: value.message, + description: value.description || "", + announcement: value.announcement, + durationMs: value.durationMs ?? DEFAULT_DURATION_MS, + persistent: value.persistent === true, + action, + }); +} + +export function validateNotificationDismiss(value) { + if (!value || + value.version !== 1 || + !isShortString(value.id, 128)) return null; + return value.id; +} + +function makeRecord(notification, announced = false) { + return { + ...notification, + announced, + pauseReasons: new Set(), + remainingMs: notification.durationMs, + startedAt: 0, + timer: null, + view: null, + }; +} + +export class ZenNotifications extends HTMLElement { + static version = 1; + + #records = new Map(); + #announcementQueues = { + polite: [], + assertive: [], + }; + #announcementTimers = { + polite: null, + assertive: null, + }; + #announcementRunning = { + polite: false, + assertive: false, + }; + + constructor() { + super(); + this.handleNotify = event => { + if (!(event instanceof CustomEvent) || + !event.bubbles || + event.composed) return; + event.stopPropagation(); + this.notify(event.detail); + }; + this.handleDismiss = event => { + if (!(event instanceof CustomEvent) || + !event.bubbles || + event.composed) return; + event.stopPropagation(); + const id = validateNotificationDismiss(event.detail); + if (id) this.dismiss(id, "programmatic"); + }; + this.handleVisibility = () => { + for (const record of this.#records.values()) { + if (document.hidden) this.pause(record, "hidden"); + else this.resume(record, "hidden"); + } + }; + } + + connectedCallback() { + if (!this.stack) this.createInternalElements(); + this.addEventListener("zen-notify", this.handleNotify); + this.addEventListener( + "zen-dismiss-notification", + this.handleDismiss, + ); + document.addEventListener( + "visibilitychange", + this.handleVisibility, + ); + this.render(); + } + + disconnectedCallback() { + this.removeEventListener("zen-notify", this.handleNotify); + this.removeEventListener( + "zen-dismiss-notification", + this.handleDismiss, + ); + document.removeEventListener( + "visibilitychange", + this.handleVisibility, + ); + for (const record of this.#records.values()) { + this.clearTimer(record); + } + this.#records.clear(); + for (const priority of ["polite", "assertive"]) { + clearTimeout(this.#announcementTimers[priority]); + this.#announcementTimers[priority] = null; + this.#announcementQueues[priority].length = 0; + this.#announcementRunning[priority] = false; + } + for (const element of this.querySelectorAll( + ":scope > [data-zen-notifications-internal]", + )) { + element.remove(); + } + this.stack = null; + this.politeRegion = null; + this.assertiveRegion = null; + } + + createInternalElements() { + const label = this.getAttribute("aria-label") || "Notifications"; + this.stack = document.createElement("section"); + this.stack.dataset.zenNotificationStack = ""; + this.stack.dataset.zenNotificationsInternal = ""; + this.stack.setAttribute("role", "region"); + this.stack.setAttribute("aria-label", label); + + this.politeRegion = document.createElement("span"); + this.politeRegion.dataset.zenLive = "polite"; + this.politeRegion.dataset.zenNotificationsInternal = ""; + this.politeRegion.className = "zen-visually-hidden"; + this.politeRegion.setAttribute("aria-live", "polite"); + this.politeRegion.setAttribute("aria-atomic", "true"); + + this.assertiveRegion = document.createElement("span"); + this.assertiveRegion.dataset.zenLive = "assertive"; + this.assertiveRegion.dataset.zenNotificationsInternal = ""; + this.assertiveRegion.className = "zen-visually-hidden"; + this.assertiveRegion.setAttribute("aria-live", "assertive"); + this.assertiveRegion.setAttribute("aria-atomic", "true"); + + this.append( + this.stack, + this.politeRegion, + this.assertiveRegion, + ); + } + + get size() { + return this.#records.size; + } + + get visibleCount() { + return Math.min(this.#records.size, MAX_VISIBLE); + } + + notify(value) { + const notification = validateNotification(value); + if (!notification) return false; + + const previous = this.#records.get(notification.id); + if (!previous && this.#records.size >= MAX_RECORDS) { + const oldestFinite = [...this.#records.values()].find( + record => !record.persistent, + ); + if (!oldestFinite) return false; + this.removeRecord(oldestFinite.id, "overflow", false); + } + + let record; + if (previous) { + this.clearTimer(previous); + Object.assign(previous, notification); + previous.remainingMs = notification.durationMs; + this.#records.delete(notification.id); + record = previous; + } else { + record = makeRecord(notification); + } + this.#records.set(notification.id, record); + this.render(); + return true; + } + + dismiss(id, reason = "dismissed") { + if (!isShortString(id, 128) || !this.#records.has(id)) { + return false; + } + this.removeRecord(id, reason, true); + return true; + } + + removeRecord(id, reason, render) { + const record = this.#records.get(id); + if (!record) return; + this.clearTimer(record); + this.#records.delete(id); + record.view?.article.remove(); + record.view = null; + this.dispatchEvent(new CustomEvent("zen-notification-removed", { + bubbles: true, + composed: false, + detail: Object.freeze({ + version: 1, + id, + reason, + }), + })); + if (render) this.render(); + } + + clearTimer(record) { + if (!record.timer) return; + clearTimeout(record.timer); + record.remainingMs = Math.max( + 0, + record.remainingMs - (performance.now() - record.startedAt), + ); + record.timer = null; + record.startedAt = 0; + } + + pause(record, reason) { + if (record.persistent || record.pauseReasons.has(reason)) return; + record.pauseReasons.add(reason); + this.clearTimer(record); + } + + resume(record, reason) { + if (record.persistent) return; + record.pauseReasons.delete(reason); + this.schedule(record); + } + + schedule(record) { + if (record.persistent || + record.timer || + record.pauseReasons.size > 0 || + !this.#records.has(record.id)) return; + if (record.remainingMs <= 0) { + this.dismiss(record.id, "timeout"); + return; + } + record.startedAt = performance.now(); + record.timer = setTimeout(() => { + record.timer = null; + record.remainingMs = 0; + this.dismiss(record.id, "timeout"); + }, record.remainingMs); + } + + announce(record) { + if (record.announced) return; + record.announced = true; + if (record.announcement === "none") return; + this.#announcementQueues[record.announcement].push({ + id: record.id, + message: record.message, + }); + this.runAnnouncementQueue(record.announcement); + } + + runAnnouncementQueue(priority) { + if (this.#announcementRunning[priority]) return; + const next = this.#announcementQueues[priority].shift(); + if (!next) return; + this.#announcementRunning[priority] = true; + const region = priority === "assertive" + ? this.assertiveRegion + : this.politeRegion; + region.textContent = ""; + this.#announcementTimers[priority] = setTimeout(() => { + if (this.isConnected && this.#records.has(next.id)) { + region.textContent = next.message; + } + this.#announcementTimers[priority] = setTimeout(() => { + this.#announcementTimers[priority] = null; + this.#announcementRunning[priority] = false; + this.runAnnouncementQueue(priority); + }, 120); + }, 16); + } + + createNotificationElement(record) { + if (!record.view) { + const article = document.createElement("article"); + article.dataset.zenNotificationId = record.id; + + const tone = document.createElement("span"); + tone.className = "zen-notification-tone"; + tone.setAttribute("aria-hidden", "true"); + + const content = document.createElement("div"); + content.className = "zen-notification-content"; + const message = document.createElement("p"); + message.className = "zen-notification-message"; + content.append(message); + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.className = "zen-notification-dismiss"; + dismiss.textContent = "×"; + dismiss.addEventListener("click", () => { + this.dismiss(record.id, "dismissed"); + }); + + article.addEventListener("pointerenter", () => { + this.pause(record, "hover"); + }); + article.addEventListener("pointerleave", () => { + this.resume(record, "hover"); + }); + article.addEventListener("focusin", () => { + this.pause(record, "focus"); + }); + article.addEventListener("focusout", event => { + if (!article.contains(event.relatedTarget)) { + this.resume(record, "focus"); + } + }); + + article.append(tone, content, dismiss); + record.view = { + action: null, + article, + content, + description: null, + dismiss, + message, + tone, + }; + } + + const view = record.view; + view.article.dataset.tone = record.tone; + view.tone.textContent = { + info: "i", + success: "✓", + warning: "!", + error: "×", + }[record.tone]; + view.message.textContent = record.message; + view.dismiss.setAttribute( + "aria-label", + this.getAttribute("dismiss-label") || "Dismiss notification", + ); + + if (record.description) { + if (!view.description) { + view.description = document.createElement("p"); + view.description.className = "zen-notification-description"; + view.content.insertBefore( + view.description, + view.action, + ); + } + view.description.textContent = record.description; + } else if (view.description) { + view.description.remove(); + view.description = null; + } + + if (record.action) { + if (!view.action) { + view.action = document.createElement("button"); + view.action.type = "button"; + view.action.className = "zen-notification-action"; + view.action.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("zen-notification-action", { + bubbles: true, + composed: false, + detail: Object.freeze({ + version: 1, + id: record.id, + token: record.action.token, + }), + })); + }); + view.content.append(view.action); + } + view.action.textContent = record.action.label; + } else if (view.action) { + view.action.remove(); + view.action = null; + } + return view.article; + } + + render() { + if (!this.stack) return; + const records = [...this.#records.values()]; + const visible = records.slice(-MAX_VISIBLE); + const visibleIds = new Set(visible.map(record => record.id)); + + for (const record of records) { + if (visibleIds.has(record.id)) { + this.resume(record, "queued"); + } else { + this.pause(record, "queued"); + this.resume(record, "hover"); + this.resume(record, "focus"); + record.view?.article.remove(); + } + if (document.hidden) this.pause(record, "hidden"); + else this.resume(record, "hidden"); + } + + const elements = visible.map(record => + this.createNotificationElement(record) + ); + for (let index = 0; index < elements.length; index++) { + if (this.stack.children[index] !== elements[index]) { + this.stack.insertBefore( + elements[index], + this.stack.children[index] || null, + ); + } + } + while (this.stack.children.length > elements.length) { + this.stack.lastElementChild.remove(); + } + + for (const record of visible) { + const element = record.view.article; + if (element.matches(":hover")) this.pause(record, "hover"); + else this.resume(record, "hover"); + if (element.contains(document.activeElement)) { + this.pause(record, "focus"); + } else this.resume(record, "focus"); + this.announce(record); + this.schedule(record); + } + } +} + +const existing = customElements.get("zen-notifications"); +if (!existing) { + customElements.define("zen-notifications", ZenNotifications); +} else if (existing.version !== ZenNotifications.version) { + throw new Error("Incompatible zen-notifications component"); +}
--- a/design_system/src/index.html Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/src/index.html Tue Aug 04 11:57:16 2026 -0700 @@ -30,6 +30,7 @@ <a href="/components/card" data-catalog-link>Card</a> <a href="/components/alert" data-catalog-link>Alert</a> <a href="/components/field" data-catalog-link>Field</a> + <a href="/components/notifications" data-catalog-link>Notifications</a> <a href="/components/stack" data-catalog-link>Stack</a> </nav> @@ -82,6 +83,9 @@ <a href="/components/stack" data-catalog-link> <zen-card interactive><article><h3>Stack</h3><p>A tiny layout primitive driven by HTML attributes.</p></article></zen-card> </a> + <a href="/components/notifications" data-catalog-link> + <zen-card interactive><article><h3>Notifications</h3><p>A bounded, scoped, bottom-right notification stack.</p></article></zen-card> + </a> </div> </section> @@ -217,6 +221,78 @@ </zen-story> </section> + <section class="catalog-page" data-catalog-page="notifications" hidden> + <header class="page-heading"> + <p class="eyebrow">Component</p> + <h1>Notifications</h1> + <p>A scoped bottom-right stack with bounded records, deduplication, announcements, actions, and paused finite timers.</p> + </header> + <zen-story name="Notification stack"> + <template> + <zen-notifications aria-label="Catalog notifications"> + <zen-stack direction="row"> + <zen-button variant="quiet"> + <button + type="button" + data-notification-demo + data-notification-id="catalog-info" + data-tone="info" + data-message="Build started" + data-description="The worker accepted your request." + >Info</button> + </zen-button> + <zen-button> + <button + type="button" + data-notification-demo + data-notification-id="catalog-success" + data-tone="success" + data-message="Deployment complete" + >Success</button> + </zen-button> + <zen-button variant="danger"> + <button + type="button" + data-notification-demo + data-notification-id="catalog-error" + data-tone="error" + data-message="Deployment failed" + data-description="The previous release is still active." + data-action="true" + >Error with action</button> + </zen-button> + <zen-button variant="quiet"> + <button + type="button" + data-notification-demo + data-notification-id="catalog-persistent" + data-tone="warning" + data-message="Manual action required" + data-persistent="true" + >Persistent</button> + </zen-button> + <zen-button variant="quiet"> + <button + type="button" + data-notification-demo + data-notification-id="catalog-burst" + data-tone="info" + data-message="Queued notification" + data-burst="true" + >Add five</button> + </zen-button> + <zen-button variant="quiet"> + <button + type="button" + data-notification-dismiss-demo="catalog-persistent" + >Dismiss persistent</button> + </zen-button> + </zen-stack> + </zen-notifications> + </template> + </zen-story> + </section> + <section class="catalog-page" data-catalog-page="stack" hidden> <header class="page-heading"> <p class="eyebrow">Component</p>
--- a/design_system/src/storybook.js Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/src/storybook.js Tue Aug 04 11:57:16 2026 -0700 @@ -8,6 +8,7 @@ ["/components/card", "card"], ["/components/alert", "alert"], ["/components/field", "field"], + ["/components/notifications", "notifications"], ["/components/stack", "stack"], ]); @@ -63,6 +64,55 @@ }); document.addEventListener("click", event => { + const notificationDemo = event.target.closest( + "[data-notification-demo]", + ); + if (notificationDemo) { + const scope = notificationDemo.closest("zen-notifications"); + if (!scope) return; + const tone = notificationDemo.dataset.tone || "info"; + const baseId = notificationDemo.dataset.notificationId || tone; + const count = notificationDemo.dataset.burst === "true" ? 5 : 1; + for (let index = 0; index < count; index++) { + notificationDemo.dispatchEvent(new CustomEvent("zen-notify", { + bubbles: true, + composed: false, + detail: { + version: 1, + id: count > 1 ? `${baseId}-${Date.now()}-${index}` : baseId, + tone, + message: notificationDemo.dataset.message || "Notification", + description: notificationDemo.dataset.description, + announcement: tone === "error" ? "assertive" : "polite", + durationMs: 5000, + persistent: notificationDemo.dataset.persistent === "true", + action: notificationDemo.dataset.action === "true" + ? { token: `catalog-action-${Date.now()}`, label: "Retry" } + : undefined, + }, + })); + } + return; + } + + const dismissDemo = event.target.closest( + "[data-notification-dismiss-demo]", + ); + if (dismissDemo) { + dismissDemo.dispatchEvent(new CustomEvent( + "zen-dismiss-notification", + { + bubbles: true, + composed: false, + detail: { + version: 1, + id: dismissDemo.dataset.notificationDismissDemo, + }, + }, + )); + return; + } + const link = event.target.closest("a[data-catalog-link]"); if (!link || link.origin !== location.origin ||
--- a/design_system/src/styles/components.css Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/src/styles/components.css Tue Aug 04 11:57:16 2026 -0700 @@ -1,4 +1,11 @@ -:where(zen-button, zen-card, zen-alert, zen-field, zen-stack) { +:where( + zen-button, + zen-card, + zen-alert, + zen-field, + zen-stack, + zen-notifications +) { box-sizing: border-box; font-family: var(--zen-font-sans); color: var(--zen-color-text); @@ -198,6 +205,136 @@ zen-stack[gap="tight"] { --zen-stack-gap: var(--zen-space-2); } zen-stack[gap="loose"] { --zen-stack-gap: var(--zen-space-6); } +zen-notifications > [data-zen-notification-stack] { + position: fixed; + z-index: var(--zen-notification-z-index, 1000); + inset-inline-end: var(--zen-notification-inset, var(--zen-space-4)); + inset-block-end: var( + --zen-notification-bottom-inset, + var(--zen-space-4) + ); + display: grid; + inline-size: min(24rem, calc(100vi - 2rem)); + gap: var(--zen-space-3); + pointer-events: none; +} + +zen-notifications > [data-zen-notification-stack] > article { + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr) 2rem; + align-items: start; + gap: var(--zen-space-3); + padding: var(--zen-space-4); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); + color: var(--zen-color-text); + pointer-events: auto; + animation: zen-notification-enter 160ms ease-out; +} + +.zen-notification-tone { + display: grid; + width: 1.75rem; + height: 1.75rem; + place-items: center; + border-radius: 50%; + background: var(--zen-color-info); + color: var(--zen-color-on-info); + font-weight: 900; +} + +[data-tone="success"] > .zen-notification-tone { + background: var(--zen-color-success); + color: var(--zen-color-on-success); +} + +[data-tone="warning"] > .zen-notification-tone { + background: var(--zen-color-warning); + color: var(--zen-color-on-warning); +} + +[data-tone="error"] > .zen-notification-tone { + background: var(--zen-color-danger); + color: var(--zen-color-on-danger); +} + +.zen-notification-content { + display: grid; + gap: var(--zen-space-1); +} + +.zen-notification-message, +.zen-notification-description { + margin: 0; +} + +.zen-notification-message { + font-weight: 800; +} + +.zen-notification-description { + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-sm); +} + +.zen-notification-action, +.zen-notification-dismiss { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.zen-notification-action { + justify-self: start; + margin-top: var(--zen-space-2); + padding: 0; + color: var(--zen-color-info); + font-weight: 800; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.zen-notification-dismiss { + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 50%; + font-size: 1.25rem; +} + +.zen-notification-action:focus-visible, +.zen-notification-dismiss:focus-visible { + outline: 3px solid var(--zen-color-focus); + outline-offset: 2px; +} + +.zen-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@keyframes zen-notification-enter { + from { + opacity: 0; + transform: translateY(0.75rem); + } +} + +@media (forced-colors: active) { + zen-notifications > [data-zen-notification-stack] > article, + .zen-notification-tone { + border: 1px solid CanvasText; + } +} + @media (prefers-reduced-motion: reduce) { zen-button > :where(button, a), zen-card > :where(article, section) { @@ -207,4 +344,8 @@ zen-button[loading] > :where(button, a)::before { animation-duration: 1400ms; } + + zen-notifications > [data-zen-notification-stack] > article { + animation: none; + } }
--- a/design_system/src/styles/tokens.css Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/src/styles/tokens.css Tue Aug 04 11:57:16 2026 -0700 @@ -10,8 +10,11 @@ --zen-color-accent: #ffb000; --zen-color-accent-hover: #ffca4a; --zen-color-info: #2764d7; + --zen-color-on-info: #ffffff; --zen-color-success: #18794e; + --zen-color-on-success: #ffffff; --zen-color-warning: #9a5b00; + --zen-color-on-warning: #ffffff; --zen-color-danger: #c62f3e; --zen-color-on-danger: #ffffff; --zen-color-focus: #7c3aed; @@ -54,8 +57,11 @@ --zen-color-accent: #ffc247; --zen-color-accent-hover: #ffd77d; --zen-color-info: #8ab4ff; + --zen-color-on-info: #171a21; --zen-color-success: #65d6a3; + --zen-color-on-success: #171a21; --zen-color-warning: #ffc66d; + --zen-color-on-warning: #171a21; --zen-color-danger: #ff8792; --zen-color-on-danger: #171a21; --zen-color-focus: #c4a5ff; @@ -75,8 +81,11 @@ --zen-color-accent: #ffc247; --zen-color-accent-hover: #ffd77d; --zen-color-info: #8ab4ff; + --zen-color-on-info: #171a21; --zen-color-success: #65d6a3; + --zen-color-on-success: #171a21; --zen-color-warning: #ffc66d; + --zen-color-on-warning: #171a21; --zen-color-danger: #ff8792; --zen-color-on-danger: #171a21; --zen-color-focus: #c4a5ff;
--- a/design_system/test/storybook_test.js Tue Aug 04 09:14:57 2026 -0700 +++ b/design_system/test/storybook_test.js Tue Aug 04 11:57:16 2026 -0700 @@ -125,6 +125,7 @@ '/components/card', '/components/alert', '/components/field', + '/components/notifications', '/components/stack', ]) { const response = await fetch(`${baseUrl}${route}`); @@ -350,6 +351,386 @@ assert.equal(authoredDismissState.connected, true); assert.equal(authoredDismissState.events, 0); + await page.goto(`${baseUrl}/components/notifications`, { + waitUntil: 'networkidle', + }); + const notificationScope = page.locator('zen-notifications').first(); + const notificationPosition = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => { + const style = getComputedStyle(stack); + return { + bottom: style.bottom, + position: style.position, + right: style.right, + }; + }); + assert.equal(notificationPosition.position, 'fixed'); + assert.notEqual(notificationPosition.bottom, 'auto'); + assert.notEqual(notificationPosition.right, 'auto'); + + const dedupe = await notificationScope.evaluate(scope => { + const source = scope.querySelector('[data-notification-demo]'); + const send = detail => source.dispatchEvent( + new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail, + }), + ); + send({ + version: 1, + id: 'dedupe', + tone: 'info', + message: 'First announcement', + announcement: 'polite', + persistent: true, + }); + send({ + version: 1, + id: 'dedupe', + tone: 'success', + message: 'Updated without duplication', + announcement: 'polite', + persistent: true, + }); + send({ version: 99, id: 'invalid' }); + return { + size: scope.size, + visible: scope.visibleCount, + }; + }); + assert.deepEqual(dedupe, { size: 1, visible: 1 }); + assert.equal( + await notificationScope.locator('article').count(), + 1, + ); + assert.match( + await notificationScope.locator('article').textContent(), + /Updated without duplication/, + ); + await page.waitForFunction(() => + document.querySelector( + 'zen-notifications [data-zen-live="polite"]', + )?.textContent === 'First announcement' + ); + + const bounded = await notificationScope.evaluate(scope => { + let accepted = 0; + for (let index = 0; index < 25; index++) { + if (scope.notify({ + version: 1, + id: `bounded-${index}`, + tone: 'info', + message: `Bounded ${index}`, + announcement: 'none', + durationMs: 120000, + })) accepted++; + } + return { + accepted, + size: scope.size, + visible: scope.visibleCount, + }; + }); + assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 }); + assert.equal( + await notificationScope.locator('article').count(), + 3, + ); + + const actionScope = await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'action-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + window.__notificationAction = null; + scope.addEventListener('zen-notification-action', event => { + window.__notificationAction = event.detail; + }); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'action', + tone: 'error', + message: 'Action required', + announcement: 'assertive', + persistent: true, + action: { + token: 'opaque-secret-token', + label: 'Retry', + }, + }, + })); + return scope.id; + }); + assert.equal( + await page.locator(`#${actionScope}`).evaluate( + scope => scope.records, + ), + undefined, + ); + const actionArticle = page.locator( + `#${actionScope} [data-zen-notification-id="action"]`, + ); + assert.doesNotMatch( + await actionArticle.evaluate(article => article.outerHTML), + /opaque-secret-token/, + ); + await actionArticle.locator('.zen-notification-action').focus(); + await page.locator(`#${actionScope}`).evaluate(scope => { + const source = scope.querySelector('button'); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'action-neighbor', + tone: 'info', + message: 'Neighbor', + announcement: 'none', + persistent: true, + }, + })); + }); + assert.equal( + await actionArticle.locator('.zen-notification-action').evaluate( + action => document.activeElement === action, + ), + true, + ); + await actionArticle.locator('.zen-notification-action').click(); + assert.deepEqual( + await page.evaluate(() => window.__notificationAction), + { + version: 1, + id: 'action', + token: 'opaque-secret-token', + }, + ); + await page.locator(`#${actionScope}`).evaluate(scope => { + const source = scope.querySelector('button'); + source.dispatchEvent(new CustomEvent( + 'zen-dismiss-notification', + { + bubbles: true, + composed: false, + detail: { version: 1, id: 'action' }, + }, + )); + }); + assert.equal(await actionArticle.count(), 0); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'announcement-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + window.__announcements = []; + const region = scope.querySelector('[data-zen-live="polite"]'); + new MutationObserver(() => { + if (region.textContent) { + window.__announcements.push(region.textContent); + } + }).observe(region, { childList: true }); + for (let index = 0; index < 3; index++) { + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: `announcement-${index}`, + tone: 'info', + message: `Announcement ${index}`, + announcement: 'polite', + persistent: true, + }, + })); + } + }); + await page.waitForFunction(() => + window.__announcements?.length === 3 + ); + assert.deepEqual( + await page.evaluate(() => window.__announcements), + ['Announcement 0', 'Announcement 1', 'Announcement 2'], + ); + + const composedRejected = await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: true, + detail: { + version: 1, + id: 'composed', + tone: 'info', + message: 'Must be rejected', + announcement: 'none', + persistent: true, + }, + })); + return scope.size; + }); + assert.equal(composedRejected, 0); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'focus-timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'focus-timer', + tone: 'warning', + message: 'Focus timer', + announcement: 'none', + durationMs: 300, + action: { token: 'focus-token', label: 'Keep focused' }, + }, + })); + }); + const focusTimer = page.locator( + '#focus-timer-scope [data-zen-notification-id="focus-timer"]', + ); + await focusTimer.locator('.zen-notification-action').focus(); + await page.locator('#focus-timer-scope').evaluate(scope => { + const source = scope.querySelector(':scope > button'); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'focus-neighbor', + tone: 'info', + message: 'Focus neighbor', + announcement: 'none', + persistent: true, + }, + })); + }); + await page.waitForTimeout(500); + assert.equal(await focusTimer.count(), 1); + assert.equal( + await focusTimer.locator('.zen-notification-action').evaluate( + action => document.activeElement === action, + ), + true, + ); + await page.mouse.move(0, 0); + await page.locator('#catalogMain').focus(); + await page.waitForFunction(() => + !document.querySelector( + '#focus-timer-scope [data-zen-notification-id="focus-timer"]', + ) + ); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'timer', + tone: 'info', + message: 'Paused timer', + announcement: 'none', + durationMs: 300, + }, + })); + }); + const timerArticle = page.locator('#timer-scope article'); + await timerArticle.hover(); + await page.waitForTimeout(500); + assert.equal(await timerArticle.count(), 1); + await page.mouse.move(0, 0); + await page.waitForFunction(() => + !document.querySelector('#timer-scope article') + ); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'hidden-timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'hidden-timer', + tone: 'info', + message: 'Hidden timer', + announcement: 'none', + durationMs: 300, + }, + })); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForTimeout(500); + assert.equal( + await page.locator('#hidden-timer-scope article').count(), + 1, + ); + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForFunction(() => + !document.querySelector('#hidden-timer-scope article') + ); + + const isolated = await page.evaluate(() => { + const makeScope = id => { + const scope = document.createElement('zen-notifications'); + scope.id = id; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + return { scope, source }; + }; + const first = makeScope('scope-one'); + const second = makeScope('scope-two'); + first.source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'isolated', + tone: 'success', + message: 'Only first scope', + announcement: 'none', + persistent: true, + }, + })); + return [first.scope.size, second.scope.size]; + }); + assert.deepEqual(isolated, [1, 0]); + const lightCanvas = await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zen-color-canvas')