# HG changeset patch # User MrJuneJune # Date 1785869836 25200 # Node ID 7a7581f040e8ece20f4c98271cdaa1474e7f02d7 # Parent 117c4d53c9a4e94d4482fd27dd4775d0df6d87a9 [ui] Add scoped notification component Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff -r 117c4d53c9a4 -r 7a7581f040e8 .claude/skills/zenbu-design-system/SKILL.md --- 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. diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/NOTIFICATIONS.md --- /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 + +
+
+``` + +## 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. diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/README.md --- 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 + +
+
+``` + +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 diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/main.c --- 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"); diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/src/components/index.js --- 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"; diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/src/components/notifications.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"); +} diff -r 117c4d53c9a4 -r 7a7581f040e8 design_system/src/index.html --- 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 @@ Card Alert Field + Notifications Stack @@ -82,6 +83,9 @@

Stack

A tiny layout primitive driven by HTML attributes.

+ +

Notifications

A bounded, scoped, bottom-right notification stack.

+
@@ -217,6 +221,78 @@ + +