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,21 @@
1
+ {
2
+ "name": "branch-select",
3
+ "type": "registry:ui",
4
+ "title": "Branch Select",
5
+ "description": "A branch and tag picker. A Combobox underneath — repositories routinely have hundreds of branches, so search is not optional — with the default branch pinned first and tags labelled.",
6
+ "category": "Development",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "combobox",
12
+ "lib-styles"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/branch-select.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { useMemo, type ComponentProps } from 'react'\nimport { GitBranch, Tag } from 'lucide-react'\nimport { Combobox } from '@/components/ui/combobox'\nimport type { fieldSize } from '@/lib/styles'\n\n/**\n * A branch and tag picker.\n *\n * A Combobox underneath — repositories routinely have hundreds of branches, so\n * search is not optional — with the domain knowledge on top: the default branch\n * is pinned first, protected branches are marked, and tags are labelled so they\n * are not mistaken for branches.\n */\nexport type BranchOption = {\n name: string\n kind?: 'branch' | 'tag'\n isDefault?: boolean\n protected?: boolean\n}\n\nfunction BranchSelect({\n branches,\n value,\n defaultValue,\n onValueChange,\n size = 'sm',\n placeholder = 'Switch branch',\n ...props\n}: Omit<ComponentProps<typeof Combobox>, 'options' | 'size'> & {\n branches: BranchOption[]\n size?: keyof typeof fieldSize\n}) {\n const options = useMemo(() => {\n // Default first, then branches, then tags — the order people scan in.\n const ranked = [...branches].sort((a, b) => {\n if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1\n const kind = (x: BranchOption) => (x.kind === 'tag' ? 1 : 0)\n if (kind(a) !== kind(b)) return kind(a) - kind(b)\n return a.name.localeCompare(b.name)\n })\n\n return ranked.map((branch) => ({\n value: branch.name,\n label: [\n branch.name,\n branch.isDefault ? '· default' : '',\n branch.protected ? '· protected' : '',\n branch.kind === 'tag' ? '· tag' : '',\n ]\n .filter(Boolean)\n .join(' '),\n }))\n }, [branches])\n\n return (\n <Combobox\n options={options}\n value={value}\n defaultValue={defaultValue}\n onValueChange={onValueChange}\n size={size}\n placeholder={placeholder}\n searchPlaceholder=\"Find a branch or tag…\"\n emptyMessage=\"Nothing matches\"\n {...props}\n />\n )\n}\n\n/** The icon that belongs beside a ref of this kind. */\nfunction RefIcon({ kind }: { kind?: 'branch' | 'tag' }) {\n return kind === 'tag' ? <Tag /> : <GitBranch />\n}\n\nexport { BranchSelect, RefIcon }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "breadcrumb",
3
+ "type": "registry:ui",
4
+ "title": "Breadcrumb",
5
+ "description": "The trail back up the hierarchy. An ordered list in a labelled nav, with the current page marked aria-current and deliberately not linked.",
6
+ "category": "Navigation",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "primitive-slot"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/breadcrumb.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Slot } from '@/components/primitives/slot'\nimport { ChevronRight, MoreHorizontal } from 'lucide-react'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The trail back up the hierarchy.\n *\n * An ordered list inside a labelled `<nav>`, because the order carries meaning.\n * The current page is a `<span aria-current=\"page\">`, not a link — linking to\n * where you already are is noise for a screen reader.\n */\nfunction Breadcrumb({\n className,\n label = 'Breadcrumb',\n ...props\n}: ComponentProps<'nav'> & {\n children: ReactNode\n /** Accessible name for the nav landmark. */\n label?: string\n}) {\n return (\n <nav\n aria-label={label}\n data-slot=\"breadcrumb\"\n className={cn('w-full', className)}\n {...props}\n />\n )\n}\n\nfunction BreadcrumbList({ className, ...props }: ComponentProps<'ol'>) {\n return (\n <ol\n data-slot=\"breadcrumb-list\"\n className={cn(\n 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction BreadcrumbItem({ className, ...props }: ComponentProps<'li'>) {\n return (\n <li\n data-slot=\"breadcrumb-item\"\n className={cn('inline-flex items-center gap-1.5', className)}\n {...props}\n />\n )\n}\n\n/**\n * `asChild` renders the caller's own element — a router link, almost always.\n * A breadcrumb whose crumbs are plain `<a>` tags triggers a full page load in\n * a client-routed app, which is the one thing a breadcrumb must not do.\n */\nfunction BreadcrumbLink({\n className,\n asChild = false,\n ...props\n}: ComponentProps<'a'> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'a'\n\n return (\n <Comp\n data-slot=\"breadcrumb-link\"\n className={cn(\n 'hover:text-foreground px-0.5 transition-colors duration-150 ease-out motion-reduce:transition-none',\n radius.xs,\n focusRing,\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction BreadcrumbPage({ className, ...props }: ComponentProps<'span'>) {\n return (\n <span\n data-slot=\"breadcrumb-page\"\n role=\"link\"\n aria-disabled=\"true\"\n aria-current=\"page\"\n className={cn('text-foreground font-medium', className)}\n {...props}\n />\n )\n}\n\nfunction BreadcrumbSeparator({\n className,\n children,\n ...props\n}: ComponentProps<'li'>) {\n return (\n <li\n role=\"presentation\"\n aria-hidden=\"true\"\n data-slot=\"breadcrumb-separator\"\n // Sizes an icon child; a text child (a slash, a chevron glyph) inherits\n // the list's own size and colour, which is what you want for punctuation.\n className={cn('select-none [&>svg]:size-3.5', className)}\n {...props}\n >\n {children ?? <ChevronRight />}\n </li>\n )\n}\n\nfunction BreadcrumbEllipsis({\n className,\n label = 'More',\n ...props\n}: ComponentProps<'span'> & {\n /** Screen-reader text for the collapsed items. */\n label?: ReactNode\n}) {\n return (\n <span\n role=\"presentation\"\n aria-hidden=\"true\"\n data-slot=\"breadcrumb-ellipsis\"\n className={cn('flex size-5 items-center justify-center', className)}\n {...props}\n >\n <MoreHorizontal className=\"size-3.5\" />\n <span className=\"sr-only\">{label}</span>\n </span>\n )\n}\n\nexport {\n Breadcrumb,\n BreadcrumbEllipsis,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbList,\n BreadcrumbPage,\n BreadcrumbSeparator,\n}\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "bridge-status",
3
+ "type": "registry:ui",
4
+ "title": "Bridge Status",
5
+ "description": "A cross-chain transfer as its two legs. Not one progress bar: the legs fail independently and the second cannot begin until the first is final, so a single bar implies a state that does not exist.",
6
+ "category": "Crypto",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "fmt",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "progress",
15
+ "spinner"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/bridge-status.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ArrowRight, Check, TriangleAlert } from 'lucide-react'\nimport { Progress } from '@/components/ui/progress'\nimport { Spinner } from '@/components/ui/spinner'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A cross-chain transfer, shown as its two legs.\n *\n * Two legs, not one progress bar, because they fail independently and the\n * second cannot begin until the first is final. A single bar implies a transfer\n * that can be at 60% — it cannot; it is either waiting on source finality or\n * waiting on destination execution, and those have very different remedies.\n *\n * The waiting state names what is being waited on. \"Pending\" for twenty minutes\n * with no explanation is the most common bridge support ticket; \"waiting for 64\n * source confirmations\" is the same wait with the anxiety removed.\n */\nexport type BridgeLeg = {\n chain: ReactNode\n state: 'pending' | 'active' | 'done' | 'failed'\n /** e.g. \"12 / 64 confirmations\" */\n detail?: ReactNode\n progress?: number\n href?: string\n}\n\nfunction BridgeStatus({\n source,\n destination,\n amount,\n startedAt,\n now,\n estimate,\n error,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n source: BridgeLeg\n destination: BridgeLeg\n amount?: ReactNode\n startedAt?: Date\n now?: Date\n /** Expected total time, e.g. \"~14 min\". */\n estimate?: ReactNode\n error?: ReactNode\n}) {\n const Leg = ({ leg, label }: { leg: BridgeLeg; label: string }) => (\n <div className={cn('bg-secondary/60 flex flex-1 flex-col gap-2 p-3', radius.control)}>\n <div className=\"flex items-center gap-2\">\n <span\n className={cn(\n 'flex size-5 shrink-0 items-center justify-center rounded-full [corner-shape:round]',\n leg.state === 'done' && 'bg-[var(--green)] text-[var(--green-foreground)]',\n leg.state === 'failed' && 'bg-[var(--destructive)] text-[var(--destructive-foreground)]',\n leg.state === 'pending' && 'bg-border text-muted-foreground',\n )}\n >\n {leg.state === 'done' ? (\n <Check className=\"size-3\" />\n ) : leg.state === 'failed' ? (\n <TriangleAlert className=\"size-3\" />\n ) : leg.state === 'active' ? (\n <Spinner size=\"xs\" label={label} />\n ) : null}\n </span>\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{leg.chain}</span>\n </div>\n\n <p className=\"text-muted-foreground text-xs\">{label}</p>\n\n {/* Name the wait — \"pending\" with no explanation is the support ticket. */}\n {leg.detail && (\n <p className=\"text-xs tabular-nums\">{leg.detail}</p>\n )}\n {leg.progress !== undefined && leg.state === 'active' && (\n <Progress value={leg.progress * 100} className=\"h-1\" />\n )}\n </div>\n )\n\n return (\n <div\n data-slot=\"bridge-status\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <span className=\"text-sm font-medium\">Bridging {amount}</span>\n {estimate && (\n <span className=\"text-muted-foreground text-xs\">{estimate} expected</span>\n )}\n </div>\n\n <div className=\"flex items-stretch gap-2\">\n <Leg leg={source} label=\"Source\" />\n <span className=\"text-muted-foreground/40 flex shrink-0 items-center\" aria-hidden=\"true\">\n <ArrowRight className=\"size-4\" />\n </span>\n <Leg leg={destination} label=\"Destination\" />\n </div>\n\n {error && (\n <p className=\"text-[var(--destructive-soft-foreground)] text-xs\">{error}</p>\n )}\n\n {startedAt && (\n <p className=\"text-muted-foreground/80 text-xs\">\n Started <Fmt type=\"relative\" value={startedAt} now={now} />\n </p>\n )}\n </div>\n )\n}\n\nexport { BridgeStatus }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "build-log",
3
+ "type": "registry:ui",
4
+ "title": "Build Log",
5
+ "description": "Steps with their own output and timing bars. Failed steps open by default and successful ones stay closed — nobody opens a build log to read the steps that worked.",
6
+ "category": "Build & Perf",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "spinner",
14
+ "terminal"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/build-log.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, ChevronDown, ChevronRight, Clock, X } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { Terminal } from '@/components/ui/terminal'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A build's steps, each with its own output and duration.\n *\n * Failed steps open by default and successful ones stay closed. A build log is\n * opened for exactly one reason and it is not to read the successful steps; a\n * fully-expanded log means scrolling past 4,000 lines of dependency\n * installation to find the error.\n *\n * Durations get a bar relative to the slowest step, so the step that is making\n * the build slow is visible without reading every number. That is the second\n * reason anyone opens a build log.\n *\n * A running step shows a spinner and no duration rather than a duration that\n * ticks; a number that changes while you read it is harder to compare than one\n * that is simply absent until final.\n */\nexport type BuildStepStatus = 'success' | 'failed' | 'running' | 'skipped' | 'pending'\n\nexport type BuildStep = {\n id: string\n name: ReactNode\n status: BuildStepStatus\n /** Milliseconds. Omit while running. */\n duration?: number\n output?: string\n /** Overrides the default open/closed rule. */\n defaultOpen?: boolean\n}\n\nconst STATUS = {\n success: { Icon: Check, color: 'var(--green-soft-foreground)', label: 'Succeeded' },\n failed: { Icon: X, color: 'var(--destructive-soft-foreground)', label: 'Failed' },\n running: { Icon: Clock, color: 'var(--blue-soft-foreground)', label: 'Running' },\n skipped: { Icon: ChevronRight, color: 'var(--muted-foreground)', label: 'Skipped' },\n pending: { Icon: Clock, color: 'var(--muted-foreground)', label: 'Pending' },\n} as const\n\nfunction formatDuration(ms: number) {\n if (ms < 1000) return `${Math.round(ms)}ms`\n if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`\n return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`\n}\n\nfunction BuildLog({\n steps,\n totalLabel = 'Total',\n runningLabel = 'Running',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n steps: BuildStep[]\n totalLabel?: ReactNode\n /** Accessible name for the in-progress spinner. */\n runningLabel?: string\n}) {\n // Failures open, successes closed — nobody opens a build log for the wins.\n const [open, setOpen] = useState<string[]>(() =>\n steps.filter((s) => s.defaultOpen ?? s.status === 'failed').map((s) => s.id),\n )\n\n const slowest = useMemo(\n () => steps.reduce((max, step) => Math.max(max, step.duration ?? 0), 0),\n [steps],\n )\n const total = steps.reduce((sum, step) => sum + (step.duration ?? 0), 0)\n\n return (\n <div\n data-slot=\"build-log\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <ul className=\"divide-border/60 list-none divide-y\">\n {steps.map((step) => {\n const meta = STATUS[step.status]\n const Icon = meta.Icon\n const expanded = open.includes(step.id)\n const hasOutput = Boolean(step.output)\n\n return (\n <li key={step.id}>\n <button\n type=\"button\"\n aria-expanded={hasOutput ? expanded : undefined}\n disabled={!hasOutput}\n onClick={() =>\n setOpen((current) =>\n current.includes(step.id)\n ? current.filter((id) => id !== step.id)\n : [...current, step.id],\n )\n }\n className={cn(\n 'flex w-full items-center gap-2.5 p-3 text-start',\n hasOutput && interactive,\n focusRing,\n )}\n >\n {hasOutput ? (\n expanded ? (\n <ChevronDown className=\"text-muted-foreground size-3.5 shrink-0\" aria-hidden=\"true\" />\n ) : (\n <ChevronRight className=\"text-muted-foreground size-3.5 shrink-0\" aria-hidden=\"true\" />\n )\n ) : (\n <span className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n )}\n\n {step.status === 'running' ? (\n <Spinner size=\"xs\" label={runningLabel} className=\"shrink-0\" />\n ) : (\n <Icon className=\"size-4 shrink-0\" style={{ color: meta.color }} aria-label={meta.label} />\n )}\n\n <span\n className={cn(\n 'min-w-0 flex-1 truncate text-sm',\n step.status === 'skipped' && 'text-muted-foreground line-through',\n )}\n >\n {step.name}\n </span>\n\n {/* Where the time went, without reading every number. */}\n {step.duration !== undefined && slowest > 0 && (\n <span className=\"hidden h-1 w-20 shrink-0 overflow-hidden rounded-full bg-[var(--secondary)] sm:block\">\n <span\n className=\"block h-full\"\n style={{\n width: `${(step.duration / slowest) * 100}%`,\n background: step.duration === slowest ? 'var(--amber)' : 'var(--muted-foreground)',\n }}\n />\n </span>\n )}\n\n <span className=\"text-muted-foreground w-16 shrink-0 text-end text-xs tabular-nums\">\n {step.duration !== undefined ? formatDuration(step.duration) : ''}\n </span>\n </button>\n\n {expanded && step.output && (\n <div className=\"border-border/60 border-t p-3\">\n <Terminal content={step.output} showLineNumbers copyable />\n </div>\n )}\n </li>\n )\n })}\n </ul>\n\n {total > 0 && (\n <div className=\"border-border text-muted-foreground flex items-center justify-between border-t p-3 text-xs\">\n <span>{totalLabel}</span>\n <span className=\"font-medium tabular-nums\">{formatDuration(total)}</span>\n </div>\n )}\n </div>\n )\n}\n\nexport { BuildLog, formatDuration as formatBuildDuration }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "bulk-action-bar",
3
+ "type": "registry:ui",
4
+ "title": "Bulk Action Bar",
5
+ "description": "The bar that appears once rows are selected. It states the count in words next to the destructive button, because \"Delete\" and \"Delete 4,812 records\" deserve different amounts of hesitation.",
6
+ "category": "Admin",
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/bulk-action-bar.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { X } from 'lucide-react'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The bar that appears once rows are selected.\n *\n * Floating over the content rather than pushing it down. A bar that inserts\n * itself into the flow shifts every row the moment you tick a checkbox, and the\n * second checkbox you aim for has moved — the classic way to select the wrong\n * thing.\n *\n * Destructive actions state the count in the label. \"Delete\" and \"Delete 240\n * users\" deserve different amounts of hesitation, and the number is the only\n * thing that supplies it.\n */\nfunction BulkActionBar({\n count,\n onClear,\n children,\n label,\n floating = true,\n barLabel = 'Bulk actions',\n clearLabel = 'Clear selection',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n count: number\n onClear?: () => void\n children?: ReactNode\n label?: (count: number) => ReactNode\n /** Overlay the content instead of taking layout space. */\n floating?: boolean\n /** Accessible name for the bar itself. */\n barLabel?: string\n clearLabel?: string\n}) {\n if (count === 0) return null\n\n return (\n <div\n role=\"region\"\n aria-label={barLabel}\n data-slot=\"bulk-action-bar\"\n className={cn(\n 'bg-foreground text-background flex flex-wrap items-center gap-3 p-3',\n radius.control,\n // Overlaid, so ticking a box never moves the next one.\n floating &&\n 'fixed inset-x-4 bottom-4 z-40 mx-auto w-fit max-w-[calc(100%-2rem)] sm:inset-x-auto sm:start-1/2 sm:-translate-x-1/2',\n className,\n )}\n {...props}\n >\n <span className=\"text-sm font-medium whitespace-nowrap\">\n {label ? label(count) : `${count} selected`}\n </span>\n\n <span className=\"flex flex-wrap items-center gap-1.5\">{children}</span>\n\n {onClear && (\n <button\n type=\"button\"\n onClick={onClear}\n aria-label={clearLabel}\n className={cn(\n 'text-background/70 hover:text-background ms-1 flex size-6 shrink-0 items-center justify-center',\n radius.xs,\n 'focus-visible:ring-background/50 outline-none focus-visible:ring-2',\n )}\n >\n <X className=\"size-3.5\" />\n </button>\n )}\n </div>\n )\n}\n\nexport { BulkActionBar }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "bundle-treemap",
3
+ "type": "registry:ui",
4
+ "title": "Bundle Treemap",
5
+ "description": "Bundle size by module, laid out by compressed size. Raw bytes overstate text-heavy modules dramatically — a 400KB locale file that gzips to 12KB dominates a raw treemap and barely matters on the wire.",
6
+ "category": "Build & Perf",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "fmt",
10
+ "lib-styles",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/bundle-treemap.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { dataFills, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Bundle size by module, as a treemap.\n *\n * Area is size, so the thing taking the space is the thing that looks big. A\n * sorted bar chart answers \"what is largest\"; a treemap answers \"what is this\n * bundle *made of*\", which is the question you have when a build doubles.\n *\n * Gzipped size drives the layout when it is supplied. Raw bytes overstate\n * text-heavy modules dramatically — a 400KB locale file that gzips to 12KB\n * dominates a raw-size treemap and is nearly irrelevant on the wire.\n *\n * Squarified layout, not a naive slice-and-dice: long thin slivers cannot be\n * compared by eye or clicked, which defeats the point of the shape.\n */\nexport type BundleModule = {\n name: string\n /** Raw bytes. */\n size: number\n /** Compressed bytes. Drives the layout when present. */\n gzip?: number\n}\n\ntype Tile = { module: BundleModule; x: number; y: number; w: number; h: number }\n\n/**\n * Squarified treemap over a 0–1 box. Rows are grown while the worst aspect\n * ratio in the row keeps improving, which is what avoids slivers.\n */\nfunction squarify(values: number[], width: number, height: number): [number, number, number, number][] {\n const total = values.reduce((sum, v) => sum + v, 0)\n if (total <= 0) return values.map(() => [0, 0, 0, 0])\n\n const out: [number, number, number, number][] = []\n let x = 0, y = 0, w = width, h = height\n let index = 0\n\n while (index < values.length) {\n const vertical = w >= h\n const side = vertical ? h : w\n const remaining = values.slice(index).reduce((sum, v) => sum + v, 0)\n const scale = (w * h) / remaining\n\n const row: number[] = []\n let best = Infinity\n\n while (index + row.length < values.length) {\n const next = [...row, values[index + row.length]]\n const area = next.reduce((sum, v) => sum + v, 0) * scale\n const thickness = area / side\n const worst = Math.max(\n ...next.map((v) => {\n const length = (v * scale) / thickness\n return Math.max(thickness / length, length / thickness)\n }),\n )\n if (worst > best) break\n best = worst\n row.push(values[index + row.length])\n }\n\n const area = row.reduce((sum, v) => sum + v, 0) * scale\n const thickness = area / side\n let offset = 0\n for (const value of row) {\n const length = (value * scale) / thickness\n out.push(\n vertical\n ? [x, y + offset, thickness, length]\n : [x + offset, y, length, thickness],\n )\n offset += length\n }\n\n if (vertical) { x += thickness; w -= thickness } else { y += thickness; h -= thickness }\n index += row.length\n }\n return out\n}\n\n/** Compressed size when asked for and available, raw bytes otherwise. */\nfunction weigh(module: BundleModule, useGzip: boolean) {\n return useGzip && module.gzip !== undefined ? module.gzip : module.size\n}\n\nfunction BundleTreemap({\n modules,\n height = 260,\n useGzip = true,\n totalLabel = 'Total',\n gzipLabel = 'gzipped',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children' | 'height'> & {\n modules: BundleModule[]\n height?: number\n /** Lay out by compressed size when available — raw bytes mislead. */\n useGzip?: boolean\n totalLabel?: ReactNode\n gzipLabel?: ReactNode\n}) {\n const [hover, setHover] = useState<BundleModule | null>(null)\n\n const tiles = useMemo<Tile[]>(() => {\n const by = (m: BundleModule) => weigh(m, useGzip)\n const sorted = [...modules].sort((a, b) => by(b) - by(a))\n const boxes = squarify(sorted.map(by), 1, 1)\n return sorted.map((module, i) => ({\n module,\n x: boxes[i][0],\n y: boxes[i][1],\n w: boxes[i][2],\n h: boxes[i][3],\n }))\n }, [modules, useGzip])\n\n const total = modules.reduce((sum, m) => sum + m.size, 0)\n const totalGzip = modules.reduce((sum, m) => sum + (m.gzip ?? 0), 0)\n\n return (\n <div data-slot=\"bundle-treemap\" className={cn('flex flex-col gap-2', className)} {...props}>\n <div\n className={cn(surface, radius.surface, 'relative w-full overflow-hidden')}\n style={{ height }}\n onMouseLeave={() => setHover(null)}\n >\n {tiles.map((tile, index) => (\n <button\n key={tile.module.name}\n type=\"button\"\n title={`${tile.module.name} — ${tile.module.size} bytes`}\n onMouseEnter={() => setHover(tile.module)}\n className=\"absolute overflow-hidden border border-[var(--card)] p-1 text-start text-[10px] leading-tight\"\n style={{\n insetInlineStart: `${tile.x * 100}%`,\n top: `${tile.y * 100}%`,\n width: `${tile.w * 100}%`,\n height: `${tile.h * 100}%`,\n background: dataFills[index % dataFills.length],\n opacity: hover && hover !== tile.module ? 0.7 : 1,\n color: 'var(--background)',\n }}\n >\n {tile.w > 0.09 && tile.h > 0.07 ? tile.module.name : ''}\n </button>\n ))}\n </div>\n\n <p className=\"text-muted-foreground flex flex-wrap gap-x-3 text-xs tabular-nums\">\n <span>\n {totalLabel} <Fmt type=\"bytes\" value={total} />\n </span>\n {totalGzip > 0 && (\n <span>\n <Fmt type=\"bytes\" value={totalGzip} /> {gzipLabel}\n </span>\n )}\n {hover && (\n <span className=\"ms-auto font-mono\">\n {hover.name} — <Fmt type=\"bytes\" value={hover.size} />\n {hover.gzip !== undefined && (\n <>\n {' ('}\n <Fmt type=\"bytes\" value={hover.gzip} /> {gzipLabel})\n </>\n )}\n </span>\n )}\n </p>\n </div>\n )\n}\n\nexport { BundleTreemap, squarify }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "button-group",
3
+ "type": "registry:ui",
4
+ "title": "Button Group",
5
+ "description": "Joins controls into one segmented unit — buttons, inputs, selects, or a mix. Inner corners are squared and adjacent borders overlap, so the row reads as a single control rather than a queue of separate ones.",
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/button-group.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { controlSize, type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Joins controls into one segmented unit — buttons, inputs, selects, or a mix.\n *\n * Not to be confused with `Group`, which is a tray *behind* a set of cards.\n * This one welds its children edge to edge.\n *\n * Two things make that work. The inner corners are squared so only the outer\n * two stay round, and each item after the first is pulled back a pixel so the\n * two adjacent borders land on top of each other instead of stacking into a\n * 2px seam.\n *\n * The child rules reach one level deeper as well as at the top level: Select\n * and Combobox render a positioning wrapper around their trigger, so the\n * element that actually carries the radius is a button one level down.\n */\nconst CHILD = [\n // Stacking context per item, so a focus ring is drawn over its neighbour\n // rather than clipped behind it.\n '[&>*]:relative [&>*:focus-within]:z-10 [&>*:hover]:z-10',\n].join(' ')\n\nconst HORIZONTAL = [\n '[&>*:not(:first-child)]:rounded-s-none [&>*:not(:last-child)]:rounded-e-none',\n '[&>*:not(:first-child)>button]:rounded-s-none [&>*:not(:last-child)>button]:rounded-e-none',\n '[&>*:not(:first-child)]:-ms-px',\n].join(' ')\n\nconst VERTICAL = [\n '[&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none',\n '[&>*:not(:first-child)>button]:rounded-t-none [&>*:not(:last-child)>button]:rounded-b-none',\n '[&>*:not(:first-child)]:-mt-px',\n].join(' ')\n\n/**\n * A joined row that becomes a joined column below the breakpoint.\n *\n * The two axes are written under `max-*` and the bare breakpoint so they are\n * mutually exclusive by media query. The obvious alternative — stack by\n * default, then undo it at the breakpoint — cannot work: restoring a squared\n * corner means naming the radius the child already had, and `rounded-t-[inherit]`\n * inherits the *group's* radius, not the button's.\n *\n * Both axes carry the `>button` rules too, for the wrapper Select and Combobox\n * render around their trigger.\n */\nconst RESPONSIVE_ROW = {\n sm: [\n 'max-sm:flex-col',\n 'max-sm:[&>*:not(:first-child)]:rounded-t-none max-sm:[&>*:not(:last-child)]:rounded-b-none',\n 'max-sm:[&>*:not(:first-child)>button]:rounded-t-none max-sm:[&>*:not(:last-child)>button]:rounded-b-none',\n 'max-sm:[&>*:not(:first-child)]:-mt-px',\n 'sm:[&>*:not(:first-child)]:rounded-s-none sm:[&>*:not(:last-child)]:rounded-e-none',\n 'sm:[&>*:not(:first-child)>button]:rounded-s-none sm:[&>*:not(:last-child)>button]:rounded-e-none',\n 'sm:[&>*:not(:first-child)]:-ms-px',\n ].join(' '),\n md: [\n 'max-md:flex-col',\n 'max-md:[&>*:not(:first-child)]:rounded-t-none max-md:[&>*:not(:last-child)]:rounded-b-none',\n 'max-md:[&>*:not(:first-child)>button]:rounded-t-none max-md:[&>*:not(:last-child)>button]:rounded-b-none',\n 'max-md:[&>*:not(:first-child)]:-mt-px',\n 'md:[&>*:not(:first-child)]:rounded-s-none md:[&>*:not(:last-child)]:rounded-e-none',\n 'md:[&>*:not(:first-child)>button]:rounded-s-none md:[&>*:not(:last-child)>button]:rounded-e-none',\n 'md:[&>*:not(:first-child)]:-ms-px',\n ].join(' '),\n lg: [\n 'max-lg:flex-col',\n 'max-lg:[&>*:not(:first-child)]:rounded-t-none max-lg:[&>*:not(:last-child)]:rounded-b-none',\n 'max-lg:[&>*:not(:first-child)>button]:rounded-t-none max-lg:[&>*:not(:last-child)>button]:rounded-b-none',\n 'max-lg:[&>*:not(:first-child)]:-mt-px',\n 'lg:[&>*:not(:first-child)]:rounded-s-none lg:[&>*:not(:last-child)]:rounded-e-none',\n 'lg:[&>*:not(:first-child)>button]:rounded-s-none lg:[&>*:not(:last-child)>button]:rounded-e-none',\n 'lg:[&>*:not(:first-child)]:-ms-px',\n ].join(' '),\n} as const\n\nconst buttonGroupVariants = cva([CHILD, 'inline-flex items-stretch'].join(' '), {\n variants: {\n orientation: {\n horizontal: HORIZONTAL,\n vertical: `flex-col ${VERTICAL}`,\n },\n /** Let fields take the free space, for a field-plus-button row. */\n grow: {\n true: 'flex w-full [&>[data-slot=combobox]]:flex-1 [&>[data-slot=field]]:flex-1 [&>[data-slot=select]]:flex-1',\n false: '',\n },\n },\n defaultVariants: { orientation: 'horizontal', grow: false },\n})\n\ntype ButtonGroupProps = ComponentProps<'div'> &\n VariantProps<typeof buttonGroupVariants> & {\n /**\n * Breakpoint a horizontal group becomes a row at. Defaults to `false`: a\n * pair of icon buttons is fine at any width, and stacking it would be\n * surprising. Set it for a field-and-button row, which is not.\n */\n responsive?: Responsive\n }\n\nfunction ButtonGroup({\n className,\n orientation = 'horizontal',\n grow,\n responsive = false,\n ...props\n}: ButtonGroupProps) {\n const stacks = orientation === 'horizontal' && responsive !== false\n\n return (\n <div\n // `group` rather than `toolbar`: a toolbar implies arrow-key navigation\n // between its controls, which this does not provide. These are ordinary\n // tab stops that happen to share edges.\n role=\"group\"\n data-slot=\"button-group\"\n data-orientation={orientation ?? 'horizontal'}\n className={cn(\n // `null`, not undefined: cva skips a variant only on an explicit null,\n // and falls back to the default for undefined — which would lay the\n // unprefixed horizontal rules underneath the responsive ones.\n buttonGroupVariants({ orientation: stacks ? null : orientation, grow }),\n stacks ? RESPONSIVE_ROW[responsive] : undefined,\n className,\n )}\n {...props}\n />\n )\n}\n\n/**\n * A non-interactive segment — a unit, a prefix, a currency symbol.\n *\n * Sized from the same scales as the controls beside it, so it lines up without\n * the caller matching heights by hand.\n *\n * `variant` has to match whatever it is welded to. A bordered segment next to a\n * filled input reads as a mistake: the group is meant to look like one control,\n * and half of it having an outline breaks that. It defaults to `secondary`,\n * which is what the fields beside it default to.\n */\nconst textVariants = {\n default: 'border-border bg-secondary border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n} as const\n\nfunction ButtonGroupText({\n className,\n size = 'default',\n variant = 'secondary',\n ...props\n}: ComponentProps<'div'> & {\n size?: 'sm' | 'default' | 'lg'\n variant?: keyof typeof textVariants\n}) {\n const height = { sm: controlSize.sm, default: controlSize.md, lg: controlSize.lg }[size]\n\n return (\n <div\n data-slot=\"button-group-text\"\n className={cn(\n 'text-muted-foreground inline-flex shrink-0 items-center font-medium whitespace-nowrap',\n textVariants[variant],\n height,\n // The height scale carries a gap and an icon trim meant for buttons.\n 'gap-0',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** A hairline between two segments that share a fill. */\nfunction ButtonGroupSeparator({\n className,\n orientation = 'horizontal',\n ...props\n}: ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical' }) {\n return (\n <div\n role=\"separator\"\n data-slot=\"button-group-separator\"\n className={cn(\n 'bg-border relative z-10 self-stretch',\n orientation === 'horizontal' ? 'w-px' : 'h-px w-full',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }\nexport type { ButtonGroupProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "button",
3
+ "type": "registry:ui",
4
+ "title": "Button",
5
+ "description": "A control for a single action. Six variants across eight colour sets, ten sizes, and `asChild` to render as any element. Destructive is a colour, not a variant.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "class-variance-authority@^0.7.1"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "primitive-slot"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/button.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { Slot } from '@/components/primitives/slot'\nimport {\n buttonText,\n colorSet,\n controlBase,\n controlSize,\n tintStyle,\n type ColorSet,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Every variant reads the `--ui-*` set, so `color` restyles all of them at once.\n *\n * Press is colour only, like hover, and it lands instantly: `active:duration-0`\n * skips the transition on the way down so the button answers the click, while\n * the release still eases back. Nothing moves, resizes or gains elevation.\n */\nconst SOLID = 'bg-[var(--ui)] text-[var(--ui-fg)] hover:bg-[var(--ui-hover)]'\nconst SOFT = 'bg-[var(--ui-soft)] text-[var(--ui-soft-fg)] hover:bg-[var(--ui-soft-hover)]'\n\n/**\n * Press continues past hover in the same direction, mixed with `--press-shade`\n * so one declaration is correct in both themes and for every colour set.\n */\nconst SOLID_PRESS =\n 'active:bg-[color-mix(in_oklab,var(--ui-hover),var(--press-shade)_14%)]'\nconst SOFT_PRESS =\n 'active:bg-[color-mix(in_oklab,var(--ui-soft-hover),var(--press-shade)_10%)]'\n\nconst buttonVariants = cva(\n [\n controlBase,\n // Focus ring picks up the active colour set instead of a fixed accent.\n 'focus-visible:ring-[color-mix(in_oklab,var(--ui),transparent_55%)]',\n 'focus-visible:border-[var(--ui)]',\n ].join(' '),\n {\n variants: {\n variant: {\n /** Solid fill in the current colour set — neutral unless `color` says otherwise. */\n default: `${SOLID} ${SOLID_PRESS}`,\n /** Identical to `default`, named for when the colour is the point. */\n colored: `${SOLID} ${SOLID_PRESS}`,\n /** Tinted fill: low-contrast background, saturated text. */\n secondary: `${SOFT} ${SOFT_PRESS}`,\n /** Stroke only — never a fill. The border matches the text exactly. */\n outline:\n 'border bg-transparent border-[var(--ui-soft-fg)] text-[var(--ui-soft-fg)] hover:border-[var(--ui-soft-fg-hover)] hover:text-[var(--ui-soft-fg-hover)] active:bg-[var(--ui-soft)]',\n ghost: `text-[var(--ui-soft-fg)] hover:bg-[var(--ui-soft)] ${SOFT_PRESS}`,\n link: 'text-[var(--ui-soft-fg)] underline-offset-4 hover:underline active:opacity-70',\n },\n color: colorSet,\n // `buttonText` trails `controlSize` so twMerge takes the smaller label\n // size. Icon-only sizes carry no text at all.\n size: {\n xs: `${controlSize.xs} ${buttonText.xs}`,\n sm: `${controlSize.sm} ${buttonText.sm}`,\n default: `${controlSize.md} ${buttonText.md}`,\n lg: `${controlSize.lg} ${buttonText.lg}`,\n xl: `${controlSize.xl} ${buttonText.xl}`,\n 'icon-xs': controlSize.iconXs,\n 'icon-sm': controlSize.iconSm,\n icon: controlSize.icon,\n 'icon-lg': controlSize.iconLg,\n 'icon-xl': controlSize.iconXl,\n },\n },\n defaultVariants: {\n variant: 'default',\n color: 'neutral',\n size: 'default',\n },\n },\n)\n\ntype ButtonProps = Omit<ComponentProps<'button'>, 'color'> &\n VariantProps<typeof buttonVariants> & {\n /** Render the child element instead of a `<button>`, keeping the styles. */\n asChild?: boolean\n /**\n * Any CSS colour, used instead of a named `color` set. Fill, text, tint and\n * border are all derived from it, and every variant keeps working.\n */\n tint?: string\n }\n\nfunction Button({\n className,\n variant,\n color,\n size,\n asChild = false,\n tint,\n style,\n ...props\n}: ButtonProps) {\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n data-slot=\"button\"\n className={cn(buttonVariants({ variant, color, size, className }))}\n style={tint ? { ...tintStyle(tint), ...style } : style}\n {...props}\n />\n )\n}\n\nexport { Button, buttonVariants }\nexport type { ButtonProps, ColorSet }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "cache-stats",
3
+ "type": "registry:ui",
4
+ "title": "Cache Stats",
5
+ "description": "Hit rate with the numbers behind it. 99% on 40 requests a day is noise and 80% on 4 million is a cost centre, so volume sits beside the percentage.",
6
+ "category": "Runtime",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "fmt",
10
+ "lib-styles",
11
+ "lib-utils",
12
+ "sparkline"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/cache-stats.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Cache hit rate, with the numbers behind it.\n *\n * Hit rate alone is not interpretable: 99% on 40 requests a day is noise, and\n * 80% on 4 million is a cost centre. Volume is shown beside the percentage for\n * exactly that reason.\n *\n * Evictions are separated from misses. A miss means the key was never there; an\n * eviction means it was there and the cache threw it away, which is a sizing\n * problem rather than a warming problem — and the fix is completely different.\n *\n * The rate is computed here from hits and misses rather than accepted as a\n * prop, so it cannot disagree with the counts printed under it.\n */\nfunction CacheStats({\n hits,\n misses,\n evictions,\n keys,\n bytes,\n history,\n label = 'Cache',\n hitRateLabel = 'hit rate',\n hitsLabel = 'hits',\n missesLabel = 'misses',\n evictionsLabel = 'evictions',\n keysLabel = 'keys',\n sizeLabel = 'size',\n evictionNote = 'Evictions mean the cache is too small for the working set — not that it is cold.',\n locale = 'en-GB',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n hits: number\n misses: number\n evictions?: number\n keys?: number\n bytes?: number\n /** Hit rate over time, 0–1 per sample. */\n history?: number[]\n label?: ReactNode\n hitRateLabel?: ReactNode\n hitsLabel?: ReactNode\n missesLabel?: ReactNode\n evictionsLabel?: ReactNode\n keysLabel?: ReactNode\n sizeLabel?: ReactNode\n /** Shown when evictions are non-zero. */\n evictionNote?: ReactNode\n locale?: string\n}) {\n const total = hits + misses\n // Derived, so it cannot disagree with the counts beneath it.\n const rate = total > 0 ? hits / total : 0\n const tone =\n rate >= 0.9 ? 'var(--green)' : rate >= 0.7 ? 'var(--amber)' : 'var(--destructive)'\n\n return (\n <div\n data-slot=\"cache-stats\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline gap-x-2\">\n <span className=\"text-muted-foreground text-xs font-medium\">{label}</span>\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n <Fmt type=\"number\" value={total} locale={locale} /> requests\n </span>\n </div>\n\n <p className=\"flex items-baseline gap-2\">\n <span className=\"text-3xl font-semibold tabular-nums\" style={{ color: tone }}>\n <Fmt type=\"percent\" value={rate} decimals={1} locale={locale} />\n </span>\n <span className=\"text-muted-foreground text-xs\">{hitRateLabel}</span>\n </p>\n\n {history && history.length > 1 && (\n <Sparkline values={history} variant=\"area\" color={tone} className=\"h-8\" />\n )}\n\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4\">\n <div>\n <dt className=\"text-muted-foreground\">{hitsLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={hits} locale={locale} />\n </dd>\n </div>\n <div>\n <dt className=\"text-muted-foreground\">{missesLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={misses} locale={locale} />\n </dd>\n </div>\n {/* Separate from misses: a sizing problem, not a warming problem. */}\n {evictions !== undefined && (\n <div>\n <dt className=\"text-muted-foreground\">{evictionsLabel}</dt>\n <dd\n className=\"mt-0.5 font-medium tabular-nums\"\n style={{ color: evictions > 0 ? 'var(--amber-soft-foreground)' : undefined }}\n >\n <Fmt type=\"number\" value={evictions} locale={locale} />\n </dd>\n </div>\n )}\n {keys !== undefined && (\n <div>\n <dt className=\"text-muted-foreground\">{keysLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={keys} locale={locale} />\n </dd>\n </div>\n )}\n {bytes !== undefined && (\n <div>\n <dt className=\"text-muted-foreground\">{sizeLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"bytes\" value={bytes} locale={locale} />\n </dd>\n </div>\n )}\n </dl>\n\n {Boolean(evictions) && evictionNote && (\n <p className=\"text-muted-foreground border-border border-t pt-3 text-xs\">{evictionNote}</p>\n )}\n </div>\n )\n}\n\nexport { CacheStats }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "calendar",
3
+ "type": "registry:ui",
4
+ "title": "Calendar",
5
+ "description": "A date grid in single or range mode, with presets, times, dropdown captions and multiple months. No date library — the grid, weekday names and times all come from Intl.",
6
+ "category": "Display",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "lib-date",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "select",
16
+ "separator"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/calendar.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\nimport { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Select } from '@/components/ui/select'\nimport { Separator } from '@/components/ui/separator'\nimport {\n addMonths,\n formatMonth,\n isAfter,\n isBefore,\n isInRange,\n isRangeEnd,\n isRangeStart,\n isSameDay,\n isSameMonth,\n monthWeeks,\n orderRange,\n setTime,\n startOfDay,\n startOfMonth,\n startOfWeek,\n withTimeOf,\n type DateRange,\n} from '@/lib/date'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A date picker grid, in single or range mode.\n *\n * No date library — the grid, weekday names, month labels and times all come\n * from `Intl` plus the helpers in `lib/date.ts`. Always six weeks per month, so\n * paging never changes the height and the layout around it never jumps.\n *\n * Range picking is drafted before it is committed: the first click sets an\n * anchor, the pointer paints a preview, and the second click (or the release of\n * a drag) confirms. Dragging and clicking are the same gesture here, which is\n * why the release is watched on the window rather than on a cell.\n */\nexport type CalendarMode = 'single' | 'range'\nexport type CalendarPreset = { label: string; range: DateRange }\nexport type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\ntype CalendarBaseProps = {\n /** Months shown side by side. */\n numberOfMonths?: number\n month?: Date\n defaultMonth?: Date\n onMonthChange?: (month: Date) => void\n weekStartsOn?: WeekStart\n locale?: string\n /** Bounds. Days outside them cannot be picked. */\n fromDate?: Date\n toDate?: Date\n disabled?: (date: Date) => boolean\n showOutsideDays?: boolean\n /** A static heading, or month and year dropdowns for fast navigation. */\n captionLayout?: 'label' | 'dropdown'\n showTime?: boolean\n /** Minute granularity in the time selects. */\n timeStep?: number\n renderDay?: (date: Date) => ReactNode\n footer?: ReactNode\n /** Accessible name for the previous-month button. */\n previousMonthLabel?: string\n /** Accessible name for the next-month button. */\n nextMonthLabel?: string\n /** Accessible name for the month dropdown, under `captionLayout=\"dropdown\"`. */\n monthLabel?: string\n /** Accessible name for the year dropdown. */\n yearLabel?: string\n /** Names the hour select, given the row's own label (\"Start\", \"End\"). */\n hourLabel?: (row: string) => string\n /** Names the minute select the same way. */\n minuteLabel?: (row: string) => string\n className?: string\n}\n\ntype CalendarSingleProps = CalendarBaseProps & {\n mode?: 'single'\n selected?: Date\n defaultSelected?: Date\n onSelect?: (date: Date | undefined) => void\n presets?: never\n}\n\ntype CalendarRangeProps = CalendarBaseProps & {\n mode: 'range'\n selected?: DateRange\n defaultSelected?: DateRange\n onSelect?: (range: DateRange | undefined) => void\n /** Quick ranges listed beside the grid. */\n presets?: CalendarPreset[]\n}\n\nexport type CalendarProps = CalendarSingleProps | CalendarRangeProps\n\nfunction Calendar(props: CalendarProps) {\n const {\n numberOfMonths = 1,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n weekStartsOn = 1,\n locale = 'en-GB',\n fromDate,\n toDate,\n disabled,\n showOutsideDays = true,\n captionLayout = 'label',\n showTime = false,\n timeStep = 5,\n renderDay,\n footer,\n previousMonthLabel = 'Previous month',\n nextMonthLabel = 'Next month',\n monthLabel = 'Month',\n yearLabel = 'Year',\n hourLabel = (row) => `${row} hour`,\n minuteLabel = (row) => `${row} minute`,\n className,\n } = props\n\n const range = props.mode === 'range'\n\n /* ---------------------------------------------------------- selection */\n\n const controlled = props.selected !== undefined\n const [ownSelected, setOwnSelected] = useState<Date | DateRange | undefined>(\n props.defaultSelected,\n )\n const selected = controlled ? props.selected : ownSelected\n\n const single = range ? undefined : (selected as Date | undefined)\n const picked = range ? (selected as DateRange | undefined) : undefined\n\n // Read out of the union before the callback, so the dependency is the handler\n // itself rather than the whole props object.\n const onSelectProp = props.onSelect as\n | ((value: Date | DateRange | undefined) => void)\n | undefined\n\n const emit = useCallback(\n (next: Date | DateRange | undefined) => {\n if (!controlled) setOwnSelected(next)\n onSelectProp?.(next)\n },\n [controlled, onSelectProp],\n )\n\n /* -------------------------------------------------------------- month */\n\n const seed = useMemo(() => {\n if (defaultMonth) return startOfMonth(defaultMonth)\n const from = range ? picked?.from : single\n return startOfMonth(from ?? new Date())\n // Seeds the uncontrolled month once; later changes come from setMonth.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n const [ownMonth, setOwnMonth] = useState(seed)\n const month = monthProp ? startOfMonth(monthProp) : ownMonth\n\n const setMonth = (next: Date) => {\n const value = startOfMonth(next)\n if (!monthProp) setOwnMonth(value)\n onMonthChange?.(value)\n }\n\n /* ------------------------------------------------------- range draft */\n\n const [anchor, setAnchor] = useState<Date | null>(null)\n const [hover, setHover] = useState<Date | null>(null)\n const dragging = useRef(false)\n const dragFrom = useRef<Date | null>(null)\n\n const unavailable = useCallback(\n (date: Date) => {\n if (fromDate && isBefore(date, fromDate)) return true\n if (toDate && isAfter(date, toDate)) return true\n return disabled?.(date) ?? false\n },\n [disabled, fromDate, toDate],\n )\n\n const commit = useCallback(\n (a: Date, b: Date) => {\n const ordered = orderRange(a, b)\n emit({\n from: withTimeOf(ordered.from!, picked?.from),\n to: withTimeOf(ordered.to!, picked?.to),\n })\n },\n [emit, picked],\n )\n\n // A drag can end anywhere on the page, so the release is watched globally.\n useEffect(() => {\n if (!range) return\n\n const onUp = () => {\n if (!dragging.current) return\n dragging.current = false\n const from = dragFrom.current\n // Press and release on one day is a click: keep the anchor pending so the\n // next click closes the range.\n if (from && hover && !isSameDay(from, hover)) {\n commit(from, hover)\n setAnchor(null)\n }\n }\n\n window.addEventListener('pointerup', onUp)\n return () => window.removeEventListener('pointerup', onUp)\n }, [commit, hover, range])\n\n /** What the grid paints: the draft while picking, otherwise the selection. */\n const shown: DateRange | undefined = useMemo(() => {\n if (!range) return undefined\n if (anchor) return orderRange(anchor, hover ?? anchor)\n return picked\n }, [anchor, hover, range, picked])\n\n function onDayDown(date: Date) {\n if (unavailable(date)) return\n\n if (!range) {\n // Clicking the selected day clears it — the only way to undo a choice in\n // a picker with no clear button.\n const same = single && isSameDay(single, date)\n emit(same ? undefined : withTimeOf(date, single))\n return\n }\n\n if (anchor) {\n commit(anchor, date)\n setAnchor(null)\n dragging.current = false\n dragFrom.current = null\n return\n }\n\n setAnchor(date)\n setHover(date)\n dragging.current = true\n dragFrom.current = date\n }\n\n /* ------------------------------------------------------------ render */\n\n const months = Array.from({ length: Math.max(1, numberOfMonths) }, (_, i) =>\n addMonths(month, i),\n )\n\n const weekdays = useMemo(() => {\n const base = startOfWeek(new Date(), weekStartsOn)\n const format = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n return Array.from({ length: 7 }, (_, i) =>\n format.format(new Date(base.getTime() + i * 86_400_000)),\n )\n }, [locale, weekStartsOn])\n\n return (\n <div\n data-slot=\"calendar\"\n data-mode={props.mode ?? 'single'}\n className={cn('w-fit select-none', className)}\n onPointerLeave={() => {\n if (!dragging.current) setHover(null)\n }}\n >\n <div className=\"flex gap-5\">\n {range && props.presets?.length ? (\n <Presets\n presets={props.presets}\n onPick={(next) => {\n emit(next)\n if (next.from) setMonth(next.from)\n setAnchor(null)\n }}\n />\n ) : null}\n\n <div className=\"grid gap-3\">\n <Caption\n captionLayout={captionLayout}\n fromDate={fromDate}\n monthLabel={monthLabel}\n nextMonthLabel={nextMonthLabel}\n previousMonthLabel={previousMonthLabel}\n yearLabel={yearLabel}\n locale={locale}\n month={month}\n onMonthChange={setMonth}\n toDate={toDate}\n />\n\n <div className=\"flex gap-6\">\n {months.map((m) => (\n <Month\n key={m.toISOString()}\n locale={locale}\n mode={props.mode ?? 'single'}\n month={m}\n onDayDown={onDayDown}\n onDayEnter={(date) => range && setHover(date)}\n range={shown}\n renderDay={renderDay}\n selected={single}\n showCaption={months.length > 1}\n showOutsideDays={showOutsideDays}\n unavailable={unavailable}\n weekdays={weekdays}\n weekStartsOn={weekStartsOn}\n />\n ))}\n </div>\n\n {showTime && (\n <>\n <Separator />\n <TimeRow\n hourLabel={hourLabel}\n locale={locale}\n minuteLabel={minuteLabel}\n mode={props.mode ?? 'single'}\n onChange={emit}\n range={picked}\n single={single}\n step={timeStep}\n />\n </>\n )}\n\n {footer}\n </div>\n </div>\n </div>\n )\n}\n\n/* ------------------------------------------------------------- sub-parts */\n\nfunction Presets({\n presets,\n onPick,\n}: {\n presets: CalendarPreset[]\n onPick: (range: DateRange) => void\n}) {\n return (\n <div className=\"border-border flex w-36 shrink-0 flex-col gap-1 border-e pe-3\">\n {presets.map((preset) => (\n <Button\n key={preset.label}\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => onPick(preset.range)}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )\n}\n\nfunction Caption({\n month,\n onMonthChange,\n locale,\n captionLayout,\n fromDate,\n toDate,\n previousMonthLabel,\n nextMonthLabel,\n monthLabel,\n yearLabel,\n}: {\n month: Date\n onMonthChange: (month: Date) => void\n locale: string\n captionLayout: 'label' | 'dropdown'\n fromDate?: Date\n toDate?: Date\n previousMonthLabel: string\n nextMonthLabel: string\n monthLabel: string\n yearLabel: string\n}) {\n const monthNames = useMemo(() => {\n const format = new Intl.DateTimeFormat(locale, { month: 'long' })\n return Array.from({ length: 12 }, (_, i) => format.format(new Date(2024, i, 1)))\n }, [locale])\n\n const years = useMemo(() => {\n const now = month.getFullYear()\n const first = fromDate?.getFullYear() ?? now - 10\n const last = toDate?.getFullYear() ?? now + 10\n return Array.from({ length: Math.max(1, last - first + 1) }, (_, i) => first + i)\n }, [month, fromDate, toDate])\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={previousMonthLabel}\n disabled={fromDate ? isBefore(addMonths(month, -1), startOfMonth(fromDate)) : false}\n onClick={() => onMonthChange(addMonths(month, -1))}\n >\n <ChevronLeft />\n </Button>\n\n {captionLayout === 'dropdown' ? (\n <div className=\"flex flex-1 gap-1.5\">\n <Select\n size=\"sm\"\n aria-label={monthLabel}\n value={String(month.getMonth())}\n onValueChange={(v) =>\n onMonthChange(new Date(month.getFullYear(), Number(v), 1))\n }\n options={monthNames.map((name, i) => ({ value: String(i), label: name }))}\n />\n <Select\n size=\"sm\"\n aria-label={yearLabel}\n value={String(month.getFullYear())}\n onValueChange={(v) =>\n onMonthChange(new Date(Number(v), month.getMonth(), 1))\n }\n options={years.map((year) => ({\n value: String(year),\n label: String(year),\n }))}\n />\n </div>\n ) : (\n <div aria-live=\"polite\" className=\"flex-1 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={nextMonthLabel}\n disabled={toDate ? isAfter(addMonths(month, 1), startOfMonth(toDate)) : false}\n onClick={() => onMonthChange(addMonths(month, 1))}\n >\n <ChevronRight />\n </Button>\n </div>\n )\n}\n\nfunction Month({\n month,\n weekStartsOn,\n weekdays,\n locale,\n mode,\n selected,\n range,\n unavailable,\n onDayDown,\n onDayEnter,\n showOutsideDays,\n showCaption,\n renderDay,\n}: {\n month: Date\n weekStartsOn: WeekStart\n weekdays: string[]\n locale: string\n mode: CalendarMode\n selected?: Date\n range?: DateRange\n unavailable: (date: Date) => boolean\n onDayDown: (date: Date) => void\n onDayEnter: (date: Date) => void\n showOutsideDays: boolean\n showCaption: boolean\n renderDay?: (date: Date) => ReactNode\n}) {\n const weeks = useMemo(\n () => monthWeeks(month, weekStartsOn),\n [month, weekStartsOn],\n )\n const today = startOfDay(new Date())\n\n return (\n <div>\n {showCaption && (\n <div className=\"mb-2 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <div role=\"grid\" className=\"grid grid-cols-7\">\n {weekdays.map((day) => (\n <div\n key={day}\n role=\"columnheader\"\n aria-label={day}\n className={cn(\n 'text-muted-foreground grid place-items-center text-[11px] font-medium',\n renderDay ? 'h-9 w-12' : 'size-9',\n )}\n >\n <span aria-hidden=\"true\">{day.slice(0, 2)}</span>\n </div>\n ))}\n\n {weeks.flat().map((date) => {\n const outside = !isSameMonth(date, month)\n if (outside && !showOutsideDays) {\n return (\n <div\n key={date.toISOString()}\n className={renderDay ? 'h-14 w-12' : 'size-9'}\n />\n )\n }\n\n const off = unavailable(date)\n const start = isRangeStart(date, range)\n const end = isRangeEnd(date, range)\n const middle = isInRange(date, range) && !start && !end\n const isSelected =\n mode === 'range' ? start || end : Boolean(selected && isSameDay(date, selected))\n\n return (\n <button\n key={date.toISOString()}\n type=\"button\"\n role=\"gridcell\"\n disabled={off}\n aria-selected={isSelected || middle}\n aria-current={isSameDay(date, today) ? 'date' : undefined}\n onPointerDown={() => onDayDown(date)}\n onPointerEnter={() => onDayEnter(date)}\n className={cn(\n 'relative grid text-sm tabular-nums',\n renderDay ? 'h-14 w-12 place-items-start p-1.5' : 'size-9 place-items-center',\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'disabled:pointer-events-none disabled:opacity-30',\n outside && 'text-muted-foreground/50',\n // The band has to be square where it continues and rounded only\n // at the ends, or a range reads as separate pills.\n middle && 'bg-accent text-accent-foreground rounded-none',\n (start || end) && 'bg-primary text-primary-foreground font-medium',\n start && !end && 'rounded-s-lg rounded-e-none',\n end && !start && 'rounded-e-lg rounded-s-none',\n start && end && radius.control,\n mode === 'single' && isSelected && cn(radius.control, 'bg-primary text-primary-foreground font-medium'),\n !isSelected && !middle && cn(radius.control, 'hover:bg-accent hover:text-accent-foreground'),\n !isSelected && !middle && isSameDay(date, today) && 'border-border border font-medium',\n )}\n >\n {renderDay ? renderDay(date) : date.getDate()}\n </button>\n )\n })}\n </div>\n </div>\n )\n}\n\nfunction TimeRow({\n mode,\n single,\n range,\n step,\n locale,\n onChange,\n hourLabel,\n minuteLabel,\n}: {\n mode: CalendarMode\n single?: Date\n range?: DateRange\n step: number\n locale: string\n onChange: (value: Date | DateRange | undefined) => void\n hourLabel: (row: string) => string\n minuteLabel: (row: string) => string\n}) {\n const hours = Array.from({ length: 24 }, (_, i) => i)\n const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step)\n const pad = (n: number) => String(n).padStart(2, '0')\n\n function field(date: Date | undefined, apply: (next: Date) => void, label: string) {\n // Times are meaningless without a day, so the selects wait for one.\n const base = date ?? startOfDay(new Date())\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-muted-foreground w-10 text-xs\">{label}</span>\n <Select\n size=\"sm\"\n aria-label={hourLabel(label)}\n disabled={!date}\n value={String(base.getHours())}\n onValueChange={(v) => apply(setTime(base, Number(v), base.getMinutes()))}\n className=\"w-20\"\n options={hours.map((h) => ({ value: String(h), label: pad(h) }))}\n />\n <Select\n size=\"sm\"\n aria-label={minuteLabel(label)}\n disabled={!date}\n value={String(Math.round(base.getMinutes() / step) * step % 60)}\n onValueChange={(v) => apply(setTime(base, base.getHours(), Number(v)))}\n className=\"w-20\"\n options={minutes.map((m) => ({ value: String(m), label: pad(m) }))}\n />\n </div>\n )\n }\n\n if (mode === 'single') {\n return (\n <div className=\"flex flex-col gap-2\" data-locale={locale}>\n {field(single, (next) => onChange(next), 'Time')}\n </div>\n )\n }\n\n return (\n <div className=\"flex flex-col gap-2\">\n {field(range?.from, (next) => onChange({ ...range, from: next }), 'From')}\n {field(range?.to, (next) => onChange({ ...range, to: next }), 'To')}\n </div>\n )\n}\n\nexport { Calendar }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "campaign-card",
3
+ "type": "registry:ui",
4
+ "title": "Campaign Card",
5
+ "description": "An affiliate offer with its performance. EPC leads, not revenue — £40,000 and £4,000 cannot be ranked without knowing the traffic behind each, and earnings per click is the only comparable figure.",
6
+ "category": "Affiliate",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "badge",
10
+ "fmt",
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "sparkline"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/campaign-card.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An affiliate offer with its performance.\n *\n * EPC — earnings per click — is the headline rather than total revenue, because\n * it is the only figure comparable between offers. A campaign with £40,000 of\n * revenue and one with £4,000 cannot be ranked without knowing the traffic\n * behind each.\n *\n * Conversion rate is computed from clicks and conversions rather than accepted\n * as a prop, so it cannot disagree with the two numbers printed beside it.\n */\nexport type CampaignStatus = 'active' | 'paused' | 'ended' | 'pending'\n\nconst STATUS = {\n active: { label: 'Active', color: 'green' },\n paused: { label: 'Paused', color: 'amber' },\n ended: { label: 'Ended', color: 'neutral' },\n pending: { label: 'Pending review', color: 'blue' },\n} as const\n\nfunction CampaignCard({\n name,\n advertiser,\n status,\n clicks = 0,\n conversions = 0,\n revenue = 0,\n payout,\n currency = 'USD',\n locale = 'en-GB',\n history,\n tags,\n href,\n termsLabel = 'Terms',\n clicksLabel = 'Clicks',\n conversionsLabel = 'Conversions',\n revenueLabel = 'Revenue',\n epcLabel = 'EPC',\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'children'> & {\n name: ReactNode\n advertiser?: ReactNode\n status: CampaignStatus\n clicks?: number\n conversions?: number\n revenue?: number\n /** Commission terms, e.g. \"30% rev share\" or \"$25 CPA\". */\n payout?: ReactNode\n currency?: string\n locale?: string\n history?: number[]\n tags?: ReactNode\n href?: string\n /** Precedes the commission terms. */\n termsLabel?: ReactNode\n clicksLabel?: ReactNode\n conversionsLabel?: ReactNode\n revenueLabel?: ReactNode\n /** Earnings per click — the comparable figure. */\n epcLabel?: ReactNode\n}) {\n const meta = STATUS[status]\n // Derived, so it cannot disagree with the numbers beside it.\n const conversionRate = clicks > 0 ? conversions / clicks : 0\n const epc = clicks > 0 ? revenue / clicks : 0\n\n return (\n <article\n data-slot=\"campaign-card\"\n data-status={status}\n className={cn(\n surface,\n radius.surface,\n 'flex flex-col gap-3 p-4',\n status !== 'active' && 'opacity-80',\n className,\n )}\n {...props}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"min-w-0 flex-1\">\n <h3 className=\"truncate text-sm font-medium\">\n {href ? (\n <a href={href} className=\"hover:underline underline-offset-4\">\n {name}\n </a>\n ) : (\n name\n )}\n </h3>\n {advertiser && (\n <p className=\"text-muted-foreground truncate text-xs\">{advertiser}</p>\n )}\n </div>\n <Badge size=\"sm\" color={meta.color}>\n {meta.label}\n </Badge>\n </div>\n\n {payout && (\n <p className=\"text-sm\">\n <span className=\"text-muted-foreground text-xs\">{termsLabel} </span>\n <span className=\"font-medium\">{payout}</span>\n </p>\n )}\n\n {tags && <div className=\"flex flex-wrap gap-1.5\">{tags}</div>}\n\n {history && history.length > 1 && (\n <Sparkline values={history} variant=\"area\" color=\"var(--blue)\" className=\"h-8\" />\n )}\n\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4\">\n <div>\n <dt className=\"text-muted-foreground\">{clicksLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={clicks} locale={locale} />\n </dd>\n </div>\n <div>\n <dt className=\"text-muted-foreground\">{conversionsLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"number\" value={conversions} locale={locale} />\n <span className=\"text-muted-foreground ms-1 font-normal\">\n {(conversionRate * 100).toFixed(1)}%\n </span>\n </dd>\n </div>\n <div>\n <dt className=\"text-muted-foreground\">{revenueLabel}</dt>\n <dd className=\"mt-0.5 font-medium tabular-nums\">\n <Fmt type=\"currency\" value={revenue} currency={currency} locale={locale} decimals={0} />\n </dd>\n </div>\n {/* The comparable figure — revenue alone cannot rank two offers. */}\n <div>\n <dt className=\"text-muted-foreground\">{epcLabel}</dt>\n <dd className=\"mt-0.5 font-semibold tabular-nums\">\n <Fmt type=\"currency\" value={epc} currency={currency} locale={locale} decimals={2} />\n </dd>\n </div>\n </dl>\n </article>\n )\n}\n\nexport { CampaignCard }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "candlestick-chart",
3
+ "type": "registry:ui",
4
+ "title": "Candlestick Chart",
5
+ "description": "OHLC candles with a volume strip. Unlike every other chart here it does not stretch — a stretched candle varies its body width with the container while the wick stays one unit, and the body-versus-wick reading stops working.",
6
+ "category": "Crypto",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/candlestick-chart.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * OHLC candles with an optional volume strip.\n *\n * Candles are drawn in an unstretched SVG — a fixed viewBox scaled by the\n * container, with `preserveAspectRatio` left at its default. Every other chart\n * here stretches, but a stretched candle is a lie: the body width would vary\n * with the container while the wick stays one unit, and the classic reading of\n * body-versus-wick stops working.\n *\n * The price axis is padded to the extremes of the *wicks*, not the bodies, or\n * the highest high clips at the top edge exactly when it matters.\n */\nexport type Candle = {\n time: string | number\n open: number\n high: number\n low: number\n close: number\n volume?: number\n}\n\nfunction CandlestickChart({\n candles,\n height = 220,\n showVolume = true,\n upColor = 'var(--green)',\n downColor = 'var(--destructive)',\n locale = 'en-GB',\n upLabel = 'Up',\n downLabel = 'Down',\n volumeLabel = 'Volume below',\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'height'> & {\n candles: Candle[]\n height?: number\n showVolume?: boolean\n upColor?: string\n downColor?: string\n locale?: string\n /** Legend entries. */\n upLabel?: ReactNode\n downLabel?: ReactNode\n volumeLabel?: ReactNode\n}) {\n const titleId = useId()\n\n const { min, max, maxVolume } = useMemo(() => {\n if (candles.length === 0) return { min: 0, max: 1, maxVolume: 1 }\n // Wicks, not bodies: the extreme is the whole point of a high and a low.\n const low = Math.min(...candles.map((c) => c.low))\n const high = Math.max(...candles.map((c) => c.high))\n const pad = (high - low || 1) * 0.06\n return {\n min: low - pad,\n max: high + pad,\n maxVolume: Math.max(...candles.map((c) => c.volume ?? 0), 1),\n }\n }, [candles])\n\n if (candles.length === 0) return null\n\n const width = candles.length * 10\n const priceHeight = showVolume ? 76 : 100\n const span = max - min || 1\n\n const x = (index: number) => index * 10 + 5\n const y = (price: number) => ((max - price) / span) * priceHeight\n\n const money = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 })\n\n return (\n <figure\n data-slot=\"candlestick-chart\"\n className={cn('flex min-w-0 flex-col gap-2', className)}\n {...props}\n >\n <div className=\"flex min-w-0 gap-2\">\n <div\n className=\"text-muted-foreground/70 flex shrink-0 flex-col justify-between text-[10px] tabular-nums\"\n style={{ height }}\n aria-hidden=\"true\"\n >\n <span>{money.format(max)}</span>\n <span>{money.format((max + min) / 2)}</span>\n <span>{money.format(min)}</span>\n </div>\n\n <div className=\"min-w-0 flex-1 overflow-x-auto\">\n <svg\n viewBox={`0 0 ${width} 100`}\n style={{ height, width: Math.max(width * 2, 100) }}\n role=\"img\"\n aria-labelledby={titleId}\n >\n <title id={titleId}>\n {`${candles.length} candles, ${money.format(min)} to ${money.format(max)}`}\n </title>\n\n {candles.map((candle, index) => {\n const up = candle.close >= candle.open\n const colour = up ? upColor : downColor\n const bodyTop = y(Math.max(candle.open, candle.close))\n const bodyBottom = y(Math.min(candle.open, candle.close))\n\n return (\n <g key={index}>\n <line\n x1={x(index)}\n x2={x(index)}\n y1={y(candle.high)}\n y2={y(candle.low)}\n stroke={colour}\n strokeWidth={1}\n />\n <rect\n x={x(index) - 3}\n y={bodyTop}\n width={6}\n // A doji still needs a visible line.\n height={Math.max(bodyBottom - bodyTop, 0.75)}\n fill={up ? 'none' : colour}\n stroke={colour}\n strokeWidth={1}\n />\n\n {showVolume && candle.volume !== undefined && (\n <rect\n x={x(index) - 3}\n y={100 - (candle.volume / maxVolume) * 18}\n width={6}\n height={(candle.volume / maxVolume) * 18}\n fill={colour}\n opacity={0.35}\n />\n )}\n </g>\n )\n })}\n </svg>\n </div>\n </div>\n\n <figcaption className=\"text-muted-foreground flex flex-wrap gap-x-4 text-xs\">\n <span className=\"flex items-center gap-1.5\">\n <span className=\"size-2.5 rounded-sm border\" style={{ borderColor: upColor }} />\n {upLabel}\n </span>\n <span className=\"flex items-center gap-1.5\">\n <span className=\"size-2.5 rounded-sm\" style={{ backgroundColor: downColor }} />\n {downLabel}\n </span>\n {showVolume && <span>{volumeLabel}</span>}\n </figcaption>\n </figure>\n )\n}\n\nexport { CandlestickChart }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "card-input",
3
+ "type": "registry:ui",
4
+ "title": "Card Input",
5
+ "description": "Card number, expiry and CVC in one control, with brand detection and a Luhn check. Client-side validation catches the typo; it never claims the card is good.",
6
+ "category": "Finance",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils",
13
+ "mask-input"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/card-input.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { CreditCard, Lock } from 'lucide-react'\nimport { MaskInput } from '@/components/ui/mask-input'\nimport { fieldBase, fieldSize, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Card number, expiry and CVC as one control.\n *\n * The number is validated with the Luhn checksum, which catches a mistyped\n * digit before the request is made. That matters commercially, not just for\n * polish: failed authorisations count against a merchant's decline rate, and\n * card networks price on it.\n *\n * The brand is detected from the leading digits so the CVC length can follow —\n * American Express uses four, everyone else three, and a fixed `maxlength` of\n * three silently truncates an Amex code.\n *\n * Nothing here stores or transmits anything. In production the fields belong in\n * a PSP iframe so the page never touches a PAN; this is the shape for a demo,\n * a test harness, or a PSP that hands you styling control.\n */\nexport type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\n\n/** Brand from the issuer identification number. */\nfunction detectBrand(digits: string): CardBrand {\n if (/^4/.test(digits)) return 'visa'\n if (/^5[1-5]/.test(digits) || /^2(2[2-9]|[3-6]|7[01]|720)/.test(digits)) return 'mastercard'\n if (/^3[47]/.test(digits)) return 'amex'\n if (/^6(011|5|4[4-9])/.test(digits)) return 'discover'\n return 'unknown'\n}\n\n/** Luhn checksum — catches a single mistyped digit and most transpositions. */\nfunction luhnValid(digits: string) {\n if (digits.length < 12) return false\n let sum = 0\n let double = false\n for (let i = digits.length - 1; i >= 0; i--) {\n let d = Number(digits[i])\n if (double) {\n d *= 2\n if (d > 9) d -= 9\n }\n sum += d\n double = !double\n }\n return sum % 10 === 0\n}\n\nconst BRAND_LABEL: Record<CardBrand, string> = {\n visa: 'Visa',\n mastercard: 'Mastercard',\n amex: 'American Express',\n discover: 'Discover',\n unknown: 'Card',\n}\n\nfunction CardInput({\n onCardChange,\n size = 'md',\n numberLabel = 'Card number',\n expiryLabel = 'Expiry date',\n expiryPlaceholder = 'MM/YY',\n cvcLabel = 'Security code',\n invalidNote = 'That card number does not check out — one of the digits is wrong.',\n disabled = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n onCardChange?: (card: {\n number: string\n expiry: string\n cvc: string\n brand: CardBrand\n valid: boolean\n }) => void\n size?: keyof typeof fieldSize\n /** Accessible name for the number field. */\n numberLabel?: string\n expiryLabel?: string\n expiryPlaceholder?: string\n cvcLabel?: string\n /** Shown when the digits fail the Luhn check. */\n invalidNote?: ReactNode\n disabled?: boolean\n}) {\n const [number, setNumber] = useState('')\n const [expiry, setExpiry] = useState('')\n const [cvc, setCvc] = useState('')\n\n const brand = detectBrand(number)\n // Amex is 15 digits in 4-6-5 groups with a 4-digit code.\n const amex = brand === 'amex'\n const numberMask = amex ? '#### ###### #####' : '#### #### #### ####'\n const cvcLength = amex ? 4 : 3\n\n const numberValid = luhnValid(number)\n const expiryValid = /^(0[1-9]|1[0-2])\\d{2}$/.test(expiry)\n\n const emit = (next: Partial<{ number: string; expiry: string; cvc: string }>) => {\n const card = { number, expiry, cvc, ...next }\n onCardChange?.({\n ...card,\n brand: detectBrand(card.number),\n valid:\n luhnValid(card.number) &&\n /^(0[1-9]|1[0-2])\\d{2}$/.test(card.expiry) &&\n card.cvc.length === (detectBrand(card.number) === 'amex' ? 4 : 3),\n })\n }\n\n return (\n <div data-slot=\"card-input\" className={cn('flex flex-col gap-2', className)} {...props}>\n <div className=\"relative\">\n <MaskInput\n // Remount when the mask changes, or the formatted value is stale.\n key={numberMask}\n mask={numberMask}\n size={size}\n disabled={disabled}\n aria-label={numberLabel}\n autoComplete=\"cc-number\"\n error={number.length > 0 && number.length >= 12 && !numberValid}\n onValueChange={(_, raw) => {\n setNumber(raw)\n emit({ number: raw })\n }}\n />\n <span className=\"text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-xs font-medium\">\n {number ? BRAND_LABEL[brand] : <CreditCard className=\"size-4\" aria-hidden=\"true\" />}\n </span>\n </div>\n\n <div className=\"flex gap-2\">\n <MaskInput\n mask=\"##/##\"\n size={size}\n disabled={disabled}\n aria-label={expiryLabel}\n autoComplete=\"cc-exp\"\n placeholder={expiryPlaceholder}\n error={expiry.length === 4 && !expiryValid}\n onValueChange={(_, raw) => {\n setExpiry(raw)\n emit({ expiry: raw })\n }}\n className=\"flex-1\"\n />\n\n <div\n className={cn(\n fieldBase,\n fieldSize[size],\n 'border-border bg-background flex-1 border',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n )}\n >\n <Lock className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n <input\n inputMode=\"numeric\"\n maxLength={cvcLength}\n disabled={disabled}\n value={cvc}\n aria-label={cvcLabel}\n autoComplete=\"cc-csc\"\n placeholder={amex ? '4 digits' : 'CVC'}\n onChange={(event) => {\n const next = event.target.value.replace(/\\D/g, '').slice(0, cvcLength)\n setCvc(next)\n emit({ cvc: next })\n }}\n className=\"w-full min-w-0 bg-transparent tabular-nums outline-none\"\n />\n </div>\n </div>\n\n {number.length >= 12 && !numberValid && (\n <p className={cn('text-[var(--destructive-soft-foreground)] text-xs', radius.xs)}>\n {invalidNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { CardInput, detectBrand, luhnValid }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "card",
3
+ "type": "registry:ui",
4
+ "title": "Card",
5
+ "description": "A surface that groups related content. Header and footer are optional and draw their own dividers — a Card holding only a CardBody is just a padded box.",
6
+ "category": "Display",
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/card.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { createContext, use, type ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cardPadding, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A surface that groups related content, split into three optional sections.\n *\n * Header and footer draw their own dividers; a Card holding only a CardBody is\n * just a padded box, with no stray rules. The Card's `size` reaches its sections\n * through context, so `<Card size=\"lg\">` re-pads all of them and a section can\n * still override its own.\n */\ntype CardSize = keyof typeof cardPadding\n\nconst CardContext = createContext<CardSize>('default')\n\n// `overflow-hidden` is what lets a tinted header sit flush in the rounded\n// corner instead of poking a square edge out of it.\nconst cardVariants = cva([surface, 'flex flex-col overflow-hidden'].join(' '), {\n variants: {\n variant: {\n default: '',\n /** Filled, no border — for a card sitting on the page background. */\n secondary: 'bg-secondary border-transparent',\n /** Outline only, no fill. */\n ghost: 'bg-transparent',\n },\n },\n defaultVariants: { variant: 'default' },\n})\n\ntype CardProps = ComponentProps<'div'> &\n VariantProps<typeof cardVariants> & { size?: CardSize }\n\nfunction Card({\n className,\n variant,\n size = 'default',\n ...props\n}: CardProps) {\n return (\n <CardContext value={size}>\n <div\n data-slot=\"card\"\n data-size={size}\n className={cn(cardVariants({ variant }), radius.panel, className)}\n {...props}\n />\n </CardContext>\n )\n}\n\nfunction useCardPadding(override?: CardSize) {\n const inherited = use(CardContext)\n return cardPadding[override ?? inherited]\n}\n\nfunction CardHeader({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-header\"\n className={cn(\n // A shade darker than the body, so the header reads as a distinct band\n // without needing a heavier rule under it.\n 'border-border bg-[var(--card-header)] flex flex-col gap-1 border-b',\n useCardPadding(size),\n className,\n )}\n {...props}\n />\n )\n}\n\n/**\n * The heading level is a prop because the right one depends on where the card\n * sits: a card directly under the page's `h1` should be an `h2`, and one inside\n * a section that already has an `h2` should be an `h3`. Hard-coding it means\n * every page that nests differently skips a level.\n */\nfunction CardTitle({\n className,\n as: Comp = 'h3',\n ...props\n}: ComponentProps<'h3'> & { as?: 'h2' | 'h3' | 'h4' | 'div' }) {\n return (\n <Comp\n data-slot=\"card-title\"\n className={cn('text-sm leading-none font-semibold', className)}\n {...props}\n />\n )\n}\n\nfunction CardDescription({ className, ...props }: ComponentProps<'p'>) {\n return (\n <p\n data-slot=\"card-description\"\n className={cn('text-muted-foreground text-xs', className)}\n {...props}\n />\n )\n}\n\nfunction CardBody({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-body\"\n className={cn('flex-1', useCardPadding(size), className)}\n {...props}\n />\n )\n}\n\nfunction CardFooter({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-footer\"\n className={cn(\n 'border-border flex items-center gap-2 border-t',\n useCardPadding(size),\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n Card,\n CardBody,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n cardVariants,\n}\nexport type { CardProps, CardSize }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "carousel",
3
+ "type": "registry:ui",
4
+ "title": "Carousel",
5
+ "description": "A horizontally scrolling row of slides, built on CSS scroll-snap rather than a transform track — so touch swiping, momentum and keyboard scrolling all keep working.",
6
+ "category": "Display",
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/carousel.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A horizontally scrolling row of slides.\n *\n * Built on CSS scroll-snap rather than a transform-driven track: touch swiping,\n * momentum, keyboard scrolling and the scrollbar all keep working, and the\n * component only has to move the scroll position and read it back.\n */\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_SLIDE_LABEL: (index: number, total: number) => string = (index, total) => `${index} of ${total}`\nconst DEFAULT_DOT_LABEL: (index: number) => string = (index) => `Go to slide ${index}`\n\nfunction Carousel({\n className,\n children,\n slidesToShow = 1,\n gap = 12,\n showControls = true,\n showDots = true,\n label = 'Carousel',\n previousLabel = 'Previous slide',\n nextLabel = 'Next slide',\n slideLabel = DEFAULT_SLIDE_LABEL,\n dotLabel = DEFAULT_DOT_LABEL,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n children: ReactNode[]\n slidesToShow?: number\n gap?: number\n showControls?: boolean\n showDots?: boolean\n label?: string\n previousLabel?: string\n nextLabel?: string\n /** Accessible name for a slide, given its 1-based index and the total. */\n slideLabel?: (index: number, total: number) => string\n /** Accessible name for a dot, given its 1-based index. */\n dotLabel?: (index: number) => string\n}) {\n const trackRef = useRef<HTMLDivElement>(null)\n const [index, setIndex] = useState(0)\n const [atStart, setAtStart] = useState(true)\n const [atEnd, setAtEnd] = useState(false)\n\n const sync = useCallback(() => {\n const track = trackRef.current\n if (!track) return\n\n const slide = track.scrollWidth / children.length\n setIndex(Math.round(track.scrollLeft / slide))\n setAtStart(track.scrollLeft <= 1)\n // 1px of slack: sub-pixel layout means scrollLeft rarely hits the exact max.\n setAtEnd(track.scrollLeft + track.clientWidth >= track.scrollWidth - 1)\n }, [children.length])\n\n useEffect(() => {\n const track = trackRef.current\n if (!track) return\n sync()\n track.addEventListener('scroll', sync, { passive: true })\n return () => track.removeEventListener('scroll', sync)\n }, [sync])\n\n function scrollTo(next: number) {\n const track = trackRef.current\n if (!track) return\n const slide = track.scrollWidth / children.length\n track.scrollTo({ left: slide * next, behavior: 'smooth' })\n }\n\n return (\n <div\n role=\"region\"\n aria-roledescription=\"carousel\"\n aria-label={label}\n data-slot=\"carousel\"\n className={cn('relative w-full', className)}\n {...props}\n >\n <div\n ref={trackRef}\n className=\"flex snap-x snap-mandatory overflow-x-auto scroll-smooth\"\n style={{ gap }}\n >\n {children.map((slide, i) => (\n <div\n key={i}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={slideLabel(i + 1, children.length)}\n className=\"min-w-0 shrink-0 snap-start\"\n style={{\n flexBasis: `calc((100% - ${gap * (slidesToShow - 1)}px) / ${slidesToShow})`,\n }}\n >\n {slide}\n </div>\n ))}\n </div>\n\n {showControls && (\n <div className=\"mt-3 flex items-center justify-between gap-2\">\n <div className=\"flex gap-1\">\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={previousLabel}\n disabled={atStart}\n onClick={() => scrollTo(Math.max(0, index - 1))}\n >\n <ChevronLeft />\n </Button>\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={nextLabel}\n disabled={atEnd}\n onClick={() => scrollTo(Math.min(children.length - 1, index + 1))}\n >\n <ChevronRight />\n </Button>\n </div>\n\n {showDots && (\n <div className=\"flex items-center gap-1.5\">\n {children.map((_, i) => (\n <button\n key={i}\n type=\"button\"\n aria-label={dotLabel(i + 1)}\n aria-current={i === index}\n onClick={() => scrollTo(i)}\n className={cn(\n 'size-1.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none',\n focusRing,\n i === index ? 'bg-foreground' : 'bg-border',\n )}\n />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { Carousel }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "cart",
3
+ "type": "registry:ui",
4
+ "title": "Cart",
5
+ "description": "A basket with quantities and per-line totals. Stock limits are enforced at the stepper, not on submit — letting someone add a ninth of eight units and saying so at checkout is the worst possible order.",
6
+ "category": "Commerce",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "fmt",
13
+ "lib-styles",
14
+ "lib-utils"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/cart.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Minus, Plus, Trash2 } from 'lucide-react'\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 * A basket: lines, quantities, per-line totals.\n *\n * Amounts are integer minor units, matching `LedgerTable` and `MoneyInput`.\n * Money that has been through a float has already lost — `0.1 + 0.2` is\n * 0.30000000000000004, and a basket is exactly where that surfaces.\n *\n * Each line shows its unit price *and* its line total once quantity exceeds\n * one. Showing only the total makes it impossible to check, and showing only\n * the unit price makes the sum look wrong.\n *\n * Quantity controls are stepper buttons rather than a free number field. A cart\n * quantity is realistically 1–10, and a text input invites \"12\" where \"1\" and a\n * stray keystroke were meant. Remove is separate from decrement, so reaching\n * zero is never how you delete something.\n *\n * Stock limits are enforced at the control, not on submit. Letting someone add\n * a ninth of eight units and telling them at checkout is the worst order.\n */\nexport type CartLine = {\n id: string\n name: ReactNode\n /** Unit price in minor units. */\n price: number\n quantity: number\n /** Small square image. */\n image?: ReactNode\n variant?: ReactNode\n /** Caps the increment control. */\n max?: number\n note?: ReactNode\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_DECREASE_LABEL: (item: string) => string = (item) => `Decrease quantity of ${item}`\nconst DEFAULT_INCREASE_LABEL: (item: string) => string = (item) => `Increase quantity of ${item}`\nconst DEFAULT_STOCK_NOTE: (max: number) => ReactNode = (max) => `Only ${max} in stock.`\n\nfunction Cart({\n lines,\n currency = 'USD',\n locale = 'en-GB',\n onQuantityChange,\n onRemove,\n empty,\n removeLabel = 'Remove from basket',\n decreaseLabel = DEFAULT_DECREASE_LABEL,\n increaseLabel = DEFAULT_INCREASE_LABEL,\n eachLabel = 'each',\n stockNote = DEFAULT_STOCK_NOTE,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n lines: CartLine[]\n currency?: string\n locale?: string\n onQuantityChange?: (id: string, quantity: number) => void\n onRemove?: (id: string) => void\n empty?: ReactNode\n removeLabel?: string\n /** Accessible names for the quantity stepper, given the line's name. */\n decreaseLabel?: (item: string) => string\n increaseLabel?: (item: string) => string\n /** Follows the unit price. */\n eachLabel?: ReactNode\n /** Shown once a line hits its stock cap. */\n stockNote?: (max: number) => ReactNode\n}) {\n const money = (minor: number) => (\n <Fmt type=\"currency\" value={minor / 100} currency={currency} locale={locale} decimals={2} />\n )\n\n if (lines.length === 0) {\n return (\n <div\n data-slot=\"cart\"\n className={cn(surface, radius.surface, 'p-8 text-center', className)}\n {...props}\n >\n <p className=\"text-muted-foreground text-sm\">{empty ?? 'Your basket is empty.'}</p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"cart\"\n className={cn(surface, radius.surface, 'divide-border list-none divide-y', className)}\n {...(props as ComponentProps<'ul'>)}\n >\n {lines.map((line) => {\n const atMax = line.max !== undefined && line.quantity >= line.max\n return (\n <li key={line.id} className=\"flex items-start gap-3 p-3\">\n {line.image && (\n <span\n className={cn('bg-secondary size-14 shrink-0 overflow-hidden', radius.control)}\n aria-hidden=\"true\"\n >\n {line.image}\n </span>\n )}\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <div className=\"flex items-start gap-2\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\">{line.name}</p>\n {line.variant && (\n <p className=\"text-muted-foreground truncate text-xs\">{line.variant}</p>\n )}\n </div>\n {onRemove && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={removeLabel}\n className=\"shrink-0\"\n onClick={() => onRemove(line.id)}\n >\n <Trash2 />\n </Button>\n )}\n </div>\n\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n {onQuantityChange ? (\n <div className={cn('bg-secondary flex items-center', radius.control)}>\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={decreaseLabel(typeof line.name === 'string' ? line.name : 'item')}\n disabled={line.quantity <= 1}\n onClick={() => onQuantityChange(line.id, line.quantity - 1)}\n >\n <Minus />\n </Button>\n <span\n className=\"min-w-7 text-center text-xs font-medium tabular-nums\"\n aria-live=\"polite\"\n >\n {line.quantity}\n </span>\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={increaseLabel(typeof line.name === 'string' ? line.name : 'item')}\n // Capped here, not rejected at checkout.\n disabled={atMax}\n onClick={() => onQuantityChange(line.id, line.quantity + 1)}\n >\n <Plus />\n </Button>\n </div>\n ) : (\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n ×{line.quantity}\n </span>\n )}\n\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {money(line.price)} {eachLabel}\n </span>\n\n <span className=\"ms-auto text-sm font-medium tabular-nums\">\n {money(line.price * line.quantity)}\n </span>\n </div>\n\n {atMax && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n {stockNote(line.max!)}\n </p>\n )}\n {line.note && <p className=\"text-muted-foreground text-xs\">{line.note}</p>}\n </div>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { Cart }\n"
21
+ }
22
+ ]
23
+ }