astralyx-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/bin/index.d.ts +2 -0
  4. package/bin/index.js +404 -0
  5. package/bin/registry.d.ts +37 -0
  6. package/bin/registry.js +69 -0
  7. package/package.json +89 -0
  8. package/registry/index.json +3914 -0
  9. package/registry/items/accordion.json +22 -0
  10. package/registry/items/address-input.json +21 -0
  11. package/registry/items/agent-steps.json +23 -0
  12. package/registry/items/alert-dialog.json +22 -0
  13. package/registry/items/alert-triage.json +25 -0
  14. package/registry/items/alert.json +21 -0
  15. package/registry/items/anomaly-chart.json +18 -0
  16. package/registry/items/api-keys.json +25 -0
  17. package/registry/items/aspect-ratio.json +18 -0
  18. package/registry/items/attachment-preview.json +23 -0
  19. package/registry/items/audit-log.json +26 -0
  20. package/registry/items/avatar.json +21 -0
  21. package/registry/items/badge.json +22 -0
  22. package/registry/items/banner.json +22 -0
  23. package/registry/items/benchmark-table.json +20 -0
  24. package/registry/items/bet-input.json +23 -0
  25. package/registry/items/blame-view.json +22 -0
  26. package/registry/items/branch-select.json +21 -0
  27. package/registry/items/breadcrumb.json +22 -0
  28. package/registry/items/bridge-status.json +24 -0
  29. package/registry/items/build-log.json +23 -0
  30. package/registry/items/bulk-action-bar.json +21 -0
  31. package/registry/items/bundle-treemap.json +20 -0
  32. package/registry/items/button-group.json +21 -0
  33. package/registry/items/button.json +22 -0
  34. package/registry/items/cache-stats.json +21 -0
  35. package/registry/items/calendar.json +25 -0
  36. package/registry/items/campaign-card.json +22 -0
  37. package/registry/items/candlestick-chart.json +18 -0
  38. package/registry/items/card-input.json +22 -0
  39. package/registry/items/card.json +21 -0
  40. package/registry/items/carousel.json +22 -0
  41. package/registry/items/cart.json +23 -0
  42. package/registry/items/chain-select.json +24 -0
  43. package/registry/items/changelog-entry.json +21 -0
  44. package/registry/items/chart.json +19 -0
  45. package/registry/items/checkbox.json +22 -0
  46. package/registry/items/checkout-summary.json +20 -0
  47. package/registry/items/citations.json +22 -0
  48. package/registry/items/code-block.json +23 -0
  49. package/registry/items/code-search.json +22 -0
  50. package/registry/items/cohort-table.json +19 -0
  51. package/registry/items/collapsible.json +23 -0
  52. package/registry/items/color-picker.json +24 -0
  53. package/registry/items/combobox.json +23 -0
  54. package/registry/items/command.json +23 -0
  55. package/registry/items/commit-graph.json +20 -0
  56. package/registry/items/commit-list.json +26 -0
  57. package/registry/items/composer.json +30 -0
  58. package/registry/items/confidence-meter.json +18 -0
  59. package/registry/items/connection-string.json +24 -0
  60. package/registry/items/container-list.json +24 -0
  61. package/registry/items/context-menu.json +20 -0
  62. package/registry/items/context-picker.json +25 -0
  63. package/registry/items/coordinate-input.json +21 -0
  64. package/registry/items/copy-button.json +22 -0
  65. package/registry/items/cost-breakdown.json +20 -0
  66. package/registry/items/coverage-report.json +22 -0
  67. package/registry/items/cron-schedule.json +24 -0
  68. package/registry/items/curl-command.json +18 -0
  69. package/registry/items/data-grid.json +24 -0
  70. package/registry/items/date-picker.json +24 -0
  71. package/registry/items/date-range-compare.json +24 -0
  72. package/registry/items/dependency-list.json +23 -0
  73. package/registry/items/deploy-list.json +27 -0
  74. package/registry/items/description-list.json +18 -0
  75. package/registry/items/device-list.json +24 -0
  76. package/registry/items/dialog.json +24 -0
  77. package/registry/items/diff-proposal.json +23 -0
  78. package/registry/items/diff-stat.json +18 -0
  79. package/registry/items/diff-view.json +23 -0
  80. package/registry/items/donut.json +19 -0
  81. package/registry/items/drawer.json +19 -0
  82. package/registry/items/dropdown-menu.json +24 -0
  83. package/registry/items/dropzone.json +22 -0
  84. package/registry/items/empty.json +19 -0
  85. package/registry/items/endpoint-list.json +24 -0
  86. package/registry/items/env-diff.json +23 -0
  87. package/registry/items/env-vars.json +24 -0
  88. package/registry/items/error-boundary.json +22 -0
  89. package/registry/items/eval-results.json +21 -0
  90. package/registry/items/feature-flag.json +23 -0
  91. package/registry/items/feedback.json +23 -0
  92. package/registry/items/field.json +19 -0
  93. package/registry/items/file-tree.json +21 -0
  94. package/registry/items/fingerprint-diff.json +23 -0
  95. package/registry/items/flame-graph.json +19 -0
  96. package/registry/items/fmt.json +16 -0
  97. package/registry/items/form.json +21 -0
  98. package/registry/items/fraud-verdict.json +24 -0
  99. package/registry/items/funnel.json +18 -0
  100. package/registry/items/gas-tracker.json +21 -0
  101. package/registry/items/gauge.json +18 -0
  102. package/registry/items/governance-proposal.json +24 -0
  103. package/registry/items/group.json +21 -0
  104. package/registry/items/health-checks.json +23 -0
  105. package/registry/items/heatmap-grid.json +19 -0
  106. package/registry/items/hover-card.json +20 -0
  107. package/registry/items/http-status.json +19 -0
  108. package/registry/items/incident-card.json +24 -0
  109. package/registry/items/input-file.json +22 -0
  110. package/registry/items/input-otp.json +19 -0
  111. package/registry/items/input.json +23 -0
  112. package/registry/items/invoice-list.json +24 -0
  113. package/registry/items/ip-cluster.json +22 -0
  114. package/registry/items/json-viewer.json +21 -0
  115. package/registry/items/jwt-inspector.json +25 -0
  116. package/registry/items/kanban.json +20 -0
  117. package/registry/items/kbd.json +18 -0
  118. package/registry/items/label-picker.json +24 -0
  119. package/registry/items/label.json +18 -0
  120. package/registry/items/leaderboard.json +23 -0
  121. package/registry/items/ledger-table.json +22 -0
  122. package/registry/items/lib-date.json +14 -0
  123. package/registry/items/lib-highlighter.json +16 -0
  124. package/registry/items/lib-styles.json +14 -0
  125. package/registry/items/lib-use-clipboard.json +14 -0
  126. package/registry/items/lib-utils.json +16 -0
  127. package/registry/items/lighthouse-score.json +19 -0
  128. package/registry/items/liquidity-position.json +23 -0
  129. package/registry/items/live-announcer.json +16 -0
  130. package/registry/items/location-picker.json +26 -0
  131. package/registry/items/log-viewer.json +24 -0
  132. package/registry/items/login-form.json +26 -0
  133. package/registry/items/map-embed.json +22 -0
  134. package/registry/items/market-table.json +23 -0
  135. package/registry/items/mask-input.json +19 -0
  136. package/registry/items/media-gallery.json +25 -0
  137. package/registry/items/mention-input.json +21 -0
  138. package/registry/items/menubar.json +21 -0
  139. package/registry/items/merge-conflict.json +20 -0
  140. package/registry/items/message.json +25 -0
  141. package/registry/items/migration-list.json +23 -0
  142. package/registry/items/mint-panel.json +23 -0
  143. package/registry/items/model-comparison.json +24 -0
  144. package/registry/items/model-select.json +20 -0
  145. package/registry/items/moderation-queue.json +26 -0
  146. package/registry/items/money-input.json +19 -0
  147. package/registry/items/multi-select.json +23 -0
  148. package/registry/items/multiplier-chart.json +18 -0
  149. package/registry/items/navigation-menu.json +23 -0
  150. package/registry/items/network-status.json +23 -0
  151. package/registry/items/nft-card.json +23 -0
  152. package/registry/items/notification-inbox.json +25 -0
  153. package/registry/items/number-input.json +21 -0
  154. package/registry/items/odds-display.json +21 -0
  155. package/registry/items/on-call-schedule.json +25 -0
  156. package/registry/items/order-book.json +19 -0
  157. package/registry/items/page-header.json +18 -0
  158. package/registry/items/pagination.json +21 -0
  159. package/registry/items/password-input.json +21 -0
  160. package/registry/items/payment-method.json +23 -0
  161. package/registry/items/payout-status.json +24 -0
  162. package/registry/items/permission-matrix.json +22 -0
  163. package/registry/items/pipeline.json +23 -0
  164. package/registry/items/popover.json +22 -0
  165. package/registry/items/port-table.json +22 -0
  166. package/registry/items/portfolio-balance.json +22 -0
  167. package/registry/items/postback-config.json +26 -0
  168. package/registry/items/presence.json +20 -0
  169. package/registry/items/price-ticker.json +21 -0
  170. package/registry/items/pricing-table.json +24 -0
  171. package/registry/items/primitive-collapsible.json +16 -0
  172. package/registry/items/primitive-dialog.json +16 -0
  173. package/registry/items/primitive-dismissable.json +14 -0
  174. package/registry/items/primitive-field.json +16 -0
  175. package/registry/items/primitive-focus-trap.json +14 -0
  176. package/registry/items/primitive-media-query.json +14 -0
  177. package/registry/items/primitive-popper.json +14 -0
  178. package/registry/items/primitive-portal.json +14 -0
  179. package/registry/items/primitive-radio-group.json +16 -0
  180. package/registry/items/primitive-router.json +14 -0
  181. package/registry/items/primitive-slot.json +16 -0
  182. package/registry/items/primitive-tabs.json +16 -0
  183. package/registry/items/progress.json +21 -0
  184. package/registry/items/prompt-input.json +25 -0
  185. package/registry/items/prompt-template.json +23 -0
  186. package/registry/items/pull-request-card.json +25 -0
  187. package/registry/items/query-editor.json +25 -0
  188. package/registry/items/query-plan.json +23 -0
  189. package/registry/items/queue-monitor.json +22 -0
  190. package/registry/items/radio-group.json +22 -0
  191. package/registry/items/range-slider.json +19 -0
  192. package/registry/items/rate-limit-meter.json +20 -0
  193. package/registry/items/rating.json +21 -0
  194. package/registry/items/reasoning-block.json +23 -0
  195. package/registry/items/regex-tester.json +25 -0
  196. package/registry/items/release-list.json +22 -0
  197. package/registry/items/request-builder.json +28 -0
  198. package/registry/items/resizable.json +22 -0
  199. package/registry/items/resource-meter.json +19 -0
  200. package/registry/items/response-viewer.json +26 -0
  201. package/registry/items/retrieval-results.json +23 -0
  202. package/registry/items/revenue-share-table.json +20 -0
  203. package/registry/items/review-thread.json +25 -0
  204. package/registry/items/risk-score.json +18 -0
  205. package/registry/items/root-cause-tree.json +20 -0
  206. package/registry/items/round-history.json +21 -0
  207. package/registry/items/runbook-steps.json +24 -0
  208. package/registry/items/schema-table.json +22 -0
  209. package/registry/items/schema-viewer.json +22 -0
  210. package/registry/items/scroll-area.json +18 -0
  211. package/registry/items/seed-phrase.json +22 -0
  212. package/registry/items/select.json +24 -0
  213. package/registry/items/separator.json +18 -0
  214. package/registry/items/service-status.json +21 -0
  215. package/registry/items/session-list.json +24 -0
  216. package/registry/items/sheet.json +24 -0
  217. package/registry/items/shortcut-sheet.json +19 -0
  218. package/registry/items/sidebar.json +24 -0
  219. package/registry/items/skeleton.json +21 -0
  220. package/registry/items/skip-link.json +19 -0
  221. package/registry/items/slider.json +21 -0
  222. package/registry/items/slo-budget.json +19 -0
  223. package/registry/items/sparkline.json +18 -0
  224. package/registry/items/spinner.json +20 -0
  225. package/registry/items/stack-trace.json +23 -0
  226. package/registry/items/staking-panel.json +24 -0
  227. package/registry/items/stat.json +21 -0
  228. package/registry/items/status-checks.json +23 -0
  229. package/registry/items/stepper.json +22 -0
  230. package/registry/items/streaming-text.json +19 -0
  231. package/registry/items/subscription-state.json +22 -0
  232. package/registry/items/suggestions.json +19 -0
  233. package/registry/items/swap-panel.json +22 -0
  234. package/registry/items/switch.json +21 -0
  235. package/registry/items/symbol-outline.json +22 -0
  236. package/registry/items/table-of-contents.json +19 -0
  237. package/registry/items/table.json +19 -0
  238. package/registry/items/tabs.json +22 -0
  239. package/registry/items/tag-input.json +21 -0
  240. package/registry/items/terminal.json +20 -0
  241. package/registry/items/test-results.json +24 -0
  242. package/registry/items/textarea.json +21 -0
  243. package/registry/items/theme-toggle.json +20 -0
  244. package/registry/items/theme.json +18 -0
  245. package/registry/items/thread-list.json +24 -0
  246. package/registry/items/ticket-card.json +24 -0
  247. package/registry/items/time-picker.json +23 -0
  248. package/registry/items/timeline.json +19 -0
  249. package/registry/items/timezone-select.json +18 -0
  250. package/registry/items/toast.json +22 -0
  251. package/registry/items/toggle.json +21 -0
  252. package/registry/items/token-amount.json +18 -0
  253. package/registry/items/token-approvals.json +25 -0
  254. package/registry/items/token-select.json +25 -0
  255. package/registry/items/token-usage.json +20 -0
  256. package/registry/items/tool-call.json +24 -0
  257. package/registry/items/toolbar.json +19 -0
  258. package/registry/items/tooltip.json +19 -0
  259. package/registry/items/topology-map.json +20 -0
  260. package/registry/items/tour.json +25 -0
  261. package/registry/items/traffic-split.json +20 -0
  262. package/registry/items/transaction-list.json +23 -0
  263. package/registry/items/transaction-status.json +25 -0
  264. package/registry/items/tree.json +21 -0
  265. package/registry/items/two-factor-setup.json +25 -0
  266. package/registry/items/typing-indicator.json +19 -0
  267. package/registry/items/typography.json +18 -0
  268. package/registry/items/upload-list.json +24 -0
  269. package/registry/items/uptime-strip.json +19 -0
  270. package/registry/items/user-menu.json +24 -0
  271. package/registry/items/utm-builder.json +24 -0
  272. package/registry/items/validator-list.json +23 -0
  273. package/registry/items/video-player.json +23 -0
  274. package/registry/items/virtual-list.json +18 -0
  275. package/registry/items/visually-hidden.json +19 -0
  276. package/registry/items/wallet-address.json +22 -0
  277. package/registry/items/wallet-connect.json +25 -0
  278. package/registry/items/webhook-inspector.json +26 -0
  279. package/registry/items/websocket-frames.json +24 -0
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "stepper",
3
+ "type": "registry:ui",
4
+ "title": "Stepper",
5
+ "description": "Progress through a fixed sequence. One `current` index drives the common case; any step can override its own status for a failure or a skip in the middle.",
6
+ "category": "Navigation",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "spinner"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/stepper.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, X } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Progress through a fixed sequence of stages.\n *\n * Status is derived from `current` unless a step overrides it, so the common\n * case is one number rather than a status on every entry — but a failed or\n * skipped step in the middle still has to be expressible, hence the override.\n *\n * Horizontal stacks to vertical below the breakpoint. Four labelled stages\n * across a phone leaves about seventy pixels each, which truncates every label\n * to a word.\n */\nexport type Step = {\n id: string\n label: ReactNode\n description?: ReactNode\n status?: StepStatus\n icon?: ReactNode\n}\n\nexport type StepStatus = 'pending' | 'active' | 'complete' | 'failed' | 'skipped'\n\nconst MARKER = {\n pending: 'border-border text-muted-foreground bg-background',\n active: 'border-primary bg-primary text-primary-foreground',\n complete: 'border-[var(--green)] bg-[var(--green)] text-[var(--green-foreground)]',\n failed: 'border-[var(--destructive)] bg-[var(--destructive)] text-[var(--destructive-foreground)]',\n skipped: 'border-border text-muted-foreground/60 bg-background',\n} as const\n\nconst CONNECTOR = {\n pending: 'bg-border',\n active: 'bg-border',\n complete: 'bg-[var(--green)]',\n failed: 'bg-[var(--destructive)]',\n skipped: 'bg-border',\n} as const\n\nconst RESPONSIVE_ROW = {\n sm: 'flex-col sm:flex-row sm:items-start',\n md: 'flex-col md:flex-row md:items-start',\n lg: 'flex-col lg:flex-row lg:items-start',\n} as const\n\nfunction statusOf(step: Step, index: number, current: number): StepStatus {\n if (step.status) return step.status\n if (index < current) return 'complete'\n if (index === current) return 'active'\n return 'pending'\n}\n\nfunction Stepper({\n steps,\n current = 0,\n orientation = 'horizontal',\n responsive = 'sm',\n className,\n ...props\n}: Omit<ComponentProps<'ol'>, 'children'> & {\n steps: Step[]\n /** Index of the active step. Earlier steps read as complete. */\n current?: number\n orientation?: 'horizontal' | 'vertical'\n responsive?: Responsive\n}) {\n const stacks = orientation === 'horizontal' && responsive !== false\n const vertical = orientation === 'vertical'\n\n return (\n <ol\n data-slot=\"stepper\"\n data-orientation={orientation}\n className={cn(\n 'flex list-none',\n vertical && 'flex-col',\n !vertical && !stacks && 'flex-row items-start',\n stacks && RESPONSIVE_ROW[responsive],\n className,\n )}\n {...props}\n >\n {steps.map((step, index) => {\n const status = statusOf(step, index, current)\n const last = index === steps.length - 1\n const connector = CONNECTOR[statusOf(step, index, current)]\n\n return (\n <li\n key={step.id}\n data-status={status}\n aria-current={status === 'active' ? 'step' : undefined}\n className={cn(\n 'flex min-w-0 gap-3',\n vertical && 'pb-5 last:pb-0',\n !vertical && stacks && 'pb-5 sm:flex-1 sm:flex-col sm:pb-0 last:pb-0',\n !vertical && !stacks && 'flex-1 flex-col',\n )}\n >\n <div\n className={cn(\n 'relative flex shrink-0',\n vertical || stacks ? 'flex-col items-center' : 'w-full items-center',\n !vertical && stacks && 'sm:w-full sm:flex-row',\n )}\n >\n <span\n className={cn(\n 'z-10 flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold [corner-shape:round]',\n \"[&_svg:not([class*='size-'])]:size-3.5\",\n MARKER[status],\n )}\n >\n {step.icon ??\n (status === 'complete' ? (\n <Check />\n ) : status === 'failed' ? (\n <X />\n ) : status === 'active' ? (\n // `size` sizes the ring; className only sizes the wrapper,\n // which would leave the ring at its default and overflow\n // the marker.\n <Spinner size=\"xs\" label=\"In progress\" />\n ) : (\n index + 1\n ))}\n </span>\n\n {/* The connector belongs to the step before the gap, so it takes\n that step's colour — a completed stage joins to the next with\n a completed line.\n\n `-bottom-5` matches the row's `pb-5`. A flex child stretches\n to the content box, not the padding box, so `bottom-0` stops\n short and leaves a visible break between every pair of\n steps. */}\n {!last && (\n <span\n aria-hidden=\"true\"\n className={cn(\n connector,\n vertical || stacks\n ? 'absolute top-6 -bottom-5 w-px'\n : 'ms-2 h-px flex-1',\n !vertical &&\n stacks &&\n 'sm:relative sm:top-auto sm:bottom-auto sm:ms-2 sm:h-px sm:w-auto sm:flex-1',\n )}\n />\n )}\n </div>\n\n <div\n className={cn(\n 'flex min-w-0 flex-col gap-0.5 pb-1',\n !vertical && !stacks && 'pe-4',\n !vertical && stacks && 'sm:pe-4',\n )}\n >\n <span\n className={cn(\n 'truncate text-sm font-medium',\n status === 'pending' && 'text-muted-foreground',\n status === 'skipped' && 'text-muted-foreground/60 line-through',\n )}\n >\n {step.label}\n </span>\n {step.description && (\n <span className=\"text-muted-foreground text-xs\">\n {step.description}\n </span>\n )}\n </div>\n </li>\n )\n })}\n </ol>\n )\n}\n\nexport { Stepper }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "streaming-text",
3
+ "type": "registry:ui",
4
+ "title": "Streaming Text",
5
+ "description": "Text revealed as it arrives, with a caret while it is still coming. Driven by elapsed time rather than a per-character interval, so it never drifts or dumps a backlog after a background tab.",
6
+ "category": "AI",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils",
10
+ "primitive-media-query"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/streaming-text.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import { useEffect, useRef, useState, type ComponentProps } from 'react'\nimport { useMediaQuery } from '@/components/primitives/media-query'\nimport { cn } from '@/lib/utils'\n\n/**\n * Text revealed as it arrives, with a caret while it is still coming.\n *\n * Reveal is driven by elapsed time rather than a per-character interval. A\n * `setInterval` per character drifts under load and queues up a backlog when\n * the tab is inactive, so a message that streamed while you were away finishes\n * by dumping a thousand characters at once. Reading the clock each frame makes\n * the reveal correct at any frame rate.\n *\n * Under `prefers-reduced-motion` the text appears complete immediately. The\n * animation carries no information the text does not, so there is nothing to\n * lose by skipping it.\n *\n * The output is a live region: a screen reader should hear the answer arrive,\n * not sit in silence until it finishes.\n */\nfunction StreamingText({\n text,\n speed = 240,\n streaming = true,\n caret = true,\n onDone,\n className,\n ...props\n}: Omit<ComponentProps<'span'>, 'children'> & {\n text: string\n /** Characters per second. */\n speed?: number\n /** False renders the whole string at once. */\n streaming?: boolean\n caret?: boolean\n onDone?: () => void\n}) {\n const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')\n const instant = !streaming || reduced\n\n const [count, setCount] = useState(instant ? text.length : 0)\n const doneRef = useRef(false)\n\n useEffect(() => {\n if (instant) {\n setCount(text.length)\n return\n }\n\n doneRef.current = false\n setCount(0)\n\n const started = performance.now()\n let frame = 0\n\n const tick = (now: number) => {\n // Derived from elapsed time, never accumulated per frame — so a dropped\n // or delayed frame catches up instead of falling behind.\n const next = Math.min(Math.floor(((now - started) / 1000) * speed), text.length)\n setCount(next)\n\n if (next < text.length) {\n frame = requestAnimationFrame(tick)\n } else if (!doneRef.current) {\n doneRef.current = true\n onDone?.()\n }\n }\n\n frame = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(frame)\n // `onDone` is deliberately not a dependency: a caller passing an inline\n // arrow would otherwise restart the stream on every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [text, speed, instant])\n\n const complete = count >= text.length\n\n return (\n <span\n data-slot=\"streaming-text\"\n data-streaming={!complete || undefined}\n aria-live=\"polite\"\n aria-busy={!complete}\n className={cn('whitespace-pre-wrap', className)}\n {...props}\n >\n {text.slice(0, count)}\n {caret && !complete && (\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground ms-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-pulse motion-reduce:animate-none\"\n />\n )}\n </span>\n )\n}\n\nexport { StreamingText }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "subscription-state",
3
+ "type": "registry:ui",
4
+ "title": "Subscription State",
5
+ "description": "Plan, price and what happens next. A cancelled subscription with three weeks of access left is the case people most often get wrong, and a status badge alone never says it.",
6
+ "category": "Commerce",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "badge",
10
+ "button",
11
+ "fmt",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/subscription-state.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Where a subscription stands: plan, price, renewal, and what happens next.\n *\n * \"What happens next\" is the point. Every state here answers a different\n * question — when am I charged, when do I lose access, what do I owe — and a\n * status badge alone answers none of them. A cancelled subscription that still\n * has three weeks of access left is the case people most often get wrong.\n *\n * Past-due is given the strongest treatment and its own action, because it is\n * the only state where the customer can lose access to something they thought\n * they had paid for, and it is usually fixed in one click.\n *\n * A trial shows days remaining rather than the end date. \"Ends 14 March\" needs\n * a calendar; \"4 days left\" does not.\n */\nexport type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'paused'\n\nconst STATUS = {\n active: { label: 'Active', color: 'green' },\n trialing: { label: 'Trial', color: 'blue' },\n past_due: { label: 'Payment failed', color: 'destructive' },\n canceled: { label: 'Cancelling', color: 'amber' },\n paused: { label: 'Paused', color: 'neutral' },\n} as const\n\nfunction daysUntil(date: Date) {\n return Math.ceil((date.getTime() - Date.now()) / 86_400_000)\n}\n\nfunction SubscriptionState({\n plan,\n status,\n price,\n currency = 'USD',\n locale = 'en-GB',\n period = 'month',\n renewsAt,\n endsAt,\n trialEndsAt,\n seats,\n actions,\n onFixPayment,\n seatsLabel = 'seats in use',\n renewsLabel = 'Renews',\n fixPaymentLabel = 'Update payment method',\n className,\n ...props\n}: Omit<ComponentProps<'section'>, 'children'> & {\n plan: ReactNode\n status: SubscriptionStatus\n /** Minor units per period. */\n price?: number\n currency?: string\n locale?: string\n period?: 'month' | 'year'\n renewsAt?: Date\n /** When access actually stops — for a cancelled or paused subscription. */\n endsAt?: Date\n trialEndsAt?: Date\n seats?: { used: number; total: number }\n actions?: ReactNode\n onFixPayment?: () => void\n /** Follows the used-of-total seat count. */\n seatsLabel?: ReactNode\n /** Precedes the renewal date. */\n renewsLabel?: ReactNode\n fixPaymentLabel?: ReactNode\n}) {\n const meta = STATUS[status]\n const trialDays = trialEndsAt ? daysUntil(trialEndsAt) : undefined\n\n return (\n <section\n data-slot=\"subscription-state\"\n data-status={status}\n className={cn(\n surface,\n radius.surface,\n 'flex flex-col gap-3 p-4',\n status === 'past_due' && 'border-[var(--destructive)]',\n className,\n )}\n {...props}\n >\n <header className=\"flex flex-wrap items-start gap-2\">\n <div className=\"min-w-0 flex-1\">\n <h3 className=\"truncate text-sm font-medium\">{plan}</h3>\n {price !== undefined && (\n <p className=\"text-muted-foreground text-xs tabular-nums\">\n <Fmt\n type=\"currency\"\n value={price / 100}\n currency={currency}\n locale={locale}\n decimals={price % 100 === 0 ? 0 : 2}\n />\n {' / '}\n {period}\n </p>\n )}\n </div>\n <Badge size=\"sm\" color={meta.color}>\n {meta.label}\n </Badge>\n </header>\n\n {seats && (\n <p className=\"text-xs\">\n <span className=\"font-medium tabular-nums\">\n {seats.used} of {seats.total}\n </span>\n <span className=\"text-muted-foreground\"> {seatsLabel}</span>\n </p>\n )}\n\n {/* What happens next, in words — the badge alone answers nothing. */}\n <p className=\"text-muted-foreground text-xs\">\n {status === 'trialing' && trialDays !== undefined && (\n <>\n {trialDays > 0\n ? `${trialDays} day${trialDays === 1 ? '' : 's'} left in your trial`\n : 'Your trial ends today'}\n {renewsAt && (\n <>\n {' — billing starts '}\n <Fmt type=\"date\" value={renewsAt} format=\"D MMM\" locale={locale} />\n </>\n )}\n </>\n )}\n\n {status === 'active' && renewsAt && (\n <>\n {renewsLabel} <Fmt type=\"date\" value={renewsAt} format=\"D MMMM YYYY\" locale={locale} />\n </>\n )}\n\n {/* The case people get wrong: cancelled, but still paid up. */}\n {status === 'canceled' && endsAt && (\n <>\n Access continues until{' '}\n <Fmt type=\"date\" value={endsAt} format=\"D MMMM YYYY\" locale={locale} /> — no further\n charges.\n </>\n )}\n\n {status === 'paused' && (\n <>\n Billing paused\n {renewsAt && (\n <>\n {' until '}\n <Fmt type=\"date\" value={renewsAt} format=\"D MMMM YYYY\" locale={locale} />\n </>\n )}\n .\n </>\n )}\n\n {status === 'past_due' && (\n <span className=\"text-[var(--destructive-soft-foreground)]\">\n We could not take the last payment\n {endsAt && (\n <>\n . Access ends{' '}\n <Fmt type=\"date\" value={endsAt} format=\"D MMMM\" locale={locale} />\n </>\n )}\n .\n </span>\n )}\n </p>\n\n {(actions || (status === 'past_due' && onFixPayment)) && (\n <div className=\"border-border flex flex-wrap gap-2 border-t pt-3\">\n {status === 'past_due' && onFixPayment && (\n <Button size=\"sm\" onClick={onFixPayment}>\n {fixPaymentLabel}\n </Button>\n )}\n {actions}\n </div>\n )}\n </section>\n )\n}\n\nexport { SubscriptionState }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "suggestions",
3
+ "type": "registry:ui",
4
+ "title": "Suggestions",
5
+ "description": "Starter prompts, offered before the first message. Buttons rather than a list, because each one performs an action.",
6
+ "category": "AI",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "button",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/suggestions.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\n/**\n * Starter prompts, offered before the first message.\n *\n * Buttons rather than a list: each one performs an action, and the pill shape\n * signals \"tap me\" in a way a bulleted list does not.\n */\nfunction Suggestions({\n className,\n items,\n onSelect,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: string[]\n onSelect?: (prompt: string) => void\n}) {\n return (\n <div\n data-slot=\"suggestions\"\n className={cn('flex flex-wrap gap-2', className)}\n {...props}\n >\n {items.map((item) => (\n <Button\n key={item}\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => onSelect?.(item)}\n // Long prompts must not stretch the row into one wide line.\n className=\"max-w-full justify-start\"\n >\n <span className=\"truncate\">{item}</span>\n </Button>\n ))}\n </div>\n )\n}\n\nexport { Suggestions }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "swap-panel",
3
+ "type": "registry:ui",
4
+ "title": "Swap Panel",
5
+ "description": "The pay/receive form of a token swap. Price impact is surfaced rather than buried behind a details toggle, and above a threshold the action is disabled outright.",
6
+ "category": "Crypto",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/swap-panel.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ArrowDown, Settings2, TriangleAlert } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The pay/receive form of a token swap.\n *\n * Price impact is surfaced, not buried behind a details toggle. A swap that\n * moves the pool 12% is a bad trade whether or not the user expanded a panel,\n * and above `impactWarn` the figure is coloured and above `impactBlock` the\n * action is disabled outright. Interfaces that hide this are why people lose\n * money to thin liquidity.\n *\n * The minimum received is shown as an amount rather than only a slippage\n * percentage — \"0.5%\" is abstract, \"you get at least 1,240 USDC\" is the number\n * the user is actually agreeing to.\n */\nexport type SwapSide = {\n symbol: ReactNode\n amount: string\n /** Fiat value of the amount. */\n fiat?: number\n balance?: ReactNode\n icon?: ReactNode\n}\n\n/**\n * Default formatters at module scope — an inline arrow default is a value\n * the React Compiler cannot reorder, and it bails on the whole component.\n */\nconst DEFAULT_BLOCKED_NOTE = (limit: number) =>\n `Price impact is above ${limit}%. This trade would lose a significant part of its value.`\n\nfunction SwapPanel({\n from,\n to,\n rate,\n priceImpact,\n minimumReceived,\n networkFee,\n slippage = 0.5,\n impactWarn = 3,\n impactBlock = 15,\n onSwap,\n onFlip,\n onSettings,\n action = 'Swap',\n title = 'Swap',\n settingsLabel = 'Swap settings',\n flipLabel = 'Flip direction',\n rateLabel = 'Rate',\n priceImpactLabel = 'Price impact',\n minimumReceivedLabel = 'Minimum received',\n slippageLabel = 'Slippage tolerance',\n networkFeeLabel = 'Network fee',\n blockedLabel = 'Price impact too high',\n blockedNote = DEFAULT_BLOCKED_NOTE,\n disabled = false,\n currency = 'USD',\n locale = 'en-GB',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n from: SwapSide\n to: SwapSide\n rate?: ReactNode\n /** Percentage, e.g. 1.2. */\n priceImpact?: number\n minimumReceived?: ReactNode\n networkFee?: ReactNode\n slippage?: number\n impactWarn?: number\n impactBlock?: number\n onSwap?: () => void\n onFlip?: () => void\n onSettings?: () => void\n action?: ReactNode\n title?: ReactNode\n settingsLabel?: string\n flipLabel?: string\n rateLabel?: ReactNode\n priceImpactLabel?: ReactNode\n minimumReceivedLabel?: ReactNode\n slippageLabel?: ReactNode\n networkFeeLabel?: ReactNode\n /** Replaces the action label once impact exceeds `impactBlock`. */\n blockedLabel?: ReactNode\n /** Warning shown at that point, given the threshold. */\n blockedNote?: (limit: number) => ReactNode\n disabled?: boolean\n currency?: string\n locale?: string\n}) {\n const money = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n maximumFractionDigits: 2,\n })\n\n const severe = priceImpact !== undefined && priceImpact >= impactBlock\n const warn = priceImpact !== undefined && priceImpact >= impactWarn\n\n const Side = ({ side, label }: { side: SwapSide; label: string }) => (\n <div className={cn('bg-secondary/60 flex flex-col gap-2 p-3', radius.control)}>\n <div className=\"text-muted-foreground flex items-center justify-between gap-2 text-xs\">\n <span>{label}</span>\n {side.balance && <span>Balance {side.balance}</span>}\n </div>\n <div className=\"flex items-center gap-2\">\n <span className=\"min-w-0 flex-1 truncate text-2xl font-semibold tabular-nums\">\n {side.amount}\n </span>\n <span className=\"bg-card border-border flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm font-medium [corner-shape:round]\">\n {side.icon}\n {side.symbol}\n </span>\n </div>\n {side.fiat !== undefined && (\n <span className=\"text-muted-foreground/70 text-xs tabular-nums\">\n {money.format(side.fiat)}\n </span>\n )}\n </div>\n )\n\n return (\n <div\n data-slot=\"swap-panel\"\n className={cn(surface, radius.surface, 'flex w-full flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"text-sm font-medium\">{title}</span>\n {onSettings && (\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={settingsLabel} onClick={onSettings}>\n <Settings2 />\n </Button>\n )}\n </div>\n\n <div className=\"relative flex flex-col gap-1\">\n <Side side={from} label=\"You pay\" />\n\n <div className=\"flex justify-center\">\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={flipLabel}\n onClick={onFlip}\n className=\"border-card -my-3 z-10 border-4\"\n >\n <ArrowDown />\n </Button>\n </div>\n\n <Side side={to} label=\"You receive\" />\n </div>\n\n <dl className=\"flex flex-col gap-1.5 text-xs\">\n {rate && (\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{rateLabel}</dt>\n <dd className=\"tabular-nums\">{rate}</dd>\n </div>\n )}\n\n {priceImpact !== undefined && (\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{priceImpactLabel}</dt>\n <dd\n className={cn(\n 'tabular-nums',\n severe\n ? 'font-medium text-[var(--destructive-soft-foreground)]'\n : warn\n ? 'text-[var(--amber-soft-foreground)]'\n : 'text-[var(--green-soft-foreground)]',\n )}\n >\n {priceImpact.toFixed(2)}%\n </dd>\n </div>\n )}\n\n {minimumReceived && (\n <div className=\"flex justify-between gap-2\">\n {/* An amount, not just a percentage: this is what is being agreed. */}\n <dt className=\"text-muted-foreground\">{minimumReceivedLabel}</dt>\n <dd className=\"tabular-nums\">{minimumReceived}</dd>\n </div>\n )}\n\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{slippageLabel}</dt>\n <dd className=\"tabular-nums\">{slippage}%</dd>\n </div>\n\n {networkFee && (\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{networkFeeLabel}</dt>\n <dd className=\"tabular-nums\">{networkFee}</dd>\n </div>\n )}\n </dl>\n\n {severe && (\n <p className=\"flex items-start gap-2 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {blockedNote(impactBlock)}\n </p>\n )}\n\n <Button className=\"w-full\" disabled={disabled || severe} onClick={onSwap}>\n {severe ? blockedLabel : action}\n </Button>\n </div>\n )\n}\n\nexport { SwapPanel }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "switch",
3
+ "type": "registry:ui",
4
+ "title": "Switch",
5
+ "description": "An instant on/off toggle. A native checkbox with role=\"switch\", so it submits with a form and toggles from the keyboard.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "class-variance-authority@^0.7.1"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/switch.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { interactive, switchSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A checkbox under the hood, presented as a track and thumb.\n *\n * `role=\"switch\"` on the native input keeps form submission and keyboard\n * toggling while announcing the right thing. The thumb travels by `translate`,\n * which is a transform — but it is the control's *state*, not a hover flourish,\n * so the kit's no-motion rule does not apply.\n */\nconst trackVariants = cva(\n [\n 'relative inline-flex shrink-0 items-center',\n 'bg-secondary border-border border',\n 'rounded-full [corner-shape:round]',\n interactive,\n 'peer-checked:bg-primary peer-checked:border-primary',\n 'peer-focus-visible:border-ring peer-focus-visible:ring-ring/50 peer-focus-visible:ring-[3px]',\n 'peer-disabled:pointer-events-none peer-disabled:opacity-50',\n // `peer-*` reaches siblings only, and the thumb is a descendant — so the\n // track owns these variants and targets the thumb through a child selector.\n 'peer-checked:[&>span]:translate-x-[var(--thumb)]',\n 'peer-checked:[&>span]:bg-primary-foreground',\n ].join(' '),\n {\n variants: {\n size: {\n sm: switchSize.sm,\n default: switchSize.default,\n lg: switchSize.lg,\n },\n },\n defaultVariants: { size: 'default' },\n },\n)\n\n/**\n * The thumb pairs with whatever token the track is painted in, so it always has\n * contrast: `--muted-foreground` on the subtle off track, `--primary-foreground`\n * on the solid on track. It cannot use `--background` — that is near-black in\n * dark mode, so the thumb would read as a black dot, and near-white in light\n * mode, where it would vanish into the off track. With no shadows in the kit,\n * colour is the only thing separating the thumb from its track.\n */\nconst THUMB = [\n 'pointer-events-none block rounded-full [corner-shape:round]',\n 'bg-muted-foreground size-[var(--thumb)]',\n 'transition-[transform,background-color] duration-150 ease-out',\n 'motion-reduce:transition-none',\n].join(' ')\n\ntype SwitchProps = Omit<ComponentProps<'input'>, 'size' | 'type'> &\n VariantProps<typeof trackVariants> & {\n label?: ReactNode\n description?: ReactNode\n /**\n * Which side the label sits on.\n *\n * `start` is what a settings row wants: label left, control right. Without\n * it, `justify-between` on the container spreads a control-then-label row\n * and strands the text against the far edge.\n */\n labelPosition?: 'start' | 'end'\n containerClassName?: string\n }\n\nfunction Switch({\n className,\n containerClassName,\n size,\n label,\n description,\n labelPosition = 'end',\n ...props\n}: SwitchProps) {\n const text = (label || description) && (\n <span className=\"grid gap-0.5 leading-tight\">\n {label && <span className=\"text-sm font-medium\">{label}</span>}\n {description && (\n <span className=\"text-muted-foreground text-xs\">{description}</span>\n )}\n </span>\n )\n\n return (\n <label\n data-slot=\"switch\"\n className={cn(\n 'flex w-fit items-center gap-2.5 select-none',\n props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',\n containerClassName,\n )}\n >\n <input type=\"checkbox\" role=\"switch\" className=\"peer sr-only\" {...props} />\n\n {/* Safe to put the label here: `peer-*` compiles to the general sibling\n combinator, which matches the track wherever it follows the input. */}\n {labelPosition === 'start' && text}\n\n <span className={cn(trackVariants({ size }), className)}>\n <span className={THUMB} />\n </span>\n\n {labelPosition === 'end' && text}\n </label>\n )\n}\n\nexport { Switch, trackVariants as switchVariants }\nexport type { SwitchProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "symbol-outline",
3
+ "type": "registry:ui",
4
+ "title": "Symbol Outline",
5
+ "description": "A file's symbols as a tree, in file order rather than alphabetical. An outline is a map of the document you are reading; re-sorting it makes the outline and the editor disagree about where things are.",
6
+ "category": "Code Tools",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "input",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/symbol-outline.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Box, Braces, FunctionSquare, Hash, Search, Type, Variable } from 'lucide-react'\nimport { Input } from '@/components/ui/input'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file's symbols as a tree.\n *\n * Filtering keeps ancestors of a match. Flattening to matched leaves loses the\n * class a method belongs to, and `render` on its own is useless when six\n * components define one.\n *\n * Order is the order in the file, not alphabetical. An outline is a map of the\n * document you are reading; re-sorting it means the outline and the editor\n * disagree about where things are.\n *\n * Exported symbols are marked. \"Is this part of the public surface\" is the\n * question an outline gets used for most, and it cannot be answered from the\n * name.\n */\nexport type SymbolKind =\n | 'function' | 'class' | 'interface' | 'type' | 'constant' | 'variable' | 'property'\n\nexport type CodeSymbol = {\n id: string\n name: string\n kind: SymbolKind\n line?: number\n exported?: boolean\n children?: CodeSymbol[]\n}\n\nconst ICON = {\n function: FunctionSquare,\n class: Box,\n interface: Braces,\n type: Type,\n constant: Hash,\n variable: Variable,\n property: Hash,\n} as const\n\n/** Keeps a node when it matches, or when any descendant does. */\nfunction filterTree(nodes: CodeSymbol[], needle: string): CodeSymbol[] {\n if (!needle) return nodes\n const out: CodeSymbol[] = []\n for (const node of nodes) {\n const children = filterTree(node.children ?? [], needle)\n if (node.name.toLowerCase().includes(needle) || children.length > 0) {\n out.push({ ...node, children })\n }\n }\n return out\n}\n\nfunction SymbolOutline({\n symbols,\n onSelect,\n selected,\n searchable = true,\n searchPlaceholder = 'Filter symbols',\n searchLabel = 'Filter symbols',\n emptyMessage = 'No symbols match.',\n exportedLabel = 'exported',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n symbols: CodeSymbol[]\n onSelect?: (id: string) => void\n selected?: string\n searchable?: boolean\n searchPlaceholder?: string\n searchLabel?: string\n emptyMessage?: ReactNode\n /** Accessible name for the export marker. */\n exportedLabel?: string\n}) {\n const [query, setQuery] = useState('')\n const tree = useMemo(() => filterTree(symbols, query.trim().toLowerCase()), [symbols, query])\n\n const render = (nodes: CodeSymbol[], depth = 0): ReactNode =>\n nodes.map((symbol) => {\n const Icon = ICON[symbol.kind]\n return (\n <li key={symbol.id}>\n <button\n type=\"button\"\n onClick={() => onSelect?.(symbol.id)}\n style={{ paddingInlineStart: depth * 14 + 12 }}\n className={cn(\n 'flex w-full items-center gap-2 py-1.5 pe-3 text-start',\n selected === symbol.id && 'bg-accent/50',\n onSelect && interactive,\n focusRing,\n )}\n >\n <Icon className=\"text-muted-foreground size-3.5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate font-mono text-xs\">{symbol.name}</span>\n {/* The question an outline is opened to answer. */}\n {symbol.exported && (\n <span\n className=\"size-1.5 shrink-0 rounded-full bg-[var(--green)]\"\n aria-label={exportedLabel}\n />\n )}\n {symbol.line !== undefined && (\n <span className=\"text-muted-foreground/50 shrink-0 text-xs tabular-nums\">\n {symbol.line}\n </span>\n )}\n </button>\n {symbol.children && symbol.children.length > 0 && (\n <ul className=\"list-none\">{render(symbol.children, depth + 1)}</ul>\n )}\n </li>\n )\n })\n\n return (\n <div\n data-slot=\"symbol-outline\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n {searchable && (\n <div className=\"border-border border-b p-3\">\n <Input\n size=\"sm\"\n value={query}\n icon={<Search />}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n )}\n\n {tree.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyMessage}</p>\n ) : (\n <ul className=\"list-none py-1.5\">{render(tree)}</ul>\n )}\n </div>\n )\n}\n\nexport { SymbolOutline, filterTree as filterSymbols }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "table-of-contents",
3
+ "type": "registry:ui",
4
+ "title": "Table of Contents",
5
+ "description": "An anchor list that tracks which heading is on screen, through IntersectionObserver rather than scroll offsets. Reads the DOM by default, so it works for rendered markdown.",
6
+ "category": "Navigation",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/table-of-contents.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import { useEffect, useState, type ComponentProps } from 'react'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An anchor list that tracks which heading is on screen.\n *\n * Scroll-spy through `IntersectionObserver`, not scroll offsets. Measuring\n * positions on every scroll event means reading layout in a handler that fires\n * dozens of times a second, and the browser already computes this.\n *\n * The `rootMargin` pulls the detection band up to the top quarter of the\n * viewport. Without it the active item is whatever is nearest the bottom, so\n * the highlight sits one or two headings ahead of what you are reading.\n *\n * Headings are found in the DOM rather than passed in, so it stays correct for\n * content rendered from markdown where the caller has no list to give.\n */\nexport type TocItem = {\n id: string\n label: string\n level: number\n}\n\nfunction useHeadings(selector: string, container?: string) {\n const [items, setItems] = useState<TocItem[]>([])\n\n useEffect(() => {\n const root = container ? document.querySelector(container) : document\n if (!root) return\n\n const found = [...root.querySelectorAll<HTMLElement>(selector)]\n .filter((node) => node.id)\n .map((node) => ({\n id: node.id,\n label: node.textContent ?? '',\n level: Number(node.tagName.slice(1)) || 2,\n }))\n\n setItems(found)\n }, [selector, container])\n\n return items\n}\n\nfunction TableOfContents({\n items: itemsProp,\n selector = 'h2, h3',\n container,\n label = 'On this page',\n className,\n ...props\n}: ComponentProps<'nav'> & {\n /** Supply headings directly, or let it read the DOM. */\n items?: TocItem[]\n selector?: string\n container?: string\n label?: string\n}) {\n const found = useHeadings(selector, container)\n const items = itemsProp ?? found\n const [active, setActive] = useState<string | null>(null)\n\n useEffect(() => {\n if (items.length === 0) return\n\n const observer = new IntersectionObserver(\n (entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)\n\n if (visible[0]) setActive(visible[0].target.id)\n },\n // Detection band across the top quarter of the viewport.\n { rootMargin: '0px 0px -75% 0px', threshold: 0 },\n )\n\n for (const item of items) {\n const node = document.getElementById(item.id)\n if (node) observer.observe(node)\n }\n\n return () => observer.disconnect()\n }, [items])\n\n if (items.length === 0) return null\n\n const minLevel = Math.min(...items.map((item) => item.level))\n\n return (\n <nav\n data-slot=\"table-of-contents\"\n aria-label={label}\n className={cn('flex flex-col gap-1 text-sm', className)}\n {...props}\n >\n <p className=\"text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase\">\n {label}\n </p>\n\n <ul className=\"flex list-none flex-col\">\n {items.map((item) => {\n const current = active === item.id\n return (\n <li key={item.id}>\n <a\n href={`#${item.id}`}\n aria-current={current ? 'location' : undefined}\n style={{ paddingInlineStart: `${(item.level - minLevel) * 12 + 8}px` }}\n className={cn(\n 'block truncate py-1 pe-2',\n radius.xs,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n current\n ? 'text-foreground font-medium'\n : 'text-muted-foreground hover:text-foreground',\n )}\n >\n {item.label}\n </a>\n </li>\n )\n })}\n </ul>\n </nav>\n )\n}\n\nexport { TableOfContents, useHeadings }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "table",
3
+ "type": "registry:ui",
4
+ "title": "Table",
5
+ "description": "Rows and columns of structured data. The wrapper scrolls rather than the page, so a wide table never forces horizontal scroll on the whole document.",
6
+ "category": "Display",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/table.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps } from 'react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Rows and columns of structured data.\n *\n * The wrapper scrolls rather than the page, so a wide table never forces\n * horizontal scroll on the whole document — the single most common table bug.\n */\nfunction Table({\n className,\n containerClassName,\n ...props\n}: ComponentProps<'table'> & { containerClassName?: string }) {\n return (\n <div\n data-slot=\"table-container\"\n className={cn(\n surface,\n radius.panel,\n 'w-full overflow-x-auto',\n containerClassName,\n )}\n >\n <table\n data-slot=\"table\"\n className={cn('w-full caption-bottom text-sm', className)}\n {...props}\n />\n </div>\n )\n}\n\nfunction TableHeader({ className, ...props }: ComponentProps<'thead'>) {\n return (\n <thead\n data-slot=\"table-header\"\n className={cn('[&_tr]:border-border [&_tr]:border-b', className)}\n {...props}\n />\n )\n}\n\nfunction TableBody({ className, ...props }: ComponentProps<'tbody'>) {\n return (\n <tbody\n data-slot=\"table-body\"\n className={cn(\n 'border-border [&_tr]:border-b [&_tr:last-child]:border-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction TableFooter({ className, ...props }: ComponentProps<'tfoot'>) {\n return (\n <tfoot\n data-slot=\"table-footer\"\n className={cn(\n 'border-border bg-muted/40 border-t font-medium [&>tr]:last:border-b-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction TableRow({ className, ...props }: ComponentProps<'tr'>) {\n return (\n <tr\n data-slot=\"table-row\"\n className={cn(\n 'border-border hover:bg-muted/40 data-[state=selected]:bg-muted transition-colors duration-150 ease-out motion-reduce:transition-none',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction TableHead({ className, ...props }: ComponentProps<'th'>) {\n return (\n <th\n data-slot=\"table-head\"\n className={cn(\n 'text-muted-foreground h-10 px-4 text-start align-middle text-xs font-medium whitespace-nowrap',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction TableCell({ className, ...props }: ComponentProps<'td'>) {\n return (\n <td\n data-slot=\"table-cell\"\n className={cn('px-4 py-2.5 align-middle', className)}\n {...props}\n />\n )\n}\n\nfunction TableCaption({ className, ...props }: ComponentProps<'caption'>) {\n return (\n <caption\n data-slot=\"table-caption\"\n className={cn('text-muted-foreground mt-3 px-4 pb-3 text-xs', className)}\n {...props}\n />\n )\n}\n\nexport {\n Table,\n TableBody,\n TableCaption,\n TableCell,\n TableFooter,\n TableHead,\n TableHeader,\n TableRow,\n}\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "tabs",
3
+ "type": "registry:ui",
4
+ "title": "Tabs",
5
+ "description": "Switch between panels in the same context. Full WAI-ARIA keyboard support: arrows rove, Home and End jump, and only the active tab is in the tab order.",
6
+ "category": "Navigation",
7
+ "dependencies": [
8
+ "class-variance-authority@^0.7.1"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "primitive-tabs"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/tabs.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { TabsProvider, tabIds, useTabs } from '@/components/primitives/tabs'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\nconst listVariants = cva('flex', {\n variants: {\n variant: {\n /** Segmented control: a filled track with the active tab lifted out. */\n solid: 'bg-muted gap-0.5 p-0.5',\n /** A rule with the active tab underlined. */\n underline: 'border-border gap-4 border-b',\n },\n orientation: {\n horizontal: 'flex-row items-center',\n vertical: 'flex-col items-stretch',\n },\n },\n compoundVariants: [\n { variant: 'underline', orientation: 'vertical', class: 'border-b-0 border-r' },\n ],\n defaultVariants: { variant: 'solid', orientation: 'horizontal' },\n})\n\nconst triggerVariants = cva(\n [\n 'inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap',\n 'text-sm font-medium',\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'disabled:pointer-events-none disabled:opacity-50',\n focusRing,\n ].join(' '),\n {\n variants: {\n variant: {\n solid: cn(\n radius.control,\n 'px-3 py-1.5',\n 'text-muted-foreground hover:text-foreground',\n 'data-[state=active]:bg-background data-[state=active]:text-foreground',\n ),\n underline: cn(\n 'border-b-2 border-transparent px-1 py-2',\n 'text-muted-foreground hover:text-foreground',\n 'data-[state=active]:border-foreground data-[state=active]:text-foreground',\n ),\n },\n },\n defaultVariants: { variant: 'solid' },\n },\n)\n\ntype TabsProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n orientation?: 'horizontal' | 'vertical'\n activationMode?: 'automatic' | 'manual'\n}\n\nfunction Tabs({\n className,\n value,\n defaultValue,\n onValueChange,\n orientation = 'horizontal',\n activationMode = 'automatic',\n ...props\n}: TabsProps) {\n return (\n <TabsProvider\n value={value}\n defaultValue={defaultValue}\n onValueChange={onValueChange}\n orientation={orientation}\n activationMode={activationMode}\n >\n <div\n data-slot=\"tabs\"\n className={cn(\n 'flex gap-3',\n orientation === 'vertical' ? 'flex-row' : 'flex-col',\n className,\n )}\n {...props}\n />\n </TabsProvider>\n )\n}\n\nfunction TabsList({\n className,\n variant,\n ...props\n}: ComponentProps<'div'> & VariantProps<typeof listVariants>) {\n const { orientation, onListKeyDown } = useTabs()\n\n return (\n <div\n role=\"tablist\"\n data-slot=\"tabs-list\"\n aria-orientation={orientation}\n onKeyDown={onListKeyDown}\n className={cn(\n listVariants({ variant, orientation }),\n variant === 'underline' ? '' : radius.control,\n 'w-fit',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction TabsTrigger({\n className,\n variant,\n value,\n ...props\n}: Omit<ComponentProps<'button'>, 'value'> &\n VariantProps<typeof triggerVariants> & { value: string }) {\n const { value: selected, select, baseId } = useTabs()\n const active = selected === value\n const ids = tabIds(baseId, value)\n\n return (\n <button\n type=\"button\"\n role=\"tab\"\n id={ids.trigger}\n data-slot=\"tabs-trigger\"\n data-value={value}\n data-state={active ? 'active' : 'inactive'}\n aria-selected={active}\n // Only the active panel is mounted, so only the active trigger can point\n // at one. Setting it unconditionally leaves every inactive tab with a\n // dangling reference to an element that is not in the document.\n aria-controls={active ? ids.panel : undefined}\n // Only the active tab is in the tab order; arrows move between the rest.\n tabIndex={active ? 0 : -1}\n onClick={() => select(value)}\n className={cn(triggerVariants({ variant }), className)}\n {...props}\n />\n )\n}\n\nfunction TabsContent({\n className,\n value,\n ...props\n}: ComponentProps<'div'> & { value: string }) {\n const { value: selected, baseId } = useTabs()\n if (selected !== value) return null\n\n const ids = tabIds(baseId, value)\n\n return (\n <div\n role=\"tabpanel\"\n id={ids.panel}\n data-slot=\"tabs-content\"\n aria-labelledby={ids.trigger}\n tabIndex={0}\n className={cn('outline-none', className)}\n {...props}\n />\n )\n}\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger, listVariants, triggerVariants }\nexport type { TabsProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "tag-input",
3
+ "type": "registry:ui",
4
+ "title": "Tag Input",
5
+ "description": "Free-text chips for keywords, recipients or labels. Backspace on an empty field removes the last tag, and pasting a comma-separated list splits it rather than making one long tag.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/tag-input.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { useRef, useState, type ComponentProps, type KeyboardEvent } from 'react'\nimport { X } from 'lucide-react'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Free-text chips: keywords, recipients, labels.\n *\n * Backspace on an empty field removes the last tag, which is the behaviour\n * everyone expects from every mail client. It removes rather than \"selects\n * then removes\" — the two-step version is more careful and nobody discovers it.\n *\n * Pasting splits on the delimiters too, so pasting a comma-separated list gives\n * a list of tags rather than one tag with commas in it.\n *\n * Each chip's remove button is a real tab stop. A chip you can only delete by\n * pointer is unremovable from the keyboard once you have tabbed past the field.\n */\nfunction TagInput({\n value: valueProp,\n defaultValue = [],\n onValueChange,\n placeholder = 'Add and press Enter',\n delimiters = [',', 'Enter', 'Tab'],\n max,\n allowDuplicates = false,\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n validate,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n value?: string[]\n defaultValue?: string[]\n onValueChange?: (tags: string[]) => void\n placeholder?: string\n /** Keys and characters that commit the current text. */\n delimiters?: string[]\n max?: number\n allowDuplicates?: boolean\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n disabled?: boolean\n /** Return false to reject an entry. */\n validate?: (tag: string) => boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<string[]>(defaultValue)\n const tags = controlled ? valueProp : uncontrolled\n\n const [draft, setDraft] = useState('')\n const inputRef = useRef<HTMLInputElement>(null)\n\n function setTags(next: string[]) {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n }\n\n function add(raw: string) {\n const tag = raw.trim()\n if (!tag) return\n if (max !== undefined && tags.length >= max) return\n if (!allowDuplicates && tags.includes(tag)) return\n if (validate && !validate(tag)) return\n setTags([...tags, tag])\n }\n\n function addMany(text: string) {\n const characters = delimiters.filter((d) => d.length === 1)\n const pattern = characters.length\n ? new RegExp(`[${characters.map((c) => `\\\\${c}`).join('')}\\n]`)\n : /\\n/\n const parts = text.split(pattern)\n let next = [...tags]\n for (const part of parts) {\n const tag = part.trim()\n if (!tag) continue\n if (max !== undefined && next.length >= max) break\n if (!allowDuplicates && next.includes(tag)) continue\n if (validate && !validate(tag)) continue\n next = [...next, tag]\n }\n setTags(next)\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n if (delimiters.includes(event.key)) {\n // Tab only commits when there is something to commit, so an empty field\n // still moves focus.\n if (event.key === 'Tab' && !draft.trim()) return\n event.preventDefault()\n add(draft)\n setDraft('')\n return\n }\n\n if (event.key === 'Backspace' && draft === '' && tags.length > 0) {\n event.preventDefault()\n setTags(tags.slice(0, -1))\n }\n }\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n }[variant]\n\n const full = max !== undefined && tags.length >= max\n\n return (\n <div\n data-slot=\"tag-input\"\n onClick={() => inputRef.current?.focus()}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n // Grows with its content rather than scrolling one line of chips.\n 'h-auto min-h-[--spacing(9)] flex-wrap gap-1.5 py-1.5',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n {...props}\n >\n {tags.map((tag, index) => (\n <span\n key={`${tag}-${index}`}\n className={cn(\n 'bg-secondary text-secondary-foreground inline-flex h-6 shrink-0 items-center gap-1 ps-2 pe-1 text-xs',\n radius.xs,\n )}\n >\n {tag}\n <button\n type=\"button\"\n aria-label={`Remove ${tag}`}\n disabled={disabled}\n onClick={(event) => {\n event.stopPropagation()\n setTags(tags.filter((_, i) => i !== index))\n }}\n className={cn(\n 'text-muted-foreground hover:text-foreground flex size-4 items-center justify-center',\n radius.xs,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n )}\n >\n <X className=\"size-3\" />\n </button>\n </span>\n ))}\n\n <input\n ref={inputRef}\n type=\"text\"\n value={draft}\n disabled={disabled || full}\n placeholder={full ? `Limit of ${max} reached` : placeholder}\n onKeyDown={onKeyDown}\n onChange={(event) => setDraft(event.target.value)}\n onBlur={() => {\n if (draft.trim()) {\n add(draft)\n setDraft('')\n }\n }}\n onPaste={(event) => {\n const text = event.clipboardData.getData('text')\n if (!delimiters.some((d) => d.length === 1 && text.includes(d)) && !text.includes('\\n')) {\n return\n }\n event.preventDefault()\n addMany(text)\n }}\n className={cn(fieldInput, 'min-w-24 flex-1')}\n />\n </div>\n )\n}\n\nexport { TagInput }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "terminal",
3
+ "type": "registry:ui",
4
+ "title": "Terminal",
5
+ "description": "Console output with ANSI colour. Follow-tail sticks to the bottom only while the reader is already there, so scrolling up to read something is never undone by the next line.",
6
+ "category": "Development",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "copy-button",
10
+ "lib-styles",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/terminal.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import { useEffect, useMemo, useRef, type ComponentProps } from 'react'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Console output with ANSI colour, for build and deploy logs.\n *\n * The parser handles SGR sequences only — colour, bold, dim, reset. Cursor\n * movement and screen clearing are dropped rather than half-implemented:\n * honouring them means maintaining a screen buffer, and a log pane is an\n * append-only transcript, not a terminal emulator.\n *\n * The surface follows the theme rather than being a fixed black panel: a\n * white-on-black block in the middle of a light page reads as a screenshot\n * pasted into the document. The ANSI palette maps onto theme tokens, so the\n * same output stays legible either way.\n *\n * Follow-tail sticks to the bottom only while the reader is already there.\n * Scrolling up to read something and being yanked back down by the next line\n * is the single worst thing a log viewer can do.\n */\n\n// Built at runtime rather than written as a literal escape, so the source file\n// stays free of control characters — they survive copy/paste badly and are\n// invisible in review.\nconst ESC = String.fromCharCode(27)\nconst ANSI_PATTERN = new RegExp(ESC + '\\\\[([0-9;]*)m', 'g')\n\nconst FG = {\n 30: 'text-[oklch(0.4_0_0)]',\n 31: 'text-[var(--destructive-soft-foreground)]',\n 32: 'text-[var(--green-soft-foreground)]',\n 33: 'text-[var(--amber-soft-foreground)]',\n 34: 'text-[var(--blue-soft-foreground)]',\n 35: 'text-[var(--violet-soft-foreground)]',\n 36: 'text-[var(--cyan-soft-foreground)]',\n 37: 'text-foreground',\n 90: 'text-muted-foreground',\n 91: 'text-[var(--destructive-soft-foreground)]',\n 92: 'text-[var(--green-soft-foreground)]',\n 93: 'text-[var(--amber-soft-foreground)]',\n 94: 'text-[var(--blue-soft-foreground)]',\n 95: 'text-[var(--violet-soft-foreground)]',\n 96: 'text-[var(--cyan-soft-foreground)]',\n 97: 'text-foreground',\n} as const\n\ntype Span = { text: string; className: string }\n\n/** Split one line into styled spans at its SGR escapes. */\nfunction parseAnsi(line: string): Span[] {\n const spans: Span[] = []\n let index = 0\n let colour = ''\n let bold = false\n let dim = false\n\n ANSI_PATTERN.lastIndex = 0\n let match: RegExpExecArray | null\n\n const push = (text: string) => {\n if (!text) return\n spans.push({\n text,\n className: cn(colour, bold && 'font-semibold', dim && 'opacity-60'),\n })\n }\n\n while ((match = ANSI_PATTERN.exec(line))) {\n push(line.slice(index, match.index))\n index = match.index + match[0].length\n\n for (const part of (match[1] || '0').split(';')) {\n const code = Number(part)\n if (code === 0) {\n colour = ''\n bold = false\n dim = false\n } else if (code === 1) bold = true\n else if (code === 2) dim = true\n else if (code === 22) {\n bold = false\n dim = false\n } else if (code in FG) colour = FG[code as keyof typeof FG]\n }\n }\n push(line.slice(index))\n return spans\n}\n\nfunction Terminal({\n content,\n lines,\n follow = false,\n showLineNumbers = false,\n copyable = true,\n title,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'content' | 'title'> & {\n /** Raw output, split on newlines. Use `lines` when you already have them. */\n content?: string\n lines?: string[]\n /** Stick to the bottom as content grows — but only if already at the bottom. */\n follow?: boolean\n showLineNumbers?: boolean\n copyable?: boolean\n title?: string\n}) {\n const rows = useMemo(\n () => lines ?? (content ?? '').replace(/\\n$/, '').split('\\n'),\n [content, lines],\n )\n const scrollRef = useRef<HTMLDivElement>(null)\n const pinned = useRef(true)\n\n useEffect(() => {\n if (!follow) return\n const node = scrollRef.current\n if (node && pinned.current) node.scrollTop = node.scrollHeight\n }, [rows, follow])\n\n return (\n <div\n data-slot=\"terminal\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n {(title || copyable) && (\n <div className=\"border-border bg-muted/40 flex h-9 items-center gap-2 border-b px-3\">\n <span className=\"text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs\">\n {title}\n </span>\n {copyable && (\n <CopyButton value={() => rows.join('\\n')} label=\"Copy output\" />\n )}\n </div>\n )}\n\n <div\n ref={scrollRef}\n onScroll={(event) => {\n const node = event.currentTarget\n // A little slack, so \"close enough to the bottom\" still counts.\n pinned.current =\n node.scrollHeight - node.scrollTop - node.clientHeight < 24\n }}\n className=\"bg-card max-h-80 overflow-auto p-3 font-mono text-xs leading-relaxed\"\n >\n {rows.map((line, index) => (\n <div key={index} className=\"flex min-w-max gap-3\">\n {showLineNumbers && (\n <span\n aria-hidden=\"true\"\n className=\"text-muted-foreground/50 min-w-8 shrink-0 text-end tabular-nums select-none\"\n >\n {index + 1}\n </span>\n )}\n <span className=\"text-foreground/85 whitespace-pre\">\n {parseAnsi(line).map((span, spanIndex) => (\n <span key={spanIndex} className={span.className || undefined}>\n {span.text}\n </span>\n ))}\n </span>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\nexport { Terminal, parseAnsi }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "test-results",
3
+ "type": "registry:ui",
4
+ "title": "Test Results",
5
+ "description": "A test run with suites, tests and failure output. Failures expand by default and passes stay collapsed — nobody opens a report to read what passed.",
6
+ "category": "Development",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "fmt",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "spinner"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/test-results.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, ChevronDown, ChevronRight, CircleSlash, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Spinner } from '@/components/ui/spinner'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A test run: suites, their tests, and the output of anything that failed.\n *\n * Failures are expanded by default and passes are collapsed. Nobody opens a\n * test report to read what passed — the useful default is the one that puts the\n * failure output on screen without a click.\n *\n * Suite state is derived from its tests rather than passed in, so a suite\n * cannot claim to pass while containing a failure.\n */\nexport type TestStatus = 'passed' | 'failed' | 'skipped' | 'running'\n\nexport type TestCase = {\n id: string\n name: string\n status: TestStatus\n /** Seconds. */\n duration?: number\n error?: ReactNode\n}\n\nexport type TestSuite = {\n id: string\n name: string\n tests: TestCase[]\n}\n\nconst ICON = {\n passed: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]' },\n failed: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]' },\n skipped: { Icon: CircleSlash, tone: 'text-muted-foreground/60' },\n running: { Icon: null, tone: 'text-[var(--blue-soft-foreground)]' },\n} as const\n\nfunction suiteStatus(suite: TestSuite): TestStatus {\n if (suite.tests.some((test) => test.status === 'failed')) return 'failed'\n if (suite.tests.some((test) => test.status === 'running')) return 'running'\n if (suite.tests.every((test) => test.status === 'skipped')) return 'skipped'\n return 'passed'\n}\n\nfunction TestResults({\n suites,\n className,\n ...props\n}: ComponentProps<'div'> & { suites: TestSuite[] }) {\n // Failing suites open, passing ones closed — the useful default.\n const [expanded, setExpanded] = useState<string[]>(() =>\n suites.filter((suite) => suiteStatus(suite) === 'failed').map((suite) => suite.id),\n )\n\n const all = suites.flatMap((suite) => suite.tests)\n const tally = {\n passed: all.filter((test) => test.status === 'passed').length,\n failed: all.filter((test) => test.status === 'failed').length,\n skipped: all.filter((test) => test.status === 'skipped').length,\n }\n const total = all.reduce((sum, test) => sum + (test.duration ?? 0), 0)\n\n return (\n <div\n data-slot=\"test-results\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border-b p-3\">\n <span\n className={cn(\n 'text-sm font-medium',\n tally.failed > 0 ? 'text-[var(--destructive-soft-foreground)]' : 'text-[var(--green-soft-foreground)]',\n )}\n >\n {tally.failed > 0\n ? `${tally.failed} failing`\n : `${tally.passed} passing`}\n </span>\n <span className=\"text-muted-foreground text-xs\">\n {all.length} tests in {suites.length} suites\n </span>\n {tally.skipped > 0 && <Badge size=\"sm\">{tally.skipped} skipped</Badge>}\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n <Fmt type=\"duration\" value={total} />\n </span>\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {suites.map((suite) => {\n const status = suiteStatus(suite)\n const { Icon, tone } = ICON[status]\n const open = expanded.includes(suite.id)\n\n return (\n <li key={suite.id}>\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() =>\n setExpanded((current) =>\n open\n ? current.filter((id) => id !== suite.id)\n : [...current, suite.id],\n )\n }\n className={cn(\n 'hover:bg-accent/40 flex w-full items-center gap-2 px-3 py-2 text-start',\n interactive,\n focusRing,\n )}\n >\n {open ? (\n <ChevronDown className=\"text-muted-foreground size-3.5 shrink-0\" />\n ) : (\n <ChevronRight className=\"text-muted-foreground size-3.5 shrink-0\" />\n )}\n {Icon ? (\n <Icon className={cn('size-4 shrink-0', tone)} aria-hidden=\"true\" />\n ) : (\n <Spinner size=\"xs\" className={cn('shrink-0', tone)} label=\"Running\" />\n )}\n <span className=\"min-w-0 flex-1 truncate font-mono text-xs\">\n {suite.name}\n </span>\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n {suite.tests.length}\n </span>\n </button>\n\n {open && (\n <ul className=\"list-none\">\n {suite.tests.map((test) => {\n const testIcon = ICON[test.status]\n return (\n <li key={test.id} className=\"border-border/40 border-t\">\n <div className=\"flex items-center gap-2 py-1.5 pe-3 ps-9\">\n {testIcon.Icon ? (\n <testIcon.Icon\n className={cn('size-3.5 shrink-0', testIcon.tone)}\n aria-hidden=\"true\"\n />\n ) : (\n <Spinner size=\"xs\" className=\"shrink-0\" label=\"Running\" />\n )}\n <span\n className={cn(\n 'min-w-0 flex-1 text-sm',\n test.status === 'skipped' &&\n 'text-muted-foreground/60 line-through',\n )}\n >\n {test.name}\n </span>\n {test.duration !== undefined && (\n <span className=\"text-muted-foreground/70 shrink-0 text-xs tabular-nums\">\n <Fmt type=\"duration\" value={test.duration} />\n </span>\n )}\n </div>\n\n {test.error && (\n <div className=\"bg-[color-mix(in_oklab,var(--destructive),transparent_94%)] px-3 py-2 ps-9\">\n {test.error}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { TestResults, suiteStatus }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "textarea",
3
+ "type": "registry:ui",
4
+ "title": "Textarea",
5
+ "description": "The multi-line sibling of Input. Same variants, same padding rule, with a resize control and no icon slot.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "class-variance-authority@^0.7.1"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/textarea.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import {\n useCallback,\n useLayoutEffect,\n useRef,\n type ComponentProps,\n} from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { fieldBase, fieldInput } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The multi-line sibling of Input, and monochrome for the same reason.\n *\n * It styles the `<textarea>` directly rather than wrapping it: there is no icon\n * slot to place and no padding to click past, so a wrapper would add nothing.\n * Padding follows the same rule as a field — the leading side is the vertical\n * inset plus 4px — with the vertical inset applied literally, since height here\n * is content-driven rather than fixed.\n */\nconst textareaVariants = cva(\n [fieldBase, 'items-stretch', fieldInput, 'block'].join(' '),\n {\n variants: {\n variant: {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n },\n size: {\n sm: 'min-h-16 py-1.5 ps-2.5 pe-1.5 text-sm rounded-[var(--radius-control-sm)]',\n default:\n 'min-h-20 py-2 ps-3 pe-2 text-sm rounded-[var(--radius-control-md)]',\n lg: 'min-h-24 py-2.5 ps-3.5 pe-2.5 text-sm rounded-[var(--radius-control-lg)]',\n },\n // The native grip is never drawn — see `[data-slot='textarea']` in\n // index.css. Dragging the edge still works where the browser allows it.\n resize: {\n none: 'resize-none',\n vertical: 'resize-y',\n both: 'resize',\n },\n error: {\n true: 'border-destructive focus-within:border-destructive',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n resize: 'none',\n error: false,\n },\n },\n)\n\ntype TextareaProps = Omit<ComponentProps<'textarea'>, 'size'> &\n VariantProps<typeof textareaVariants> & {\n /** Grow with the content instead of scrolling. Forces `resize=\"none\"`. */\n autoResize?: boolean\n }\n\nfunction Textarea({\n className,\n variant,\n size,\n resize,\n error,\n autoResize = false,\n value,\n onChange,\n ref,\n ...props\n}: TextareaProps) {\n const innerRef = useRef<HTMLTextAreaElement>(null)\n\n const fit = useCallback(() => {\n const element = innerRef.current\n if (!element || !autoResize) return\n\n // Collapse first: scrollHeight only shrinks once the box is smaller than\n // its content, so measuring without this makes the field grow-only.\n element.style.height = 'auto'\n element.style.height = `${element.scrollHeight}px`\n }, [autoResize])\n\n // Layout effect, not effect: resizing after paint shows a one-frame jump.\n // Runs on mount and whenever a controlled value changes underneath us.\n useLayoutEffect(fit, [fit, value])\n\n return (\n <textarea\n data-slot=\"textarea\"\n ref={(node) => {\n innerRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) ref.current = node\n }}\n aria-invalid={error || undefined}\n value={value}\n onChange={(event) => {\n onChange?.(event)\n // Uncontrolled fields never change `value`, so measure here too.\n if (!onChange || value === undefined) fit()\n }}\n className={cn(\n textareaVariants({\n variant,\n size,\n resize: autoResize ? 'none' : resize,\n error,\n }),\n autoResize && 'overflow-hidden',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport { Textarea, textareaVariants }\nexport type { TextareaProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "theme-toggle",
3
+ "type": "registry:ui",
4
+ "title": "Theme Toggle",
5
+ "description": "Switches the document between light and dark. Uncontrolled by default: it reads the class already on the element rather than flashing to its own idea of the default on mount.",
6
+ "category": "Utility",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/theme-toggle.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import { useEffect, useState } from 'react'\nimport { Moon, Sun } from 'lucide-react'\nimport { Button, type ButtonProps } from '@/components/ui/button'\n\n/**\n * Switches the document between light and dark.\n *\n * The class goes on `document.documentElement`, not on a wrapper. `.dark`\n * defines the token values on the element it lands on and everything below\n * inherits them, so a wrapper deeper in the tree cannot switch a subtree back —\n * a descendant has no way to undo variables set above it.\n *\n * Uncontrolled by default: it reads the class already on the element, so it\n * agrees with whatever the page shipped with instead of flashing to its own\n * idea of the default on mount. Pass `dark`/`onDarkChange` when the app owns\n * the state.\n */\nfunction ThemeToggle({\n dark: darkProp,\n onDarkChange,\n variant = 'secondary',\n size = 'icon-sm',\n className,\n ...props\n}: Omit<ButtonProps, 'children' | 'onChange'> & {\n dark?: boolean\n onDarkChange?: (dark: boolean) => void\n}) {\n const controlled = darkProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(false)\n\n // Read the live class on mount rather than assuming a default.\n useEffect(() => {\n if (controlled) return\n setUncontrolled(document.documentElement.classList.contains('dark'))\n }, [controlled])\n\n const dark = controlled ? darkProp : uncontrolled\n\n useEffect(() => {\n document.documentElement.classList.toggle('dark', dark)\n }, [dark])\n\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={size}\n aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}\n aria-pressed={dark}\n className={className}\n onClick={() => {\n const next = !dark\n if (!controlled) setUncontrolled(next)\n onDarkChange?.(next)\n }}\n {...props}\n >\n {dark ? <Moon /> : <Sun />}\n </Button>\n )\n}\n\nexport { ThemeToggle }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "theme",
3
+ "type": "registry:theme",
4
+ "title": "Theme",
5
+ "description": "Colour tokens, the surface ladder, and the keyframes components rely on.",
6
+ "dependencies": [
7
+ "tailwindcss@^4.3.3",
8
+ "tw-animate-css@^1.4.0"
9
+ ],
10
+ "registryDependencies": [],
11
+ "files": [
12
+ {
13
+ "path": "styles/astralyx.css",
14
+ "type": "registry:theme",
15
+ "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n /* Tells the platform which way to render native controls, form widgets and\n default scrollbars. Without it a dark page still gets light checkboxes. */\n color-scheme: light;\n\n --radius: 1.25rem;\n\n /*\n * Control corner radii, tuned per control height rather than derived from\n * --radius: the shared scale's fixed offsets do not track height, so a step\n * that looks right on h-12 is a different proportion entirely on h-7.\n *\n * Each is half its control's height — the roundest a squircle can be before\n * the corners meet. Under `corner-shape: squircle` these render as iOS-style\n * continuous corners, which is the point; they are deliberately NOT pills.\n *\n * xs has no entry: that one size is a true `rounded-full` pill.\n */\n /*\n * Group radii. Concentric with the cards inside: a container's radius should\n * be the inner radius plus the padding between them, or the gap between the\n * two curves visibly narrows at the corners.\n *\n * card radius 30px (--radius-3xl) + the group's own padding\n */\n --radius-group-sm: 42px; /* 30 + 12 */\n --radius-group-md: 48px; /* 30 + 18 */\n --radius-group-lg: 54px; /* 30 + 24 */\n\n /* Small square indicators — a checkbox is too small for the control scale,\n where any step would round it into a circle. */\n --radius-check-xs: 2px; /* 8px box — diff squares, status dots */\n --radius-check-sm: 5px; /* 16px box */\n --radius-check-md: 6px; /* 20px box */\n --radius-check-lg: 8px; /* 24px box */\n\n --radius-control-sm: 16px; /* h-8 / 32px */\n --radius-control-md: 18px; /* h-9 / 36px */\n --radius-control-lg: 20px; /* h-10 / 40px */\n --radius-control-xl: 24px; /* h-12 / 48px */\n\n /*\n * How far a tinted control shifts its fill and text on hover. Negative\n * darkens, which is right on a light background; `.dark` flips the sign.\n * Consumed by `tintStyle()` through relative colour syntax.\n */\n --tint-shift: -0.08;\n /* How far a custom tint moves to become readable *text* on the page surface.\n The named colour sets ship a `-soft-foreground` for this; an arbitrary tint\n has to derive one, and the raw colour as text measures ~3.5:1. Larger than\n --tint-shift, which is only a hover nudge. */\n --tint-text-shift: -0.26;\n\n /*\n * Which way a pressed control moves. Hover already steps in the right\n * direction per theme; press continues in that same direction, so mixing\n * toward black on light and white on dark keeps one rule working for all\n * eight colour sets and for `tint`.\n */\n --press-shade: black;\n\n --background: oklch(1 0 0);\n --foreground: oklch(0.145 0 0);\n --card: oklch(1 0 0);\n --card-foreground: oklch(0.145 0 0);\n --popover: oklch(1 0 0);\n --popover-foreground: oklch(0.145 0 0);\n --primary: oklch(0.205 0 0);\n --primary-foreground: oklch(0.985 0 0);\n --primary-hover: oklch(0.32 0 0);\n --secondary: oklch(0.97 0 0);\n /* Recessed trays. Group sits above CardHeader so a card inside a group still\n reads as the raised element. */\n --card-header: oklch(0.988 0 0);\n --group: oklch(0.97 0 0);\n\n /* The sidebar is deliberately theme-independent: black ground, light ink, in\n both themes. Declared only here — repeating them under `.dark` is what\n would make the rail flip. */\n --sidebar: oklch(0.08 0 0);\n --sidebar-foreground: oklch(0.985 0 0);\n --secondary-foreground: oklch(0.205 0 0);\n --secondary-hover: oklch(0.93 0 0);\n --secondary-foreground-hover: oklch(0.08 0 0);\n --muted: oklch(0.97 0 0);\n /* Set by the worst surface it lands on, not the best. At 0.556 this cleared\n AA on --card (4.73:1) but missed on --secondary (4.34:1), which is where\n muted text actually sits most often — menu rows, tab triggers, field\n hints. 0.544 gives 4.56 on secondary and 4.98 on card. */\n --muted-foreground: oklch(0.544 0 0);\n --accent: oklch(0.97 0 0);\n --accent-foreground: oklch(0.205 0 0);\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --destructive-hover: oklch(0.51 0.235 27.325);\n --destructive-soft: oklch(0.95 0.04 27.325);\n --destructive-soft-foreground: oklch(0.47 0.2 27.325);\n --destructive-soft-hover: oklch(0.91 0.055 27.325);\n --destructive-soft-foreground-hover: oklch(0.4 0.21 27.325);\n --border: oklch(0.922 0 0);\n /* Border while a field has focus — more contrast, not literally darker: in\n dark mode \"darker\" would mean invisible, so it brightens instead. */\n --border-active: oklch(0.62 0 0);\n --input: oklch(0.922 0 0);\n --ring: oklch(0.708 0 0);\n\n /* Color sets: solid pair (--x / --x-foreground) plus a tinted pair. */\n --blue: oklch(0.55 0.19 258);\n --blue-foreground: oklch(0.985 0 0);\n --blue-soft: oklch(0.95 0.04 258);\n --blue-soft-foreground: oklch(0.45 0.16 258);\n --blue-hover: oklch(0.48 0.19 258);\n --blue-soft-hover: oklch(0.91 0.055 258);\n --blue-soft-foreground-hover: oklch(0.38 0.17 258);\n\n --violet: oklch(0.55 0.23 295);\n --violet-foreground: oklch(0.985 0 0);\n --violet-soft: oklch(0.95 0.045 295);\n --violet-soft-foreground: oklch(0.45 0.19 295);\n --violet-hover: oklch(0.48 0.22 295);\n --violet-soft-hover: oklch(0.91 0.06 295);\n --violet-soft-foreground-hover: oklch(0.38 0.2 295);\n\n /* 3.27:1 before — the worst of the set. */\n --cyan: oklch(0.53 0.12 210);\n --cyan-foreground: oklch(0.985 0 0);\n --cyan-soft: oklch(0.95 0.04 210);\n --cyan-soft-foreground: oklch(0.44 0.1 210);\n --cyan-hover: oklch(0.55 0.12 210);\n --cyan-soft-hover: oklch(0.91 0.05 210);\n --cyan-soft-foreground-hover: oklch(0.37 0.11 210);\n\n /* Darkened from 0.58 so white foreground text clears AA on a solid fill\n (3.84:1 before, 4.61 now). Amber solves the same problem the other way,\n with a dark foreground — it is too light to carry white at any usable\n saturation. */\n --green: oklch(0.535 0.15 150);\n --green-foreground: oklch(0.985 0 0);\n --green-soft: oklch(0.95 0.05 150);\n --green-soft-foreground: oklch(0.42 0.12 150);\n --green-hover: oklch(0.51 0.14 150);\n --green-soft-hover: oklch(0.91 0.065 150);\n --green-soft-foreground-hover: oklch(0.35 0.13 150);\n\n --amber: oklch(0.78 0.15 75);\n --amber-foreground: oklch(0.25 0.05 75);\n --amber-soft: oklch(0.95 0.06 80);\n --amber-soft-foreground: oklch(0.45 0.11 70);\n --amber-hover: oklch(0.72 0.16 75);\n --amber-soft-hover: oklch(0.91 0.08 80);\n --amber-soft-foreground-hover: oklch(0.38 0.12 70);\n\n /* 4.22:1 before. */\n --rose: oklch(0.575 0.21 15);\n --rose-foreground: oklch(0.985 0 0);\n --rose-soft: oklch(0.95 0.04 15);\n --rose-soft-foreground: oklch(0.47 0.19 15);\n --rose-hover: oklch(0.53 0.2 15);\n --rose-soft-hover: oklch(0.91 0.055 15);\n --rose-soft-foreground-hover: oklch(0.4 0.2 15);\n\n --chart-1: oklch(0.646 0.222 41.116);\n --chart-2: oklch(0.6 0.118 184.704);\n --chart-3: oklch(0.398 0.07 227.392);\n --chart-4: oklch(0.828 0.189 84.429);\n --chart-5: oklch(0.769 0.188 70.08);\n}\n\n.dark {\n color-scheme: dark;\n\n --background: oklch(0.08 0 0);\n --foreground: oklch(0.985 0 0);\n --card: oklch(0.13 0 0);\n --card-foreground: oklch(0.985 0 0);\n --popover: oklch(0.13 0 0);\n --popover-foreground: oklch(0.985 0 0);\n --primary: oklch(0.922 0 0);\n --primary-foreground: oklch(0.13 0 0);\n --primary-hover: oklch(0.82 0 0);\n --tint-shift: 0.08;\n --tint-text-shift: 0.18;\n --press-shade: white;\n --secondary: oklch(0.18 0 0);\n --card-header: oklch(0.19 0 0);\n --group: oklch(0.23 0 0);\n --secondary-foreground: oklch(0.985 0 0);\n --secondary-hover: oklch(0.24 0 0);\n --secondary-foreground-hover: oklch(1 0 0);\n --muted: oklch(0.18 0 0);\n --muted-foreground: oklch(0.708 0 0);\n --accent: oklch(0.2 0 0);\n --accent-foreground: oklch(0.985 0 0);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.16 0.02 22.216);\n --destructive-hover: oklch(0.77 0.17 22.216);\n --destructive-soft: oklch(0.704 0.191 22.216 / 18%);\n --destructive-soft-foreground: oklch(0.85 0.1 22.216);\n --destructive-soft-hover: oklch(0.704 0.191 22.216 / 30%);\n --destructive-soft-foreground-hover: oklch(0.92 0.08 22.216);\n --border: oklch(1 0 0 / 12%);\n --border-active: oklch(1 0 0 / 40%);\n --input: oklch(1 0 0 / 15%);\n --ring: oklch(0.556 0 0);\n\n --blue: oklch(0.62 0.19 258);\n --blue-foreground: oklch(0.16 0.02 258);\n --blue-soft: oklch(0.62 0.19 258 / 18%);\n --blue-soft-foreground: oklch(0.82 0.11 258);\n --blue-hover: oklch(0.7 0.17 258);\n --blue-soft-hover: oklch(0.62 0.19 258 / 30%);\n --blue-soft-foreground-hover: oklch(0.9 0.09 258);\n\n --violet: oklch(0.65 0.22 295);\n --violet-foreground: oklch(0.16 0.02 295);\n --violet-soft: oklch(0.65 0.22 295 / 18%);\n --violet-soft-foreground: oklch(0.84 0.11 295);\n --violet-hover: oklch(0.72 0.2 295);\n --violet-soft-hover: oklch(0.65 0.22 295 / 30%);\n --violet-soft-foreground-hover: oklch(0.91 0.09 295);\n\n --cyan: oklch(0.72 0.11 210);\n --cyan-foreground: oklch(0.16 0.02 210);\n --cyan-soft: oklch(0.72 0.11 210 / 18%);\n --cyan-soft-foreground: oklch(0.85 0.08 210);\n --cyan-hover: oklch(0.79 0.1 210);\n --cyan-soft-hover: oklch(0.72 0.11 210 / 30%);\n --cyan-soft-foreground-hover: oklch(0.92 0.06 210);\n\n --green: oklch(0.68 0.15 150);\n --green-foreground: oklch(0.16 0.02 150);\n --green-soft: oklch(0.68 0.15 150 / 18%);\n --green-soft-foreground: oklch(0.84 0.11 150);\n --green-hover: oklch(0.75 0.14 150);\n --green-soft-hover: oklch(0.68 0.15 150 / 30%);\n --green-soft-foreground-hover: oklch(0.91 0.09 150);\n\n --amber: oklch(0.8 0.15 75);\n --amber-foreground: oklch(0.2 0.04 75);\n --amber-soft: oklch(0.8 0.15 75 / 18%);\n --amber-soft-foreground: oklch(0.88 0.1 80);\n --amber-hover: oklch(0.86 0.14 75);\n --amber-soft-hover: oklch(0.8 0.15 75 / 30%);\n --amber-soft-foreground-hover: oklch(0.94 0.08 80);\n\n --rose: oklch(0.68 0.2 15);\n --rose-foreground: oklch(0.16 0.02 15);\n --rose-soft: oklch(0.68 0.2 15 / 18%);\n --rose-soft-foreground: oklch(0.85 0.1 15);\n --rose-hover: oklch(0.75 0.18 15);\n --rose-soft-hover: oklch(0.68 0.2 15 / 30%);\n --rose-soft-foreground-hover: oklch(0.92 0.08 15);\n\n --chart-1: oklch(0.488 0.243 264.376);\n --chart-2: oklch(0.696 0.17 162.48);\n --chart-3: oklch(0.769 0.188 70.08);\n --chart-4: oklch(0.627 0.265 303.9);\n --chart-5: oklch(0.645 0.246 16.439);\n}\n\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-border: var(--border);\n --color-input: var(--input);\n --color-ring: var(--ring);\n\n --color-blue: var(--blue);\n --color-blue-foreground: var(--blue-foreground);\n --color-blue-soft: var(--blue-soft);\n --color-blue-soft-foreground: var(--blue-soft-foreground);\n --color-violet: var(--violet);\n --color-violet-foreground: var(--violet-foreground);\n --color-violet-soft: var(--violet-soft);\n --color-violet-soft-foreground: var(--violet-soft-foreground);\n --color-cyan: var(--cyan);\n --color-cyan-foreground: var(--cyan-foreground);\n --color-cyan-soft: var(--cyan-soft);\n --color-cyan-soft-foreground: var(--cyan-soft-foreground);\n --color-green: var(--green);\n --color-green-foreground: var(--green-foreground);\n --color-green-soft: var(--green-soft);\n --color-green-soft-foreground: var(--green-soft-foreground);\n --color-amber: var(--amber);\n --color-amber-foreground: var(--amber-foreground);\n --color-amber-soft: var(--amber-soft);\n --color-amber-soft-foreground: var(--amber-soft-foreground);\n --color-rose: var(--rose);\n --color-rose-foreground: var(--rose-foreground);\n --color-rose-soft: var(--rose-soft);\n --color-rose-soft-foreground: var(--rose-soft-foreground);\n\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n\n --radius-xs: calc(var(--radius) - 8px);\n --radius-sm: calc(var(--radius) - 6px);\n --radius-md: calc(var(--radius) - 4px);\n --radius-lg: calc(var(--radius) - 2px);\n --radius-xl: var(--radius);\n --radius-2xl: calc(var(--radius) + 4px);\n --radius-3xl: calc(var(--radius) + 10px);\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n }\n\n /*\n * iOS-style continuous corners. `corner-shape: squircle` reshapes whatever\n * `border-radius` already says, so every rounded thing in the kit — and the\n * focus ring, which follows the same shape — becomes a squircle with no per\n * component work. Browsers without it keep circular corners, which is why the\n * radius scale still has to look right on its own.\n *\n * This MUST stay inside `@layer base`. Unlayered declarations outrank every\n * layered one, so as a top-level rule it silently beat the\n * `[corner-shape:round]` utility that xs controls use to opt back out into a\n * true pill.\n */\n @supports (corner-shape: squircle) {\n *,\n *::before,\n *::after {\n corner-shape: squircle;\n }\n\n /*\n * Fully rounded means a real circle or pill, never a squircle.\n *\n * A superellipse at 50% radius is a rounded rectangle, so without this a\n * spinner renders as a cube, progress bars get square ends, and every dot\n * turns into a tiny box. Higher specificity than the `*` rule above, so it\n * wins without !important.\n */\n .rounded-full {\n corner-shape: round;\n }\n }\n\n body {\n @apply bg-background text-foreground;\n font-synthesis: none;\n text-rendering: optimizeLegibility;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n}\n\n/* Shiki dual-theme output: both palettes ship in the markup as CSS variables,\n so switching themes needs no re-highlight. See src/lib/highlighter.ts. */\n.shiki,\n.shiki span {\n color: var(--shiki-light);\n background-color: var(--shiki-light-bg);\n}\n\n.dark .shiki,\n.dark .shiki span {\n color: var(--shiki-dark);\n background-color: var(--shiki-dark-bg);\n}\n\n/* Line numbers render in a pseudo-element gutter; the editable overlay in\n CodeBlock reserves the same 3.5ch on its left edge to stay aligned. */\n.code-block--numbers .shiki code {\n counter-reset: code-line;\n}\n\n.code-block--numbers .shiki .line {\n counter-increment: code-line;\n}\n\n.code-block--numbers .shiki .line::before {\n content: counter(code-line);\n display: inline-block;\n width: 2ch;\n margin-left: -3.5ch;\n margin-right: 1.5ch;\n text-align: right;\n color: var(--muted-foreground);\n opacity: 0.5;\n}\n\n.shiki .line[data-highlighted] {\n display: inline-block;\n width: 100%;\n /* Marker bar drawn as a gradient rather than an inset box-shadow, which the\n kit avoids; a border would shift the code by 2px. */\n background-color: var(--accent);\n background-image: linear-gradient(\n to right,\n var(--ring) 0 2px,\n transparent 2px\n );\n}\n\n/*\n * Range input. The native track and thumb only take styles through vendor\n * pseudo-elements, which Tailwind cannot target, so they live here. Size and\n * fill come from variables the Slider component sets per instance.\n */\n.slider {\n appearance: none;\n background: transparent;\n cursor: pointer;\n}\n\n.slider::-webkit-slider-runnable-track {\n height: var(--slider-track);\n border-radius: 999px;\n corner-shape: round;\n background: linear-gradient(\n to right,\n var(--foreground) 0 var(--slider-progress, 0%),\n var(--secondary) var(--slider-progress, 0%) 100%\n );\n}\n\n.slider::-moz-range-track {\n height: var(--slider-track);\n border-radius: 999px;\n corner-shape: round;\n background: var(--secondary);\n}\n\n.slider::-moz-range-progress {\n height: var(--slider-track);\n border-radius: 999px;\n corner-shape: round;\n background: var(--foreground);\n}\n\n.slider::-webkit-slider-thumb {\n appearance: none;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: 999px;\n corner-shape: round;\n background: var(--background);\n border: 2px solid var(--foreground);\n /* Centre the thumb on the track rather than on the input box. */\n margin-top: calc((var(--slider-track) - var(--slider-thumb)) / 2);\n transition: border-color 150ms ease-out;\n}\n\n.slider::-moz-range-thumb {\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: 999px;\n corner-shape: round;\n background: var(--background);\n border: 2px solid var(--foreground);\n}\n\n.slider:hover::-webkit-slider-thumb,\n.slider:focus-visible::-webkit-slider-thumb {\n border-color: var(--border-active);\n}\n\n.slider:disabled {\n cursor: not-allowed;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .slider::-webkit-slider-thumb {\n transition: none;\n }\n}\n\n/*\n * Hide the native resize grip. It is drawn by the browser in the bottom-right\n * corner and cannot be styled, so it never matches the kit — and it collides\n * with the rounded corner. Dragging the edge still resizes where `resize` is\n * not `none`; only the icon goes.\n */\n[data-slot='textarea']::-webkit-resizer {\n display: none;\n}\n\n/*\n * Native <dialog> backdrop. It is a pseudo-element in the top layer, so it\n * cannot be reached from a class — these rules are the only way to style it.\n */\ndialog::backdrop {\n background-color: color-mix(in oklab, black, transparent 55%);\n animation: dialog-backdrop-in 150ms ease-out;\n}\n\ndialog[data-slot='sheet']::backdrop {\n background-color: color-mix(in oklab, black, transparent 60%);\n}\n\n@keyframes dialog-backdrop-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes dialog-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes sheet-in-right {\n from {\n translate: 100% 0;\n }\n}\n\n@keyframes sheet-in-left {\n from {\n translate: -100% 0;\n }\n}\n\n@keyframes sheet-in-top {\n from {\n translate: 0 -100%;\n }\n}\n\n@keyframes sheet-in-bottom {\n from {\n translate: 0 100%;\n }\n}\n\ndialog[open] {\n animation: dialog-in 150ms ease-out;\n}\n\ndialog[open][data-side='right'] {\n animation: sheet-in-right 200ms ease-out;\n}\n\ndialog[open][data-side='left'] {\n animation: sheet-in-left 200ms ease-out;\n}\n\ndialog[open][data-side='top'] {\n animation: sheet-in-top 200ms ease-out;\n}\n\ndialog[open][data-side='bottom'] {\n animation: sheet-in-bottom 200ms ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n dialog[open],\n dialog[open][data-side],\n dialog::backdrop {\n animation: none;\n }\n}\n\n/* Indeterminate progress: a bar that sweeps rather than fills. */\n@keyframes progress-indeterminate {\n 0% {\n translate: -100% 0;\n }\n 100% {\n translate: 350% 0;\n }\n}\n\n/* Toasts arrive from the edge they are anchored to. */\n@keyframes toast-in {\n from {\n opacity: 0;\n translate: 0 0.5rem;\n }\n}\n\n/*\n * Custom scrollbars, everywhere.\n *\n * Applied globally rather than to one component: every overflowing surface in\n * the kit — a menu, a code block, a table wrapper, the sidebar, the page — gets\n * the same bar, so nothing drops back to the platform default.\n *\n * The two mechanisms are kept apart on purpose. Chromium ignores every\n * `::-webkit-scrollbar` rule the moment `scrollbar-color` is set to anything but\n * `auto`, so declaring both means the shaped version silently never applies\n * there. The `@supports` query hands Chromium and Safari the pseudo-elements —\n * which can do the rounded, inset thumb — and gives Firefox the standard\n * properties, which are all it understands.\n *\n * No JavaScript overlay anywhere, so native momentum, keyboard scrolling and\n * accessibility stay intact.\n */\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track,\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n background-color: var(--border);\n border-radius: 999px;\n corner-shape: round;\n /* Transparent border plus content-box clipping insets the thumb, so it reads\n as a 4px pill rather than filling the whole gutter. */\n border: 3px solid transparent;\n background-clip: content-box;\n transition: background-color 150ms ease-out;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background-color: var(--border-active);\n}\n\n@supports not selector(::-webkit-scrollbar) {\n :root {\n /* Inherited, so this reaches every scrollable descendant. */\n scrollbar-color: var(--border) transparent;\n }\n\n * {\n /* Not inherited — it has to be set on each scroll container. */\n scrollbar-width: thin;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *::-webkit-scrollbar-thumb {\n transition: none;\n }\n}\n\n/*\n * Astralyx logo blink. Ported from the Lottie timeline on astralyx.dev: two\n * quick blinks, then a longer one where the left eye only closes to 22% — the\n * wink is in the original, not an accident.\n *\n * `transform-box: view-box` resolves transform-origin in viewBox units, so each\n * eyelid pivots on its own hinge rather than the SVG's top-left corner.\n */\n.ax-eyelid-l,\n.ax-eyelid-r {\n transform-box: view-box;\n}\n\n.ax-eyelid-r {\n transform-origin: 25.52px 31.14px;\n animation: ax-blink-r 3s steps(1, end) infinite;\n}\n\n.ax-eyelid-l {\n transform-origin: 9.94px 30.24px;\n animation: ax-blink-l 3s steps(1, end) infinite;\n}\n\n@keyframes ax-blink-r {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(1); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@keyframes ax-blink-l {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(0.22); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ax-eyelid-l,\n .ax-eyelid-r {\n animation: none;\n transform: scaleY(0);\n }\n}\n\n/* The pending-reply dots. Opacity only, so it sits inside the motion rule. */\n@keyframes message-dot {\n 0%, 60%, 100% { opacity: 0.3; }\n 30% { opacity: 1; }\n}\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "thread-list",
3
+ "type": "registry:ui",
4
+ "title": "Thread List",
5
+ "description": "An inbox of conversations. Unread is carried by weight and a count, not by colour alone, and the timestamp coarsens with age — a message from March does not need a clock time.",
6
+ "category": "Messaging",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "avatar",
12
+ "badge",
13
+ "fmt",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/thread-list.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Paperclip, Users } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A conversation list — inbox, DMs, support threads.\n *\n * Unread is carried by weight and a dot, never by colour alone. A list where\n * unread rows are simply a shade darker is unreadable to a good share of people\n * and invisible in bright sunlight, which is where phones are used.\n *\n * The preview is the last message regardless of who sent it, prefixed with\n * \"You:\" when it was yours. A list that only previews incoming messages leaves\n * a thread you just replied to looking untouched.\n */\nexport type Thread = {\n id: string\n title: ReactNode\n /** Last message, whoever sent it. */\n preview?: ReactNode\n /** True when the last message is the current user's. */\n fromMe?: boolean\n at?: Date\n unread?: number\n participants?: string[]\n avatar?: ReactNode\n attachments?: number\n badge?: ReactNode\n muted?: boolean\n}\n\nfunction ThreadList({\n threads,\n selected,\n onSelect,\n now,\n locale = 'en-GB',\n emptyLabel = 'No conversations',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n threads: Thread[]\n selected?: string\n onSelect?: (id: string) => void\n now?: Date\n locale?: string\n emptyLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"thread-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {threads.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {threads.map((thread) => {\n const unread = (thread.unread ?? 0) > 0\n const active = selected === thread.id\n\n return (\n <li key={thread.id}>\n <button\n type=\"button\"\n aria-current={active ? 'true' : undefined}\n onClick={() => onSelect?.(thread.id)}\n className={cn(\n 'hover:bg-accent/40 flex w-full items-start gap-3 p-3 text-start',\n interactive,\n focusRing,\n active && 'bg-accent/60',\n )}\n >\n <span className=\"relative mt-0.5 shrink-0\">\n {thread.avatar ??\n (thread.participants && thread.participants.length > 1 ? (\n <span className=\"bg-secondary text-muted-foreground flex size-8 items-center justify-center rounded-full [corner-shape:round]\">\n <Users className=\"size-4\" />\n </span>\n ) : (\n <Avatar size=\"sm\" name={thread.participants?.[0] ?? String(thread.title)} />\n ))}\n {/* A dot as well as weight — colour alone is not enough. */}\n {unread && !thread.muted && (\n <span\n aria-hidden=\"true\"\n className=\"bg-primary ring-background absolute -end-0.5 -top-0.5 size-2.5 rounded-full ring-2 [corner-shape:round]\"\n />\n )}\n </span>\n\n <span className=\"min-w-0 flex-1\">\n <span className=\"flex items-baseline gap-2\">\n <span\n className={cn(\n 'min-w-0 flex-1 truncate text-sm',\n unread && !thread.muted ? 'font-semibold' : 'font-medium',\n )}\n >\n {thread.title}\n </span>\n {thread.at && (\n <span className=\"text-muted-foreground shrink-0 text-xs whitespace-nowrap\">\n <Fmt type=\"relative\" value={thread.at} now={now} locale={locale} />\n </span>\n )}\n </span>\n\n <span className=\"mt-0.5 flex items-center gap-1.5\">\n {thread.attachments ? (\n <Paperclip className=\"text-muted-foreground size-3 shrink-0\" aria-hidden=\"true\" />\n ) : null}\n <span\n className={cn(\n 'min-w-0 flex-1 truncate text-xs',\n unread && !thread.muted ? 'text-foreground' : 'text-muted-foreground',\n )}\n >\n {/* Prefixed when it was yours, so a thread you just\n replied to does not look untouched. */}\n {thread.fromMe && <span className=\"text-muted-foreground\">You: </span>}\n {thread.preview}\n </span>\n\n {thread.badge}\n {unread && (\n <Badge size=\"sm\" color={thread.muted ? 'neutral' : 'blue'} className=\"shrink-0\">\n {thread.unread}\n </Badge>\n )}\n </span>\n </span>\n </button>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { ThreadList }\n"
22
+ }
23
+ ]
24
+ }