changeset 253:fdf3816959cb

[ui] Add Sonner-like notification stack Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 12:21:19 -0700
parents 7a7581f040e8
children 2b6e732087ff
files .claude/skills/zenbu-design-system/SKILL.md design_system/NOTIFICATIONS.md design_system/src/components/notifications.js design_system/src/styles/components.css design_system/test/storybook_test.js
diffstat 5 files changed, 256 insertions(+), 12 deletions(-) [+]
line wrap: on
line diff
--- a/.claude/skills/zenbu-design-system/SKILL.md	Tue Aug 04 11:57:16 2026 -0700
+++ b/.claude/skills/zenbu-design-system/SKILL.md	Tue Aug 04 12:21:19 2026 -0700
@@ -24,6 +24,8 @@
   non-composed `zen-notify` events and bounded queues.
 - Keep `zen-notifications` behavior aligned with
   `design_system/NOTIFICATIONS.md`.
+- Notification presentation uses a measured collapsed depth stack that expands
+  on hover/focus; do not import Sonner or Radix.
 - 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.
--- a/design_system/NOTIFICATIONS.md	Tue Aug 04 11:57:16 2026 -0700
+++ b/design_system/NOTIFICATIONS.md	Tue Aug 04 12:21:19 2026 -0700
@@ -75,6 +75,9 @@
 
 - Three records are rendered.
 - Twenty additional records may remain queued.
+- The idle stack layers older records behind the newest with depth and scale.
+- Hovering the stack or focusing an action expands all visible records using
+  their measured heights; no Sonner/Radix runtime or CSS is used.
 - Overflow evicts the oldest finite record; all-persistent queues reject new
   records.
 - Queued records do not count down.
--- a/design_system/src/components/notifications.js	Tue Aug 04 11:57:16 2026 -0700
+++ b/design_system/src/components/notifications.js	Tue Aug 04 12:21:19 2026 -0700
@@ -88,6 +88,10 @@
     polite: false,
     assertive: false,
   };
