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')