{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inbox-app","type":"registry:block","title":"Inbox App","description":"Mail client with a folder rail and unread counts (virtual folders like Starred via `filter`), a searchable message list with unread dots, stars, checkboxes and a gliding selection highlight, a reading pane that cross-fades between messages, and swipe-right-to-archive / swipe-left-to-delete rows that slide out with an Undo bar.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/inbox-app.tsx","type":"registry:component","target":"@components/motion/inbox-app.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/inbox-app\n\nimport {\n  Archive,\n  ArrowLeft,\n  Inbox,\n  MailOpen,\n  Search,\n  Star,\n  Trash2,\n  Undo2,\n  X,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  type PanInfo,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface InboxFolder {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  /** Virtual folder (e.g. Starred): decides membership instead of `message.folder`. */\n  filter?: (message: InboxMessage) => boolean;\n}\n\nfunction inFolder(message: InboxMessage, folder: InboxFolder) {\n  return folder.filter ? folder.filter(message) : message.folder === folder.id;\n}\n\nexport interface InboxMessage {\n  id: string;\n  folder: string;\n  from: { name: string; email?: string };\n  subject: string;\n  /** One-line preview shown in the list. */\n  preview: string;\n  /** Full body for the reading pane. Strings keep their line breaks. */\n  body?: ReactNode;\n  /** ISO date string; formatted with `locale` + `timeZone`. */\n  date: string;\n  unread?: boolean;\n  starred?: boolean;\n}\n\nexport type InboxAction = \"archive\" | \"delete\";\n\nexport interface InboxAppProps {\n  folders: InboxFolder[];\n  /** Controlled messages. */\n  messages?: InboxMessage[];\n  /** Uncontrolled initial messages. */\n  defaultMessages?: InboxMessage[];\n  onMessagesChange?: (messages: InboxMessage[]) => void;\n  /** Controlled open folder. */\n  folder?: string;\n  defaultFolder?: string;\n  onFolderChange?: (folder: string) => void;\n  /** Controlled open message id. */\n  selectedId?: string | null;\n  defaultSelectedId?: string | null;\n  onSelectedChange?: (id: string | null) => void;\n  /** Fires after archive/delete with the affected ids (before any undo). */\n  onAction?: (action: InboxAction, ids: string[]) => void;\n  /** Fires when an archive/delete is undone. */\n  onUndo?: (action: InboxAction, ids: string[]) => void;\n  /** Folder that archived mail moves to. Default \"archive\". */\n  archiveFolder?: string;\n  /** Folder that deleted mail moves to; deleting inside it removes for good. Default \"trash\". */\n  trashFolder?: string;\n  /** How long the undo bar stays, in ms. Default 5000. */\n  undoTimeout?: number;\n  /** Default \"en-US\" so server and client agree. */\n  locale?: string;\n  /** Default \"UTC\" so server and client agree. */\n  timeZone?: string;\n  className?: string;\n}\n\n// Past this drag distance (px) a row commits to archive (right) or delete (left).\nconst SWIPE_COMMIT = 96;\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\ntype UndoState = {\n  key: number;\n  action: InboxAction;\n  ids: string[];\n  previous: InboxMessage[];\n};\n\n/**\n * Mail client block: folder rail with unread counts, a searchable message\n * list (unread dots, stars, checkboxes, gliding selection) and a reading pane\n * that cross-fades between messages. Rows swipe right to archive and left to\n * delete (or use the toolbar), slide out, and an undo bar restores them.\n */\nexport function InboxApp({\n  folders,\n  messages: messagesProp,\n  defaultMessages = [],\n  onMessagesChange,\n  folder: folderProp,\n  defaultFolder,\n  onFolderChange,\n  selectedId: selectedProp,\n  defaultSelectedId = null,\n  onSelectedChange,\n  onAction,\n  onUndo,\n  archiveFolder = \"archive\",\n  trashFolder = \"trash\",\n  undoTimeout = 5000,\n  locale = \"en-US\",\n  timeZone = \"UTC\",\n  className,\n}: InboxAppProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [messages, setMessages] = useControllable(\n    messagesProp,\n    defaultMessages,\n    onMessagesChange,\n  );\n  const [folder, setFolder] = useControllable(\n    folderProp,\n    defaultFolder ?? folders[0]?.id ?? \"\",\n    onFolderChange,\n  );\n  const [selectedId, setSelectedId] = useControllable<string | null>(\n    selectedProp,\n    defaultSelectedId,\n    onSelectedChange,\n  );\n  const [query, setQuery] = useState(\"\");\n  const [checked, setChecked] = useState<Set<string>>(() => new Set());\n  const [exitDir, setExitDir] = useState<1 | -1>(1);\n  const [undo, setUndo] = useState<UndoState | null>(null);\n  // Narrow containers show either the list or the reader.\n  const [reading, setReading] = useState(false);\n  const undoKey = useRef(0);\n\n  const dateFormat = useMemo(\n    () => new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", timeZone }),\n    [locale, timeZone],\n  );\n  const fullDateFormat = useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        dateStyle: \"medium\",\n        timeStyle: \"short\",\n        timeZone,\n      }),\n    [locale, timeZone],\n  );\n\n  const activeFolder = folders.find((f) => f.id === folder);\n\n  const unreadByFolder = useMemo(() => {\n    const counts: Record<string, number> = {};\n    for (const f of folders) {\n      counts[f.id] = messages.filter((m) => m.unread && inFolder(m, f)).length;\n    }\n    return counts;\n  }, [messages, folders]);\n\n  const visible = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    return messages\n      .filter((m) => (activeFolder ? inFolder(m, activeFolder) : m.folder === folder))\n      .filter(\n        (m) =>\n          !q ||\n          m.subject.toLowerCase().includes(q) ||\n          m.from.name.toLowerCase().includes(q) ||\n          m.preview.toLowerCase().includes(q),\n      )\n      .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));\n  }, [messages, folder, query, activeFolder]);\n\n  const selected = visible.find((m) => m.id === selectedId) ?? null;\n\n  // Undo bar auto-dismisses; a new action restarts the timer via its key.\n  useEffect(() => {\n    if (!undo) return;\n    const timer = window.setTimeout(() => setUndo(null), undoTimeout);\n    return () => window.clearTimeout(timer);\n  }, [undo, undoTimeout]);\n\n  const patch = useCallback(\n    (ids: string[], change: Partial<InboxMessage>) => {\n      const set = new Set(ids);\n      setMessages(messages.map((m) => (set.has(m.id) ? { ...m, ...change } : m)));\n    },\n    [messages, setMessages],\n  );\n\n  const open = (id: string) => {\n    setSelectedId(id);\n    setReading(true);\n    const target = messages.find((m) => m.id === id);\n    if (target?.unread) patch([id], { unread: false });\n  };\n\n  const run = (action: InboxAction, ids: string[]) => {\n    if (ids.length === 0) return;\n    const set = new Set(ids);\n    const destination = action === \"archive\" ? archiveFolder : trashFolder;\n    const permanent = action === \"delete\" && folder === trashFolder;\n    const next = permanent\n      ? messages.filter((m) => !set.has(m.id))\n      : messages.map((m) => (set.has(m.id) ? { ...m, folder: destination } : m));\n    // Archive slides right, delete slides left — matching the swipe directions.\n    setExitDir(action === \"archive\" ? 1 : -1);\n    setMessages(next);\n    setChecked((prev) => {\n      const copy = new Set(prev);\n      for (const id of ids) copy.delete(id);\n      return copy;\n    });\n    if (selectedId && set.has(selectedId)) {\n      // Move the reader to the next message left in the list, if any.\n      const order = visible.map((m) => m.id);\n      const at = order.indexOf(selectedId);\n      const remaining = order.filter((id) => !set.has(id));\n      setSelectedId(remaining[Math.min(at, remaining.length - 1)] ?? null);\n      if (remaining.length === 0) setReading(false);\n    }\n    undoKey.current += 1;\n    setUndo({ key: undoKey.current, action, ids, previous: messages });\n    onAction?.(action, ids);\n  };\n\n  const restore = () => {\n    if (!undo) return;\n    setExitDir(undo.action === \"archive\" ? 1 : -1);\n    setMessages(undo.previous);\n    onUndo?.(undo.action, undo.ids);\n    setUndo(null);\n  };\n\n  const checkedIds = visible.filter((m) => checked.has(m.id)).map((m) => m.id);\n  const allChecked = visible.length > 0 && checkedIds.length === visible.length;\n\n  const toggleChecked = (id: string) =>\n    setChecked((prev) => {\n      const copy = new Set(prev);\n      if (copy.has(id)) copy.delete(id);\n      else copy.add(id);\n      return copy;\n    });\n\n  const switchFolder = (id: string) => {\n    setFolder(id);\n    setChecked(new Set());\n    setSelectedId(null);\n    setReading(false);\n  };\n\n  const fade = { duration: 0.2, ease: EASE_OUT };\n\n  return (\n    <div\n      className={cn(\n        \"@container relative flex h-[26rem] w-full overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm\",\n        className,\n      )}\n    >\n      {/* Folder rail */}\n      <nav\n        aria-label=\"Mail folders\"\n        className=\"hidden w-36 shrink-0 flex-col gap-0.5 border-r border-border bg-muted/40 p-2 @lg:flex\"\n      >\n        <LayoutGroup id={`${baseId}-folders`}>\n          {folders.map((f) => {\n            const active = f.id === folder;\n            const count = unreadByFolder[f.id] ?? 0;\n            return (\n              <button\n                key={f.id}\n                type=\"button\"\n                aria-current={active ? \"page\" : undefined}\n                onClick={() => switchFolder(f.id)}\n                className={cn(\n                  \"relative flex h-8 items-center gap-2 rounded-lg px-2 text-left text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary\",\n                  active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {active ? (\n                  <motion.span\n                    layoutId=\"folder-pill\"\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                    className=\"absolute inset-0 rounded-lg border border-border bg-background shadow-sm\"\n                  />\n                ) : null}\n                <span className=\"relative shrink-0 [&_svg]:size-3.5\">\n                  {f.icon ?? <Inbox aria-hidden />}\n                </span>\n                <span className=\"relative min-w-0 flex-1 truncate\">{f.label}</span>\n                {count > 0 ? (\n                  <span className=\"relative tabular-nums text-[10px] text-muted-foreground\">\n                    {count}\n                    <span className=\"sr-only\"> unread</span>\n                  </span>\n                ) : null}\n              </button>\n            );\n          })}\n        </LayoutGroup>\n      </nav>\n\n      {/* Message list */}\n      <section\n        aria-label=\"Messages\"\n        className={cn(\n          \"flex min-w-0 flex-1 flex-col border-r border-border @xl:w-60 @xl:flex-none\",\n          reading ? \"hidden @xl:flex\" : \"flex\",\n        )}\n      >\n        <div className=\"flex flex-col gap-2 border-b border-border p-2\">\n          {/* Narrow containers swap the folder rail for a select. */}\n          <select\n            aria-label=\"Folder\"\n            value={folder}\n            onChange={(e) => switchFolder(e.target.value)}\n            className=\"h-8 rounded-lg border border-border bg-background px-2 text-xs @lg:hidden\"\n          >\n            {folders.map((f) => (\n              <option key={f.id} value={f.id}>\n                {f.label}\n                {unreadByFolder[f.id] ? ` (${unreadByFolder[f.id]})` : \"\"}\n              </option>\n            ))}\n          </select>\n          <label className=\"flex h-8 items-center gap-2 rounded-lg border border-border bg-background px-2 text-xs focus-within:ring-2 focus-within:ring-primary\">\n            <Search aria-hidden className=\"size-3.5 shrink-0 text-muted-foreground\" />\n            <span className=\"sr-only\">Search mail</span>\n            <input\n              type=\"search\"\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              placeholder=\"Search mail\"\n              className=\"min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden\"\n            />\n            {query ? (\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => setQuery(\"\")}\n                className=\"rounded text-muted-foreground hover:text-foreground\"\n              >\n                <X aria-hidden className=\"size-3.5\" />\n              </button>\n            ) : null}\n          </label>\n          <div className=\"flex h-6 items-center gap-2 px-1\">\n            <Checkbox\n              aria-label={allChecked ? \"Deselect all\" : \"Select all\"}\n              checked={allChecked}\n              indeterminate={checkedIds.length > 0 && !allChecked}\n              onCheckedChange={() =>\n                setChecked(allChecked ? new Set() : new Set(visible.map((m) => m.id)))\n              }\n              className=\"[&>button]:size-4\"\n            />\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {checkedIds.length > 0 ? (\n                <motion.div\n                  key=\"bulk\"\n                  initial={{ opacity: 0, x: reduce ? 0 : -6 }}\n                  animate={{ opacity: 1, x: 0 }}\n                  exit={{ opacity: 0, x: reduce ? 0 : -6 }}\n                  transition={fade}\n                  className=\"flex flex-1 items-center gap-1 text-xs\"\n                >\n                  <span className=\"mr-auto tabular-nums text-muted-foreground\">\n                    {checkedIds.length} selected\n                  </span>\n                  <IconButton label=\"Archive selected\" onClick={() => run(\"archive\", checkedIds)}>\n                    <Archive aria-hidden />\n                  </IconButton>\n                  <IconButton label=\"Delete selected\" onClick={() => run(\"delete\", checkedIds)}>\n                    <Trash2 aria-hidden />\n                  </IconButton>\n                </motion.div>\n              ) : (\n                <motion.span\n                  key=\"count\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={fade}\n                  className=\"text-xs text-muted-foreground\"\n                >\n                  {visible.length} {visible.length === 1 ? \"conversation\" : \"conversations\"}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </div>\n        </div>\n\n        <ul\n          aria-label=\"Message list\"\n          className=\"relative flex-1 overflow-y-auto overflow-x-hidden p-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n        >\n          <LayoutGroup id={`${baseId}-rows`}>\n            <AnimatePresence initial={false} mode=\"popLayout\" custom={exitDir}>\n              {visible.map((m) => (\n                <MessageRow\n                  key={m.id}\n                  message={m}\n                  active={m.id === selected?.id}\n                  checked={checked.has(m.id)}\n                  date={dateFormat.format(new Date(m.date))}\n                  reduce={Boolean(reduce)}\n                  onOpen={() => open(m.id)}\n                  onCheck={() => toggleChecked(m.id)}\n                  onStar={() => patch([m.id], { starred: !m.starred })}\n                  onSwipe={(action) => run(action, [m.id])}\n                />\n              ))}\n            </AnimatePresence>\n          </LayoutGroup>\n          {visible.length === 0 ? (\n            <li className=\"flex h-full flex-col items-center justify-center gap-1 px-4 py-10 text-center text-xs text-muted-foreground\">\n              <MailOpen aria-hidden className=\"size-5\" />\n              {query ? `No mail matches “${query}”` : \"Nothing here\"}\n            </li>\n          ) : null}\n        </ul>\n      </section>\n\n      {/* Reading pane */}\n      <section\n        aria-label=\"Reading pane\"\n        className={cn(\n          \"relative min-w-0 flex-1 flex-col\",\n          reading ? \"flex\" : \"hidden @xl:flex\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"wait\">\n          {selected ? (\n            <motion.article\n              key={selected.id}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.12 } }}\n              transition={fade}\n              className=\"flex min-h-0 flex-1 flex-col\"\n            >\n              <div className=\"flex items-center gap-1 border-b border-border p-2\">\n                <IconButton\n                  label=\"Back to list\"\n                  onClick={() => setReading(false)}\n                  className=\"@xl:hidden\"\n                >\n                  <ArrowLeft aria-hidden />\n                </IconButton>\n                <IconButton label=\"Archive\" onClick={() => run(\"archive\", [selected.id])}>\n                  <Archive aria-hidden />\n                </IconButton>\n                <IconButton label=\"Delete\" onClick={() => run(\"delete\", [selected.id])}>\n                  <Trash2 aria-hidden />\n                </IconButton>\n                <IconButton\n                  label=\"Mark as unread\"\n                  onClick={() => {\n                    patch([selected.id], { unread: true });\n                    setSelectedId(null);\n                    setReading(false);\n                  }}\n                >\n                  <MailOpen aria-hidden />\n                </IconButton>\n                <IconButton\n                  label={selected.starred ? \"Unstar\" : \"Star\"}\n                  pressed={Boolean(selected.starred)}\n                  onClick={() => patch([selected.id], { starred: !selected.starred })}\n                  className=\"ml-auto\"\n                >\n                  <Star aria-hidden className={cn(selected.starred && \"fill-current text-chart-4\")} />\n                </IconButton>\n              </div>\n              <div className=\"min-h-0 flex-1 overflow-y-auto p-4 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\">\n                <h2 className=\"text-base font-semibold leading-snug\">{selected.subject}</h2>\n                <div className=\"mt-3 flex items-center gap-2.5\">\n                  <Initials name={selected.from.name} />\n                  <div className=\"min-w-0 flex-1 text-xs\">\n                    <p className=\"truncate font-medium\">{selected.from.name}</p>\n                    {selected.from.email ? (\n                      <p className=\"truncate text-muted-foreground\">{selected.from.email}</p>\n                    ) : null}\n                  </div>\n                  <time dateTime={selected.date} className=\"shrink-0 text-[11px] text-muted-foreground\">\n                    {fullDateFormat.format(new Date(selected.date))}\n                  </time>\n                </div>\n                <div className=\"mt-4 whitespace-pre-line text-sm leading-relaxed text-foreground/90\">\n                  {selected.body ?? selected.preview}\n                </div>\n              </div>\n            </motion.article>\n          ) : (\n            <motion.div\n              key=\"empty\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={fade}\n              className=\"flex flex-1 flex-col items-center justify-center gap-2 text-xs text-muted-foreground\"\n            >\n              <span className=\"grid size-10 place-items-center rounded-full bg-muted\">\n                <MailOpen aria-hidden className=\"size-4\" />\n              </span>\n              Select a message to read\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </section>\n\n      {/* Undo bar */}\n      <div aria-live=\"polite\" className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n        <AnimatePresence>\n          {undo ? (\n            <motion.div\n              key={undo.key}\n              initial={{ opacity: 0, y: reduce ? 0 : 16, scale: reduce ? 1 : 0.96 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={{ opacity: 0, y: reduce ? 0 : 8, transition: { duration: 0.15 } }}\n              transition={SPRING_LAYOUT}\n              className=\"pointer-events-auto flex items-center gap-3 rounded-full bg-foreground py-1.5 pl-4 pr-1.5 text-xs text-background shadow-lg\"\n            >\n              <span>\n                {undo.ids.length === 1 ? \"Conversation\" : `${undo.ids.length} conversations`}{\" \"}\n                {undo.action === \"archive\" ? \"archived\" : \"deleted\"}\n              </span>\n              <button\n                type=\"button\"\n                onClick={restore}\n                className=\"inline-flex h-7 items-center gap-1 rounded-full bg-background/15 px-2.5 font-medium outline-none hover:bg-background/25 focus-visible:ring-2 focus-visible:ring-background\"\n              >\n                <Undo2 aria-hidden className=\"size-3.5\" />\n                Undo\n              </button>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction MessageRow({\n  message,\n  active,\n  checked,\n  date,\n  reduce,\n  onOpen,\n  onCheck,\n  onStar,\n  onSwipe,\n}: {\n  message: InboxMessage;\n  active: boolean;\n  checked: boolean;\n  date: string;\n  reduce: boolean;\n  onOpen: () => void;\n  onCheck: () => void;\n  onStar: () => void;\n  onSwipe: (action: InboxAction) => void;\n}) {\n  const x = useMotionValue(0);\n  const archiveOpacity = useTransform(x, [0, SWIPE_COMMIT], [0, 1]);\n  const deleteOpacity = useTransform(x, [-SWIPE_COMMIT, 0], [1, 0]);\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    if (info.offset.x > SWIPE_COMMIT) onSwipe(\"archive\");\n    else if (info.offset.x < -SWIPE_COMMIT) onSwipe(\"delete\");\n  };\n\n  return (\n    <motion.li\n      layout={reduce ? false : \"position\"}\n      variants={{\n        exit: (dir: number) => ({\n          opacity: 0,\n          x: reduce ? 0 : `${dir * 100}%`,\n          transition: { duration: 0.22, ease: EASE_OUT },\n        }),\n      }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit=\"exit\"\n      transition={SPRING_LAYOUT}\n      className=\"relative mb-0.5 overflow-hidden rounded-lg\"\n    >\n      {/* Swipe hints behind the row */}\n      <motion.span\n        aria-hidden\n        style={{ opacity: archiveOpacity }}\n        className=\"absolute inset-0 flex items-center rounded-lg bg-chart-2/20 pl-3 text-foreground\"\n      >\n        <Archive className=\"size-4\" />\n      </motion.span>\n      <motion.span\n        aria-hidden\n        style={{ opacity: deleteOpacity }}\n        className=\"absolute inset-0 flex items-center justify-end rounded-lg bg-destructive/20 pr-3 text-destructive\"\n      >\n        <Trash2 className=\"size-4\" />\n      </motion.span>\n\n      <motion.div\n        drag=\"x\"\n        dragConstraints={{ left: 0, right: 0 }}\n        dragElastic={0.5}\n        dragSnapToOrigin\n        onDragEnd={onDragEnd}\n        style={{ x }}\n        className=\"relative flex cursor-grab items-start gap-2 rounded-lg bg-card px-2 py-2 active:cursor-grabbing\"\n      >\n        {active ? (\n          <motion.span\n            layoutId=\"row-highlight\"\n            transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n            className=\"absolute inset-0 rounded-lg bg-primary/10 ring-1 ring-primary/30\"\n          />\n        ) : checked ? (\n          <span className=\"absolute inset-0 rounded-lg bg-muted\" />\n        ) : null}\n        <div className=\"relative mt-0.5 flex flex-col items-center gap-1.5\">\n          <Checkbox\n            aria-label={`Select ${message.subject}`}\n            checked={checked}\n            onCheckedChange={onCheck}\n            className=\"[&>button]:size-4\"\n          />\n          <button\n            type=\"button\"\n            aria-label={message.starred ? \"Unstar\" : \"Star\"}\n            aria-pressed={Boolean(message.starred)}\n            onClick={onStar}\n            className=\"rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary\"\n          >\n            <motion.span\n              key={message.starred ? \"on\" : \"off\"}\n              initial={reduce ? false : { scale: 0.6 }}\n              animate={{ scale: 1 }}\n              transition={SPRING_LAYOUT}\n              className=\"block\"\n            >\n              <Star\n                aria-hidden\n                className={cn(\"size-3.5\", message.starred && \"fill-current text-chart-4\")}\n              />\n            </motion.span>\n          </button>\n        </div>\n        <button\n          type=\"button\"\n          onClick={onOpen}\n          aria-current={active ? \"true\" : undefined}\n          className=\"relative min-w-0 flex-1 rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-primary\"\n        >\n          <span className=\"flex items-center gap-1.5\">\n            <AnimatePresence initial={false}>\n              {message.unread ? (\n                <motion.span\n                  key=\"dot\"\n                  initial={{ scale: reduce ? 1 : 0, opacity: 0 }}\n                  animate={{ scale: 1, opacity: 1 }}\n                  exit={{ scale: reduce ? 1 : 0, opacity: 0 }}\n                  transition={SPRING_LAYOUT}\n                  className=\"size-1.5 shrink-0 rounded-full bg-primary\"\n                />\n              ) : null}\n            </AnimatePresence>\n            {message.unread ? <span className=\"sr-only\">Unread. </span> : null}\n            <span\n              className={cn(\n                \"min-w-0 flex-1 truncate text-xs\",\n                message.unread ? \"font-semibold\" : \"font-medium text-foreground/80\",\n              )}\n            >\n              {message.from.name}\n            </span>\n            <time dateTime={message.date} className=\"shrink-0 text-[10px] text-muted-foreground\">\n              {date}\n            </time>\n          </span>\n          <span\n            className={cn(\n              \"mt-0.5 block truncate text-xs\",\n              message.unread ? \"font-medium text-foreground\" : \"text-foreground/80\",\n            )}\n          >\n            {message.subject}\n          </span>\n          <span className=\"block truncate text-[11px] text-muted-foreground\">\n            {message.preview}\n          </span>\n        </button>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  children,\n  pressed,\n  className,\n}: {\n  label: string;\n  onClick: () => void;\n  children: ReactNode;\n  pressed?: boolean;\n  className?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      aria-pressed={pressed}\n      onClick={onClick}\n      className={cn(\n        \"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary [&_svg]:size-3.5\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction Initials({ name }: { name: string }) {\n  const letters = name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => part[0]?.toUpperCase())\n    .join(\"\");\n  return (\n    <span\n      aria-hidden\n      className=\"grid size-8 shrink-0 place-items-center rounded-full bg-primary/15 text-[11px] font-semibold text-foreground\"\n    >\n      {letters}\n    </span>\n  );\n}\n"},{"path":"components/motion/checkbox.tsx","type":"registry:component","target":"@components/motion/checkbox.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  /** Associates an external message (e.g. a form error) with the control. */\n  \"aria-describedby\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": ariaDescribedBy,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        aria-describedby={ariaDescribedBy}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          TOUCH_TARGET_CLASS,\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"}]}