+  #exitTimers = new Map();
+  #exitingElements = new Set();
+  #layoutFrame = null;
+  #resizeObserver = null;
 
   constructor() {
     super();
@@ -116,6 +120,12 @@
 
   connectedCallback() {
     if (!this.stack) this.createInternalElements();
+    if (!this.#resizeObserver &&
+        typeof ResizeObserver === "function") {
+      this.#resizeObserver = new ResizeObserver(() => {
+        this.requestLayout();
+      });
+    }
     this.addEventListener("zen-notify", this.handleNotify);
     this.addEventListener(
       "zen-dismiss-notification",
@@ -141,6 +151,14 @@
     for (const record of this.#records.values()) {
       this.clearTimer(record);
     }
+    cancelAnimationFrame(this.#layoutFrame);
+    this.#layoutFrame = null;
+    this.#resizeObserver?.disconnect();
+    for (const timer of this.#exitTimers.values()) {
+      clearTimeout(timer);
+    }
+    this.#exitTimers.clear();
+    this.#exitingElements.clear();
     this.#records.clear();
     for (const priority of ["polite", "assertive"]) {
       clearTimeout(this.#announcementTimers[priority]);
@@ -236,7 +254,22 @@
     if (!record) return;
     this.clearTimer(record);
     this.#records.delete(id);
-    record.view?.article.remove();
+    const article = record.view?.article;
+    if (article && render && article.isConnected) {
+      article.dataset.removing = "true";
+      this.#exitingElements.add(article);
+      const exitTimer = setTimeout(() => {
+        this.#resizeObserver?.unobserve(article);
+        article.remove();
+        this.#exitingElements.delete(article);
+        this.#exitTimers.delete(article);
+        this.requestLayout();
+      }, 180);
+      this.#exitTimers.set(article, exitTimer);
+    } else if (article) {
+      this.#resizeObserver?.unobserve(article);
+      article.remove();
+    }
     record.view = null;
     this.dispatchEvent(new CustomEvent("zen-notification-removed", {
       bubbles: true,
@@ -370,6 +403,7 @@
         message,
         tone,
       };
+      this.#resizeObserver?.observe(article);
     }
 
     const view = record.view;
@@ -427,6 +461,71 @@
     return view.article;
   }
 
+  requestLayout() {
+    cancelAnimationFrame(this.#layoutFrame);
+    this.#layoutFrame = requestAnimationFrame(() => {
+      this.#layoutFrame = null;
+      this.layoutStack();
+    });
+  }
+
+  layoutStack() {
+    if (!this.stack) return;
+    const visible = [...this.#records.values()]
+      .slice(-MAX_VISIBLE)
+      .filter(record => record.view?.article.isConnected);
+    const styles = getComputedStyle(this.stack);
+    const configuredGap = Number.parseFloat(
+      styles.getPropertyValue("--zen-notification-gap"),
+    );
+    const gap = Number.isFinite(configuredGap)
+      ? configuredGap
+      : 12;
+    let expandedHeight = 0;
+    let newestHeight = 0;
+
+    for (let index = visible.length - 1; index >= 0; index--) {
+      const record = visible[index];
+      const article = record.view.article;
+      const depth = visible.length - 1 - index;
+      const height = article.offsetHeight;
+      if (depth === 0) newestHeight = height;
+      article.style.setProperty(
+        "--zen-notification-depth",
+        String(depth),
+      );
+      article.style.setProperty(
+        "--zen-notification-collapsed-offset",
+        `${-depth * 10}px`,
+      );
+      article.style.setProperty(
+        "--zen-notification-collapsed-opacity",
+        String(Math.max(0.55, 1 - depth * 0.18)),
+      );
+      article.dataset.depth = String(depth);
+      article.style.setProperty(
+        "--zen-notification-offset",
+        `${-expandedHeight}px`,
+      );
+      article.style.setProperty(
+        "--zen-notification-scale",
+        String(Math.max(0.88, 1 - depth * 0.055)),
+      );
+      article.style.zIndex = String(visible.length - depth);
+      expandedHeight += height + gap;
+    }
+
+    this.stack.dataset.count = String(visible.length);
+    this.stack.style.setProperty(
+      "--zen-notification-collapsed-height",
+      `${newestHeight + Math.max(0, visible.length - 1) * 10}px`,
+    );
+    this.stack.style.setProperty(
+      "--zen-notification-expanded-height",
+      `${Math.max(0, expandedHeight - gap)}px`,
+    );
+  }
+
   render() {
     if (!this.stack) return;
     const records = [...this.#records.values()];
@@ -440,7 +539,10 @@
         this.pause(record, "queued");
         this.resume(record, "hover");
         this.resume(record, "focus");
-        record.view?.article.remove();
+        if (record.view?.article) {
+          this.#resizeObserver?.unobserve(record.view.article);
+          record.view.article.remove();
+        }
       }
       if (document.hidden) this.pause(record, "hidden");
       else this.resume(record, "hidden");
@@ -450,15 +552,22 @@
       this.createNotificationElement(record)
     );
     for (let index = 0; index < elements.length; index++) {
-      if (this.stack.children[index] !== elements[index]) {
+      const current = [...this.stack.children]
+        .filter(element => !this.#exitingElements.has(element))[index];
+      if (current !== elements[index]) {
         this.stack.insertBefore(
           elements[index],
-          this.stack.children[index] || null,
+          current || null,
         );
       }
+      this.#resizeObserver?.observe(elements[index]);
     }
-    while (this.stack.children.length > elements.length) {
-      this.stack.lastElementChild.remove();
+    const desired = new Set(elements);
+    for (const element of [...this.stack.children]) {
+      if (!desired.has(element) &&
+          !this.#exitingElements.has(element)) {
+        element.remove();
+      }
     }
 
     for (const record of visible) {
@@ -471,6 +580,7 @@
       this.announce(record);
       this.schedule(record);
     }
+    this.requestLayout();
   }
 }
 
--- a/design_system/src/styles/components.css	Tue Aug 04 11:57:16 2026 -0700
+++ b/design_system/src/styles/components.css	Tue Aug 04 12:21:19 2026 -0700
@@ -213,13 +213,25 @@
     --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;
+  height: var(--zen-notification-collapsed-height, 0);
+  --zen-notification-gap: 12px;
+  pointer-events: auto;
+  isolation: isolate;
+  transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+zen-notifications > [data-zen-notification-stack]:is(
+  :hover,
+  :focus-within
+) {
+  height: var(--zen-notification-expanded-height, 0);
 }
 
 zen-notifications > [data-zen-notification-stack] > article {
+  position: absolute;
+  inset-inline: 0;
+  inset-block-end: 0;
   display: grid;
   grid-template-columns: 1.75rem minmax(0, 1fr) 2rem;
   align-items: start;
@@ -230,8 +242,46 @@
   box-shadow: var(--zen-shadow-md);
   background: var(--zen-color-surface-raised);
   color: var(--zen-color-text);
+  opacity: var(--zen-notification-collapsed-opacity, 1);
+  pointer-events: none;
+  transform:
+    translate3d(
+      0,
+      var(--zen-notification-collapsed-offset, 0),
+      0
+    )
+    scale(var(--zen-notification-scale, 1));
+  transform-origin: center bottom;
+  transition:
+    opacity 180ms ease,
+    transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
+  will-change: transform;
+  animation: zen-notification-enter 160ms ease-out;
+}
+
+zen-notifications > [data-zen-notification-stack] > article[data-depth="0"] {
   pointer-events: auto;
-  animation: zen-notification-enter 160ms ease-out;
+}
+
+zen-notifications > [data-zen-notification-stack]:is(
+  :hover,
+  :focus-within
+) > article {
+  opacity: 1;
+  pointer-events: auto;
+  transform:
+    translate3d(
+      0,
+      var(--zen-notification-offset, 0px),
+      0
+    )
+    scale(1);
+}
+
+zen-notifications > [data-zen-notification-stack] > article[data-removing] {
+  opacity: 0;
+  pointer-events: none;
+  transform: translate3d(110%, 0, 0) scale(0.96);
 }
 
 .zen-notification-tone {
@@ -324,7 +374,6 @@
 @keyframes zen-notification-enter {
   from {
     opacity: 0;
-    transform: translateY(0.75rem);
   }
 }
 
@@ -347,5 +396,10 @@
 
   zen-notifications > [data-zen-notification-stack] > article {
     animation: none;
+    transition: none;
+  }
+
+  zen-notifications > [data-zen-notification-stack] {
+    transition: none;
   }
 }
--- a/design_system/test/storybook_test.js	Tue Aug 04 11:57:16 2026 -0700
+++ b/design_system/test/storybook_test.js	Tue Aug 04 12:21:19 2026 -0700
@@ -438,6 +438,81 @@
       await notificationScope.locator('article').count(),
       3,
     );
+    await page.waitForFunction(() =>
+      [...document.querySelectorAll(
+        'zen-notifications article',
+      )].some(article => article.dataset.depth === '2')
+    );
+    const collapsedStack = await notificationScope.locator(
+      '[data-zen-notification-stack]',
+    ).evaluate(stack => {
+      const articles = [...stack.querySelectorAll('article')];
+      return {
+        depths: articles.map(article => article.dataset.depth),
+        height: stack.getBoundingClientRect().height,
+        positions: articles.map(article =>
+          getComputedStyle(article).position
+        ),
+        transforms: articles.map(article =>
+          getComputedStyle(article).transform
+        ),
+      };
+    });
+    assert.deepEqual(collapsedStack.depths, ['2', '1', '0']);
+    assert.deepEqual(
+      collapsedStack.positions,
+      ['absolute', 'absolute', 'absolute'],
+    );
+    await notificationScope.locator('article').last().hover();
+    await page.waitForTimeout(300);
+    const expandedStack = await notificationScope.locator(
+      '[data-zen-notification-stack]',
+    ).evaluate(stack => ({
+      height: stack.getBoundingClientRect().height,
+      rects: [...stack.querySelectorAll('article')].map(article => {
+        const bounds = article.getBoundingClientRect();
+        return {
+          bottom: bounds.bottom,
+          left: bounds.left,
+          right: bounds.right,
+          top: bounds.top,
+        };
+      }),
+      tops: [...stack.querySelectorAll('article')].map(
+        article => Math.round(article.getBoundingClientRect().top),
+      ),
+      transforms: [...stack.querySelectorAll('article')].map(
+        article => getComputedStyle(article).transform,
+      ),
+    }));
+    assert.ok(expandedStack.height > collapsedStack.height);
+    assert.equal(new Set(expandedStack.tops).size, 3);
+    const expandedGaps = [
+      expandedStack.rects[1].top - expandedStack.rects[0].bottom,
+      expandedStack.rects[2].top - expandedStack.rects[1].bottom,
+    ];
+    for (const gap of expandedGaps) {
+      assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps));
+    }
+    assert.notDeepEqual(
+      expandedStack.transforms,
+      collapsedStack.transforms,
+    );
+    const gapX =
+      (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2;
+    const gapY =
+      (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2;
+    await page.mouse.move(gapX, gapY);
+    await page.waitForTimeout(100);
+    const gapState = await notificationScope.locator(
+      '[data-zen-notification-stack]',
+    ).evaluate(stack => ({
+      height: stack.getBoundingClientRect().height,
+      hovered: stack.matches(':hover'),
+    }));
+    assert.equal(gapState.hovered, true);
+    assert.ok(gapState.height >= expandedStack.height - 1);
+    await page.mouse.move(0, 0);
 
     const actionScope = await page.evaluate(() => {
       const scope = document.createElement('zen-notifications');
@@ -522,7 +597,7 @@
         },
       ));
     });
-    assert.equal(await actionArticle.count(), 0);
+    await actionArticle.waitFor({ state: 'detached' });
 
     await page.evaluate(() => {
       const scope = document.createElement('zen-notifications');