{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-preferences",
  "title": "Notification Preferences",
  "description": "An events × channels notification matrix that saves as you toggle: per-switch pending and single-switch rollback on failure, locked always-on rows, a disconnected channel column with a real Connect entry point, row/column bulk actions with a bulk-vs-manual marker, and a quiet-hours window that wraps past midnight.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/notification-preferences.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, BellRing, Check, Loader2, Lock, Moon, TriangleAlert, Unplug, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  NotificationChannel,\n  NotificationEvent,\n  NotificationPreferenceChange,\n  NotificationPreferencesData,\n  NotificationPreferencesStatus,\n  PreferenceOrigin,\n  QuietHours,\n} from \"./notification-preferences.contract\"\n\n/* -------------------------------------------------------------------------- */\n/* time                                                                        */\n/* -------------------------------------------------------------------------- */\n\n/** \"HH:MM\" → minutes since midnight, or null when the string isn't a wall clock. */\nexport function parseWallClock(value: string): number | null {\n  const match = /^([01]\\d|2[0-3]):([0-5]\\d)$/.exec(value)\n  return match ? Number(match[1]) * 60 + Number(match[2]) : null\n}\n\n/**\n * Half-open window [start, end) over a 24-hour dial.\n *\n * The whole point is the wrapping case: 22:00 → 07:00 is *two* arcs on the dial,\n * so the naive `start <= t && t <= end` test answers \"no\" for every minute of the\n * night — i.e. quiet hours that never fire, which is exactly the failure you\n * don't notice until a user is woken at 03:00. When start > end the window is\n * the union `t >= start || t < end` instead.\n *\n * `start === end` is a zero-length window (nothing is quiet), not a 24-hour one:\n * \"quiet from 09:00 to 09:00\" would otherwise mute the account forever.\n */\nexport function isWithinQuietHours(now: string, start: string, end: string): boolean {\n  const t = parseWallClock(now)\n  const s = parseWallClock(start)\n  const e = parseWallClock(end)\n  if (t === null || s === null || e === null) return false\n  if (s === e) return false\n  return s < e ? t >= s && t < e : t >= s || t < e\n}\n\n/** Length of the same window, in minutes — wraps the same way. */\nexport function quietHoursDuration(start: string, end: string): number {\n  const s = parseWallClock(start)\n  const e = parseWallClock(end)\n  if (s === null || e === null) return 0\n  return (e - s + 24 * 60) % (24 * 60)\n}\n\nfunction formatDuration(minutes: number): string {\n  const h = Math.floor(minutes / 60)\n  const m = minutes % 60\n  if (h === 0) return `${m} min`\n  return m === 0 ? `${h} h` : `${h} h ${m} min`\n}\n\n/** 00:00 … 23:30 plus whatever the data already holds, so a 22:15 window can't vanish from the select. */\nfunction timeOptions(...pinned: string[]): string[] {\n  const set = new Set<string>()\n  for (let minutes = 0; minutes < 24 * 60; minutes += 30) {\n    set.add(`${String(Math.floor(minutes / 60)).padStart(2, \"0\")}:${String(minutes % 60).padStart(2, \"0\")}`)\n  }\n  for (const value of pinned) if (parseWallClock(value) !== null) set.add(value)\n  return [...set].sort()\n}\n\n/* -------------------------------------------------------------------------- */\n/* cells                                                                       */\n/* -------------------------------------------------------------------------- */\n\n/** Unit separator: ids are opaque, so a printable joiner could collide with one. */\nconst KEY_SEPARATOR = \"\\u001f\"\nconst cellKey = (eventId: string, channelId: string) => `${eventId}${KEY_SEPARATOR}${channelId}`\n\ntype SaveState = \"idle\" | \"saving\" | \"failed\"\n\ninterface CellModel {\n  key: string\n  on: boolean\n  /** locked-on: this event can't be muted on this channel */\n  locked: boolean\n  /** the channel has no integration yet — the stored value is shown but frozen */\n  unavailable: boolean\n  interactive: boolean\n  origin: PreferenceOrigin | undefined\n  save: SaveState\n}\n\nexport interface NotificationPreferencesProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\">,\n    NotificationPreferencesData {\n  /**\n   * Immediate save, one call per cell. Resolving means persisted; rejecting (or\n   * throwing) rolls back *that one switch* and leaves every other cell alone.\n   * Omitting it makes the whole matrix read-only — a switch with nowhere to\n   * report to must not pretend to be operable.\n   */\n  onPreferenceChange?: (change: NotificationPreferenceChange) => void | Promise<void>\n  /** Same optimistic contract for the quiet-hours block, which saves as one unit. */\n  onQuietHoursChange?: (next: QuietHours) => void | Promise<void>\n  /** Required for a disconnected column to offer anything better than a dead cell. */\n  onConnectChannel?: (channelId: string) => void\n  /** shown next to the error message; omit to hide the retry affordance entirely */\n  onRetry?: () => void\n  title?: string\n  description?: string\n  /** accessible caption for the matrix table (visually hidden) */\n  caption?: string\n  /** any CSS width for the pinned event column; capped at 42vw so it can't eat a phone screen */\n  eventColumnWidth?: string\n  skeletonRows?: number\n}\n\nfunction clampInt(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min\n  return Math.min(Math.max(Math.round(value), min), max)\n}\n\nexport const NotificationPreferences = React.forwardRef<HTMLDivElement, NotificationPreferencesProps>(\n  (\n    {\n      status,\n      channels,\n      events,\n      preferences,\n      quietHours,\n      onPreferenceChange,\n      onQuietHoursChange,\n      onConnectChannel,\n      onRetry,\n      title = \"Notification preferences\",\n      description = \"Pick how each event reaches you. Changes save the moment you flip a switch.\",\n      caption = \"Notification events by delivery channel\",\n      eventColumnWidth = \"15rem\",\n      skeletonRows = 6,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    /** Optimistic overrides layered over the props: for save-as-you-toggle they ARE the persisted values. */\n    const [overrides, setOverrides] = React.useState<Record<string, boolean>>({})\n    const [origins, setOrigins] = React.useState<Record<string, PreferenceOrigin>>({})\n    const [saveStates, setSaveStates] = React.useState<Record<string, SaveState>>({})\n    const [quietOverride, setQuietOverride] = React.useState<QuietHours | null>(null)\n    const [quietSave, setQuietSave] = React.useState<SaveState>(\"idle\")\n    const [announcement, setAnnouncement] = React.useState(\"\")\n\n    /** Per-cell request counter: a stale rejection must not undo a newer intent. */\n    const cellSeqRef = React.useRef<Record<string, number>>({})\n    const quietSeqRef = React.useRef(0)\n\n    // Latest-ref: callbacks stay out of dependency arrays, and a consumer passing a\n    // fresh arrow function on every render can't restart anything.\n    const changeRef = React.useRef(onPreferenceChange)\n    const quietChangeRef = React.useRef(onQuietHoursChange)\n    React.useEffect(() => {\n      changeRef.current = onPreferenceChange\n      quietChangeRef.current = onQuietHoursChange\n    })\n\n    const editable = typeof onPreferenceChange === \"function\"\n    const quietEditable = typeof onQuietHoursChange === \"function\"\n\n    const requiredSets = React.useMemo(() => {\n      const map = new Map<string, Set<string>>()\n      for (const event of events) map.set(event.id, new Set(event.requiredChannels ?? []))\n      return map\n    }, [events])\n\n    const readCell = React.useCallback(\n      (eventId: string, channelId: string) =>\n        overrides[cellKey(eventId, channelId)] ?? preferences[eventId]?.[channelId] ?? false,\n      [overrides, preferences],\n    )\n\n    const describeCell = (event: NotificationEvent, channel: NotificationChannel) =>\n      `${event.label} · ${channel.label}`\n\n    /**\n     * One cell, one request, one rollback. Every write goes through here — the row\n     * and column actions are just several of these, so a bulk edit inherits the\n     * same per-switch pending state and the same \"only this one reverts\" failure.\n     */\n    const commitCell = (\n      event: NotificationEvent,\n      channel: NotificationChannel,\n      next: boolean,\n      origin: PreferenceOrigin,\n    ) => {\n      const key = cellKey(event.id, channel.id)\n      const previous = readCell(event.id, channel.id)\n      const seq = (cellSeqRef.current[key] ?? 0) + 1\n      cellSeqRef.current[key] = seq\n\n      setOverrides(current => ({ ...current, [key]: next }))\n      setOrigins(current => ({ ...current, [key]: origin }))\n      setSaveStates(current => ({ ...current, [key]: \"saving\" }))\n\n      const settle = (ok: boolean) => {\n        // Superseded: the user has already asked for something newer on this cell.\n        if (cellSeqRef.current[key] !== seq) return\n        if (ok) {\n          setSaveStates(current => ({ ...current, [key]: \"idle\" }))\n          return\n        }\n        // Roll back exactly one key — every other cell keeps its own value and its\n        // own in-flight request.\n        setOverrides(current => ({ ...current, [key]: previous }))\n        setSaveStates(current => ({ ...current, [key]: \"failed\" }))\n        setAnnouncement(\n          `Couldn't save ${describeCell(event, channel)}. Left ${previous ? \"on\" : \"off\"}. Try the switch again.`,\n        )\n      }\n\n      // new Promise(resolve => resolve(fn())) — Promise.resolve(fn()) can't catch a\n      // handler that throws synchronously, and the switch would spin forever.\n      new Promise<void>(resolve => {\n        resolve(changeRef.current?.({ eventId: event.id, channelId: channel.id, enabled: next, origin }))\n      }).then(\n        () => settle(true),\n        () => settle(false),\n      )\n    }\n\n    const toggleCell = (event: NotificationEvent, channel: NotificationChannel, model: CellModel) => {\n      if (model.interactive) {\n        commitCell(event, channel, !model.on, \"manual\")\n        setAnnouncement(`${describeCell(event, channel)} ${!model.on ? \"on\" : \"off\"}`)\n        return\n      }\n      // aria-disabled, not disabled: the switch keeps focus and can still explain\n      // itself instead of silently swallowing the click.\n      if (!editable) {\n        setAnnouncement(\"This panel is read-only.\")\n      } else if (model.unavailable) {\n        setAnnouncement(\n          channel.connectHint ?? `${channel.label} isn't connected yet. Connect it to use this column.`,\n        )\n      } else if (model.locked) {\n        setAnnouncement(event.requiredReason ?? `${describeCell(event, channel)} can't be turned off.`)\n      }\n    }\n\n    /** Row action: every connected, unlocked channel for one event. */\n    const setRow = (event: NotificationEvent, next: boolean) => {\n      if (!editable) return\n      const required = requiredSets.get(event.id) ?? new Set<string>()\n      const targets = channels.filter(channel => channel.connected && !required.has(channel.id))\n      const skipped = channels.filter(channel => !channel.connected || required.has(channel.id))\n      for (const channel of targets) {\n        setOrigins(current => ({ ...current, [cellKey(event.id, channel.id)]: \"bulk\" }))\n        if (readCell(event.id, channel.id) !== next) commitCell(event, channel, next, \"bulk\")\n      }\n      setAnnouncement(\n        `${event.label}: ${next ? \"on\" : \"off\"} for ${targets.length} ${targets.length === 1 ? \"channel\" : \"channels\"}` +\n          (skipped.length > 0 ? `. Skipped ${skipped.map(channel => channel.label).join(\", \")}.` : \".\"),\n      )\n    }\n\n    /** Column action: every event for one channel. */\n    const setColumn = (channel: NotificationChannel, next: boolean) => {\n      if (!editable || !channel.connected) return\n      const targets = events.filter(event => !(requiredSets.get(event.id) ?? new Set()).has(channel.id))\n      const skipped = events.length - targets.length\n      for (const event of targets) {\n        setOrigins(current => ({ ...current, [cellKey(event.id, channel.id)]: \"bulk\" }))\n        if (readCell(event.id, channel.id) !== next) commitCell(event, channel, next, \"bulk\")\n      }\n      setAnnouncement(\n        `${channel.label}: ${next ? \"on\" : \"off\"} for ${targets.length} of ${events.length} notifications` +\n          (skipped > 0 ? `. ${skipped} always-on ${skipped === 1 ? \"notification stays\" : \"notifications stay\"} on.` : \".\"),\n      )\n    }\n\n    const quietValue = quietOverride ?? quietHours\n    const commitQuietHours = (next: QuietHours) => {\n      if (!quietEditable || !quietValue) return\n      const previous = quietValue\n      const seq = quietSeqRef.current + 1\n      quietSeqRef.current = seq\n      setQuietOverride(next)\n      setQuietSave(\"saving\")\n      new Promise<void>(resolve => {\n        resolve(quietChangeRef.current?.(next))\n      }).then(\n        () => {\n          if (quietSeqRef.current === seq) setQuietSave(\"idle\")\n        },\n        () => {\n          if (quietSeqRef.current !== seq) return\n          setQuietOverride(previous)\n          setQuietSave(\"failed\")\n          setAnnouncement(\"Couldn't save quiet hours. Your previous window is still in effect.\")\n        },\n      )\n    }\n\n    /* ---------------------------------------------------------------- render */\n\n    const resolvedStatus: NotificationPreferencesStatus =\n      status === \"ready\" && (events.length === 0 || channels.length === 0) ? \"empty\" : status\n\n    // A 15rem pinned column leaves ~60px of matrix on a 375px phone. The cap is what\n    // keeps the same default usable from 375px up; the columns scroll under it.\n    const nameWidth = `min(${eventColumnWidth}, 42vw)`\n    const skeletonRowCount = clampInt(skeletonRows, 1, 24)\n    const skeletonColCount = clampInt(channels.length || 4, 1, 12)\n\n    const saveCounts = Object.values(saveStates)\n    const savingCount = saveCounts.filter(state => state === \"saving\").length\n    const failedCount = saveCounts.filter(state => state === \"failed\").length\n    const savedCount = saveCounts.filter(state => state === \"idle\").length\n    const summary =\n      savingCount > 0\n        ? { tone: \"text-muted-foreground\", text: `Saving ${savingCount} ${savingCount === 1 ? \"change\" : \"changes\"}…` }\n        : failedCount > 0\n          ? {\n              tone: \"text-destructive\",\n              text: `${failedCount} ${failedCount === 1 ? \"change\" : \"changes\"} couldn't be saved`,\n            }\n          : savedCount > 0\n            ? { tone: \"text-muted-foreground\", text: \"All changes saved\" }\n            : null\n\n    const alwaysOn = events.filter(event => (event.requiredChannels ?? []).length > 0)\n    const disconnected = channels.filter(channel => !channel.connected)\n\n    const bulkButtonClass =\n      \"flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n\n    return (\n      <div className={cn(\"@container/np flex w-full flex-col gap-4\", className)} ref={ref} {...props}>\n        <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n          {announcement}\n        </span>\n\n        <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-1\">\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <h3 className=\"text-sm font-semibold\">{title}</h3>\n            {/* \"changes save as you toggle\" describes nothing when there is\n                nothing to toggle — don't promise it in the empty/error branches. */}\n            {(resolvedStatus === \"ready\" || resolvedStatus === \"loading\") && (\n              <p className=\"text-sm text-muted-foreground\">{description}</p>\n            )}\n          </div>\n          {resolvedStatus === \"ready\" && summary && (\n            <span className={cn(\"flex shrink-0 items-center gap-1.5 text-xs\", summary.tone)} data-np-summary=\"\">\n              {savingCount > 0 && (\n                <Loader2 aria-hidden=\"true\" className=\"size-3.5 animate-spin motion-reduce:animate-none\" />\n              )}\n              {failedCount > 0 && savingCount === 0 && <TriangleAlert aria-hidden=\"true\" className=\"size-3.5\" />}\n              {summary.text}\n            </span>\n          )}\n        </div>\n\n        {resolvedStatus === \"loading\" && (\n          // The skeleton itself is aria-hidden (it has nothing to read), so the\n          // wait needs one sentence that a screen reader can actually reach.\n          <span className=\"sr-only\">Loading your notification settings…</span>\n        )}\n\n        {resolvedStatus === \"loading\" && (\n          // Same table geometry as the ready branch, and the same overflow-auto: a\n          // skeleton drawn with its own layout lines up on one viewport and jumps on\n          // every other, and overflow-hidden here would silently crop wide matrices.\n          <div aria-hidden=\"true\" className=\"w-full overflow-auto rounded-lg border bg-card\">\n            <table className=\"w-max min-w-full border-separate border-spacing-0 text-sm\">\n              <thead>\n                <tr>\n                  <th className=\"border-b border-r px-3 py-2\" style={{ width: nameWidth, minWidth: nameWidth }}>\n                    <div className=\"h-3 w-24 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                  </th>\n                  {Array.from({ length: skeletonColCount }, (_, col) => (\n                    <th className=\"min-w-24 border-b border-r px-2 py-2 last:border-r-0\" key={col}>\n                      <div className=\"mx-auto h-3 w-12 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                    </th>\n                  ))}\n                </tr>\n              </thead>\n              <tbody>\n                {Array.from({ length: skeletonRowCount }, (_, row) => (\n                  <tr key={row}>\n                    <td\n                      className={cn(\"border-r px-3 py-3\", row < skeletonRowCount - 1 && \"border-b\")}\n                      style={{ width: nameWidth, minWidth: nameWidth }}\n                    >\n                      <div\n                        className=\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\"\n                        style={{ width: `${86 - (row % 3) * 16}%` }}\n                      />\n                    </td>\n                    {Array.from({ length: skeletonColCount }, (_, col) => (\n                      <td\n                        className={cn(\n                          \"min-w-24 border-r px-2 py-3 last:border-r-0\",\n                          row < skeletonRowCount - 1 && \"border-b\",\n                        )}\n                        key={col}\n                      >\n                        <div className=\"mx-auto h-5 w-9 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n                      </td>\n                    ))}\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        )}\n\n        {resolvedStatus === \"empty\" && (\n          <div className=\"flex flex-col items-center gap-2 rounded-lg border border-dashed py-14 text-center\">\n            <BellRing aria-hidden=\"true\" className=\"size-8 text-muted-foreground\" />\n            <p className=\"text-sm font-medium\">No notifications to configure yet</p>\n            <p className=\"max-w-sm text-sm text-muted-foreground\">\n              Once this workspace sends its first notification, every event shows up here with a switch per channel.\n            </p>\n          </div>\n        )}\n\n        {resolvedStatus === \"error\" && (\n          <div className=\"flex flex-col items-center gap-3 rounded-lg border py-14 text-center\">\n            <AlertCircle aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-medium\">Couldn&apos;t load your notification settings</p>\n              <p className=\"text-sm text-muted-foreground\">\n                Nothing was changed — you&apos;re still receiving what you were before.\n              </p>\n            </div>\n            {onRetry && (\n              <button\n                className=\"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n                onClick={onRetry}\n                type=\"button\"\n              >\n                Try again\n              </button>\n            )}\n          </div>\n        )}\n\n        {resolvedStatus === \"ready\" && (\n          <>\n            <div className=\"w-full overflow-auto rounded-lg border bg-card\">\n              {/* border-separate: with border-collapse the borders belong to the table,\n                  not the cell, so the pinned column loses its right edge the moment\n                  the channel columns scroll under it. */}\n              <table className=\"w-max min-w-full border-separate border-spacing-0 text-sm\">\n                <caption className=\"sr-only\">{caption}</caption>\n                <thead>\n                  <tr>\n                    <th\n                      className=\"sticky left-0 top-0 z-40 border-b border-r bg-card px-3 py-2 text-left align-bottom\"\n                      scope=\"col\"\n                      style={{ width: nameWidth, minWidth: nameWidth }}\n                    >\n                      <span className=\"text-xs font-medium text-muted-foreground\">Notification</span>\n                    </th>\n                    {channels.map(channel => (\n                      <th\n                        className={cn(\n                          \"sticky top-0 z-30 min-w-24 border-b border-r bg-card px-2 py-2 align-bottom last:border-r-0\",\n                          !channel.connected && \"bg-muted\",\n                        )}\n                        key={channel.id}\n                        scope=\"col\"\n                        // Equal suggestions keep the channels the same width: with auto\n                        // layout the column carrying the longest header text would\n                        // otherwise take several times its neighbours' share.\n                        style={{ width: \"9rem\" }}\n                      >\n                        {/* max-w: a long connect hint would otherwise set the column's\n                            min-content width and stretch one channel to 3× the others. */}\n                        <div className=\"mx-auto flex max-w-40 flex-col items-center gap-1 text-center wrap-anywhere\">\n                          <span className=\"flex items-center gap-1 text-xs font-medium\">\n                            {!channel.connected && <Unplug aria-hidden=\"true\" className=\"size-3 shrink-0\" />}\n                            {channel.label}\n                          </span>\n                          {channel.description && channel.connected && (\n                            <span className=\"text-xs font-normal text-muted-foreground\">{channel.description}</span>\n                          )}\n\n                          {channel.connected ? (\n                            editable && (\n                              <span className=\"flex items-center gap-0.5\">\n                                <button\n                                  aria-label={`Turn on ${channel.label} for every notification`}\n                                  className={bulkButtonClass}\n                                  data-np-bulk=\"column-on\"\n                                  data-np-channel={channel.id}\n                                  onClick={() => setColumn(channel, true)}\n                                  type=\"button\"\n                                >\n                                  <Check aria-hidden=\"true\" className=\"size-3.5\" />\n                                </button>\n                                <button\n                                  aria-label={`Turn off ${channel.label} for every notification it isn't required for`}\n                                  className={bulkButtonClass}\n                                  data-np-bulk=\"column-off\"\n                                  data-np-channel={channel.id}\n                                  onClick={() => setColumn(channel, false)}\n                                  type=\"button\"\n                                >\n                                  <X aria-hidden=\"true\" className=\"size-3.5\" />\n                                </button>\n                              </span>\n                            )\n                          ) : (\n                            <>\n                              <span\n                                className=\"text-xs font-normal text-muted-foreground\"\n                                id={`${uid}-channel-${channel.id}-hint`}\n                              >\n                                {channel.connectHint ?? \"Not connected yet.\"}\n                              </span>\n                              {/* A greyed-out column with no way forward is a dead end;\n                                  the entry point is only drawn when it can be honoured. */}\n                              {onConnectChannel && (\n                                <button\n                                  className=\"cursor-pointer rounded-md border bg-background px-2 py-0.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n                                  data-np-connect={channel.id}\n                                  onClick={() => onConnectChannel(channel.id)}\n                                  type=\"button\"\n                                >\n                                  {channel.connectLabel ?? `Connect ${channel.label}`}\n                                </button>\n                              )}\n                            </>\n                          )}\n                        </div>\n                      </th>\n                    ))}\n                  </tr>\n                </thead>\n                <tbody>\n                  {events.map((event, rowIndex) => {\n                    const required = requiredSets.get(event.id) ?? new Set<string>()\n                    const isLast = rowIndex === events.length - 1\n                    const requiredId = `${uid}-event-${event.id}-required`\n                    return (\n                      <tr className=\"group/row\" key={event.id}>\n                        <th\n                          className={cn(\n                            \"sticky left-0 z-20 border-r bg-card px-3 py-2 text-left align-middle font-normal\",\n                            \"before:absolute before:inset-0 before:-z-10 group-hover/row:before:bg-foreground/5\",\n                            !isLast && \"border-b\",\n                          )}\n                          scope=\"row\"\n                          style={{ width: nameWidth, minWidth: nameWidth }}\n                        >\n                          <div className=\"flex items-center justify-between gap-2\">\n                            <span className=\"flex min-w-0 flex-col\">\n                              <span className=\"font-medium wrap-anywhere\">{event.label}</span>\n                              {event.description && (\n                                <span className=\"text-xs text-muted-foreground wrap-anywhere\">{event.description}</span>\n                              )}\n                              {required.size > 0 && (\n                                <span\n                                  className=\"mt-0.5 flex items-center gap-1 text-xs text-muted-foreground wrap-anywhere\"\n                                  id={requiredId}\n                                >\n                                  <Lock aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                                  {event.requiredReason ?? \"Always on — this notification can't be turned off.\"}\n                                </span>\n                              )}\n                            </span>\n                            {editable && (\n                              // Container query, not a viewport one: below ~28rem these two\n                              // 24px buttons cost more label room than they're worth, and the\n                              // panel may well be in a narrow column on a wide screen.\n                              <span className=\"hidden shrink-0 items-center gap-0.5 @md/np:flex\">\n                                <button\n                                  aria-label={`Turn on every connected channel for ${event.label}`}\n                                  className={bulkButtonClass}\n                                  data-np-bulk=\"row-on\"\n                                  data-np-event={event.id}\n                                  onClick={() => setRow(event, true)}\n                                  type=\"button\"\n                                >\n                                  <Check aria-hidden=\"true\" className=\"size-3.5\" />\n                                </button>\n                                <button\n                                  aria-label={`Turn off every channel for ${event.label} it isn't required for`}\n                                  className={bulkButtonClass}\n                                  data-np-bulk=\"row-off\"\n                                  data-np-event={event.id}\n                                  onClick={() => setRow(event, false)}\n                                  type=\"button\"\n                                >\n                                  <X aria-hidden=\"true\" className=\"size-3.5\" />\n                                </button>\n                              </span>\n                            )}\n                          </div>\n                        </th>\n\n                        {channels.map(channel => {\n                          const key = cellKey(event.id, channel.id)\n                          const locked = required.has(channel.id)\n                          const unavailable = !channel.connected\n                          const model: CellModel = {\n                            key,\n                            // Locked means locked-on: the stored value can't contradict the rule.\n                            on: locked ? true : readCell(event.id, channel.id),\n                            locked,\n                            unavailable,\n                            interactive: editable && !locked && !unavailable,\n                            origin: origins[key],\n                            save: saveStates[key] ?? \"idle\",\n                          }\n                          const noteId = `${uid}-cell-${event.id}-${channel.id}-note`\n                          const note =\n                            model.save === \"failed\"\n                              ? \"Last change couldn't be saved and was reverted. Activate again to retry.\"\n                              : model.origin === \"bulk\"\n                                ? \"Set by a row or column action.\"\n                                : null\n                          const describedBy =\n                            [\n                              locked ? requiredId : null,\n                              unavailable ? `${uid}-channel-${channel.id}-hint` : null,\n                              note ? noteId : null,\n                            ]\n                              .filter(Boolean)\n                              .join(\" \") || undefined\n\n                          return (\n                            <td\n                              className={cn(\n                                \"relative isolate min-w-24 border-r px-2 py-2 text-center align-middle last:border-r-0\",\n                                \"before:absolute before:inset-0 before:-z-10 group-hover/row:before:bg-foreground/5\",\n                                !isLast && \"border-b\",\n                                unavailable && \"bg-muted/50\",\n                              )}\n                              key={channel.id}\n                            >\n                              <span className=\"flex items-center justify-center gap-1\">\n                                <button\n                                  aria-busy={model.save === \"saving\" || undefined}\n                                  aria-checked={model.on}\n                                  aria-describedby={describedBy}\n                                  aria-disabled={!model.interactive || undefined}\n                                  aria-label={describeCell(event, channel)}\n                                  className={cn(\n                                    \"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 outline-none\",\n                                    \"transition-colors motion-reduce:transition-none\",\n                                    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n                                    model.on ? \"bg-primary\" : \"bg-input\",\n                                    // Frozen column: dimmed, but the stored value must\n                                    // still read at a glance — knob position AND tint.\n                                    model.unavailable && (model.on ? \"bg-primary/40\" : \"bg-muted\"),\n                                    model.unavailable && \"ring-1 ring-inset ring-border\",\n                                    model.save === \"failed\" && \"ring-2 ring-destructive\",\n                                    model.interactive ? \"cursor-pointer\" : \"cursor-not-allowed\",\n                                  )}\n                                  data-np-channel={channel.id}\n                                  data-np-event={event.id}\n                                  data-np-locked={locked ? \"\" : undefined}\n                                  data-np-origin={model.origin}\n                                  data-np-save={model.save}\n                                  data-np-switch=\"\"\n                                  data-np-unavailable={unavailable ? \"\" : undefined}\n                                  onClick={() => toggleCell(event, channel, model)}\n                                  role=\"switch\"\n                                  type=\"button\"\n                                >\n                                  <span\n                                    className={cn(\n                                      \"pointer-events-none flex size-4 items-center justify-center rounded-full bg-background shadow-sm\",\n                                      \"transition-transform motion-reduce:transition-none\",\n                                      model.on && \"translate-x-4\",\n                                    )}\n                                  >\n                                    {model.save === \"saving\" ? (\n                                      <Loader2\n                                        aria-hidden=\"true\"\n                                        className=\"size-2.5 animate-spin text-muted-foreground motion-reduce:animate-none\"\n                                      />\n                                    ) : locked ? (\n                                      <Lock aria-hidden=\"true\" className=\"size-2.5 text-muted-foreground\" />\n                                    ) : null}\n                                  </span>\n                                </button>\n                                {model.save === \"failed\" && (\n                                  <TriangleAlert aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-destructive\" />\n                                )}\n                              </span>\n                              {/* Shape, not colour: a dot marks \"this came from a bulk action\",\n                                  and it disappears the moment the cell is set by hand. */}\n                              {model.origin === \"bulk\" && model.save !== \"failed\" && (\n                                <span\n                                  aria-hidden=\"true\"\n                                  className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-muted-foreground\"\n                                  data-np-bulk-dot=\"\"\n                                />\n                              )}\n                              {note && (\n                                <span className=\"sr-only\" id={noteId}>\n                                  {note}\n                                </span>\n                              )}\n                            </td>\n                          )\n                        })}\n                      </tr>\n                    )\n                  })}\n                </tbody>\n              </table>\n            </div>\n\n            <div className=\"flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs text-muted-foreground\">\n              <span className=\"flex items-center gap-1.5\">\n                <span aria-hidden=\"true\" className=\"size-1.5 rounded-full bg-muted-foreground\" />\n                Set by a row or column action\n              </span>\n              {alwaysOn.length > 0 && (\n                <span className=\"flex items-center gap-1.5\">\n                  <Lock aria-hidden=\"true\" className=\"size-3.5\" />\n                  Always on — can&apos;t be turned off\n                </span>\n              )}\n              {disconnected.length > 0 && (\n                <span className=\"flex items-center gap-1.5\">\n                  <Unplug aria-hidden=\"true\" className=\"size-3.5\" />\n                  {disconnected.map(channel => channel.label).join(\", \")} not connected\n                </span>\n              )}\n            </div>\n\n            {quietValue && (\n              <QuietHoursPanel\n                alwaysOnLabels={alwaysOn.map(event => event.label)}\n                editable={quietEditable}\n                onChange={commitQuietHours}\n                saveState={quietSave}\n                uid={uid}\n                value={quietValue}\n              />\n            )}\n          </>\n        )}\n      </div>\n    )\n  },\n)\n\nNotificationPreferences.displayName = \"NotificationPreferences\"\n\n/* -------------------------------------------------------------------------- */\n/* quiet hours                                                                 */\n/* -------------------------------------------------------------------------- */\n\nconst SELECT_CLASS =\n  // The closed control stays bg-transparent to match the panel, but the option rows\n  // carry explicit token colors: browsers that paint the popup from the control's own\n  // colors would otherwise draw near-white text on a default white listbox.\n  \"appearance-none rounded-md border bg-transparent px-2 py-1 text-sm text-foreground [&>option]:bg-background [&>option]:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n\nfunction QuietHoursPanel({\n  alwaysOnLabels,\n  editable,\n  onChange,\n  saveState,\n  uid,\n  value,\n}: {\n  alwaysOnLabels: string[]\n  editable: boolean\n  onChange: (next: QuietHours) => void\n  saveState: SaveState\n  uid: string\n  value: QuietHours\n}) {\n  const headingId = `${uid}-quiet-heading`\n  const startId = `${uid}-quiet-start`\n  const endId = `${uid}-quiet-end`\n  const options = timeOptions(value.start, value.end)\n  const wraps = (parseWallClock(value.start) ?? 0) > (parseWallClock(value.end) ?? 0)\n  const duration = quietHoursDuration(value.start, value.end)\n  const quietNow = value.enabled && value.now !== undefined && isWithinQuietHours(value.now, value.start, value.end)\n\n  return (\n    <div aria-labelledby={headingId} className=\"flex flex-col gap-3 rounded-lg border bg-card p-4\" role=\"group\">\n      <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-2\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h4 className=\"flex items-center gap-1.5 text-sm font-medium\" id={headingId}>\n            <Moon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n            Quiet hours\n          </h4>\n          <p className=\"text-sm text-muted-foreground\">\n            Hold non-urgent notifications overnight. Times are in {value.timeZone} — the account&apos;s time zone, not\n            this device&apos;s.\n          </p>\n        </div>\n        <button\n          aria-checked={value.enabled}\n          aria-disabled={!editable || undefined}\n          aria-label=\"Quiet hours\"\n          className={cn(\n            \"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 outline-none\",\n            \"transition-colors motion-reduce:transition-none\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n            value.enabled ? \"bg-primary\" : \"bg-input\",\n            saveState === \"failed\" && \"ring-2 ring-destructive\",\n            editable ? \"cursor-pointer\" : \"cursor-not-allowed\",\n          )}\n          data-np-quiet-toggle=\"\"\n          onClick={() => {\n            if (editable) onChange({ ...value, enabled: !value.enabled })\n          }}\n          role=\"switch\"\n          type=\"button\"\n        >\n          <span\n            className={cn(\n              \"pointer-events-none flex size-4 items-center justify-center rounded-full bg-background shadow-sm\",\n              \"transition-transform motion-reduce:transition-none\",\n              value.enabled && \"translate-x-4\",\n            )}\n          >\n            {saveState === \"saving\" && (\n              <Loader2\n                aria-hidden=\"true\"\n                className=\"size-2.5 animate-spin text-muted-foreground motion-reduce:animate-none\"\n              />\n            )}\n          </span>\n        </button>\n      </div>\n\n      <div className=\"flex flex-wrap items-end gap-3\">\n        <div className=\"flex flex-col gap-1\">\n          <label className=\"text-xs font-medium text-muted-foreground\" htmlFor={startId}>\n            From\n          </label>\n          <select\n            className={SELECT_CLASS}\n            data-np-quiet-start=\"\"\n            disabled={!editable}\n            id={startId}\n            onChange={event => onChange({ ...value, start: event.target.value })}\n            value={value.start}\n          >\n            {options.map(option => (\n              <option key={option} value={option}>\n                {option}\n              </option>\n            ))}\n          </select>\n        </div>\n        <div className=\"flex flex-col gap-1\">\n          <label className=\"text-xs font-medium text-muted-foreground\" htmlFor={endId}>\n            To\n          </label>\n          <select\n            className={SELECT_CLASS}\n            data-np-quiet-end=\"\"\n            disabled={!editable}\n            id={endId}\n            onChange={event => onChange({ ...value, end: event.target.value })}\n            value={value.end}\n          >\n            {options.map(option => (\n              <option key={option} value={option}>\n                {option}\n              </option>\n            ))}\n          </select>\n        </div>\n        <p className=\"min-w-0 flex-1 text-xs text-muted-foreground\">\n          {/* The window is stated in full, including the day it lands on: a bare\n              \"22:00 – 07:00\" reads like an empty range to anyone scanning quickly. */}\n          {value.start} – {value.end}\n          {wraps ? \" next day\" : \"\"} · {formatDuration(duration)}\n          {duration === 0 && \" (nothing is muted)\"}\n        </p>\n      </div>\n\n      <p\n        className={cn(\"text-xs\", quietNow ? \"font-medium text-foreground\" : \"text-muted-foreground\")}\n        data-np-quiet-now={quietNow ? \"true\" : \"false\"}\n      >\n        {value.enabled\n          ? value.now === undefined\n            ? \"Quiet hours are on.\"\n            : quietNow\n              ? `Quiet right now (${value.now}) — held until ${value.end}.`\n              : `Delivering right now (${value.now}) — quiet starts at ${value.start}.`\n          : \"Quiet hours are off — notifications are delivered as they happen.\"}\n        {alwaysOnLabels.length > 0 && ` ${alwaysOnLabels.join(\" and \")} always come through.`}\n      </p>\n\n      {saveState === \"failed\" && (\n        <p className=\"flex items-center gap-1.5 text-xs text-destructive\">\n          <TriangleAlert aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          Couldn&apos;t save quiet hours — your previous window is still in effect.\n        </p>\n      )}\n    </div>\n  )\n}\n\nexport default NotificationPreferences\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/notification-preferences.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * A wall-clock time, 24-hour, zero padded. Deliberately a string and not a\n * Date: quiet hours are a *local* rule (\"don't ping me at night\"), not an\n * instant, so storing them as a timestamp would silently re-anchor the window\n * every time the user travels.\n */\nexport const wallClockSchema = z.string().regex(/^([01]\\d|2[0-3]):([0-5]\\d)$/, \"expected HH:MM (24-hour)\")\n\n/**\n * One column. `connected: false` is a first-class state, not a disabled flag:\n * the column still shows the user's stored choices, but nothing can be toggled\n * until the integration exists — so the header must offer a way to connect it.\n */\nexport const notificationChannelSchema = z.object({\n  id: z.string(),\n  label: z.string(),\n  /** short subtitle under the column label (delivery address, handle, …) */\n  description: z.string().optional(),\n  connected: z.boolean(),\n  /** why the column is inert and what connecting buys; shown when connected === false */\n  connectHint: z.string().optional(),\n  /** label for the connect entry point (default \"Connect\") */\n  connectLabel: z.string().optional(),\n})\n\n/**\n * One row. `requiredChannels` is how \"you can't turn this off\" is expressed in\n * data rather than in a special case in the view: those cells render locked-on\n * with `requiredReason` attached, instead of a switch that ignores clicks.\n */\nexport const notificationEventSchema = z.object({\n  id: z.string(),\n  label: z.string(),\n  description: z.string().optional(),\n  /** channel ids this event can never be muted on (security alerts, billing failures) */\n  requiredChannels: z.array(z.string()).optional(),\n  /** the reason, surfaced next to the row and via aria-describedby on the locked cells */\n  requiredReason: z.string().optional(),\n})\n\n/**\n * preferences[eventId][channelId]. Sparse on purpose: a missing entry reads as\n * \"off\", so a new account can ship `{}` instead of a fully materialised\n * events × channels product.\n */\nexport const notificationPreferenceMapSchema = z.record(z.string(), z.record(z.string(), z.boolean()))\n\n/**\n * A single window that may wrap past midnight (start > end, e.g. 22:00 → 07:00).\n *\n * Time-zone responsibility is the host's, and only the host's: `timeZone` is a\n * display label and `now` is the user's current wall clock *already expressed in\n * that zone*. The component never reads the clock and never converts — that\n * keeps render pure and deterministic, and keeps the one hard question (\"whose\n * midnight?\") in the layer that knows the answer.\n */\nexport const quietHoursSchema = z.object({\n  enabled: z.boolean(),\n  start: wallClockSchema,\n  end: wallClockSchema,\n  /** IANA-style label shown to the user, e.g. \"Europe/Berlin\" */\n  timeZone: z.string(),\n  /** current wall clock in `timeZone`; omit to hide the \"quiet right now\" line */\n  now: wallClockSchema.optional(),\n})\n\nexport const notificationPreferencesSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  channels: z.array(notificationChannelSchema),\n  events: z.array(notificationEventSchema),\n  preferences: notificationPreferenceMapSchema,\n  quietHours: quietHoursSchema.optional(),\n})\n\nexport type NotificationChannel = z.infer<typeof notificationChannelSchema>\nexport type NotificationEvent = z.infer<typeof notificationEventSchema>\nexport type NotificationPreferenceMap = z.infer<typeof notificationPreferenceMapSchema>\nexport type QuietHours = z.infer<typeof quietHoursSchema>\nexport type NotificationPreferencesData = z.infer<typeof notificationPreferencesSchema>\nexport type NotificationPreferencesStatus = NotificationPreferencesData[\"status\"]\n\n/** Which value a cell is showing, and where that value came from. */\nexport type PreferenceOrigin = \"manual\" | \"bulk\"\n\n/** Emitted per cell, immediately — this component saves as you toggle. */\nexport interface NotificationPreferenceChange {\n  eventId: string\n  channelId: string\n  enabled: boolean\n  /** \"bulk\" = written by a row/column action, \"manual\" = a single switch */\n  origin: PreferenceOrigin\n}\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}