{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"webhooks","type":"registry:component","title":"Webhooks","description":"Settings → Webhooks, done properly: \"Send test\" runs a bead down the wire to the endpoint and back in the answer's colour, and every delivery opens into its request and response.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/webhooks.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Copy, Plus, Trash2 } from \"lucide-react\";\nimport { ConfirmAction } from \"./confirm-action\";\nimport { NumberRoll } from \"./number-roll\";\nimport { RelativeTime, type DateLike } from \"./relative-time\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { DeliveryList, StatusChip, type WebhookDelivery } from \"./webhook-deliveries\";\nimport { WebhookWire } from \"./webhook-wire\";\n\n/* ─────────────────────────────────────────────────────────\n * WEBHOOKS: Settings → Webhooks, done properly\n *\n *   add       \"Add endpoint\" opens the form above the list: the\n *             URL and the events it wants. The new endpoint\n *             fades in on top, already open on \"Send test\"\n *   test      the test is drawn as the request it is: a bead\n *             runs the hairline to the endpoint, waits while it\n *             answers, and comes home in the answer's colour;\n *             \"200 OK in 182 ms\" rolls in, and the delivery\n *             lands on top of the list\n *   inspect   every delivery opens into its request and\n *             response; Redeliver sends it again and the new\n *             attempt arrives on top\n *   secret    the signing secret stays dots until you reveal\n *             it, then copies with a drawn check\n *   pause     Enabled is a switch that saves itself; Delete\n *             stretches the bin into a confirm and folds the\n *             endpoint away\n *\n * Endpoints and deliveries are plain records; every action is\n * one async function, so it fits Svix, Hookdeck or your own\n * queue.\n * ───────────────────────────────────────────────────────── */\n\nexport type { WebhookDelivery, DateLike };\n\nexport interface WebhookEndpoint {\n  id: string;\n  url: string;\n  /** The event types it receives. */\n  events: string[];\n  enabled?: boolean;\n  /** Shown on the collapsed row. */\n  lastDelivery?: { status: number | null; at: DateLike } | null;\n}\n\nexport interface WebhookEventType {\n  value: string;\n  description?: string;\n}\n\nexport interface WebhooksProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"onToggle\"> {\n  value?: WebhookEndpoint[];\n  defaultValue?: WebhookEndpoint[];\n  onValueChange?: (endpoints: WebhookEndpoint[]) => void;\n  /** Event types an endpoint can subscribe to. */\n  events: WebhookEventType[];\n  /** Creates an endpoint. Throw to keep the form and say why. */\n  onCreate: (input: { url: string; events: string[] }) => Promise<WebhookEndpoint>;\n  /** Sends a test event and returns the delivery it made. */\n  onTest: (endpoint: WebhookEndpoint, event: string) => Promise<WebhookDelivery>;\n  /** Recent deliveries, newest first; pass the oldest one shown to page back. An empty list means there are no more. */\n  loadDeliveries?: (endpoint: WebhookEndpoint, before?: WebhookDelivery) => Promise<WebhookDelivery[]>;\n  onRedeliver?: (delivery: WebhookDelivery, endpoint: WebhookEndpoint) => Promise<WebhookDelivery>;\n  onRevealSecret?: (endpoint: WebhookEndpoint) => Promise<string>;\n  /** Turns an endpoint on or off. Throw to put the switch back. */\n  onToggle?: (endpoint: WebhookEndpoint, enabled: boolean) => Promise<unknown>;\n  onDelete?: (endpoint: WebhookEndpoint) => Promise<unknown>;\n  /** Your app's name, at the near end of the test's wire. */\n  appName?: string;\n  title?: string;\n  description?: string;\n}\n\ntype Draft = { url: string; events: string[]; status: ActionStatus; error: string | null };\ntype Log = { items: WebhookDelivery[]; loading: boolean; more: boolean; loaded: boolean };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NONE: WebhookEndpoint[] = [];\nconst SHAKE = [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\nconst split = (url: string) => {\n  try {\n    const u = new URL(url);\n    return { host: u.host, path: `${u.pathname === \"/\" ? \"\" : u.pathname}${u.search}` };\n  } catch {\n    return { host: url, path: \"\" };\n  }\n};\nconst checkUrl = (v: string) => {\n  try {\n    const u = new URL(v.trim());\n    if (u.protocol === \"https:\" || (u.protocol === \"http:\" && /^(localhost|127\\.0\\.0\\.1)$/.test(u.hostname))) return null;\n    return \"Endpoints need https:// (http:// works for localhost).\";\n  } catch {\n    return v.trim() ? \"That isn't a full URL. Start it with https://.\" : \"Enter the URL that should receive events.\";\n  }\n};\n\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  const kept = React.useRef(children);\n  if (open) kept.current = children;\n  return (\n    <div className=\"grid\" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}, opacity ${open ? 320 : 180}ms ${EASE}` }}>\n      <div className=\"min-h-0 min-w-0 overflow-clip [overflow-clip-margin:6px]\">{open ? children : kept.current}</div>\n    </div>\n  );\n}\n\nfunction Check({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"1.9\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE} 100ms` : \"none\" }} />\n    </svg>\n  );\n}\n\nfunction EventChip({ value, on, reduced, onToggle }: { value: string; on: boolean; reduced: boolean; onToggle: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={on}\n      onClick={onToggle}\n      className={`inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 font-mono text-[11.5px] transition-[background-color,color,box-shadow] duration-200 ${FOCUS} ${\n        on ? \"bg-accent text-foreground shadow-[inset_0_0_0_1px_var(--border)]\" : \"text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:text-foreground\"\n      }`}\n    >\n      <span className=\"grid\" style={{ gridTemplateColumns: on ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}` }}>\n        <span className=\"min-w-0 overflow-clip\">\n          <span className=\"mr-1 block\">\n            <Check drawn={on} reduced={reduced} />\n          </span>\n        </span>\n      </span>\n      {value}\n    </button>\n  );\n}\n\n/** The signing secret: dots until it's revealed, then the value and a copy button. */\nfunction Secret({ value, onReveal, onHide, reduced }: { value: string | null; onReveal?: () => Promise<void>; onHide: () => void; reduced: boolean }) {\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [copied, setCopied] = React.useState(false);\n  const ref = React.useRef<HTMLElement>(null);\n  React.useLayoutEffect(() => {\n    if (value && !reduced) ref.current?.animate([{ opacity: 0, filter: \"blur(4px)\" }, { opacity: 1, filter: \"blur(0px)\" }], { duration: 360, easing: EASE });\n  }, [value, reduced]);\n  React.useEffect(() => {\n    if (!copied) return;\n    const timer = window.setTimeout(() => setCopied(false), 1400);\n    return () => window.clearTimeout(timer);\n  }, [copied]);\n  return (\n    <div className=\"flex min-w-0 items-center gap-1.5\">\n      <code ref={ref} className=\"min-w-0 flex-1 truncate rounded-[8px] bg-muted px-2 py-1 font-mono text-[11.5px] text-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n        {value ?? \"whsec_••••••••••••••••\"}\n      </code>\n      {value ? (\n        <>\n          <button\n            type=\"button\"\n            aria-label={copied ? \"Secret copied\" : \"Copy secret\"}\n            onClick={async () => {\n              try {\n                await navigator.clipboard.writeText(value);\n                setCopied(true);\n              } catch {\n                /* Selectable on screen. */\n              }\n            }}\n            className={`relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-full transition-colors hover:bg-accent ${FOCUS} ${copied ? \"text-emerald-600 dark:text-emerald-400\" : \"text-muted-foreground hover:text-foreground\"}`}\n          >\n            {[false, true].map((on) => (\n              <span key={String(on)} aria-hidden=\"true\" className=\"absolute inset-0 grid place-items-center\" style={{ opacity: copied === on ? 1 : 0, transform: copied === on ? \"none\" : \"scale(0.6)\", transition: reduced ? \"none\" : `opacity 220ms ${EASE}, transform 340ms ${EASE}` }}>\n                {on ? <Check drawn={copied} reduced={reduced} /> : <Copy className=\"size-3.5\" strokeWidth={1.75} />}\n              </span>\n            ))}\n          </button>\n          <button type=\"button\" onClick={onHide} className={`h-7 shrink-0 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}>\n            Hide\n          </button>\n        </>\n      ) : onReveal ? (\n        <StatusButton\n          size=\"sm\"\n          variant=\"ghost\"\n          status={status}\n          labels={{ idle: \"Reveal\", pending: \"Revealing\", success: \"Reveal\", error: \"Try again\" }}\n          onClick={async () => {\n            setStatus(\"pending\");\n            try {\n              await onReveal();\n              setStatus(\"idle\");\n            } catch {\n              setStatus(\"error\");\n            }\n          }}\n        />\n      ) : null}\n    </div>\n  );\n}\n\nfunction Switch({ on, label, onToggle, reduced, busy }: { on: boolean; label: string; onToggle: () => void; reduced: boolean; busy: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={on}\n      aria-busy={busy || undefined}\n      onClick={onToggle}\n      className={`inline-flex h-7 cursor-pointer items-center gap-2 rounded-full pr-2.5 pl-1 text-[12px] font-medium transition-colors hover:bg-accent aria-busy:cursor-default ${FOCUS}`}\n    >\n      <span className={`relative h-5 w-9 shrink-0 rounded-full transition-[background-color,box-shadow] duration-300 ${on ? \"bg-primary\" : \"bg-muted shadow-[inset_0_0_0_1px_var(--border)]\"}`}>\n        <span className=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\" style={{ transform: on ? \"translateX(16px)\" : \"none\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }} />\n      </span>\n      <TextMorph>{busy ? \"Saving\" : label}</TextMorph>\n    </button>\n  );\n}\n\ninterface EndpointProps {\n  ep: WebhookEndpoint;\n  open: boolean;\n  log?: Log;\n  secret: string | null;\n  fresh: boolean;\n  gone: boolean;\n  toggling: boolean;\n  reduced: boolean;\n  appName: string;\n  canReveal: boolean;\n  canToggle: boolean;\n  canDelete: boolean;\n  canRedeliver: boolean;\n  act: React.RefObject<Actions>;\n}\n\ntype Actions = {\n  open: (id: string) => void;\n  test: (ep: WebhookEndpoint, event: string) => Promise<WebhookDelivery>;\n  delivered: (ep: WebhookEndpoint, d: WebhookDelivery) => void;\n  more: (ep: WebhookEndpoint) => void;\n  redeliver: (ep: WebhookEndpoint, d: WebhookDelivery) => Promise<unknown>;\n  reveal: (ep: WebhookEndpoint) => Promise<void>;\n  hide: (ep: WebhookEndpoint) => void;\n  toggle: (ep: WebhookEndpoint) => void;\n  remove: (ep: WebhookEndpoint) => Promise<void>;\n};\n\nconst Endpoint = React.memo(function Endpoint({ ep, open, log, secret, fresh, gone, toggling, reduced, appName, canReveal, canToggle, canDelete, canRedeliver, act }: EndpointProps) {\n  const ref = React.useRef<HTMLLIElement>(null);\n  const { host, path } = split(ep.url);\n  const enabled = ep.enabled !== false;\n  const last = ep.lastDelivery;\n  const failing = enabled && last && (last.status === null || last.status >= 500);\n\n  React.useLayoutEffect(() => {\n    if (fresh && !reduced) ref.current?.animate([{ opacity: 0, filter: \"blur(4px)\", transform: \"translateY(-6px)\" }, { opacity: 1, filter: \"blur(0px)\", transform: \"none\" }], { duration: 460, easing: EASE });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <li ref={ref} className=\"border-t border-border first:border-t-0\">\n      <Fold open={!gone} reduced={reduced}>\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          onClick={() => act.current.open(ep.id)}\n          className={`flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/40 @md:px-5 ${FOCUS} focus-visible:ring-inset`}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={`size-1.5 shrink-0 rounded-full transition-colors duration-500 ${!enabled ? \"bg-muted-foreground/40\" : failing ? \"bg-red-500\" : \"bg-emerald-500\"}`}\n          />\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate font-mono text-[12.5px]\">\n              <span className=\"text-foreground\">{host}</span>\n              <span className=\"text-muted-foreground\">{path}</span>\n            </span>\n            <span className=\"mt-0.5 flex flex-wrap items-center gap-x-1.5 text-[11.5px] text-muted-foreground\">\n              <span className=\"inline-flex items-baseline tabular-nums\">\n                <NumberRoll value={ep.events.length} />\n                &nbsp;<TextMorph>{ep.events.length === 1 ? \"event\" : \"events\"}</TextMorph>\n              </span>\n              <span aria-hidden=\"true\">·</span>\n              {!enabled ? (\n                <span>Disabled</span>\n              ) : last ? (\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <StatusChip status={last.status} />\n                  <RelativeTime date={last.at} />\n                </span>\n              ) : (\n                <span>No deliveries yet</span>\n              )}\n            </span>\n          </span>\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 340ms ${EASE}` }}>\n            <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </button>\n\n        <Fold open={open} reduced={reduced}>\n          <div className=\"space-y-5 px-4 pt-1 pb-4 @md:px-5 @md:pb-5\">\n            <section aria-label=\"Send a test event\">\n              <p className=\"mb-2 text-[11.5px] font-medium text-muted-foreground\">Send a test</p>\n              <WebhookWire\n                host={host}\n                events={ep.events}\n                from={appName}\n                reduced={reduced}\n                autoFocus={fresh && open}\n                onSend={(event) => act.current.test(ep, event)}\n                onDelivered={(d) => act.current.delivered(ep, d)}\n              />\n            </section>\n\n            <section aria-label=\"Recent deliveries\">\n              <p className=\"mb-1 text-[11.5px] font-medium text-muted-foreground\">Recent deliveries</p>\n              <div className=\"rounded-[14px] px-1 shadow-[inset_0_0_0_1px_var(--border)]\">\n                <DeliveryList\n                  deliveries={log?.items ?? []}\n                  loading={Boolean(log?.loading) || !log}\n                  more={Boolean(log?.more)}\n                  onMore={() => act.current.more(ep)}\n                  onRedeliver={canRedeliver ? (d) => act.current.redeliver(ep, d) : undefined}\n                  reduced={reduced}\n                />\n              </div>\n            </section>\n\n            <div className=\"grid gap-4 @lg:grid-cols-2\">\n              <section aria-label=\"Signing secret\" className=\"min-w-0\">\n                <p className=\"mb-1.5 text-[11.5px] font-medium text-muted-foreground\">Signing secret</p>\n                <Secret value={secret} onReveal={canReveal ? () => act.current.reveal(ep) : undefined} onHide={() => act.current.hide(ep)} reduced={reduced} />\n              </section>\n              <section aria-label=\"Events\" className=\"min-w-0\">\n                <p className=\"mb-1.5 text-[11.5px] font-medium text-muted-foreground\">Events</p>\n                <p className=\"flex flex-wrap gap-1\">\n                  {ep.events.map((e) => (\n                    <span key={e} className=\"rounded-full px-2 py-0.5 font-mono text-[11px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n                      {e}\n                    </span>\n                  ))}\n                </p>\n              </section>\n            </div>\n\n            {(canToggle || canDelete) && (\n              <div className=\"flex items-center justify-between gap-3 border-t border-border pt-3\">\n                {canToggle ? <Switch on={enabled} label={enabled ? \"Enabled\" : \"Disabled\"} busy={toggling} reduced={reduced} onToggle={() => !toggling && act.current.toggle(ep)} /> : <span />}\n                {canDelete && (\n                  <ConfirmAction\n                    icon={<Trash2 strokeWidth={1.75} />}\n                    label={`Delete endpoint ${host}`}\n                    labels={{ idle: \"Delete endpoint\", pending: \"Deleting\", success: \"Deleted\", error: \"Try again\" }}\n                    onConfirm={() => act.current.remove(ep)}\n                  />\n                )}\n              </div>\n            )}\n          </div>\n        </Fold>\n      </Fold>\n    </li>\n  );\n});\n\nexport function Webhooks({\n  value,\n  defaultValue = NONE,\n  onValueChange,\n  events,\n  onCreate,\n  onTest,\n  loadDeliveries,\n  onRedeliver,\n  onRevealSecret,\n  onToggle,\n  onDelete,\n  appName = \"Your app\",\n  title = \"Webhooks\",\n  description = \"Send events to your own server as they happen.\",\n  className = \"\",\n  ...props\n}: WebhooksProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [inner, setInner] = React.useState(defaultValue);\n  const endpoints = value ?? inner;\n  const [draft, setDraft] = React.useState<Draft | null>(null);\n  const [openId, setOpenId] = React.useState<string | null>(null);\n  const [logs, setLogs] = React.useState<Record<string, Log>>({});\n  const [secrets, setSecrets] = React.useState<Record<string, string>>({});\n  const [toggling, setToggling] = React.useState<Record<string, boolean>>({});\n  const [gone, setGone] = React.useState<string[]>([]);\n  const [fresh, setFresh] = React.useState<string | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const urlRef = React.useRef<HTMLInputElement>(null);\n  const addRef = React.useRef<HTMLButtonElement>(null);\n  const formRef = React.useRef<HTMLFormElement>(null);\n  const latest = React.useRef(endpoints);\n  latest.current = endpoints;\n\n  const commit = (next: WebhookEndpoint[]) => {\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n  const patch = (epId: string, p: Partial<WebhookEndpoint>) => commit(latest.current.map((e) => (e.id === epId ? { ...e, ...p } : e)));\n  const setLog = (epId: string, f: (l: Log) => Log) => setLogs((all) => ({ ...all, [epId]: f(all[epId] ?? { items: [], loading: false, more: false, loaded: false }) }));\n\n  const load = async (ep: WebhookEndpoint, before?: WebhookDelivery) => {\n    if (!loadDeliveries) return setLog(ep.id, (l) => ({ ...l, loaded: true }));\n    setLog(ep.id, (l) => ({ ...l, loading: true }));\n    try {\n      const page = await loadDeliveries(ep, before);\n      setLog(ep.id, (l) => ({ items: before ? [...l.items, ...page] : page, loading: false, loaded: true, more: page.length > 0 }));\n    } catch {\n      setLog(ep.id, (l) => ({ ...l, loading: false, loaded: true }));\n    }\n  };\n\n  const act = React.useRef<Actions>(null!);\n  act.current = {\n    open: (epId) => {\n      setFresh(null);\n      setOpenId((o) => (o === epId ? null : epId));\n      const ep = latest.current.find((e) => e.id === epId);\n      if (ep && openId !== epId && !logs[epId]?.loaded && !logs[epId]?.loading) load(ep);\n    },\n    test: (ep, event) => onTest(ep, event),\n    delivered: (ep, d) => {\n      setLog(ep.id, (l) => ({ ...l, loaded: true, items: [d, ...l.items.filter((x) => x.id !== d.id)] }));\n      patch(ep.id, { lastDelivery: { status: d.status, at: d.at } });\n    },\n    more: (ep) => {\n      const items = logs[ep.id]?.items ?? [];\n      load(ep, items[items.length - 1]);\n    },\n    redeliver: async (ep, d) => {\n      if (!onRedeliver) return;\n      const next = await onRedeliver(d, ep);\n      act.current.delivered(ep, next);\n      setAnnounce(`Redelivered ${d.event}: ${next.status ?? \"no answer\"}.`);\n    },\n    reveal: async (ep) => {\n      if (!onRevealSecret) return;\n      const s = await onRevealSecret(ep);\n      setSecrets((all) => ({ ...all, [ep.id]: s }));\n    },\n    hide: (ep) => setSecrets((all) => Object.fromEntries(Object.entries(all).filter(([k]) => k !== ep.id))),\n    toggle: async (ep) => {\n      const next = ep.enabled === false;\n      patch(ep.id, { enabled: next });\n      setToggling((t) => ({ ...t, [ep.id]: true }));\n      try {\n        await onToggle?.(ep, next);\n        setAnnounce(`${split(ep.url).host} ${next ? \"enabled\" : \"disabled\"}.`);\n      } catch (e) {\n        patch(ep.id, { enabled: !next });\n        setAnnounce(message(e, \"That change couldn't be saved.\"));\n      } finally {\n        setToggling((t) => Object.fromEntries(Object.entries(t).filter(([k]) => k !== ep.id)));\n      }\n    },\n    remove: async (ep) => {\n      await onDelete?.(ep);\n      setAnnounce(`${split(ep.url).host} deleted.`);\n      window.setTimeout(() => setGone((g) => [...g, ep.id]), 500);\n      window.setTimeout(() => {\n        commit(latest.current.filter((e) => e.id !== ep.id));\n        setGone((g) => g.filter((x) => x !== ep.id));\n        setOpenId((o) => (o === ep.id ? null : o));\n      }, 1000);\n    },\n  };\n\n  const openDraft = () => {\n    if (draft) return;\n    setDraft({ url: \"\", events: events.slice(0, 1).map((e) => e.value), status: \"idle\", error: null });\n    requestAnimationFrame(() => urlRef.current?.focus({ preventScroll: true }));\n  };\n  const closeDraft = () => {\n    setDraft(null);\n    requestAnimationFrame(() => addRef.current?.focus({ preventScroll: true }));\n  };\n\n  const submit = async () => {\n    if (!draft || draft.status === \"pending\") return;\n    const why = checkUrl(draft.url) ?? (draft.events.length ? null : \"Pick at least one event.\");\n    if (why) {\n      setDraft({ ...draft, error: why, status: \"idle\" });\n      if (!reduced) formRef.current?.querySelector(\"[data-url]\")?.animate(SHAKE, { duration: 380, easing: \"ease-out\" });\n      return;\n    }\n    setDraft({ ...draft, status: \"pending\", error: null });\n    try {\n      const ep = await onCreate({ url: draft.url.trim(), events: draft.events });\n      commit([ep, ...latest.current]);\n      setLogs((all) => ({ ...all, [ep.id]: { items: [], loading: false, more: false, loaded: true } }));\n      setFresh(ep.id);\n      setOpenId(ep.id);\n      setDraft(null);\n      setAnnounce(`${split(ep.url).host} added. Send a test to check it.`);\n    } catch (e) {\n      setDraft((d) => d && { ...d, status: \"error\", error: message(e, \"The endpoint couldn't be added.\") });\n    }\n  };\n\n  const busyDraft = draft?.status === \"pending\";\n  return (\n    <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"flex items-start justify-between gap-3 px-4 pt-4 pb-3.5 @md:px-5 @md:pt-5\">\n        <div className=\"min-w-0\">\n          <p className=\"flex items-baseline gap-2 text-[14px] font-medium tracking-[-0.01em]\">\n            {title}\n            <span className=\"text-[12px] font-normal text-muted-foreground tabular-nums\">\n              <NumberRoll value={endpoints.length} />\n            </span>\n          </p>\n          <p className=\"mt-0.5 text-[12px] text-muted-foreground\">{description}</p>\n        </div>\n        <button\n          ref={addRef}\n          type=\"button\"\n          onClick={openDraft}\n          aria-disabled={Boolean(draft) || undefined}\n          aria-expanded={Boolean(draft)}\n          aria-controls={`${id}-form`}\n          className={`inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-3 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color,opacity] hover:bg-accent aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent ${FOCUS}`}\n        >\n          <Plus className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n          Add endpoint\n        </button>\n      </div>\n\n      <Fold open={Boolean(draft)} reduced={reduced}>\n        <form\n          ref={formRef}\n          id={`${id}-form`}\n          noValidate\n          className=\"border-t border-border px-4 py-3.5 @md:px-5\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            submit();\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\" && !busyDraft) {\n              e.preventDefault();\n              closeDraft();\n            }\n          }}\n        >\n          <label htmlFor={`${id}-url`} className=\"text-[11.5px] text-muted-foreground\">\n            Endpoint URL\n          </label>\n          <input\n            ref={urlRef}\n            data-url=\"\"\n            id={`${id}-url`}\n            type=\"url\"\n            inputMode=\"url\"\n            value={draft?.url ?? \"\"}\n            readOnly={busyDraft}\n            onChange={(e) => setDraft((d) => d && { ...d, url: e.target.value, error: null, status: \"idle\" })}\n            placeholder=\"https://api.yourcompany.com/webhooks\"\n            autoComplete=\"off\"\n            spellCheck={false}\n            aria-invalid={Boolean(draft?.error) || undefined}\n            aria-describedby={`${id}-line`}\n            className={`mt-1 h-9 w-full rounded-[10px] bg-transparent px-3 font-mono text-[16px] text-foreground outline-none transition-shadow placeholder:font-sans placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[12.5px] ${\n              draft?.error ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] focus:shadow-[inset_0_0_0_1px_var(--ring)]\"\n            }`}\n          />\n          <p className=\"mt-3 text-[11.5px] text-muted-foreground\">Events</p>\n          <div role=\"group\" aria-label=\"Events to send\" className=\"mt-1.5 flex flex-wrap gap-1.5\">\n            {events.map((ev) => {\n              const on = Boolean(draft?.events.includes(ev.value));\n              return (\n                <EventChip\n                  key={ev.value}\n                  value={ev.value}\n                  on={on}\n                  reduced={reduced}\n                  onToggle={() => setDraft((d) => d && { ...d, error: null, events: on ? d.events.filter((x) => x !== ev.value) : [...d.events, ev.value] })}\n                />\n              );\n            })}\n          </div>\n          <div className=\"mt-3.5 flex flex-wrap items-center justify-end gap-x-3 gap-y-2\">\n            <p id={`${id}-line`} className={`mr-auto min-w-0 text-[11.5px] ${draft?.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n              {draft?.error ?? \"We sign every request so your server can check it came from us.\"}\n            </p>\n            <div className=\"flex items-center gap-1\">\n              <button type=\"button\" onClick={closeDraft} disabled={busyDraft} className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50 ${FOCUS}`}>\n                Cancel\n              </button>\n              <StatusButton type=\"submit\" size=\"sm\" status={draft?.status ?? \"idle\"} labels={{ idle: \"Add endpoint\", pending: \"Adding\", error: \"Try again\" }} />\n            </div>\n          </div>\n        </form>\n      </Fold>\n\n      <div className=\"border-t border-border\">\n        <ul aria-label={title}>\n          {endpoints.map((ep) => (\n            <Endpoint\n              key={ep.id}\n              ep={ep}\n              open={openId === ep.id}\n              log={logs[ep.id]}\n              secret={secrets[ep.id] ?? null}\n              fresh={fresh === ep.id}\n              gone={gone.includes(ep.id)}\n              toggling={Boolean(toggling[ep.id])}\n              reduced={reduced}\n              appName={appName}\n              canReveal={Boolean(onRevealSecret)}\n              canToggle={Boolean(onToggle)}\n              canDelete={Boolean(onDelete)}\n              canRedeliver={Boolean(onRedeliver)}\n              act={act}\n            />\n          ))}\n        </ul>\n        <Fold open={!endpoints.length && !draft} reduced={reduced}>\n          <p className=\"px-4 py-6 text-center text-[12.5px] text-muted-foreground @md:px-5\">No endpoints yet. Add one to start receiving events.</p>\n        </Fold>\n      </div>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/webhook-wire.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { PillSelect } from \"./pill-select\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport type { WebhookDelivery } from \"./webhook-deliveries\";\n\n/* ─────────────────────────────────────────────────────────\n * WEBHOOK WIRE: \"Send test\", drawn as the request it is\n *\n *   out       a bead leaves your app and runs the hairline to\n *             the endpoint\n *   waiting   the endpoint's chip pulses while it answers\n *   back      the bead comes home in the answer's colour, and\n *             the result rolls in: \"200 OK in 182 ms\"\n *   failed    a 5xx, or no answer, comes back red and the\n *             button shakes\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst TRAVEL = \"cubic-bezier(0.65,0,0.35,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst REASONS: Record<number, string> = {\n  200: \"OK\", 201: \"Created\", 202: \"Accepted\", 204: \"No Content\", 301: \"Moved\", 302: \"Found\", 400: \"Bad Request\", 401: \"Unauthorized\", 403: \"Forbidden\", 404: \"Not Found\",\n  405: \"Method Not Allowed\", 408: \"Request Timeout\", 410: \"Gone\", 413: \"Too Large\", 422: \"Unprocessable\", 429: \"Too Many Requests\", 500: \"Internal Server Error\",\n  502: \"Bad Gateway\", 503: \"Service Unavailable\", 504: \"Gateway Timeout\",\n};\nexport const reasonFor = (status: number | null) => (status === null ? \"No answer\" : REASONS[status] ?? (status < 300 ? \"OK\" : status < 500 ? \"Refused\" : \"Server error\"));\nexport const toneFor = (status: number | null) =>\n  status === null || status >= 500 ? \"red\" : status >= 400 ? \"amber\" : status >= 300 ? \"amber\" : \"emerald\";\nconst COLOR = { emerald: \"var(--color-emerald-500)\", amber: \"var(--color-amber-500)\", red: \"var(--color-red-500)\" };\nconst TEXT = { emerald: \"text-emerald-600 dark:text-emerald-400\", amber: \"text-amber-700 dark:text-amber-400\", red: \"text-red-600 dark:text-red-400\" };\n\nexport interface WebhookWireProps {\n  /** The endpoint's host, shown at the far end of the wire. */\n  host: string;\n  /** Events the test can send; the first is picked. */\n  events: string[];\n  onSend: (event: string) => Promise<WebhookDelivery>;\n  /** Called with the delivery the test made, e.g. to add it to the list. */\n  onDelivered?: (delivery: WebhookDelivery) => void;\n  reduced: boolean;\n  autoFocus?: boolean;\n  /** Your app's name at the near end. */\n  from?: string;\n}\n\nexport function WebhookWire({ host, events, onSend, onDelivered, reduced, autoFocus, from = \"You\" }: WebhookWireProps) {\n  const [event, setEvent] = React.useState(events[0] ?? \"\");\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [result, setResult] = React.useState<{ status: number | null; ms: number } | null>(null);\n  const [failure, setFailure] = React.useState<string | null>(null);\n  const lineRef = React.useRef<HTMLSpanElement>(null);\n  const beadRef = React.useRef<HTMLSpanElement>(null);\n  const hostRef = React.useRef<HTMLSpanElement>(null);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    if (!events.includes(event)) setEvent(events[0] ?? \"\");\n  }, [events, event]);\n\n  React.useEffect(() => {\n    if (autoFocus) wrapRef.current?.querySelector<HTMLButtonElement>(\"button[data-send]\")?.focus({ preventScroll: true });\n  }, [autoFocus]);\n\n  const send = async () => {\n    if (status === \"pending\" || !event) return;\n    setStatus(\"pending\");\n    setFailure(null);\n    const answer = onSend(event);\n    const bead = beadRef.current;\n    const line = lineRef.current;\n    const far = line ? Math.max(0, line.offsetWidth - 7) : 0;\n    let ping: Animation | undefined;\n    if (bead && !reduced) {\n      bead.style.background = \"var(--primary)\";\n      await bead\n        .animate([{ transform: \"translateX(0px)\", opacity: 0 }, { opacity: 1, offset: 0.12 }, { transform: `translateX(${far}px)`, opacity: 1 }], { duration: 620, easing: TRAVEL, fill: \"forwards\" })\n        .finished.catch(() => {});\n      ping = hostRef.current?.animate([{ boxShadow: \"inset 0 0 0 1px var(--border), 0 0 0 0px color-mix(in oklab, var(--primary) 35%, transparent)\" }, { boxShadow: \"inset 0 0 0 1px var(--border), 0 0 0 6px transparent\" }], {\n        duration: 900,\n        iterations: Infinity,\n        easing: \"ease-out\",\n      });\n    }\n    try {\n      const d = await answer;\n      ping?.cancel();\n      const tone = toneFor(d.status);\n      if (bead && !reduced) {\n        bead.style.background = COLOR[tone];\n        await bead\n          .animate([{ transform: `translateX(${far}px)`, opacity: 1 }, { transform: \"translateX(0px)\", opacity: 1, offset: 0.88 }, { transform: \"translateX(0px)\", opacity: 0 }], { duration: 640, easing: TRAVEL, fill: \"forwards\" })\n          .finished.catch(() => {});\n      }\n      setResult({ status: d.status, ms: Math.round(d.durationMs ?? 0) });\n      setStatus(tone === \"emerald\" ? \"success\" : \"error\");\n      onDelivered?.(d);\n    } catch (e) {\n      ping?.cancel();\n      bead?.getAnimations().forEach((a) => a.cancel());\n      setFailure(e instanceof Error && e.message ? e.message : \"The test couldn't be sent.\");\n      setStatus(\"error\");\n    }\n  };\n\n  const tone = result ? toneFor(result.status) : null;\n  return (\n    <div ref={wrapRef}>\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <PillSelect\n          label=\"Event to send\"\n          mono\n          options={events.map((ev) => ({ value: ev, label: ev }))}\n          value={event}\n          onValueChange={setEvent}\n          width={260}\n        />\n        <StatusButton\n          data-send=\"\"\n          size=\"sm\"\n          variant=\"outline\"\n          status={status}\n          labels={{ idle: \"Send test\", pending: \"Sending\", success: \"Delivered\", error: \"Send again\" }}\n          onClick={send}\n          onReset={() => setStatus(\"idle\")}\n          resetAfter={2200}\n        />\n      </div>\n\n      {/* The wire: your app, the hairline, the endpoint. */}\n      <div aria-hidden=\"true\" className=\"mt-3 flex items-center gap-2 font-mono text-[11px]\">\n        <span className=\"shrink-0 rounded-full px-2 py-0.5 text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">{from}</span>\n        <span ref={lineRef} className=\"relative h-px min-w-6 flex-1 bg-border\">\n          <span ref={beadRef} className=\"absolute -top-[3px] left-0 size-[7px] rounded-full opacity-0 will-change-transform\" />\n        </span>\n        <span ref={hostRef} className=\"max-w-[55%] shrink truncate rounded-full px-2 py-0.5 text-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n          {host}\n        </span>\n      </div>\n\n      <div\n        className=\"grid\"\n        style={{ gridTemplateRows: result || failure ? \"1fr\" : \"0fr\", opacity: result || failure ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity 260ms ${EASE}` }}\n      >\n        <div className=\"min-h-0 min-w-0 overflow-clip\">\n          <p role=\"status\" className={`flex flex-wrap items-baseline gap-x-1 pt-2 text-[12px] tabular-nums ${failure ? TEXT.red : tone ? TEXT[tone] : \"text-muted-foreground\"}`}>\n            {failure ? (\n              failure\n            ) : result ? (\n              <>\n                {result.status !== null && <NumberRoll value={result.status} className=\"font-mono font-medium\" />}\n                <TextMorph>{reasonFor(result.status)}</TextMorph>\n                <span className=\"text-muted-foreground\">in</span>\n                <span className=\"inline-flex items-baseline text-muted-foreground\">\n                  <NumberRoll value={result.ms} format={{ maximumFractionDigits: 0 }} />\n                  &nbsp;ms\n                </span>\n              </>\n            ) : null}\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/webhook-deliveries.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { RelativeTime, type DateLike } from \"./relative-time\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * WEBHOOK DELIVERIES: what was sent, and what came back\n *\n *   rows      the status code in its colour, the event, when,\n *             and how long the endpoint took; new deliveries\n *             fade in on top while the rest glide down\n *   open      a row opens by height into Request and Response,\n *             the tab highlight sliding between them\n *   redeliver Redeliver → Sending → Sent; the new attempt\n *             arrives on top\n * ───────────────────────────────────────────────────────── */\n\nexport interface WebhookDelivery {\n  id: string;\n  event: string;\n  /** The endpoint's HTTP status, or null when it never answered. */\n  status: number | null;\n  durationMs?: number;\n  at: DateLike;\n  attempt?: number;\n  /** The body sent (JSON as text). */\n  request?: string;\n  /** What came back. */\n  response?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst chip = (status: number | null) =>\n  status === null || status >= 500\n    ? \"bg-red-500/10 text-red-700 dark:text-red-400\"\n    : status >= 300\n      ? \"bg-amber-500/10 text-amber-800 dark:text-amber-400\"\n      : \"bg-emerald-500/10 text-emerald-700 dark:text-emerald-400\";\n\nexport function StatusChip({ status }: { status: number | null }) {\n  return <span className={`inline-flex h-5 w-[2.6rem] shrink-0 items-center justify-center rounded-[6px] font-mono text-[11px] font-medium tabular-nums ${chip(status)}`}>{status ?? \"—\"}</span>;\n}\n\nconst pretty = (text?: string) => {\n  if (!text) return \"\";\n  try {\n    return JSON.stringify(JSON.parse(text), null, 2);\n  } catch {\n    return text;\n  }\n};\n\nfunction Bodies({ d, reduced }: { d: WebhookDelivery; reduced: boolean }) {\n  const id = React.useId();\n  const [tab, setTab] = React.useState<\"request\" | \"response\">(\"request\");\n  const refs = React.useRef<Record<string, HTMLButtonElement | null>>({});\n  const [bar, setBar] = React.useState({ left: 0, width: 0, ready: false });\n  React.useLayoutEffect(() => {\n    const b = refs.current[tab];\n    if (b) setBar((p) => (p.left === b.offsetLeft && p.width === b.offsetWidth ? p : { left: b.offsetLeft, width: b.offsetWidth, ready: p.width > 0 }));\n  }, [tab]);\n  const body = pretty(tab === \"request\" ? d.request : d.response);\n  return (\n    <div className=\"mt-1 mb-2 ml-2 rounded-[12px] shadow-[inset_0_0_0_1px_var(--border)] @md:ml-[3.35rem]\">\n      <div role=\"tablist\" aria-label=\"Delivery body\" className=\"relative flex gap-1 px-1.5 pt-1.5\">\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute top-1.5 left-0 h-6 rounded-full bg-accent\"\n          style={{ width: bar.width, transform: `translateX(${bar.left}px)`, transition: bar.ready && !reduced ? `transform 360ms ${EASE}, width 360ms ${EASE}` : \"none\" }}\n        />\n        {([\"request\", \"response\"] as const).map((t) => (\n          <button\n            key={t}\n            ref={(el) => {\n              refs.current[t] = el;\n            }}\n            role=\"tab\"\n            id={`${id}-${t}`}\n            aria-selected={tab === t}\n            aria-controls={`${id}-panel`}\n            tabIndex={tab === t ? 0 : -1}\n            onClick={() => setTab(t)}\n            onKeyDown={(e) => {\n              if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n              e.preventDefault();\n              const next = t === \"request\" ? \"response\" : \"request\";\n              setTab(next);\n              refs.current[next]?.focus();\n            }}\n            className={`relative h-6 cursor-pointer rounded-full px-2.5 text-[11.5px] font-medium capitalize transition-colors ${FOCUS} ${tab === t ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n          >\n            {t}\n          </button>\n        ))}\n      </div>\n      <pre id={`${id}-panel`} role=\"tabpanel\" aria-labelledby={`${id}-${tab}`} className=\"max-h-56 overflow-auto px-3 py-2 font-mono text-[11.5px] leading-[1.55] whitespace-pre-wrap break-all text-foreground\">\n        {body || <span className=\"text-muted-foreground\">{tab === \"request\" ? \"No body was kept.\" : \"The endpoint sent no body.\"}</span>}\n      </pre>\n    </div>\n  );\n}\n\nfunction Redeliver({ onRedeliver }: { onRedeliver: () => Promise<unknown> }) {\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  return (\n    <StatusButton\n      size=\"sm\"\n      variant=\"ghost\"\n      status={status}\n      labels={{ idle: \"Redeliver\", pending: \"Sending\", success: \"Sent\", error: \"Try again\" }}\n      onReset={() => setStatus(\"idle\")}\n      resetAfter={1600}\n      onClick={async () => {\n        setStatus(\"pending\");\n        try {\n          await onRedeliver();\n          setStatus(\"success\");\n        } catch {\n          setStatus(\"error\");\n        }\n      }}\n    />\n  );\n}\n\nconst Row = React.memo(function Row({\n  d,\n  open,\n  reduced,\n  toggle,\n  redeliver,\n}: {\n  d: WebhookDelivery;\n  open: boolean;\n  reduced: boolean;\n  toggle: React.RefObject<(id: string) => void>;\n  redeliver?: React.RefObject<(d: WebhookDelivery) => Promise<unknown>>;\n}) {\n  return (\n    <li data-row-id={d.id} className=\"border-t border-border first:border-t-0\">\n      <div className=\"flex items-center gap-2 py-1.5 pr-1\">\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          onClick={() => toggle.current(d.id)}\n          className={`flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-[8px] px-2 py-1 text-left transition-colors hover:bg-accent/70 ${FOCUS}`}\n        >\n          <StatusChip status={d.status} />\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate font-mono text-[12px] text-foreground\">{d.event}</span>\n            <span className=\"flex min-w-0 items-baseline gap-1.5 overflow-hidden text-[11.5px] whitespace-nowrap text-muted-foreground\">\n              <RelativeTime className=\"shrink-0\" date={d.at} />\n              {/* On a narrow card the time is enough; how long it took shows when there's room. */}\n              {d.durationMs !== undefined && (\n                <span className=\"hidden gap-1.5 @sm:inline-flex\">\n                  <span aria-hidden=\"true\">·</span>\n                  <span className=\"tabular-nums\">{Math.round(d.durationMs).toLocaleString(\"en-GB\")} ms</span>\n                </span>\n              )}\n              {d.attempt && d.attempt > 1 ? (\n                <>\n                  <span aria-hidden=\"true\">·</span>\n                  <span className=\"truncate\">attempt {d.attempt}</span>\n                </>\n              ) : null}\n            </span>\n          </span>\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}>\n            <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </button>\n        {redeliver && <Redeliver onRedeliver={() => redeliver.current(d)} />}\n      </div>\n      <div className=\"grid\" inert={!open} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}` }}>\n        <div className=\"min-h-0 min-w-0 overflow-clip\">{open && <Bodies d={d} reduced={reduced} />}</div>\n      </div>\n    </li>\n  );\n});\n\nexport interface DeliveryListProps {\n  deliveries: WebhookDelivery[];\n  loading: boolean;\n  /** More can be loaded. */\n  more: boolean;\n  onMore?: () => void;\n  onRedeliver?: (d: WebhookDelivery) => Promise<unknown>;\n  reduced: boolean;\n}\n\nexport function DeliveryList({ deliveries, loading, more, onMore, onRedeliver, reduced }: DeliveryListProps) {\n  const [open, setOpen] = React.useState<string | null>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const ranks = React.useMemo(() => new Map(deliveries.map((d, i) => [d.id, i])), [deliveries]);\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n  const toggle = React.useRef<(id: string) => void>(() => {});\n  toggle.current = (id) => setOpen((o) => (o === id ? null : id));\n  const redeliver = React.useRef<(d: WebhookDelivery) => Promise<unknown>>(async () => {});\n  redeliver.current = (d) => onRedeliver?.(d) ?? Promise.resolve();\n\n  return (\n    <div>\n      <div className=\"relative\">\n        <ul ref={listRef} aria-label=\"Recent deliveries\" aria-busy={loading || undefined} className=\"relative\">\n          {deliveries.map((d) => (\n            <Row key={d.id} d={d} open={open === d.id} reduced={reduced} toggle={toggle} redeliver={onRedeliver ? redeliver : undefined} />\n          ))}\n        </ul>\n        <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n      </div>\n      {(loading || !deliveries.length || more) && (\n        <div className=\"flex h-9 items-center justify-center gap-2 text-[12px] text-muted-foreground\">\n          {loading ? (\n            <>\n              <span className=\"size-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none\" />\n              Loading deliveries\n            </>\n          ) : !deliveries.length ? (\n            \"Nothing delivered yet. Send a test to see one here.\"\n          ) : (\n            <button type=\"button\" onClick={onMore} className={`h-7 cursor-pointer rounded-full px-3 font-medium text-foreground transition-colors hover:bg-accent ${FOCUS}`}>\n              <TextMorph>Show older</TextMorph>\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"},{"path":"registry/pill-select.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * PILL SELECT: a select that's the same surface as its pill\n *\n *   open      the pill grows into its list (size and corners\n *             ease) from the edge it's lined up with, while its\n *             label fades into the options; it opens upward when\n *             there's no room below, and long lists scroll\n *   pick      the list folds back into the pill and the label\n *             morphs to the new choice; the current option\n *             carries a drawn check\n *   keys      arrows move a highlight that glides between rows,\n *             Home and End jump, typing a few letters finds an\n *             option, Enter picks, Escape folds it back\n *\n * The list lives in a portal on the page's body, placed over\n * the pill, so a clipping toolbar, fold or scroller never cuts\n * it off; it carries the theme's colours with it. Scrolling\n * the page closes it.\n *\n * Used wherever a component needs a choice from a list; never\n * the browser's own dropdown, which can't follow the theme.\n * ───────────────────────────────────────────────────────── */\n\nexport interface PillOption {\n  value: string;\n  label: string;\n  /** A second line in the list, e.g. a sample value. */\n  description?: string;\n}\n\nexport interface PillSelectProps {\n  options: PillOption[];\n  value: string | null | undefined;\n  onValueChange: (value: string) => void;\n  /** Accessible name, e.g. \"Column for Email\". */\n  label: string;\n  /** Shown while nothing is chosen. */\n  placeholder?: string;\n  /** What the pill shows for a choice; the option's label by default. */\n  display?: (option: PillOption | undefined) => string;\n  /** The pill's contents, when it needs more than text (an icon that turns). */\n  renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode;\n  /** Monospace labels, for code-like values (event names, column headers). */\n  mono?: boolean;\n  /** Which edge of the pill the list lines up with. */\n  align?: \"start\" | \"end\";\n  /** The list's width; never narrower than the pill. */\n  width?: number;\n  /** Fill the width it's given. */\n  block?: boolean;\n  /** Dims the pill's text, e.g. for \"Don't import\". */\n  muted?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst MAX_LIST = 280;\n// The theme travels with the list, so it matches the pill wherever the pill's colours were set.\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" style={{ transform: up ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}>\n      <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function PillSelect({\n  options,\n  value,\n  onValueChange,\n  label,\n  placeholder = \"Choose\",\n  display,\n  renderValue,\n  mono = false,\n  align = \"start\",\n  width = 240,\n  block = false,\n  muted = false,\n  disabled = false,\n  className = \"\",\n}: PillSelectProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [active, setActive] = React.useState(0);\n  const [pill, setPill] = React.useState({ width: 0, height: 28 });\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> });\n  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });\n  const [quiet, setQuiet] = React.useState(false);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const selected = options.find((o) => o.value === value);\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n  const text = display ? display(selected) : (selected?.label ?? placeholder);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const rect = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const w = Math.min(Math.max(width, rect.width), vw - 24);\n    // Lined up with the pill's edge, nudged back on screen.\n    const shift = align === \"start\" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w));\n    const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12;\n    const vh = window.innerHeight;\n    const below = vh - 8 - rect.top;\n    const above = rect.bottom - 8;\n    const up = below < estimate + 8 && above > below;\n    // Fixed coordinates of the pill's corner the list grows from.\n    const x = align === \"start\" ? rect.left : vw - rect.right;\n    const y = up ? vh - rect.bottom : rect.top;\n    const cs = getComputedStyle(wrap);\n    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ up, width: w, shift, x, y, theme });\n  };\n\n  const open = () => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setActive(Math.max(0, options.findIndex((o) => o.value === value)));\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(trigger);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (panel) setPanelHeight(panel.offsetHeight);\n  }, [mounted, options]);\n\n  // The highlight glides to the active row, and the list scrolls to keep it in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    setHighlight((h) => (h.top === row.offsetTop && h.height === row.offsetHeight && h.ready === (h.ready || expanded) ? h : { top: row.offsetTop, height: row.offsetHeight, ready: h.ready || expanded }));\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;\n      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;\n    }\n  }, [active, expanded, mounted]);\n\n  React.useEffect(() => {\n    if (!mounted) setHighlight({ top: 0, height: 0, ready: false });\n  }, [mounted]);\n\n  React.useEffect(() => {\n    if (expanded) listRef.current?.focus({ preventScroll: true });\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.\n    const gone = () => {\n      window.clearTimeout(timer.current);\n      setPhase(\"closed\");\n    };\n    const onScroll = (e: Event) => {\n      if (!surfaceRef.current?.contains(e.target as Node)) gone();\n    };\n    const onResize = gone;\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [expanded, close]);\n\n  const choose = (o: PillOption) => {\n    close();\n    if (o.value !== value) onValueChange(o.value);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    const last = options.length - 1;\n    const move = (to: number) => {\n      e.preventDefault();\n      setActive(Math.max(0, Math.min(last, to)));\n    };\n    if (e.key === \"ArrowDown\") move(active === last ? 0 : active + 1);\n    else if (e.key === \"ArrowUp\") move(active === 0 ? last : active - 1);\n    else if (e.key === \"Home\") move(0);\n    else if (e.key === \"End\") move(last);\n    else if (e.key === \"PageDown\") move(active + 6);\n    else if (e.key === \"PageUp\") move(active - 6);\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (options[active]) choose(options[active]);\n    } else if (e.key === \"Tab\") close(false);\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      // A few letters typed quickly find the option that starts with them.\n      const now = performance.now();\n      typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));\n      if (hit !== -1) setActive(hit);\n    }\n  };\n\n  const w = expanded ? fit.width : pill.width;\n  const h = expanded ? panelHeight : pill.height;\n  const listId = `${id}-list`;\n  const pillText = renderValue ? null : (\n    <>\n      <span className={`min-w-0 flex-1 truncate text-left ${mono ? \"font-mono text-[11.5px]\" : \"\"} ${muted || !selected ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n        {block ? text : <TextMorph>{text}</TextMorph>}\n      </span>\n      <Chevron up={expanded} reduced={reduced} />\n    </>\n  );\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative ${block ? \"flex w-full min-w-0\" : \"inline-flex\"} ${className}`}\n      onKeyDown={(e) => {\n        setQuiet(false);\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n      }}\n      onBlur={(e) => {\n        const to = e.relatedTarget as Node | null;\n        if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false);\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? listId : undefined}\n        aria-label={`${label}: ${selected?.label ?? placeholder}`}\n        disabled={disabled}\n        onClick={() => (expanded ? close() : open())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            open();\n          }\n        }}\n        onPointerDown={() => setQuiet(true)}\n        className={`inline-flex h-7 min-w-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? \"w-full\" : \"\"} ${quiet ? \"outline-none\" : FOCUS}`}\n      >\n        {renderValue ? renderValue(selected, expanded) : pillText}\n      </button>\n\n      {mounted &&\n        createPortal(\n        <div\n          ref={surfaceRef}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              close();\n            }\n          }}\n          onPointerDown={(e) => {\n            if (!(e.target as HTMLElement).closest(\"[data-scroll]\")) e.preventDefault();\n            setQuiet(true);\n          }}\n          className=\"fixed z-[60] overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            ...fit.theme,\n            [fit.up ? \"bottom\" : \"top\"]: fit.y,\n            [align === \"start\" ? \"left\" : \"right\"]: fit.x + (expanded ? (align === \"start\" ? fit.shift : -fit.shift) : 0),\n            width: w,\n            height: h,\n            borderRadius: expanded ? 16 : pill.height / 2,\n            // The pill's own hairline, the whole way: it is the same surface.\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The pill's label, fading out as the surface grows and back in as it folds. */}\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inline-flex h-7 items-center gap-1.5 px-2.5 text-[12px] font-medium whitespace-nowrap ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}\n          >\n            {renderValue ? renderValue(selected, expanded) : pillText}\n          </span>\n          <div\n            ref={panelRef}\n            className={`absolute p-1 ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}\n          >\n            <div data-scroll=\"\" className=\"overflow-y-auto overscroll-contain\" style={{ maxHeight: MAX_LIST }}>\n              <div\n                ref={listRef}\n                id={listId}\n                role=\"listbox\"\n                tabIndex={-1}\n                aria-label={label}\n                aria-activedescendant={`${id}-o${active}`}\n                onKeyDown={onListKey}\n                className=\"relative outline-none\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent\"\n                  style={{\n                    height: highlight.height,\n                    transform: `translateY(${highlight.top}px)`,\n                    opacity: highlight.ready ? 1 : 0,\n                    transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : \"none\",\n                  }}\n                />\n                {options.map((o, i) => {\n                  const on = o.value === value;\n                  return (\n                    <div\n                      key={o.value}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      id={`${id}-o${i}`}\n                      role=\"option\"\n                      aria-selected={on}\n                      onPointerMove={() => setActive(i)}\n                      onClick={() => choose(o)}\n                      className=\"relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5\"\n                    >\n                      <span className=\"min-w-0 flex-1\">\n                        <span className={`block truncate ${mono ? \"font-mono text-[12px]\" : \"text-[12.5px] font-medium\"}`}>{o.label}</span>\n                        {o.description && <span className=\"mt-0.5 block truncate text-[11.5px] text-muted-foreground\">{o.description}</span>}\n                      </span>\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\">\n                        <path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.8\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          pathLength={1}\n                          strokeDasharray={1}\n                          style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 380ms ${EASE} 160ms` }}\n                        />\n                      </svg>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n"},{"path":"registry/confirm-action.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\n\n/* ─────────────────────────────────────────────────────────\n * CONFIRM ACTION: an icon that asks before it acts\n *\n *   ask       a click and the icon's own circle stretches into\n *             a pill: the confirm button opens out to its left\n *             and the icon turns into a cross that takes it back\n *   act       the confirm button is a status button, so it says\n *             Revoking, Revoked or Try again in place\n *   leave     Escape, the cross, or a click or focus anywhere\n *             else folds it back into the icon\n *\n * Nothing opens over the row it belongs to; the question sits\n * where the icon was.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ConfirmActionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onClick\"> {\n  icon: React.ReactNode;\n  /** The icon button's accessible name, e.g. \"Revoke Production key\". */\n  label: string;\n  /** The confirm button's words per state, e.g. { idle: \"Revoke\", pending: \"Revoking\", success: \"Revoked\" }. */\n  labels?: Partial<Record<ActionStatus, string>>;\n  /** \"danger\" makes the confirm red. */\n  tone?: \"danger\" | \"default\";\n  /** Runs once on confirm. Throw (or reject) to show Try again. */\n  onConfirm: () => Promise<unknown> | unknown;\n  /** Back to the icon this long after success (ms); 0 keeps \"done\" until the row goes. */\n  resetAfter?: number;\n  disabled?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Cross() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport function ConfirmAction({\n  icon,\n  label,\n  labels,\n  tone = \"danger\",\n  onConfirm,\n  resetAfter = 0,\n  disabled = false,\n  className = \"\",\n  ...props\n}: ConfirmActionProps) {\n  const reduced = useReducedMotion();\n  const [asking, setAsking] = React.useState(false);\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const iconRef = React.useRef<HTMLButtonElement>(null);\n  const run = React.useRef(0);\n  const busy = status === \"pending\" || status === \"success\";\n\n  const cancel = React.useCallback(\n    (refocus: boolean) => {\n      if (status === \"pending\") return;\n      run.current++;\n      setAsking(false);\n      setStatus(\"idle\");\n      if (refocus) iconRef.current?.focus({ preventScroll: true });\n    },\n    [status],\n  );\n\n  // The question is open: its button takes focus once it's there to take it.\n  React.useEffect(() => {\n    if (asking && status === \"idle\") wrapRef.current?.querySelector<HTMLButtonElement>(\"[data-confirm]\")?.focus({ preventScroll: true });\n  }, [asking, status]);\n\n  React.useEffect(() => {\n    if (!asking) return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) cancel(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [asking, cancel]);\n\n  const confirm = async () => {\n    if (busy) return;\n    const mine = ++run.current;\n    setStatus(\"pending\");\n    try {\n      await onConfirm();\n      if (run.current === mine) setStatus(\"success\");\n    } catch {\n      if (run.current === mine) setStatus(\"error\");\n    }\n  };\n\n  const t = `${reduced ? 0 : 420}ms ${EASE}`;\n  const red = tone === \"danger\" ? \"text-red-600! hover:bg-red-500/10! dark:text-red-400!\" : \"\";\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative inline-flex h-7 shrink-0 items-center rounded-full ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && asking) {\n          e.preventDefault();\n          e.stopPropagation();\n          cancel(true);\n        }\n      }}\n      onBlur={(e) => {\n        if (asking && !busy && document.hasFocus() && !wrapRef.current?.contains(e.relatedTarget as Node)) cancel(false);\n      }}\n      {...props}\n    >\n      {/* The icon's circle, stretched into the pill round the question. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 rounded-full\"\n        style={{ boxShadow: `inset 0 0 0 1px ${asking ? \"var(--border)\" : \"transparent\"}`, transition: `box-shadow ${t}` }}\n      />\n      <span\n        className=\"grid\"\n        inert={!asking}\n        style={{\n          gridTemplateColumns: asking ? \"1fr\" : \"0fr\",\n          opacity: asking ? 1 : 0,\n          filter: asking || reduced ? \"none\" : \"blur(3px)\",\n          transition: `grid-template-columns ${t}, opacity ${reduced ? 0 : asking ? 320 : 160}ms ${EASE}, filter ${t}`,\n        }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-2px_0_-2px_0)]\">\n          <span className=\"flex w-max items-center pl-0.5\">\n            <StatusButton\n              data-confirm=\"\"\n              size=\"sm\"\n              variant=\"ghost\"\n              status={status}\n              labels={{ idle: \"Confirm\", pending: \"Working\", success: \"Done\", error: \"Try again\", ...labels }}\n              onClick={confirm}\n              onReset={resetAfter ? () => cancel(false) : undefined}\n              resetAfter={resetAfter || undefined}\n              className={`h-[26px]! ${status === \"error\" ? \"\" : red}`}\n            />\n          </span>\n        </span>\n      </span>\n      <button\n        ref={iconRef}\n        type=\"button\"\n        aria-label={asking ? \"Cancel\" : label}\n        aria-expanded={asking}\n        disabled={disabled}\n        onClick={() => (asking ? cancel(true) : setAsking(true))}\n        className={`relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 aria-disabled:cursor-default ${FOCUS}`}\n        aria-disabled={busy || undefined}\n      >\n        {[icon, <Cross key=\"x\" />].map((glyph, i) => {\n          const on = (i === 1) === asking;\n          return (\n            <span\n              key={i}\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 grid place-items-center [&_svg]:size-3.5\"\n              style={{\n                opacity: on ? 1 : 0,\n                transform: on ? \"none\" : \"scale(0.6)\",\n                filter: on || reduced ? \"none\" : \"blur(3px)\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              {glyph}\n            </span>\n          );\n        })}\n      </button>\n    </div>\n  );\n}\n"},{"path":"registry/relative-time.tsx","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * RELATIVE TIME: \"2 min ago\" that keeps itself true\n *\n *   tick      it re-renders only when the words would change:\n *             on the next minute under an hour, the next hour\n *             under a day; nothing runs between\n *   roll      the number rolls up (\"2 min\" → \"3 min\") and the\n *             unit morphs when it changes (\"59 min\" → \"1 h\")\n *   date      past a week it settles on the date (\"12 Sep\")\n *\n * Until it's mounted it renders the date, so the server and the\n * first paint agree; the hover title is always the full time.\n * ───────────────────────────────────────────────────────── */\n\nexport type DateLike = Date | string | number;\n\nexport interface RelativeTimeProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  date: DateLike;\n  /** Said before the time, e.g. \"Last used\". */\n  prefix?: string;\n  /** Under a minute. */\n  justNow?: string;\n  locales?: string | string[];\n}\n\nconst MINUTE = 60_000;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\nexport const toTime = (d: DateLike) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : new Date(d).getTime());\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\n/** \"12 Sep\", with the year when it isn't this one; pass locales to use the browser's own format. */\nexport function formatDate(d: DateLike, locales?: string | string[]) {\n  const t = new Date(toTime(d));\n  const sameYear = t.getFullYear() === new Date().getFullYear();\n  if (locales) return t.toLocaleDateString(locales, sameYear ? { day: \"numeric\", month: \"short\" } : { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  return `${t.getDate()} ${MONTHS[t.getMonth()]}${sameYear ? \"\" : ` ${t.getFullYear()}`}`;\n}\n\ntype Parts = { n: number | null; unit: string; next: number };\n\nfunction parts(at: number, now: number, justNow: string, locales?: string | string[]): Parts {\n  const ago = Math.max(0, now - at);\n  if (ago < MINUTE) return { n: null, unit: justNow, next: MINUTE - ago };\n  if (ago < HOUR) return { n: Math.floor(ago / MINUTE), unit: \"min ago\", next: MINUTE - (ago % MINUTE) };\n  if (ago < DAY) {\n    const h = Math.floor(ago / HOUR);\n    return { n: h, unit: h === 1 ? \"hour ago\" : \"hours ago\", next: HOUR - (ago % HOUR) };\n  }\n  if (ago < 7 * DAY) {\n    const d = Math.floor(ago / DAY);\n    return { n: d, unit: d === 1 ? \"day ago\" : \"days ago\", next: DAY - (ago % DAY) };\n  }\n  return { n: null, unit: formatDate(at, locales), next: 0 };\n}\n\nconst subscribeMounted = () => () => {};\n\nexport function RelativeTime({ date, prefix, justNow = \"just now\", locales, className = \"\", ...props }: RelativeTimeProps) {\n  const at = toTime(date);\n  const mounted = React.useSyncExternalStore(subscribeMounted, () => true, () => false);\n  const [now, setNow] = React.useState(() => Date.now());\n  const p = parts(at, mounted ? now : at + 8 * DAY, justNow, locales);\n\n  // One timer, set for the moment the words change.\n  React.useEffect(() => {\n    if (!mounted || !p.next) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), p.next + 50);\n    return () => window.clearTimeout(timer);\n  }, [mounted, p.next, now]);\n\n  React.useEffect(() => setNow(Date.now()), [at]);\n\n  const iso = new Date(at).toISOString();\n  return (\n    <time\n      dateTime={iso}\n      title={new Date(at).toLocaleString(locales ?? \"en-GB\", { dateStyle: \"medium\", timeStyle: \"short\" })}\n      className={`inline-flex items-baseline whitespace-nowrap tabular-nums ${className}`}\n      {...props}\n    >\n      {prefix && <span>{prefix}&nbsp;</span>}\n      {/* Remounted once on the client, so the first real words appear without a morph. */}\n      <Words key={mounted ? \"client\" : \"server\"} n={p.n} unit={p.unit} />\n    </time>\n  );\n}\n\nfunction Words({ n, unit }: { n: number | null; unit: string }) {\n  return (\n    <>\n      <span\n        aria-hidden={n === null || undefined}\n        className=\"inline-grid transition-[grid-template-columns] duration-[380ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\"\n        style={{ gridTemplateColumns: n === null ? \"0fr\" : \"1fr\" }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n          <span className=\"inline-flex items-baseline pr-[0.25em]\">\n            <NumberRoll value={n ?? 1} direction=\"up\" />\n          </span>\n        </span>\n      </span>\n      <TextMorph>{unit}</TextMorph>\n    </>\n  );\n}\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · webhooks · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/webhooks. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}