{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "access-request",
  "type": "registry:ui",
  "title": "Access Request",
  "description": "A compact avatar pill that unfolds into access requests with shared avatar motion, individual approve and decline actions, and coordinated spring reflow.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "lucide-react",
    "@radix-ui/react-avatar",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils",
    "avatar"
  ],
  "files": [
    {
      "path": "src/registry/ui/access-request.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useReducer,\n  useRef,\n  useState,\n  type Ref,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Check, ChevronDown, ShieldCheck, X } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { useMediaQuery } from \"@/lib/use-media-query\";\nimport { cn } from \"@/lib/utils\";\nimport { AccessRequestRow } from \"./access-request-row\";\nimport {\n  accessRequestReducer,\n  createAccessRequestState,\n  type AccessDecision,\n  type AccessRequester,\n  type AccessRequestEntry,\n} from \"./access-request-state\";\nimport { useAccessRequestMotion } from \"./use-access-request-motion\";\n\nexport type { AccessDecision, AccessRequester } from \"./access-request-state\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface AccessRequestProps {\n  requests: readonly AccessRequester[];\n  onDecision: (\n    person: AccessRequester,\n    decision: AccessDecision,\n    signal: AbortSignal,\n  ) => void | Promise<void>;\n  onResolved?: (person: AccessRequester, decision: AccessDecision) => void;\n  onDismiss?: () => void;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  disabled?: boolean;\n  width?: number;\n  stiffness?: number;\n  damping?: number;\n  speed?: number;\n  errorMessage?: string;\n  className?: string;\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst controlClass =\n  \"flex size-8 cursor-pointer items-center justify-center rounded-lg text-zinc-500 outline-none hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-500 disabled:pointer-events-none pointer-coarse:size-10 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:ring-zinc-400\";\nconst colors = [\"#ede9fe\", \"#ffe4e6\", \"#fef9c3\", \"#dcfce7\"];\n\nfunction RequestAvatar({\n  person,\n  index,\n  expanded,\n  count,\n}: {\n  person: AccessRequester;\n  index: number;\n  expanded: boolean;\n  count: number;\n}) {\n  const [initialIndex] = useState(index);\n  const colorIndex =\n    Array.from(person.id).reduce(\n      (total, letter) => total + letter.charCodeAt(0),\n      0,\n    ) % colors.length;\n  return (\n    <div\n      data-request-avatar={person.id}\n      className=\"absolute top-0 left-0\"\n      style={{\n        transform: `translate(${expanded ? 16 : 16 + initialIndex * 21}px, ${expanded ? 81 + initialIndex * 56 : 16}px)`,\n        opacity: expanded || initialIndex < 2 ? 1 : 0,\n        zIndex: count - index + 1,\n      }}\n    >\n      <Avatar className=\"size-8 border-2 border-white dark:border-[#171717]\">\n        {person.avatar && (\n          <AvatarImage src={person.avatar} alt=\"\" className=\"object-cover\" />\n        )}\n        <AvatarFallback\n          className={cn(\n            \"text-[11px] font-semibold text-zinc-800\",\n            person.emoji && \"text-[20px]\",\n          )}\n          style={{ backgroundColor: person.color ?? colors[colorIndex] }}\n        >\n          {person.emoji ??\n            person.name\n              .split(/\\s+/)\n              .filter(Boolean)\n              .slice(0, 2)\n              .map((word) => word[0])\n              .join(\"\")}\n        </AvatarFallback>\n      </Avatar>\n    </div>\n  );\n}\n\nexport function AccessRequest({\n  requests,\n  onDecision,\n  onResolved,\n  onDismiss,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  disabled = false,\n  width = 360,\n  stiffness = 420,\n  damping = 28,\n  speed = 1,\n  errorMessage = \"Couldn’t save this decision. Please try again.\",\n  className,\n  ref,\n}: AccessRequestProps) {\n  const [state, dispatch] = useReducer(\n    accessRequestReducer,\n    requests,\n    createAccessRequestState,\n  );\n  const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);\n  const [dismissing, setDismissing] = useState(false);\n  const [dismissed, setDismissed] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [initialExpanded] = useState(expanded ?? defaultExpanded);\n  const [initialCount] = useState(state.entries.length);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const pendingRef = useRef(new Map<string, AbortController>());\n  const decisionFocusRef = useRef(new Set<string>());\n  const nextFocusRef = useRef<number | null>(null);\n  const callbacksRef = useRef({ onResolved, onDismiss });\n  const dismissingRef = useRef(false);\n  const uid = useId();\n  const isOpen = expanded ?? ownExpanded;\n  const reduced = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  const count = state.entries.length;\n  const pendingCount = state.entries.filter(\n    (entry) => entry.phase === \"pending\",\n  ).length;\n  const reviewed = state.approved + state.declined;\n  const first = state.entries[0]?.person;\n  const summary = first\n    ? `${first.handle ? `@${first.handle.replace(/^@/, \"\")}` : first.name}${count > 1 ? ` and ${count - 1} ${count === 2 ? \"other\" : \"others\"}` : \"\"}`\n    : \"All caught up\";\n  const signature = JSON.stringify(\n    state.entries.map((entry) => [entry.person.id, entry.phase, entry.error]),\n  );\n  const size = Number.isFinite(width)\n    ? Math.min(480, Math.max(280, width))\n    : 360;\n\n  useAccessRequestMotion({\n    rootRef,\n    panelRef,\n    contentRef,\n    signature,\n    expanded: isOpen,\n    stiffness,\n    damping,\n    speed,\n    enabled: !dismissed,\n  });\n  const { contextSafe } = useGSAP({ scope: rootRef });\n\n  useGSAP(\n    () => {\n      callbacksRef.current = { onResolved, onDismiss };\n    },\n    { dependencies: [onResolved, onDismiss] },\n  );\n\n  useEffect(() => {\n    dispatch({ type: \"sync\", requests });\n  }, [requests]);\n\n  useEffect(() => {\n    for (const [id, controller] of pendingRef.current) {\n      const entry = state.entries.find((item) => item.person.id === id);\n      if (!entry || entry.phase === \"removing\") {\n        controller.abort();\n        pendingRef.current.delete(id);\n      }\n    }\n  }, [state.entries]);\n\n  useEffect(() => {\n    const pending = pendingRef.current;\n    return () => {\n      for (const controller of pending.values()) controller.abort();\n      pending.clear();\n    };\n  }, []);\n\n  useGSAP(\n    () => {\n      if (nextFocusRef.current === null) return;\n      const rows = Array.from(\n        contentRef.current?.querySelectorAll<HTMLElement>(\n          \"[data-request-row]\",\n        ) ?? [],\n      );\n      const row = rows[Math.min(nextFocusRef.current, rows.length - 1)];\n      (isOpen\n        ? (row?.querySelector<HTMLButtonElement>(\"button:not(:disabled)\") ??\n          triggerRef.current)\n        : triggerRef.current\n      )?.focus({ preventScroll: true });\n      nextFocusRef.current = null;\n    },\n    { scope: rootRef, dependencies: [signature, isOpen] },\n  );\n\n  const changeExpanded = (next: boolean) => {\n    if (dismissingRef.current) return;\n    if (expanded === undefined) setOwnExpanded(next);\n    onExpandedChange?.(next);\n    if (!next) triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const decide = async (\n    entry: AccessRequestEntry,\n    decision: AccessDecision,\n  ) => {\n    const { person } = entry;\n    if (\n      disabled ||\n      dismissingRef.current ||\n      entry.phase !== \"idle\" ||\n      pendingRef.current.has(person.id)\n    )\n      return;\n    const controller = new AbortController();\n    pendingRef.current.set(person.id, controller);\n    const row = Array.from(\n      contentRef.current?.querySelectorAll<HTMLElement>(\"[data-request-row]\") ??\n        [],\n    ).find((element) => element.dataset.requestRow === person.id);\n    if (row?.contains(document.activeElement))\n      decisionFocusRef.current.add(person.id);\n    dispatch({ type: \"begin\", id: person.id, decision });\n    setAnnouncement(\n      `${decision === \"approved\" ? \"Approving\" : \"Declining\"} ${person.name}.`,\n    );\n    try {\n      await onDecision(person, decision, controller.signal);\n      if (controller.signal.aborted) return;\n      dispatch({ type: \"resolve\", id: person.id, decision });\n      setAnnouncement(`${person.name} ${decision}.`);\n    } catch {\n      if (!controller.signal.aborted) {\n        dispatch({ type: \"reject\", id: person.id, message: errorMessage });\n        setAnnouncement(\n          `Decision for ${person.name} failed. Please try again.`,\n        );\n        if (\n          decisionFocusRef.current.has(person.id) &&\n          document.activeElement === document.body\n        )\n          nextFocusRef.current = state.entries.findIndex(\n            (item) => item.person.id === person.id,\n          );\n      }\n    } finally {\n      if (pendingRef.current.get(person.id) === controller)\n        pendingRef.current.delete(person.id);\n    }\n  };\n\n  const finish = (entry: AccessRequestEntry) => {\n    const rows = Array.from(\n      contentRef.current?.querySelectorAll<HTMLElement>(\"[data-request-row]\") ??\n        [],\n    );\n    const row = rows.find(\n      (element) => element.dataset.requestRow === entry.person.id,\n    );\n    if (\n      row?.contains(document.activeElement) ||\n      (decisionFocusRef.current.has(entry.person.id) &&\n        document.activeElement === document.body)\n    )\n      nextFocusRef.current = state.entries.findIndex(\n        (item) => item.person.id === entry.person.id,\n      );\n    decisionFocusRef.current.delete(entry.person.id);\n    dispatch({ type: \"remove\", id: entry.person.id });\n    if (entry.phase === \"decided\" && entry.decision)\n      callbacksRef.current.onResolved?.(entry.person, entry.decision);\n  };\n\n  const dismiss = () =>\n    contextSafe(() => {\n      if (dismissingRef.current) return;\n      dismissingRef.current = true;\n      setDismissing(true);\n      for (const [id, controller] of pendingRef.current) {\n        controller.abort();\n        dispatch({ type: \"reject\", id, message: \"Decision cancelled.\" });\n      }\n      pendingRef.current.clear();\n      gsap.to(rootRef.current, {\n        autoAlpha: 0,\n        y: reduced ? 0 : -8,\n        scale: reduced ? 1 : 0.97,\n        duration: reduced ? 0 : 0.18,\n        ease: \"power2.in\",\n        overwrite: \"auto\",\n        onComplete: () => {\n          setDismissed(true);\n          callbacksRef.current.onDismiss?.();\n        },\n      });\n    })();\n\n  if (dismissed) return null;\n\n  return (\n    <div\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      data-slot=\"access-request\"\n      data-expanded={isOpen}\n      role=\"region\"\n      aria-labelledby={`${uid}-heading`}\n      className={cn(\n        \"relative max-w-full overflow-hidden rounded-[20px] border border-zinc-200 bg-white text-zinc-900 shadow-[0_8px_30px_-12px_#00000020] dark:border-white/[0.07] dark:bg-[#171717] dark:text-zinc-100 dark:shadow-[0_8px_30px_-12px_#00000050]\",\n        className,\n      )}\n      style={{ width: size }}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") {\n          event.preventDefault();\n          event.stopPropagation();\n          if (isOpen) changeExpanded(false);\n          else dismiss();\n        }\n      }}\n    >\n      <div className=\"relative h-16\">\n        <button\n          type=\"button\"\n          data-request-compact\n          aria-expanded={isOpen}\n          aria-controls={`${uid}-panel`}\n          aria-label={\n            count\n              ? `Review ${count} access ${count === 1 ? \"request\" : \"requests\"}`\n              : \"Review completed requests\"\n          }\n          aria-hidden={isOpen}\n          inert={isOpen || dismissing}\n          tabIndex={isOpen ? -1 : 0}\n          onClick={() => changeExpanded(true)}\n          className=\"absolute inset-y-0 left-0 right-20 cursor-pointer rounded-l-[20px] text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-400\"\n          style={{ opacity: initialExpanded ? 0 : 1 }}\n        >\n          <span\n            data-request-summary\n            className=\"absolute inset-y-0 left-0 flex min-w-0 flex-col justify-center\"\n            style={{\n              transform: `translateX(${initialCount > 2 ? 108 : initialCount > 1 ? 84 : initialCount ? 60 : 58}px)`,\n            }}\n          >\n            <span className=\"truncate text-[12px] leading-5 font-medium\">\n              {summary}\n            </span>\n            <span className=\"truncate text-[11px] leading-4 text-zinc-500 dark:text-zinc-400\">\n              {pendingCount\n                ? `Reviewing ${pendingCount} ${pendingCount === 1 ? \"request\" : \"requests\"}…`\n                : count\n                  ? \"requested access\"\n                  : `${reviewed} ${reviewed === 1 ? \"request\" : \"requests\"} reviewed`}\n            </span>\n          </span>\n        </button>\n        <h3\n          id={`${uid}-heading`}\n          data-request-heading\n          className=\"pointer-events-none absolute top-[22px] left-4 text-[13px] leading-5 font-medium\"\n          style={{ opacity: initialExpanded ? 1 : 0 }}\n        >\n          Access requests\n        </h3>\n        <div className=\"absolute top-4 right-11 pointer-coarse:top-3\">\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-label={\n              isOpen ? \"Collapse access requests\" : \"Expand access requests\"\n            }\n            aria-expanded={isOpen}\n            aria-controls={`${uid}-panel`}\n            disabled={dismissing}\n            onClick={() => changeExpanded(!isOpen)}\n            className={controlClass}\n          >\n            <ChevronDown\n              data-request-chevron\n              size={17}\n              aria-hidden=\"true\"\n              style={{\n                transform: initialExpanded ? \"rotate(180deg)\" : undefined,\n              }}\n            />\n          </button>\n        </div>\n        <div className=\"absolute top-4 right-2 pointer-coarse:top-3\">\n          <button\n            type=\"button\"\n            aria-label=\"Dismiss access requests\"\n            onClick={dismiss}\n            disabled={dismissing}\n            className={controlClass}\n          >\n            <X size={17} aria-hidden=\"true\" />\n          </button>\n        </div>\n      </div>\n      <div\n        ref={panelRef}\n        id={`${uid}-panel`}\n        inert={!isOpen || dismissing}\n        aria-hidden={!isOpen}\n        className=\"overflow-hidden\"\n        style={{ height: initialExpanded ? \"auto\" : 0 }}\n      >\n        <div ref={contentRef} style={{ opacity: initialExpanded ? 1 : 0 }}>\n          <div className=\"mx-4 h-px bg-zinc-100 dark:bg-white/[0.07]\" />\n          {count ? (\n            <ul className=\"relative py-2\">\n              {state.entries.map((entry) => (\n                <AccessRequestRow\n                  key={entry.person.id}\n                  entry={entry}\n                  expanded={isOpen}\n                  disabled={disabled || dismissing}\n                  reduced={reduced}\n                  onDecision={(decision) => void decide(entry, decision)}\n                  onExit={() => finish(entry)}\n                />\n              ))}\n            </ul>\n          ) : (\n            <div className=\"flex flex-col items-center px-5 py-8 text-center\">\n              <span className=\"mb-3 flex size-10 items-center justify-center rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400\">\n                <ShieldCheck size={20} aria-hidden=\"true\" />\n              </span>\n              <p className=\"text-[13px] font-medium\">\n                {reviewed\n                  ? \"Everyone’s accounted for.\"\n                  : \"No pending requests.\"}\n              </p>\n              <p className=\"mt-1.5 text-[11px] leading-5 text-zinc-500 dark:text-zinc-400\">\n                {reviewed\n                  ? `${state.approved} approved · ${state.declined} declined`\n                  : \"New access requests will appear here.\"}\n              </p>\n            </div>\n          )}\n        </div>\n      </div>\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        {state.entries.map(({ person }, index) => (\n          <RequestAvatar\n            key={person.id}\n            person={person}\n            index={index}\n            expanded={initialExpanded}\n            count={state.entries.length}\n          />\n        ))}\n        <span\n          data-request-overflow\n          className=\"absolute top-0 left-0 flex size-8 items-center justify-center rounded-full bg-zinc-100 text-[12px] font-medium text-zinc-600 dark:bg-[#272727] dark:text-zinc-300\"\n          style={{\n            transform: \"translate(58px, 16px)\",\n            opacity: !initialExpanded && initialCount > 2 ? 1 : 0,\n          }}\n        >\n          {Math.max(0, count - 2)}\n        </span>\n        {!count && (\n          <span\n            className=\"absolute top-4 left-4 flex size-8 items-center justify-center rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400\"\n            style={{ opacity: isOpen ? 0 : 1 }}\n          >\n            <Check size={17} />\n          </span>\n        )}\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nexport default AccessRequest;\n",
      "type": "registry:ui",
      "target": "@ui/access-request.tsx"
    },
    {
      "path": "src/registry/ui/access-request-row.tsx",
      "content": "\"use client\";\n\nimport { useRef } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Check, LoaderCircle, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  AccessDecision,\n  AccessRequestEntry,\n} from \"./access-request-state\";\n\ngsap.registerPlugin(useGSAP);\n\ninterface AccessRequestRowProps {\n  entry: AccessRequestEntry;\n  expanded: boolean;\n  disabled: boolean;\n  reduced: boolean;\n  onDecision: (decision: AccessDecision) => void;\n  onExit: () => void;\n}\n\nconst actionClass =\n  \"relative flex min-h-7 w-16 min-w-16 cursor-pointer items-center justify-center gap-1 rounded-full border px-2 text-[11px] leading-4 font-medium outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-wait disabled:opacity-50 pointer-coarse:min-h-10 dark:focus-visible:ring-offset-[#171717]\";\n\nexport function AccessRequestRow({\n  entry,\n  expanded,\n  disabled,\n  reduced,\n  onDecision,\n  onExit,\n}: AccessRequestRowProps) {\n  const rowRef = useRef<HTMLLIElement>(null);\n  const callbacksRef = useRef(onExit);\n  const { person, phase, decision, error } = entry;\n  const leaving = phase === \"decided\" || phase === \"removing\";\n  const busy = phase === \"pending\";\n\n  useGSAP(\n    () => {\n      callbacksRef.current = onExit;\n    },\n    { dependencies: [onExit] },\n  );\n\n  useGSAP(\n    () => {\n      const face = rowRef.current?.querySelector(\"[data-request-face]\");\n      if (!face) return;\n      const duration = reduced ? 0 : 0.18;\n      if (leaving) {\n        const timeline = gsap.timeline({\n          onComplete: () => callbacksRef.current(),\n        });\n        timeline.to(\n          face,\n          {\n            x: reduced ? 0 : decision === \"approved\" ? 10 : -10,\n            autoAlpha: 0,\n            scale: reduced ? 1 : 0.98,\n            duration: expanded ? duration : 0,\n            ease: \"power2.in\",\n          },\n          expanded && phase === \"decided\" && !reduced ? 0.25 : 0,\n        );\n        return;\n      }\n      gsap.to(face, {\n        x: 0,\n        autoAlpha: 1,\n        scale: 1,\n        duration,\n        overwrite: \"auto\",\n      });\n      if (busy && expanded && !reduced)\n        gsap.to(\"[data-request-spinner]\", {\n          rotation: 360,\n          duration: 0.8,\n          repeat: -1,\n          ease: \"none\",\n        });\n    },\n    {\n      dependencies: [phase, decision, expanded, reduced],\n      scope: rowRef,\n      revertOnUpdate: true,\n    },\n  );\n\n  return (\n    <li\n      ref={rowRef}\n      data-request-row={person.id}\n      data-phase={phase}\n      data-decision={decision}\n      className=\"relative min-w-0 list-none\"\n      aria-busy={busy}\n    >\n      <div\n        data-request-face\n        className=\"grid min-h-14 grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-x-2.5 px-4 py-2\"\n      >\n        <span data-request-anchor className=\"size-8\" aria-hidden=\"true\" />\n        <div className=\"min-w-0\">\n          <p\n            className=\"truncate text-[12px] leading-5 font-medium\"\n            title={person.name}\n          >\n            {person.name}\n          </p>\n          {person.description && (\n            <p\n              className=\"truncate text-[10px] leading-4 text-zinc-500 dark:text-zinc-400\"\n              title={person.description}\n            >\n              {person.description}\n            </p>\n          )}\n        </div>\n        {phase === \"decided\" ? (\n          <span\n            className={cn(\n              \"flex items-center gap-1.5 text-[11px] font-medium\",\n              decision === \"approved\"\n                ? \"text-emerald-700 dark:text-emerald-400\"\n                : \"text-zinc-500 dark:text-zinc-400\",\n            )}\n          >\n            {decision === \"approved\" ? (\n              <Check size={13} aria-hidden=\"true\" />\n            ) : (\n              <X size={13} aria-hidden=\"true\" />\n            )}\n            {decision === \"approved\" ? \"Approved\" : \"Declined\"}\n          </span>\n        ) : (\n          <div className=\"flex items-center gap-1.5\">\n            {([\"approved\", \"declined\"] as const).map((value) => (\n              <button\n                key={value}\n                type=\"button\"\n                disabled={busy || disabled || leaving}\n                aria-label={`${value === \"approved\" ? \"Approve\" : \"Decline\"} ${person.name}`}\n                onClick={() => onDecision(value)}\n                className={cn(\n                  actionClass,\n                  value === \"approved\"\n                    ? \"border-transparent bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 dark:hover:bg-white/15\"\n                    : \"border-zinc-200 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:border-white/10 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100\",\n                )}\n              >\n                {busy && decision === value ? (\n                  <LoaderCircle\n                    data-request-spinner\n                    size={12}\n                    aria-hidden=\"true\"\n                  />\n                ) : value === \"approved\" ? (\n                  \"Approve\"\n                ) : (\n                  \"Decline\"\n                )}\n              </button>\n            ))}\n          </div>\n        )}\n        {error && (\n          <p\n            role=\"alert\"\n            className=\"col-start-2 col-end-4 pt-1.5 text-[11px] leading-4 text-red-600 dark:text-red-400\"\n          >\n            {error}\n          </p>\n        )}\n      </div>\n    </li>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/access-request-row.tsx"
    },
    {
      "path": "src/registry/ui/access-request-state.ts",
      "content": "export type AccessDecision = \"approved\" | \"declined\";\n\nexport interface AccessRequester {\n  id: string;\n  name: string;\n  handle?: string;\n  avatar?: string;\n  emoji?: string;\n  color?: string;\n  description?: string;\n}\n\nexport interface AccessRequestEntry {\n  person: AccessRequester;\n  phase: \"idle\" | \"pending\" | \"decided\" | \"removing\";\n  decision?: AccessDecision;\n  error?: string;\n}\n\nexport interface AccessRequestState {\n  entries: AccessRequestEntry[];\n  settledIds: string[];\n  approved: number;\n  declined: number;\n}\n\nexport type AccessRequestEvent =\n  | { type: \"sync\"; requests: readonly AccessRequester[] }\n  | { type: \"begin\" | \"resolve\"; id: string; decision: AccessDecision }\n  | { type: \"reject\"; id: string; message: string }\n  | { type: \"remove\"; id: string };\n\nexport function createAccessRequestState(\n  requests: readonly AccessRequester[],\n): AccessRequestState {\n  return {\n    entries: Array.from(\n      new Map(requests.map((person) => [person.id, person])).values(),\n    ).map((person) => ({ person, phase: \"idle\" })),\n    settledIds: [],\n    approved: 0,\n    declined: 0,\n  };\n}\n\nexport function accessRequestReducer(\n  state: AccessRequestState,\n  event: AccessRequestEvent,\n): AccessRequestState {\n  if (event.type === \"sync\") {\n    const incoming = new Map(\n      event.requests.map((person) => [person.id, person]),\n    );\n    const present = new Set(state.entries.map((entry) => entry.person.id));\n    const entries = state.entries.map((entry): AccessRequestEntry => {\n      const person = incoming.get(entry.person.id);\n      if (person)\n        return entry.phase === \"removing\"\n          ? { person, phase: \"idle\" }\n          : { ...entry, person };\n      return entry.phase === \"decided\"\n        ? entry\n        : { ...entry, phase: \"removing\", error: undefined };\n    });\n    for (const person of incoming.values()) {\n      if (!present.has(person.id) && !state.settledIds.includes(person.id))\n        entries.push({ person, phase: \"idle\" });\n    }\n    return {\n      ...state,\n      entries,\n      settledIds: state.settledIds.filter((id) => incoming.has(id)),\n    };\n  }\n\n  const entry = state.entries.find((item) => item.person.id === event.id);\n  if (!entry) return state;\n  let next: AccessRequestEntry;\n\n  switch (event.type) {\n    case \"begin\":\n      if (entry.phase !== \"idle\") return state;\n      next = {\n        ...entry,\n        phase: \"pending\",\n        decision: event.decision,\n        error: undefined,\n      };\n      break;\n    case \"resolve\":\n      if (entry.phase !== \"pending\" || entry.decision !== event.decision)\n        return state;\n      next = { ...entry, phase: \"decided\", error: undefined };\n      break;\n    case \"reject\":\n      if (entry.phase !== \"pending\") return state;\n      next = {\n        ...entry,\n        phase: \"idle\",\n        decision: undefined,\n        error: event.message,\n      };\n      break;\n    case \"remove\":\n      if (entry.phase !== \"decided\" && entry.phase !== \"removing\") return state;\n      return {\n        ...state,\n        entries: state.entries.filter((item) => item.person.id !== event.id),\n        settledIds:\n          entry.phase === \"decided\"\n            ? [...state.settledIds, event.id]\n            : state.settledIds,\n        approved:\n          state.approved +\n          Number(entry.phase === \"decided\" && entry.decision === \"approved\"),\n        declined:\n          state.declined +\n          Number(entry.phase === \"decided\" && entry.decision === \"declined\"),\n      };\n  }\n\n  return {\n    ...state,\n    entries: state.entries.map((item) =>\n      item.person.id === event.id ? next : item,\n    ),\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/access-request-state.ts"
    },
    {
      "path": "src/registry/ui/use-access-request-motion.ts",
      "content": "\"use client\";\n\nimport { useRef, type RefObject } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { calcGeneratorDuration, spring } from \"motion\";\n\ngsap.registerPlugin(useGSAP);\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nfunction anchorPosition(anchor: HTMLElement, root: HTMLElement) {\n  let x = 0;\n  let y = 0;\n  let node: HTMLElement | null = anchor;\n  while (node && node !== root) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;\n  }\n  return { x, y };\n}\n\nexport function useAccessRequestMotion({\n  rootRef,\n  panelRef,\n  contentRef,\n  signature,\n  expanded,\n  stiffness,\n  damping,\n  speed,\n  enabled,\n}: {\n  rootRef: RefObject<HTMLDivElement | null>;\n  panelRef: RefObject<HTMLDivElement | null>;\n  contentRef: RefObject<HTMLDivElement | null>;\n  signature: string;\n  expanded: boolean;\n  stiffness: number;\n  damping: number;\n  speed: number;\n  enabled: boolean;\n}) {\n  const syncRef = useRef<((immediate?: boolean) => void) | null>(null);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const panel = panelRef.current;\n      const content = contentRef.current;\n      if (!root || !panel || !content || !enabled) return;\n      const response = spring({\n        keyframes: [0, 1],\n        stiffness: bounded(stiffness, 420, 80, 600),\n        damping: bounded(damping, 28, 16, 60),\n        restSpeed: 0.001,\n        restDelta: 0.001,\n      });\n      const settle = calcGeneratorDuration(response);\n      const ease = (progress: number) =>\n        progress === 1 ? 1 : response.next(progress * settle).value;\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          let timeline: gsap.core.Timeline | null = null;\n          let positions = new Map<string, number>();\n          let wasOpen = root.dataset.expanded === \"true\";\n          const sync = context.add(\"sync\", (immediate = false) => {\n            const open = root.dataset.expanded === \"true\";\n            const opening = open && !wasOpen;\n            wasOpen = open;\n            const reduced = Boolean(context.conditions?.reduced);\n            const duration =\n              immediate || reduced\n                ? 0\n                : settle / 1000 / bounded(speed, 1, 0.5, 2);\n            const rows = Array.from(\n              content.querySelectorAll<HTMLElement>(\"[data-request-row]\"),\n            );\n            const rowMap = new Map(\n              rows.map((row) => [row.dataset.requestRow, row]),\n            );\n            const measurements = rows.map((row) => ({\n              row,\n              id: row.dataset.requestRow ?? \"\",\n              top: row.offsetTop,\n              currentY: Number(gsap.getProperty(row, \"y\")) || 0,\n            }));\n            const avatars = Array.from(\n              root.querySelectorAll<HTMLElement>(\"[data-request-avatar]\"),\n            );\n            const targets = avatars.map((avatar, index) => {\n              const row = rowMap.get(avatar.dataset.requestAvatar);\n              const anchor = row?.querySelector<HTMLElement>(\n                \"[data-request-anchor]\",\n              );\n              const position = anchor ? anchorPosition(anchor, root) : null;\n              const leaving =\n                row?.dataset.phase === \"decided\" ||\n                row?.dataset.phase === \"removing\";\n              return {\n                avatar,\n                x: open && position ? position.x : 16 + index * 21,\n                y: open && position ? position.y : 16,\n                visible: !leaving && (open || index < 2),\n                departing: open && leaving,\n                acknowledgement: row?.dataset.phase === \"decided\",\n                direction: row?.dataset.decision === \"approved\" ? 10 : -10,\n              };\n            });\n            const summaryX =\n              avatars.length > 2\n                ? 108\n                : avatars.length > 1\n                  ? 84\n                  : avatars.length\n                    ? 60\n                    : 58;\n            timeline?.kill();\n            timeline = gsap.timeline({\n              defaults: { duration, ease, overwrite: \"auto\" },\n            });\n            timeline.to(panel, { height: open ? content.offsetHeight : 0 }, 0);\n            timeline.to(\n              \"[data-request-compact]\",\n              {\n                autoAlpha: open ? 0 : 1,\n                duration: Math.min(duration, 0.14),\n                ease: \"power2.out\",\n              },\n              0,\n            );\n            timeline.to(\n              \"[data-request-heading]\",\n              {\n                autoAlpha: open ? 1 : 0,\n                x: open ? 0 : 6,\n                duration: Math.min(duration, 0.18),\n                ease: \"power2.out\",\n              },\n              open && duration ? 0.06 : 0,\n            );\n            timeline.to(\n              content,\n              {\n                opacity: open ? 1 : 0,\n                duration: Math.min(duration, 0.2),\n                ease: \"power2.out\",\n              },\n              open && duration ? 0.04 : 0,\n            );\n            timeline.to(\n              \"[data-request-chevron]\",\n              { rotation: open ? 180 : 0 },\n              0,\n            );\n            timeline.to(\n              \"[data-request-overflow]\",\n              {\n                x: 58,\n                y: 16,\n                autoAlpha: !open && avatars.length > 2 ? 1 : 0,\n                scale: open ? 0.8 : 1,\n                duration: Math.min(duration, 0.2),\n              },\n              0,\n            );\n            timeline.to(\"[data-request-summary]\", { x: summaryX }, 0);\n            gsap.set(\"[data-request-summary]\", {\n              width: Math.max(0, root.clientWidth - summaryX - 80),\n            });\n            for (const [\n              index,\n              { avatar, x, y, visible, departing, acknowledgement, direction },\n            ] of targets.entries()) {\n              timeline.to(\n                avatar,\n                {\n                  x: departing && duration ? x + direction : x,\n                  y,\n                  autoAlpha: visible ? 1 : 0,\n                  scale: visible ? 1 : departing ? 0.98 : 0.65,\n                  duration: visible ? duration : Math.min(duration, 0.18),\n                  ease: departing ? \"power2.in\" : ease,\n                },\n                departing && acknowledgement && duration\n                  ? 0.25\n                  : opening && duration\n                    ? index * 0.012\n                    : 0,\n              );\n            }\n            for (const { row, id, top, currentY } of measurements) {\n              const previous = positions.get(id);\n              if (previous !== undefined)\n                timeline.fromTo(\n                  row,\n                  { y: previous - top + currentY },\n                  { y: 0, immediateRender: true },\n                  0,\n                );\n            }\n            positions = new Map(measurements.map(({ id, top }) => [id, top]));\n          });\n          syncRef.current = (immediate) => sync(immediate);\n          sync(true);\n          let previousWidth = root.clientWidth;\n          let previousHeight = content.offsetHeight;\n          const observer = new ResizeObserver(() => {\n            const width = root.clientWidth;\n            const height = content.offsetHeight;\n            if (width === previousWidth && height === previousHeight) return;\n            previousWidth = width;\n            previousHeight = height;\n            sync();\n          });\n          observer.observe(content);\n          observer.observe(root);\n          return () => {\n            observer.disconnect();\n            timeline?.kill();\n            syncRef.current = null;\n          };\n        },\n        root,\n      );\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [stiffness, damping, speed, enabled],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    (context) => {\n      context.ignore(() => syncRef.current?.());\n    },\n    { scope: rootRef, dependencies: [signature, expanded] },\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/use-access-request-motion.ts"
    },
    {
      "path": "src/lib/use-media-query.ts",
      "content": "\"use client\";\n\nimport { useCallback, useSyncExternalStore } from \"react\";\n\nexport function useMediaQuery(query: string) {\n  const subscribe = useCallback(\n    (onChange: () => void) => {\n      const media = window.matchMedia(query);\n      media.addEventListener(\"change\", onChange);\n      return () => media.removeEventListener(\"change\", onChange);\n    },\n    [query],\n  );\n  const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);\n  return useSyncExternalStore(subscribe, getSnapshot, () => false);\n}\n",
      "type": "registry:lib"
    }
  ]
}
