view design_system/src/components/notifications.js @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents 60a876c4587a
children
line wrap: on
line source

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);
}

function resolveCssLength(element, property, fallback) {
  const probe = document.createElement("span");
  probe.style.position = "absolute";
  probe.style.inlineSize = `var(${property})`;
  probe.style.visibility = "hidden";
  element.append(probe);
  const value = Number.parseFloat(getComputedStyle(probe).inlineSize);
  probe.remove();
  return Number.isFinite(value) ? value : fallback;
}

/**
 * Validates and normalizes a notification event record.
 *
 * @param {*} value Candidate event detail.
 * @return {?Object} Frozen normalized record, or null when invalid.
 */
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,
  });
}

/**
 * Validates a programmatic notification dismissal record.
 *
 * @param {*} value Candidate event detail.
 * @return {?string} Valid notification ID, or null when invalid.
 */
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,
  };
}

/**
 * Renders a bounded, scoped notification queue.
 *
 * @extends {HTMLElement}
 */
export class ZenNotifications extends HTMLElement {
  static version = 1;

  #records = new Map();
  #announcementQueues = {
    polite: [],
    assertive: [],
  };
  #announcementTimers = {
    polite: null,
    assertive: null,
  };
  #announcementRunning = {
    polite: false,
    assertive: false,
  };
  #exitTimers = new Map();
  #exitingElements = new Set();
  #layoutFrame = null;
  #resizeObserver = null;

  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();
    if (!this.#resizeObserver &&
        typeof ResizeObserver === "function") {
      this.#resizeObserver = new ResizeObserver(() => {
        this.requestLayout();
      });
    }
    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);
    }
    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]);
      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);
    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,
      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 toneIcon = document.createElement("zen-icon");
      tone.append(toneIcon);

      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";
      const dismissIcon = document.createElement("zen-icon");
      dismissIcon.setAttribute("name", "close");
      dismiss.append(dismissIcon);
      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,
        toneIcon,
      };
      this.#resizeObserver?.observe(article);
    }

    const view = record.view;
    view.article.dataset.tone = record.tone;
    view.toneIcon.setAttribute("name", {
      info: "info",
      success: "check",
      warning: "alert",
      error: "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;
  }

  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 gap = resolveCssLength(
      this.stack,
      "--zenbu-notification-gap",
      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(
        "--zenbu-notification-depth",
        String(depth),
      );
      article.style.setProperty(
        "--zenbu-notification-collapsed-offset",
        `${-depth * 10}px`,
      );
      article.style.setProperty(
        "--zenbu-notification-collapsed-opacity",
        String(Math.max(0.55, 1 - depth * 0.18)),
      );
      article.dataset.depth = String(depth);
      article.style.setProperty(
        "--zenbu-notification-offset",
        `${-expandedHeight}px`,
      );
      article.style.setProperty(
        "--zenbu-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(
      "--zenbu-notification-collapsed-height",
      `${newestHeight + Math.max(0, visible.length - 1) * 10}px`,
    );
    this.stack.style.setProperty(
      "--zenbu-notification-expanded-height",
      `${Math.max(0, expandedHeight - gap)}px`,
    );
  }

  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");
        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");
    }

    const elements = visible.map(record =>
      this.createNotificationElement(record)
    );
    for (let index = 0; index < elements.length; index++) {
      const current = [...this.stack.children]
        .filter(element => !this.#exitingElements.has(element))[index];
      if (current !== elements[index]) {
        this.stack.insertBefore(
          elements[index],
          current || null,
        );
      }
      this.#resizeObserver?.observe(elements[index]);
    }
    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) {
      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);
    }
    this.requestLayout();
  }
}

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");
}