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,30 @@
1
+ {
2
+ "name": "composer",
3
+ "type": "registry:ui",
4
+ "title": "Composer",
5
+ "description": "A live playground: drive a set of props and watch the generated source follow. Assembled from the kit’s own controls, so anything awkward about Select, Switch or NumberInput surfaces here first.",
6
+ "category": "Utility",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "card",
13
+ "code-block",
14
+ "color-picker",
15
+ "input",
16
+ "label",
17
+ "lib-highlighter",
18
+ "lib-utils",
19
+ "number-input",
20
+ "select",
21
+ "switch"
22
+ ],
23
+ "files": [
24
+ {
25
+ "path": "components/ui/composer.tsx",
26
+ "type": "registry:ui",
27
+ "content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { RotateCcw } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardBody, CardHeader } from '@/components/ui/card'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { ColorPicker } from '@/components/ui/color-picker'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Select } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport type { Language } from '@/lib/highlighter'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live playground: drive a set of props and watch generated source follow.\n *\n * Assembled entirely from the kit's own controls, which is the point rather\n * than a flourish — this is the densest form on the site, so anything awkward\n * about Select, Switch or NumberInput surfaces here before it reaches anyone\n * else's form.\n *\n * It owns no knowledge of the documentation registry. `controls` describes the\n * inputs, `render` draws the result and `code` writes the snippet; a composer\n * works the same in a README page, a design review or a prop explorer.\n *\n * State can be controlled. The uncontrolled default is what a docs page wants,\n * but a page that needs two composers in sync — a light and a dark preview of\n * one configuration — has to be able to lift it.\n */\nexport type ComposerValue = string | boolean | number\n\nexport type ComposerControl =\n | {\n type: 'select'\n prop: string\n label: string\n options: readonly string[]\n default: string\n }\n | { type: 'boolean'; prop: string; label: string; default: boolean }\n | {\n type: 'text'\n prop: string\n label: string\n default: string\n placeholder?: string\n }\n | {\n type: 'number'\n prop: string\n label: string\n default: number\n min?: number\n max?: number\n step?: number\n }\n | { type: 'color'; prop: string; label: string; default: string }\n\nexport type ComposerState = Record<string, ComposerValue>\n\nfunction initialState(controls: ComposerControl[]): ComposerState {\n return Object.fromEntries(controls.map((control) => [control.prop, control.default]))\n}\n\ntype ComposerProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n controls: ComposerControl[]\n /** Draws the live result. */\n render: (state: ComposerState) => ReactNode\n /** Source for the current state, shown beneath the preview. */\n code?: (state: ComposerState) => string\n language?: Language\n /** Centre the preview in a taller box, for anything with real height. */\n tall?: boolean\n panelLabel?: ReactNode\n state?: ComposerState\n onStateChange?: (state: ComposerState) => void\n resetLabel?: ReactNode\n}\n\nfunction Composer({\n controls,\n render,\n code,\n language = 'tsx',\n tall = false,\n panelLabel = 'Props',\n state: stateProp,\n onStateChange,\n resetLabel = 'Reset',\n className,\n ...props\n}: ComposerProps) {\n const controlled = stateProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(() => initialState(controls))\n const state = controlled ? stateProp : uncontrolled\n\n const dirty = controls.some((control) => state[control.prop] !== control.default)\n // Two composers on one page is the ordinary docs case, so field ids are\n // namespaced per instance rather than derived from the prop name alone.\n const fieldScope = useId()\n\n function set(next: ComposerState) {\n if (!controlled) setUncontrolled(next)\n onStateChange?.(next)\n }\n\n return (\n <Card data-slot=\"composer\" className={cn('overflow-hidden', className)} {...props}>\n {/* Panel beside the preview on wide screens, beneath it on narrow ones —\n a 280px control column leaves nothing for the preview on a phone. */}\n <div className=\"grid lg:grid-cols-[minmax(0,1fr)_280px]\">\n <CardBody\n className={cn(\n 'flex items-center justify-center',\n tall ? 'min-h-80' : 'min-h-48',\n )}\n >\n {render(state)}\n </CardBody>\n\n <div className=\"border-border flex flex-col border-t lg:border-t-0 lg:border-s\">\n {/* A real CardHeader, not a label with a rule under it: the panel\n gets the same header band as every other card in the kit, and the\n band's own border replaces the Separator. */}\n <CardHeader className=\"flex-row items-center justify-between gap-2\">\n <span className=\"text-muted-foreground text-[11px] font-medium tracking-wide uppercase\">\n {panelLabel}\n </span>\n {dirty && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"-me-2\"\n onClick={() => set(initialState(controls))}\n >\n <RotateCcw />\n {resetLabel}\n </Button>\n )}\n </CardHeader>\n\n <div className=\"bg-secondary/40 flex flex-1 flex-col gap-3.5 p-4.5\">\n {controls.map((control) => (\n <ComposerField\n key={control.prop}\n scope={fieldScope}\n control={control}\n value={state[control.prop]}\n onChange={(value) => set({ ...state, [control.prop]: value })}\n />\n ))}\n\n {controls.length === 0 && (\n <p className=\"text-muted-foreground text-xs\">\n No props to configure.\n </p>\n )}\n </div>\n </div>\n </div>\n\n {code && (\n <div className=\"border-border border-t p-3\">\n <CodeBlock code={code(state)} language={language} />\n </div>\n )}\n </Card>\n )\n}\n\n/** One row of the control panel. */\nfunction ComposerField({\n control,\n value,\n onChange,\n scope,\n}: {\n control: ComposerControl\n value: ComposerValue\n onChange: (value: ComposerValue) => void\n scope: string\n resetLabel?: ReactNode\n}) {\n const id = `${scope}-${control.prop}`\n\n // A switch carries its own label, so it needs no Label above it.\n if (control.type === 'boolean') {\n return (\n <Switch\n id={id}\n size=\"sm\"\n checked={Boolean(value)}\n onChange={(event) => onChange(event.target.checked)}\n label={<span className=\"font-mono text-xs\">{control.label}</span>}\n labelPosition=\"start\"\n containerClassName=\"justify-between w-full\"\n />\n )\n }\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor={id} className=\"font-mono text-xs\">\n {control.label}\n </Label>\n\n {control.type === 'select' && (\n <Select\n size=\"sm\"\n value={String(value)}\n onValueChange={onChange}\n options={control.options.map((option) => ({\n value: option,\n label: option,\n }))}\n />\n )}\n\n {control.type === 'text' && (\n <Input\n id={id}\n size=\"sm\"\n value={String(value)}\n placeholder={control.placeholder}\n onChange={(event) => onChange(event.target.value)}\n />\n )}\n\n {control.type === 'number' && (\n <NumberInput\n id={id}\n size=\"sm\"\n value={Number(value)}\n min={control.min}\n max={control.max}\n step={control.step}\n onValueChange={(next) => onChange(next ?? 0)}\n />\n )}\n\n {control.type === 'color' && (\n <ColorPicker\n size=\"sm\"\n clearable\n value={String(value)}\n onValueChange={onChange}\n />\n )}\n </div>\n )\n}\n\nexport { Composer, initialState as composerInitialState }\nexport type { ComposerProps }\n"
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "confidence-meter",
3
+ "type": "registry:ui",
4
+ "title": "Confidence Meter",
5
+ "description": "How sure a model is, in five steps. Segmented on purpose — a smooth bar invites reading 0.834 as different from 0.851, which is not what a calibration score supports.",
6
+ "category": "AI",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/confidence-meter.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A model's confidence in an answer.\n *\n * Segmented rather than continuous, deliberately. A smooth bar invites reading\n * 0.834 as meaningfully different from 0.851, which is not what a calibration\n * score supports. Five steps say \"fairly confident\" and stop there.\n *\n * The band label is the accessible name, so a screen reader hears \"confidence:\n * high\" instead of a percentage that implies more precision than exists.\n */\nconst BANDS = [\n { max: 0.2, label: 'Very low', tone: 'bg-[var(--destructive)]' },\n { max: 0.4, label: 'Low', tone: 'bg-[var(--destructive)]' },\n { max: 0.6, label: 'Moderate', tone: 'bg-[var(--amber)]' },\n { max: 0.8, label: 'High', tone: 'bg-[var(--green)]' },\n { max: 1.01, label: 'Very high', tone: 'bg-[var(--green)]' },\n] as const\n\nconst SEGMENTS = 5\n\nfunction ConfidenceMeter({\n value,\n label = 'Confidence',\n showLabel = true,\n hint,\n size = 'default',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n /** 0 to 1. */\n value: number\n label?: ReactNode\n showLabel?: boolean\n hint?: ReactNode\n size?: 'sm' | 'default'\n}) {\n const ratio = Math.max(0, Math.min(value, 1))\n const band = BANDS.find((entry) => ratio < entry.max) ?? BANDS[BANDS.length - 1]\n const filled = Math.max(1, Math.ceil(ratio * SEGMENTS))\n\n return (\n <div\n data-slot=\"confidence-meter\"\n className={cn('flex min-w-0 flex-col gap-1', className)}\n {...props}\n >\n {showLabel && (\n <div className=\"text-muted-foreground flex items-baseline justify-between gap-2 text-xs\">\n <span className=\"font-medium\">{label}</span>\n <span>{band.label}</span>\n </div>\n )}\n\n <div\n role=\"meter\"\n aria-valuenow={Math.round(ratio * 100)}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuetext={band.label}\n aria-label={typeof label === 'string' ? label : 'Confidence'}\n className=\"flex gap-1\"\n >\n {Array.from({ length: SEGMENTS }, (_, index) => (\n <span\n key={index}\n className={cn(\n 'flex-1 rounded-full [corner-shape:round]',\n size === 'sm' ? 'h-1' : 'h-1.5',\n index < filled ? band.tone : 'bg-secondary',\n )}\n />\n ))}\n </div>\n\n {hint && <p className=\"text-muted-foreground/70 text-xs\">{hint}</p>}\n </div>\n )\n}\n\nexport { ConfidenceMeter }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "connection-string",
3
+ "type": "registry:ui",
4
+ "title": "Connection String",
5
+ "description": "A DSN parsed into the parts that matter for its driver. A file-backed database has a path and no host, user or port — pretending every database is Postgres is what makes most of these components useless outside it.",
6
+ "category": "Database",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "copy-button",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/connection-string.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Eye, EyeOff, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A connection string, parsed into the parts that matter for its driver.\n *\n * Which parts those are is not universal, and pretending otherwise is what\n * makes most of these components useless outside Postgres. A file-backed\n * database has a path and no host, user or port; a cluster URL has several\n * hosts and no single one; an ODBC string is not a URL at all. So the shape is\n * driven by a per-driver profile, and `drivers` lets you add or replace any of\n * them without touching the component.\n *\n * Two formats are parsed: URL-style (`postgres://…`, `sqlite:///…`) and the\n * semicolon key-value style SQL Server and ODBC use. A string that parses as\n * neither is reported rather than half-rendered from a regex that matched the\n * wrong separator.\n *\n * The secret is masked by default. This is the screen people paste into\n * tickets, and a DSN carries a live password in the middle of it.\n */\nexport type DsnField = 'user' | 'host' | 'port' | 'database' | 'path' | 'protocol'\n\nexport type DriverProfile = {\n /** Display name for the driver. */\n label: string\n /** Which parts to show, in order. */\n fields: DsnField[]\n /** Per-driver overrides for the field headings. */\n labels?: Partial<Record<DsnField, string>>\n /** The path is a file on disk, not a network address. */\n fileBased?: boolean\n /** Shown when the port is absent, so the effective value is visible. */\n defaultPort?: number\n /** Parameters worth flagging, mapped to why. */\n warn?: Record<string, string>\n}\n\nconst NETWORK: DsnField[] = ['protocol', 'user', 'host', 'port', 'database']\nconst FILE: DsnField[] = ['protocol', 'path']\n\n/**\n * Built-in profiles. Every one of these is overridable through `drivers`, and\n * an unknown scheme falls back to the network shape rather than refusing to\n * render — a driver nobody here anticipated is still probably a URL.\n */\nconst DRIVERS: Record<string, DriverProfile> = {\n postgres: {\n label: 'PostgreSQL', fields: NETWORK, defaultPort: 5432,\n warn: { sslmode: 'disable' },\n },\n postgresql: {\n label: 'PostgreSQL', fields: NETWORK, defaultPort: 5432,\n warn: { sslmode: 'disable' },\n },\n cockroachdb: { label: 'CockroachDB', fields: NETWORK, defaultPort: 26257, warn: { sslmode: 'disable' } },\n mysql: { label: 'MySQL', fields: NETWORK, defaultPort: 3306, warn: { tls: 'false' } },\n mariadb: { label: 'MariaDB', fields: NETWORK, defaultPort: 3306 },\n mongodb: { label: 'MongoDB', fields: NETWORK, defaultPort: 27017, warn: { tls: 'false', ssl: 'false' } },\n 'mongodb+srv': { label: 'MongoDB (SRV)', fields: ['protocol', 'user', 'host', 'database'] },\n redis: { label: 'Redis', fields: NETWORK, defaultPort: 6379 },\n rediss: { label: 'Redis (TLS)', fields: NETWORK, defaultPort: 6379 },\n clickhouse: { label: 'ClickHouse', fields: NETWORK, defaultPort: 8123 },\n mssql: { label: 'SQL Server', fields: NETWORK, defaultPort: 1433, warn: { encrypt: 'false' } },\n sqlserver: { label: 'SQL Server', fields: NETWORK, defaultPort: 1433, warn: { encrypt: 'false' } },\n amqp: { label: 'AMQP', fields: NETWORK, defaultPort: 5672 },\n // File-backed: no host, no user, no port. A path and nothing else.\n sqlite: { label: 'SQLite', fields: FILE, fileBased: true, labels: { path: 'File' } },\n sqlite3: { label: 'SQLite', fields: FILE, fileBased: true, labels: { path: 'File' } },\n file: { label: 'SQLite', fields: FILE, fileBased: true, labels: { path: 'File' } },\n duckdb: { label: 'DuckDB', fields: FILE, fileBased: true, labels: { path: 'File' } },\n libsql: { label: 'libSQL', fields: NETWORK },\n}\n\nconst FALLBACK: DriverProfile = { label: 'Database', fields: NETWORK }\n\nconst FIELD_LABELS: Record<DsnField, string> = {\n protocol: 'Driver',\n user: 'User',\n host: 'Host',\n port: 'Port',\n database: 'Database',\n path: 'Path',\n}\n\ntype Parsed = {\n scheme: string\n user: string\n password: string\n host: string\n port: string\n database: string\n path: string\n params: [string, string][]\n memory: boolean\n}\n\n/** `Server=x;Database=y;Password=z` — SQL Server and ODBC. */\nfunction parseKeyValue(dsn: string): Parsed | null {\n if (!/^[A-Za-z ]+\\s*=/.test(dsn.trim()) || !dsn.includes(';')) return null\n const entries = dsn\n .split(';')\n .map((pair) => pair.split('='))\n .filter((pair) => pair.length >= 2)\n .map(([key, ...rest]) => [key.trim().toLowerCase(), rest.join('=').trim()] as const)\n if (entries.length === 0) return null\n\n const get = (...keys: string[]) =>\n entries.find(([k]) => keys.includes(k))?.[1] ?? ''\n\n const server = get('server', 'data source', 'addr', 'host')\n const [host, port = ''] = server.split(/[,:]/)\n const known = new Set(['server', 'data source', 'addr', 'host', 'database', 'initial catalog', 'user id', 'uid', 'user', 'password', 'pwd'])\n\n return {\n scheme: 'sqlserver',\n user: get('user id', 'uid', 'user'),\n password: get('password', 'pwd'),\n host: host ?? '',\n port,\n database: get('database', 'initial catalog'),\n path: '',\n params: entries.filter(([k]) => !known.has(k)).map(([k, v]) => [k, v]),\n memory: false,\n }\n}\n\nfunction parseUrl(dsn: string): Parsed | null {\n try {\n const url = new URL(dsn)\n const scheme = url.protocol.replace(':', '').toLowerCase()\n // The text after the scheme, not `pathname`. `URL` normalises a relative\n // file path — `file:./data.db` becomes `/data.db` — which turns a path\n // relative to the working directory into an absolute one, and that is the\n // single most consequential detail in a file-backed DSN.\n const body = dsn.slice(dsn.indexOf(':') + 1).replace(/^\\/\\//, '').split('?')[0]\n const memory = /^:?memory:?$/.test(body)\n\n return {\n scheme,\n user: decodeURIComponent(url.username),\n password: decodeURIComponent(url.password),\n host: url.hostname,\n port: url.port,\n database: url.pathname.replace(/^\\//, ''),\n path: memory ? ':memory:' : body,\n params: [...url.searchParams.entries()],\n memory,\n }\n } catch {\n return null\n }\n}\n\nfunction ConnectionString({\n value,\n drivers,\n revealed: revealedProp,\n mask = '••••••••',\n invalidNote = 'That does not parse as a connection string. Expected a URL, or semicolon-separated key=value pairs.',\n unencodedNote = 'The password contains a character that must be percent-encoded. As written, the host will parse wrong.',\n relativePathNote = 'A relative path resolves against the working directory of whatever starts the process — not the config file.',\n memoryNote = 'An in-memory database is discarded when the process exits, and is not shared between connections.',\n revealLabel = 'Reveal password',\n hideLabel = 'Hide password',\n copyLabel = 'Copy connection string',\n fieldLabels,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n value: string\n /** Adds to or replaces the built-in driver profiles, keyed by scheme. */\n drivers?: Record<string, DriverProfile>\n revealed?: boolean\n mask?: string\n invalidNote?: ReactNode\n unencodedNote?: ReactNode\n /** Shown for a file-backed database given a relative path. */\n relativePathNote?: ReactNode\n memoryNote?: ReactNode\n revealLabel?: string\n hideLabel?: string\n copyLabel?: string\n /** Overrides the field headings for every driver. */\n fieldLabels?: Partial<Record<DsnField, string>>\n}) {\n const [own, setOwn] = useState(false)\n const revealed = revealedProp ?? own\n\n const parsed = useMemo(() => parseUrl(value) ?? parseKeyValue(value), [value])\n\n const profile = useMemo(() => {\n if (!parsed) return FALLBACK\n return drivers?.[parsed.scheme] ?? DRIVERS[parsed.scheme] ?? FALLBACK\n }, [parsed, drivers])\n\n const display = useMemo(() => {\n if (!parsed || revealed || !parsed.password) return value\n return value\n .replace(encodeURIComponent(parsed.password), mask)\n .replace(parsed.password, mask)\n }, [value, parsed, revealed, mask])\n\n if (!parsed) {\n return (\n <div className={cn(surface, radius.surface, 'p-4', className)} {...props}>\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {invalidNote}\n </p>\n </div>\n )\n }\n\n const shown: Record<DsnField, string> = {\n protocol: parsed.scheme,\n user: parsed.user,\n host: parsed.host,\n port: parsed.port || (profile.defaultPort ? `${profile.defaultPort}` : ''),\n database: parsed.database,\n path: parsed.path,\n }\n\n const flagged = profile.warn\n ? parsed.params.filter(([key, val]) => profile.warn?.[key] === val)\n : []\n // A raw @ or / in the password silently reassigns the host.\n const unencoded =\n /[@/?#]/.test(parsed.password) && !value.includes(encodeURIComponent(parsed.password))\n const relative =\n profile.fileBased && !parsed.memory && Boolean(parsed.path) && !parsed.path.startsWith('/')\n\n return (\n <div\n data-slot=\"connection-string\"\n data-driver={parsed.scheme}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-2\">\n <code className=\"min-w-0 flex-1 font-mono text-xs break-all\">{display}</code>\n {revealedProp === undefined && parsed.password && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={revealed ? hideLabel : revealLabel}\n aria-pressed={revealed}\n className=\"shrink-0\"\n onClick={() => setOwn((v) => !v)}\n >\n {revealed ? <EyeOff /> : <Eye />}\n </Button>\n )}\n <CopyButton value={value} label={copyLabel} className=\"shrink-0\" />\n </div>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\">{profile.label}</Badge>\n {parsed.memory && (\n <Badge size=\"sm\" color=\"amber\">\n in-memory\n </Badge>\n )}\n </div>\n\n {/* Only the fields this driver actually has. */}\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3\">\n {profile.fields.map((field) => (\n <div key={field} className={field === 'path' ? 'col-span-2 sm:col-span-3' : undefined}>\n <dt className=\"text-muted-foreground\">\n {fieldLabels?.[field] ?? profile.labels?.[field] ?? FIELD_LABELS[field]}\n </dt>\n <dd className=\"mt-0.5 truncate font-mono font-medium\">\n {shown[field] || <span className=\"text-muted-foreground/50\">—</span>}\n {field === 'port' && !parsed.port && profile.defaultPort && (\n <span className=\"text-muted-foreground/60 ms-1 font-normal\">default</span>\n )}\n </dd>\n </div>\n ))}\n </dl>\n\n {parsed.params.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {parsed.params.map(([key, val]) => (\n <Badge\n key={key}\n size=\"sm\"\n color={profile.warn?.[key] === val ? 'destructive' : 'neutral'}\n className=\"font-mono\"\n >\n {key}={val}\n </Badge>\n ))}\n </div>\n )}\n\n {flagged.map(([key, val]) => (\n <p key={key} className=\"text-xs text-[var(--destructive-soft-foreground)]\">\n <code className=\"font-mono\">\n {key}={val}\n </code>{' '}\n — credentials and results travel in the clear.\n </p>\n ))}\n\n {unencoded && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">{unencodedNote}</p>\n )}\n {relative && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">{relativePathNote}</p>\n )}\n {parsed.memory && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">{memoryNote}</p>\n )}\n </div>\n )\n}\n\nexport { ConnectionString, DRIVERS as connectionDrivers, parseUrl as parseDsn }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "container-list",
3
+ "type": "registry:ui",
4
+ "title": "Container List",
5
+ "description": "Running containers with state, image tag and resource use. Restart count is shown whenever it is non-zero — a container \"running\" with 47 restarts is crash-looping, and its state says everything is fine.",
6
+ "category": "Runtime",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "fmt",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/container-list.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Box, Play, RotateCcw, Square } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Running containers with their state, ports and resource use.\n *\n * Restart count is shown whenever it is non-zero, not just when the container\n * is unhealthy. A container that is \"running\" with 47 restarts is crash-looping\n * and its state alone says everything is fine.\n *\n * The image tag is shown separately from the repository, because `:latest` is\n * the detail that explains why two hosts are running different code.\n *\n * Actions are offered per state: you cannot start a running container, and the\n * control is absent rather than disabled — a greyed-out Start on a running\n * container is noise on every row.\n */\nexport type ContainerState = 'running' | 'exited' | 'restarting' | 'paused' | 'created'\n\nexport type Container = {\n id: string\n name: ReactNode\n /** Repository, without the tag. */\n image: string\n tag?: string\n state: ContainerState\n startedAt?: Date\n /** Non-zero is worth seeing even on a healthy container. */\n restarts?: number\n ports?: string[]\n cpu?: number\n memory?: number\n}\n\nconst STATE = {\n running: { label: 'Running', color: 'green' },\n exited: { label: 'Exited', color: 'neutral' },\n restarting: { label: 'Restarting', color: 'amber' },\n paused: { label: 'Paused', color: 'blue' },\n created: { label: 'Created', color: 'neutral' },\n} as const\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_RESTARTS_LABEL: (count: number) => ReactNode = (count) => `${count} restart${count === 1 ? '' : 's'}`\n\nfunction ContainerList({\n containers,\n onStart,\n onStop,\n onRestart,\n now,\n locale = 'en-GB',\n emptyLabel = 'No containers',\n upLabel = 'up',\n restartsLabel = DEFAULT_RESTARTS_LABEL,\n startLabel = 'Start',\n stopLabel = 'Stop',\n restartLabel = 'Restart',\n className,\n ...props\n}: ComponentProps<'div'> & {\n containers: Container[]\n onStart?: (id: string) => void\n onStop?: (id: string) => void\n onRestart?: (id: string) => void\n now?: Date\n locale?: string\n emptyLabel?: ReactNode\n /** Precedes the uptime. */\n upLabel?: ReactNode\n /** Restart count, given the number. */\n restartsLabel?: (count: number) => ReactNode\n startLabel?: string\n stopLabel?: string\n restartLabel?: string\n}) {\n if (containers.length === 0) {\n return (\n <div className={cn(surface, radius.surface, className)} {...props}>\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"container-list\"\n className={cn(surface, radius.surface, 'divide-border list-none divide-y', className)}\n {...(props as ComponentProps<'ul'>)}\n >\n {containers.map((container) => {\n const meta = STATE[container.state]\n const live = container.state === 'running' || container.state === 'restarting'\n\n return (\n <li key={container.id} className=\"flex items-start gap-3 p-3\">\n <Box className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2\">\n <span className=\"truncate text-sm font-medium\">{container.name}</span>\n <Badge size=\"sm\" color={meta.color}>\n {meta.label}\n </Badge>\n {/* Running with 47 restarts is a crash loop, not health. */}\n {Boolean(container.restarts) && (\n <Badge size=\"sm\" color={container.restarts! > 3 ? 'destructive' : 'amber'}>\n {restartsLabel(container.restarts!)}\n </Badge>\n )}\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 truncate font-mono text-xs\">\n {container.image}\n {/* The tag is why two hosts run different code. */}\n <span className=\"text-muted-foreground/60\">:{container.tag ?? 'latest'}</span>\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs\">\n {container.startedAt && live && (\n <span>\n {upLabel} <Fmt type=\"relative\" value={container.startedAt} now={now} locale={locale} />\n </span>\n )}\n {container.cpu !== undefined && (\n <span className=\"tabular-nums\">{container.cpu.toFixed(1)}% CPU</span>\n )}\n {container.memory !== undefined && (\n <span className=\"tabular-nums\">\n <Fmt type=\"bytes\" value={container.memory} />\n </span>\n )}\n {container.ports?.map((port) => (\n <span key={port} className=\"font-mono\">\n {port}\n </span>\n ))}\n </p>\n </div>\n\n {/* Absent rather than disabled — a greyed Start on every running\n row is noise. */}\n <div className=\"flex shrink-0 gap-1\">\n {!live && onStart && (\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={startLabel} onClick={() => onStart(container.id)}>\n <Play />\n </Button>\n )}\n {live && onStop && (\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={stopLabel} onClick={() => onStop(container.id)}>\n <Square />\n </Button>\n )}\n {onRestart && (\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={restartLabel} onClick={() => onRestart(container.id)}>\n <RotateCcw />\n </Button>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { ContainerList }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "context-menu",
3
+ "type": "registry:ui",
4
+ "title": "Context Menu",
5
+ "description": "A menu opened by right-click, positioned at the pointer. It has no element to anchor to — the anchor is a point — so it clamps against the viewport rather than flipping.",
6
+ "category": "Overlays",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-utils",
11
+ "primitive-dismissable"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/context-menu.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { menuItem, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A menu opened by right-click, positioned at the pointer.\n *\n * Unlike the other layers this has no element to anchor to — the anchor is a\n * point. It clamps against the viewport itself rather than flipping, since a\n * context menu should open down-and-right from the cursor whenever it fits.\n */\nexport type ContextMenuItem = {\n id: string\n label: string\n icon?: ReactNode\n shortcut?: string\n disabled?: boolean\n destructive?: boolean\n separatorBefore?: boolean\n onSelect?: () => void\n}\n\nfunction ContextMenu({\n items,\n children,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: ContextMenuItem[]\n children: ReactNode\n}) {\n const [point, setPoint] = useState<{ x: number; y: number } | null>(null)\n const menuRef = useRef<HTMLDivElement>(null)\n const triggerRef = useRef<HTMLDivElement>(null)\n\n useDismissable({\n open: point !== null,\n onDismiss: () => setPoint(null),\n refs: [menuRef],\n })\n\n // Measure after mount, then pull the menu back inside the viewport.\n useEffect(() => {\n if (!point || !menuRef.current) return\n const rect = menuRef.current.getBoundingClientRect()\n const x = Math.min(point.x, window.innerWidth - rect.width - 8)\n const y = Math.min(point.y, window.innerHeight - rect.height - 8)\n if (x !== point.x || y !== point.y) setPoint({ x, y })\n }, [point])\n\n return (\n <>\n <div\n ref={triggerRef}\n data-slot=\"context-menu-trigger\"\n onContextMenu={(event) => {\n event.preventDefault()\n setPoint({ x: event.clientX, y: event.clientY })\n }}\n className={className}\n {...props}\n >\n {children}\n </div>\n\n {point && (\n <div\n ref={menuRef}\n role=\"menu\"\n data-slot=\"context-menu\"\n style={{ position: 'fixed', top: point.y, left: point.x }}\n className={cn(menuSurface, radius.surface, 'min-w-48')}\n >\n {items.map((item) => (\n <div key={item.id}>\n {item.separatorBefore && (\n <div role=\"separator\" className=\"bg-border -mx-1 my-1 h-px\" />\n )}\n <button\n type=\"button\"\n role=\"menuitem\"\n disabled={item.disabled}\n data-disabled={item.disabled}\n onClick={() => {\n item.onSelect?.()\n setPoint(null)\n }}\n className={cn(\n menuItem,\n radius.control,\n item.destructive && 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n {item.icon}\n <span className=\"flex-1\">{item.label}</span>\n {item.shortcut && (\n <span className=\"text-muted-foreground font-mono text-xs\">\n {item.shortcut}\n </span>\n )}\n </button>\n </div>\n ))}\n </div>\n )}\n </>\n )\n}\n\nexport { ContextMenu }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "context-picker",
3
+ "type": "registry:ui",
4
+ "title": "Context Picker",
5
+ "description": "Picks what an assistant can see. A dropdown rather than a dialog, because attaching context happens mid-thought and a modal takes focus off the prompt you were writing.",
6
+ "category": "AI",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "dropdown-menu",
14
+ "input",
15
+ "lib-styles",
16
+ "lib-utils"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/context-picker.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, FileCode, Plus, Search } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Input } from '@/components/ui/input'\nimport { menuItem, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Picks what an assistant can see: files, selections, docs, tools.\n *\n * A dropdown rather than a dialog, because attaching context happens mid-thought\n * — a modal would take focus away from the prompt you were part-way through\n * writing. Already-attached sources stay in the list with a tick rather than\n * disappearing, so the menu does not reshuffle under the pointer.\n */\nexport type ContextSource = {\n id: string\n label: string\n group?: string\n detail?: string\n icon?: ReactNode\n disabled?: boolean\n}\n\nfunction ContextPicker({\n sources,\n selected = [],\n onSelect,\n onDeselect,\n trigger,\n emptyMessage = 'Nothing matches',\n triggerLabel = 'Context',\n searchPlaceholder = 'Search files and docs',\n searchLabel = 'Search context sources',\n ...props\n // 'onSelect' is also a DOM event on the underlying div; omitting it stops the\n // native signature from shadowing this one.\n}: Omit<ComponentProps<typeof DropdownMenuContent>, 'children' | 'onSelect'> & {\n sources: ContextSource[]\n selected?: string[]\n onSelect?: (id: string) => void\n onDeselect?: (id: string) => void\n trigger?: ReactNode\n emptyMessage?: string\n /** Text on the default trigger. Ignored when `trigger` is given. */\n triggerLabel?: ReactNode\n searchPlaceholder?: string\n /** Accessible name for the search field. */\n searchLabel?: string\n}) {\n const [query, setQuery] = useState('')\n\n const groups = useMemo(() => {\n const needle = query.trim().toLowerCase()\n const matched = needle\n ? sources.filter((s) =>\n `${s.label} ${s.detail ?? ''} ${s.group ?? ''}`\n .toLowerCase()\n .includes(needle),\n )\n : sources\n\n const map = new Map<string, ContextSource[]>()\n for (const source of matched) {\n const key = source.group ?? ''\n if (!map.has(key)) map.set(key, [])\n map.get(key)!.push(source)\n }\n return [...map.entries()]\n }, [sources, query])\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n {trigger ?? (\n <Button variant=\"ghost\" size=\"xs\">\n <Plus /> {triggerLabel}\n </Button>\n )}\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align=\"start\" className=\"w-72 p-0\" {...props}>\n <div className=\"border-border border-b p-1.5\">\n <Input\n size=\"sm\"\n icon={<Search />}\n placeholder={searchPlaceholder}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n aria-label={searchLabel}\n />\n </div>\n\n <div className=\"max-h-64 overflow-auto p-1\">\n {groups.length === 0 && (\n <p className=\"text-muted-foreground px-2.5 py-6 text-center text-sm\">\n {emptyMessage}\n </p>\n )}\n\n {groups.map(([group, items]) => (\n <div key={group || 'ungrouped'}>\n {group && <DropdownMenuLabel>{group}</DropdownMenuLabel>}\n {items.map((source) => {\n const on = selected.includes(source.id)\n return (\n <button\n key={source.id}\n type=\"button\"\n role=\"menuitemcheckbox\"\n aria-checked={on}\n data-disabled={source.disabled || undefined}\n disabled={source.disabled}\n onClick={() => (on ? onDeselect?.(source.id) : onSelect?.(source.id))}\n className={cn(menuItem, radius.control, 'w-full')}\n >\n {source.icon ?? <FileCode />}\n <span className=\"min-w-0 flex-1 truncate text-start\">\n {source.label}\n </span>\n {source.detail && (\n <span className=\"text-muted-foreground/70 shrink-0 font-mono text-xs\">\n {source.detail}\n </span>\n )}\n {on && <Check className=\"size-3.5 shrink-0\" />}\n </button>\n )\n })}\n </div>\n ))}\n </div>\n\n {selected.length > 0 && (\n <>\n <DropdownMenuSeparator className=\"mx-0 my-0\" />\n <div className=\"text-muted-foreground flex items-center justify-between gap-2 p-3 text-xs\">\n <span>{selected.length} attached</span>\n <Badge size=\"sm\" variant=\"secondary\">\n {selected.length}\n </Badge>\n </div>\n </>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\nexport { ContextPicker }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "coordinate-input",
3
+ "type": "registry:ui",
4
+ "title": "Coordinate Input",
5
+ "description": "Latitude and longitude, accepting decimal degrees or DMS. A latitude above 90 is reported as \"is this a longitude?\" rather than \"out of range\" — swapped pairs are the most common coordinate bug there is.",
6
+ "category": "Geo",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "copy-button",
10
+ "input",
11
+ "label",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/coordinate-input.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { useEffect, useId, useState, type ClipboardEvent, type ComponentProps, type ReactNode } from 'react'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { cn } from '@/lib/utils'\n\n/**\n * Latitude and longitude, accepting decimal degrees or DMS.\n *\n * People paste coordinates from wherever they found them — `51.5074, -0.1278`\n * from a URL, `51°30'26.6\"N 0°07'39.9\"W` from a chart. Rejecting the second\n * form means the user converts it by hand, which is exactly where a digit gets\n * dropped. Both are parsed; the canonical decimal pair is what leaves.\n *\n * A single field accepts a pasted pair and splits it, because a comma-separated\n * pair is how coordinates travel and forcing two paste operations is a small\n * cruelty.\n *\n * Ranges are checked and, more usefully, so is the *ordering*: a latitude above\n * 90 is almost always a longitude in the wrong box. Saying so is more helpful\n * than \"out of range\", and swapped pairs are the most common coordinate bug\n * there is.\n *\n * Six decimal places is the display precision — about 11cm. More digits imply an\n * accuracy no consumer GPS has.\n */\nexport type Coordinate = { lat: number; lng: number }\n\n/** Decimal degrees, or degrees/minutes/seconds with an optional hemisphere. */\nfunction parseComponent(raw: string): number | null {\n const text = raw.trim()\n if (!text) return null\n\n const decimal = Number(text)\n if (!Number.isNaN(decimal) && /^[+-]?\\d*\\.?\\d+$/.test(text)) return decimal\n\n const dms = text.match(\n /^([NSEW])?\\s*(\\d+(?:\\.\\d+)?)\\s*°?\\s*(?:(\\d+(?:\\.\\d+)?)\\s*['′]?\\s*)?(?:(\\d+(?:\\.\\d+)?)\\s*[\"″]?\\s*)?([NSEW])?$/i,\n )\n if (!dms) return null\n\n const degrees = Number(dms[2]) + Number(dms[3] ?? 0) / 60 + Number(dms[4] ?? 0) / 3600\n const hemisphere = (dms[1] ?? dms[5] ?? '').toUpperCase()\n return hemisphere === 'S' || hemisphere === 'W' ? -degrees : degrees\n}\n\n/** Splits a pasted `lat, lng` pair on a comma or whitespace between the two. */\nfunction parsePair(raw: string): Coordinate | null {\n const parts = raw.split(/\\s*,\\s*|\\s{2,}|(?<=[NS])\\s+/i).filter(Boolean)\n if (parts.length !== 2) return null\n const lat = parseComponent(parts[0])\n const lng = parseComponent(parts[1])\n return lat === null || lng === null ? null : { lat, lng }\n}\n\nfunction round(value: number) {\n // ~11cm. More digits claim an accuracy no consumer GPS has.\n return Math.round(value * 1e6) / 1e6\n}\n\nfunction CoordinateInput({\n value,\n onValueChange,\n size = 'md',\n disabled,\n copyable = true,\n latitudeLabel = 'Latitude',\n longitudeLabel = 'Longitude',\n latitudePlaceholder = '51.5074',\n longitudePlaceholder = '-0.1278',\n copyLabel = 'Copy coordinates',\n invalidNote = 'Not a coordinate',\n latitudeRangeNote = 'Above 90° — is this a longitude?',\n longitudeRangeNote = 'Beyond 180°',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange' | 'value'> & {\n value?: Coordinate | null\n onValueChange?: (value: Coordinate | null) => void\n size?: 'sm' | 'md'\n disabled?: boolean\n copyable?: boolean\n latitudeLabel?: ReactNode\n longitudeLabel?: ReactNode\n latitudePlaceholder?: string\n longitudePlaceholder?: string\n copyLabel?: string\n /** Shown when a field does not parse at all. */\n invalidNote?: ReactNode\n /** Shown for a latitude past ±90 — usually a swapped pair. */\n latitudeRangeNote?: ReactNode\n longitudeRangeNote?: ReactNode\n}) {\n const scope = useId()\n const [lat, setLat] = useState(value ? String(round(value.lat)) : '')\n const [lng, setLng] = useState(value ? String(round(value.lng)) : '')\n\n useEffect(() => {\n if (!value) return\n setLat(String(round(value.lat)))\n setLng(String(round(value.lng)))\n }, [value])\n\n const latValue = parseComponent(lat)\n const lngValue = parseComponent(lng)\n\n // Swapped pairs are the most common coordinate bug there is.\n const latError =\n lat && (latValue === null ? invalidNote : Math.abs(latValue) > 90 ? latitudeRangeNote : undefined)\n const lngError =\n lng && (lngValue === null ? invalidNote : Math.abs(lngValue) > 180 ? longitudeRangeNote : undefined)\n\n function commit(nextLat: string, nextLng: string) {\n const a = parseComponent(nextLat)\n const b = parseComponent(nextLng)\n if (a === null || b === null || Math.abs(a) > 90 || Math.abs(b) > 180) {\n onValueChange?.(null)\n return\n }\n onValueChange?.({ lat: round(a), lng: round(b) })\n }\n\n /** A pasted pair fills both boxes rather than demanding two pastes. */\n function handlePaste(event: ClipboardEvent<HTMLInputElement>) {\n const pair = parsePair(event.clipboardData.getData('text'))\n if (!pair) return\n event.preventDefault()\n const nextLat = String(round(pair.lat))\n const nextLng = String(round(pair.lng))\n setLat(nextLat)\n setLng(nextLng)\n commit(nextLat, nextLng)\n }\n\n return (\n <div\n data-slot=\"coordinate-input\"\n className={cn('flex flex-wrap items-start gap-2', className)}\n {...props}\n >\n <div className=\"flex min-w-32 flex-1 flex-col gap-1.5\">\n <Label htmlFor={`${scope}-lat`} className=\"text-xs\">\n {latitudeLabel}\n </Label>\n <Input\n id={`${scope}-lat`}\n size={size === 'sm' ? 'sm' : 'default'}\n inputMode=\"text\"\n placeholder={latitudePlaceholder}\n value={lat}\n disabled={disabled}\n error={Boolean(latError)}\n onPaste={handlePaste}\n onChange={(event) => {\n setLat(event.target.value)\n commit(event.target.value, lng)\n }}\n />\n {latError && (\n <p className=\"text-[var(--destructive-soft-foreground)] text-xs\">{latError}</p>\n )}\n </div>\n\n <div className=\"flex min-w-32 flex-1 flex-col gap-1.5\">\n <Label htmlFor={`${scope}-lng`} className=\"text-xs\">\n {longitudeLabel}\n </Label>\n <Input\n id={`${scope}-lng`}\n size={size === 'sm' ? 'sm' : 'default'}\n inputMode=\"text\"\n placeholder={longitudePlaceholder}\n value={lng}\n disabled={disabled}\n error={Boolean(lngError)}\n onPaste={handlePaste}\n onChange={(event) => {\n setLng(event.target.value)\n commit(lat, event.target.value)\n }}\n />\n {lngError && (\n <p className=\"text-[var(--destructive-soft-foreground)] text-xs\">{lngError}</p>\n )}\n </div>\n\n {copyable && latValue !== null && lngValue !== null && !latError && !lngError && (\n <CopyButton\n value={`${round(latValue)}, ${round(lngValue)}`}\n label={copyLabel}\n className=\"mt-6 shrink-0\"\n />\n )}\n </div>\n )\n}\n\nexport { CoordinateInput, parseComponent as parseCoordinate, parsePair as parseCoordinatePair }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "copy-button",
3
+ "type": "registry:ui",
4
+ "title": "Copy Button",
5
+ "description": "Copies a string and says so. Extracted after the same clipboard-plus-timer was written four times in this kit — CodeBlock, Terminal, EnvVars and Message all route through it now.",
6
+ "category": "Utility",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "lib-use-clipboard",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/copy-button.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ReactNode } from 'react'\nimport { Check, Copy, X } from 'lucide-react'\nimport { Button, type ButtonProps } from '@/components/ui/button'\nimport { useClipboard } from '@/lib/use-clipboard'\nimport { cn } from '@/lib/utils'\n\n/**\n * Copies a string and says so.\n *\n * The label changes rather than only the icon, because a tick replacing a\n * clipboard is invisible to a screen reader unless the accessible name changes\n * too — and `aria-live` on a button is the wrong tool for something the user\n * just did deliberately.\n *\n * `value` may be a function, for text that is expensive to build or that must\n * be read at click time rather than at render time.\n */\nfunction CopyButton({\n value,\n label = 'Copy',\n copiedLabel = 'Copied',\n showLabel = false,\n variant = 'ghost',\n size,\n className,\n onClick,\n ...props\n}: Omit<ButtonProps, 'value' | 'children'> & {\n value: string | (() => string)\n label?: string\n copiedLabel?: string\n /** Render the label beside the icon instead of only as the accessible name. */\n showLabel?: boolean\n children?: ReactNode\n}) {\n const { copy, state } = useClipboard()\n\n const text =\n state === 'error' ? 'Copy failed' : state === 'copied' ? copiedLabel : label\n const Icon = state === 'error' ? X : state === 'copied' ? Check : Copy\n\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={size ?? (showLabel ? 'xs' : 'icon-xs')}\n data-state={state}\n // The accessible name carries the result; the icon alone would not.\n aria-label={showLabel ? undefined : text}\n className={cn(state === 'error' && 'text-[var(--destructive-soft-foreground)]', className)}\n onClick={(event) => {\n onClick?.(event)\n void copy(typeof value === 'function' ? value() : value)\n }}\n {...props}\n >\n <Icon />\n {showLabel && text}\n </Button>\n )\n}\n\nexport { CopyButton }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "cost-breakdown",
3
+ "type": "registry:ui",
4
+ "title": "Cost Breakdown",
5
+ "description": "Spend by model, with token volume beside it. The two routinely disagree — the cheap model handling 90% of calls is often 10% of the bill — and either number alone leads to the wrong optimisation.",
6
+ "category": "AI",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "fmt",
10
+ "lib-styles",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/cost-breakdown.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import { useMemo, 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 * Spend broken down by model, with token volume.\n *\n * Cost and volume are shown side by side because they routinely disagree: the\n * cheap model handling 90% of calls is often 10% of the bill, and a chart of\n * either number alone leads to the wrong optimisation.\n *\n * Precision follows magnitude rather than position: under a unit gets four\n * decimals, anything larger gets two. A per-call cost rounded to cents is all\n * zeroes, but an aggregate carried to four is both noise and the widest string\n * the column ever has to hold.\n *\n * That width matters because the currency prefix is locale-dependent — the\n * default `en-GB` renders USD as \"US$\", so the same number is three characters\n * wider than it is under `en-US`. The column is therefore a minimum width that\n * can grow, never a fixed one that clips.\n */\nexport type CostRow = {\n id: string\n label: ReactNode\n cost: number\n /** Input and output tokens, kept apart — they are priced differently. */\n inputTokens?: number\n outputTokens?: number\n calls?: number\n color?: string\n}\n\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_VOLUME_LABEL: (share: number) => ReactNode = (share) => `(${share}% of volume)`\n\nfunction CostBreakdown({\n rows,\n currency = 'USD',\n period,\n budget,\n title = 'Spend',\n budgetLabel = 'Budget',\n callsLabel = 'calls',\n volumeLabel = DEFAULT_VOLUME_LABEL,\n inputTokensLabel = 'in',\n outputTokensLabel = 'out',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n rows: CostRow[]\n currency?: string\n period?: ReactNode\n /** Draws a budget line under the total. */\n budget?: number\n title?: ReactNode\n budgetLabel?: ReactNode\n /** Follows the call count. */\n callsLabel?: ReactNode\n /** That row's share of total calls, given the percentage. */\n volumeLabel?: (share: number) => ReactNode\n /** Follows the input-token count. */\n inputTokensLabel?: ReactNode\n outputTokensLabel?: ReactNode\n}) {\n const total = useMemo(() => rows.reduce((sum, row) => sum + row.cost, 0), [rows])\n const maxCost = Math.max(...rows.map((row) => row.cost), 0.0001)\n const totalCalls = rows.reduce((sum, row) => sum + (row.calls ?? 0), 0)\n\n return (\n <div\n data-slot=\"cost-breakdown\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-baseline gap-x-3 border-b p-3\">\n <span className=\"text-sm font-medium\">{title}</span>\n {period && <span className=\"text-muted-foreground text-xs\">{period}</span>}\n <span className=\"ms-auto text-lg font-semibold tabular-nums whitespace-nowrap\">\n <Fmt type=\"currency\" value={total} currency={currency} decimals={2} />\n </span>\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {rows.map((row, index) => {\n const share = total > 0 ? row.cost / total : 0\n const colour = row.color ?? dataFills[index % dataFills.length]\n\n return (\n <li key={row.id} className=\"flex flex-col gap-1.5 p-3\">\n <div className=\"flex flex-wrap items-baseline gap-x-3\">\n <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n <span\n aria-hidden=\"true\"\n className=\"size-2.5 shrink-0 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: colour }}\n />\n <span className=\"truncate text-sm font-medium\">{row.label}</span>\n </span>\n\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n {Math.round(share * 100)}%\n </span>\n <span className=\"min-w-20 shrink-0 text-end text-sm tabular-nums whitespace-nowrap\">\n <Fmt\n type=\"currency\"\n value={row.cost}\n currency={currency}\n decimals={row.cost < 1 ? 4 : 2}\n />\n </span>\n </div>\n\n <div className=\"bg-secondary h-1.5 w-full overflow-hidden rounded-full [corner-shape:round]\">\n <div\n className=\"h-full rounded-full [corner-shape:round]\"\n style={{ width: `${(row.cost / maxCost) * 100}%`, backgroundColor: colour }}\n />\n </div>\n\n {(row.inputTokens !== undefined ||\n row.outputTokens !== undefined ||\n row.calls !== undefined) && (\n <p className=\"text-muted-foreground/80 flex flex-wrap gap-x-3 text-xs tabular-nums\">\n {row.calls !== undefined && (\n <span>\n <Fmt type=\"number\" value={row.calls} /> {callsLabel}\n {totalCalls > 0 && (\n <span className=\"opacity-70\">\n {' '}\n {volumeLabel(Math.round((row.calls / totalCalls) * 100))}\n </span>\n )}\n </span>\n )}\n {row.inputTokens !== undefined && (\n <span>\n <Fmt type=\"number\" value={row.inputTokens} /> {inputTokensLabel}\n </span>\n )}\n {row.outputTokens !== undefined && (\n <span>\n <Fmt type=\"number\" value={row.outputTokens} /> {outputTokensLabel}\n </span>\n )}\n </p>\n )}\n </li>\n )\n })}\n </ul>\n\n {budget !== undefined && (\n <div className=\"border-border flex items-baseline justify-between gap-2 border-t p-3\">\n <span className=\"text-muted-foreground text-xs\">{budgetLabel}</span>\n <span\n className={cn(\n 'text-xs tabular-nums',\n total > budget ? 'font-medium text-[var(--destructive-soft-foreground)]' : 'text-muted-foreground',\n )}\n >\n <Fmt type=\"currency\" value={total} currency={currency} decimals={2} /> of{' '}\n <Fmt type=\"currency\" value={budget} currency={currency} decimals={2} />\n {total > budget && ' — over'}\n </span>\n </div>\n )}\n </div>\n )\n}\n\nexport { CostBreakdown }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "coverage-report",
3
+ "type": "registry:ui",
4
+ "title": "Coverage Report",
5
+ "description": "Coverage per file, ranked by uncovered lines rather than by percentage. A 400-line file at 92% has 32 untested lines; a 12-line file at 60% has 5 — and percentage ranks the small one first.",
6
+ "category": "Build & Perf",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/coverage-report.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Test coverage per file, with the uncovered lines named.\n *\n * Sorted by uncovered lines rather than by percentage. A 400-line file at 92%\n * has 32 untested lines; a 12-line file at 60% has 5. Percentage ranks the\n * small file first and that is exactly backwards for someone deciding what to\n * test next.\n *\n * Four metrics are kept apart. Statement coverage flatters: a file can hit\n * every statement and miss half its branches, which is where the bugs are.\n * Branch coverage is the one shown in the bar.\n *\n * The threshold is drawn as a line on each bar, so \"below target\" is visible\n * per row without reading numbers.\n */\nexport type CoverageFile = {\n path: string\n statements: number\n branches: number\n functions: number\n lines: number\n /** Line numbers with no coverage. */\n uncovered?: number[]\n}\n\nfunction tone(value: number, threshold: number) {\n if (value >= threshold) return 'var(--green)'\n if (value >= threshold - 0.15) return 'var(--amber)'\n return 'var(--destructive)'\n}\n\nfunction CoverageReport({\n files,\n threshold = 0.8,\n metric = 'branches',\n thresholdLabel = 'target',\n uncoveredLabel = 'uncovered lines',\n totalLabel = 'Total',\n metricLabels = {\n statements: 'Stmts',\n branches: 'Branch',\n functions: 'Funcs',\n lines: 'Lines',\n },\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n files: CoverageFile[]\n /** Ratio 0–1. Drawn as a line on every bar. */\n threshold?: number\n /** Which metric the bar shows. Branches by default — statements flatter. */\n metric?: 'statements' | 'branches' | 'functions' | 'lines'\n thresholdLabel?: ReactNode\n uncoveredLabel?: ReactNode\n totalLabel?: ReactNode\n metricLabels?: Record<'statements' | 'branches' | 'functions' | 'lines', ReactNode>\n}) {\n const [open, setOpen] = useState<string | null>(null)\n\n // Ranked by untested lines, not by percentage — that is the actionable order.\n const rows = useMemo(\n () =>\n [...files].sort(\n (a, b) => (b.uncovered?.length ?? 0) - (a.uncovered?.length ?? 0) || a[metric] - b[metric],\n ),\n [files, metric],\n )\n\n const totals = useMemo(() => {\n if (files.length === 0) return null\n const sum = (key: keyof Omit<CoverageFile, 'path' | 'uncovered'>) =>\n files.reduce((total, file) => total + file[key], 0) / files.length\n return {\n statements: sum('statements'),\n branches: sum('branches'),\n functions: sum('functions'),\n lines: sum('lines'),\n }\n }, [files])\n\n const pct = (value: number) => `${Math.round(value * 100)}%`\n\n return (\n <div\n data-slot=\"coverage-report\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n {totals && (\n <div className=\"border-border flex flex-wrap items-center gap-x-4 gap-y-1 border-b p-3\">\n <span className=\"text-sm font-medium\">{totalLabel}</span>\n {(['statements', 'branches', 'functions', 'lines'] as const).map((key) => (\n <span key={key} className=\"text-xs tabular-nums\">\n <span className=\"text-muted-foreground\">{metricLabels[key]} </span>\n <span style={{ color: tone(totals[key], threshold) }}>{pct(totals[key])}</span>\n </span>\n ))}\n <span className=\"text-muted-foreground ms-auto text-xs\">\n {thresholdLabel} {pct(threshold)}\n </span>\n </div>\n )}\n\n <ul className=\"divide-border/60 list-none divide-y\">\n {rows.map((file) => {\n const value = file[metric]\n const expanded = open === file.path\n const missing = file.uncovered ?? []\n\n return (\n <li key={file.path}>\n <button\n type=\"button\"\n aria-expanded={expanded}\n disabled={missing.length === 0}\n onClick={() => setOpen(expanded ? null : file.path)}\n className={cn(\n 'flex w-full items-center gap-3 p-3 text-start',\n missing.length > 0 && interactive,\n focusRing,\n )}\n >\n {missing.length > 0 ? (\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 <span className=\"min-w-0 flex-1 truncate font-mono text-xs\">{file.path}</span>\n\n {missing.length > 0 && (\n <Badge size=\"sm\" color={value >= threshold ? 'neutral' : 'amber'}>\n {missing.length}\n </Badge>\n )}\n\n <span className=\"relative hidden h-1.5 w-32 shrink-0 overflow-hidden rounded-full bg-[var(--secondary)] sm:block\">\n <span\n className=\"absolute inset-y-0 start-0\"\n style={{ width: `${value * 100}%`, background: tone(value, threshold) }}\n />\n {/* The target, drawn per row so \"below\" needs no reading. */}\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground/40 absolute inset-y-0 w-px\"\n style={{ insetInlineStart: `${threshold * 100}%` }}\n />\n </span>\n\n <span\n className=\"w-10 shrink-0 text-end text-xs font-medium tabular-nums\"\n style={{ color: tone(value, threshold) }}\n >\n {pct(value)}\n </span>\n </button>\n\n {expanded && missing.length > 0 && (\n <div className=\"bg-muted/30 border-border/60 border-t p-3\">\n <p className=\"text-muted-foreground mb-1.5 text-xs\">{uncoveredLabel}</p>\n <p className=\"font-mono text-xs break-words\">{missing.join(', ')}</p>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { CoverageReport }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "cron-schedule",
3
+ "type": "registry:ui",
4
+ "title": "Cron Schedule",
5
+ "description": "Scheduled jobs: when each next runs and how the last went. An overdue job — next run in the past with nothing running — is called out, since that is the failure a schedule list exists to catch.",
6
+ "category": "Operations",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "fmt",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "spinner"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/cron-schedule.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Clock, Pause, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Spinner } from '@/components/ui/spinner'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Scheduled jobs: when each next runs, and how the last one went.\n *\n * Both times are shown relative, because \"in 4 hours\" and \"2 hours ago\" are the\n * questions people actually have about a cron. The absolute time is in the\n * title attribute for when it matters.\n *\n * An overdue job — next run in the past while nothing is running — is called\n * out explicitly. That is the failure mode a schedule list exists to catch, and\n * it is invisible if you only render the timestamp.\n */\nexport type CronJob = {\n id: string\n name: string\n /** Standard cron expression, shown verbatim. */\n expression: string\n description?: ReactNode\n nextRun?: Date\n lastRun?: Date\n lastStatus?: 'success' | 'failed' | 'running'\n /** Seconds. */\n lastDuration?: number\n paused?: boolean\n}\n\nconst STATUS = {\n success: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]', label: 'Succeeded' },\n failed: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]', label: 'Failed' },\n running: { Icon: null, tone: 'text-[var(--blue-soft-foreground)]', label: 'Running' },\n} as const\n\nfunction CronSchedule({\n jobs,\n now,\n locale = 'en-GB',\n pausedLabel = 'Paused',\n overdueLabel = 'Overdue',\n nextLabel = 'Next',\n lastLabel = 'Last',\n tookLabel = 'Took',\n className,\n ...props\n}: ComponentProps<'div'> & {\n jobs: CronJob[]\n now?: Date\n locale?: string\n pausedLabel?: ReactNode\n /** Badge on a job whose next run is already in the past. */\n overdueLabel?: ReactNode\n /** Precedes the next-run time. */\n nextLabel?: ReactNode\n /** Precedes the last-run time. */\n lastLabel?: ReactNode\n /** Precedes the last run's duration. */\n tookLabel?: ReactNode\n}) {\n const reference = now ?? new Date()\n\n return (\n <div\n data-slot=\"cron-schedule\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {jobs.map((job) => {\n const status = job.lastStatus ? STATUS[job.lastStatus] : undefined\n const overdue =\n !job.paused &&\n job.lastStatus !== 'running' &&\n job.nextRun !== undefined &&\n job.nextRun < reference\n\n return (\n <li\n key={job.id}\n data-paused={job.paused || undefined}\n className={cn('flex flex-col gap-2 p-3', job.paused && 'opacity-60')}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n {job.paused ? (\n <Pause className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n ) : status?.Icon ? (\n <status.Icon\n className={cn('size-4 shrink-0', status.tone)}\n aria-hidden=\"true\"\n />\n ) : status ? (\n <Spinner size=\"xs\" className={cn('shrink-0', status.tone)} label=\"Running\" />\n ) : (\n <Clock className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n )}\n\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {job.name}\n </span>\n\n <code className=\"bg-secondary text-muted-foreground shrink-0 rounded-sm px-1.5 py-0.5 font-mono text-xs\">\n {job.expression}\n </code>\n\n {job.paused && <Badge size=\"sm\">{pausedLabel}</Badge>}\n {overdue && (\n <Badge size=\"sm\" color=\"destructive\">\n {overdueLabel}\n </Badge>\n )}\n </div>\n\n {job.description && (\n <p className=\"text-muted-foreground text-sm\">{job.description}</p>\n )}\n\n <div className=\"text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs\">\n {job.nextRun && !job.paused && (\n <span title={job.nextRun.toISOString()}>\n {nextLabel} <Fmt type=\"relative\" value={job.nextRun} now={reference} locale={locale} />\n </span>\n )}\n {job.lastRun && (\n <span title={job.lastRun.toISOString()}>\n {lastLabel} <Fmt type=\"relative\" value={job.lastRun} now={reference} locale={locale} />\n {status && ` · ${status.label.toLowerCase()}`}\n </span>\n )}\n {job.lastDuration !== undefined && (\n <span className=\"tabular-nums\">\n {tookLabel} <Fmt type=\"duration\" value={job.lastDuration} />\n </span>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { CronSchedule }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "curl-command",
3
+ "type": "registry:ui",
4
+ "title": "Curl Command",
5
+ "description": "A request as a runnable curl command, shell-quoted and with secrets masked. This output gets pasted into tickets, and an Authorization header pasted into a ticket is a leaked credential.",
6
+ "category": "API",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "code-block"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/curl-command.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import { useMemo, type ComponentProps } from 'react'\nimport { CodeBlock } from '@/components/ui/code-block'\n\n/**\n * A request rendered as a runnable curl command.\n *\n * Every value is shell-quoted with single quotes and an escape for embedded\n * ones. A JSON body pasted straight into a command line is the classic way to\n * produce a command that looks right and silently truncates at the first\n * apostrophe in someone's surname.\n *\n * Headers matching `redact` are masked. The whole point of this component is\n * that the output gets pasted into a terminal, a ticket, or a chat — and an\n * Authorization header pasted into a ticket is a leaked credential.\n *\n * Long commands are broken across lines with a trailing backslash, which is\n * what makes the difference between something a reader can check and a\n * 400-character single line.\n */\nexport type CurlHeader = { name: string; value: string }\n\n/** POSIX single-quoting: end the quote, escape the quote, reopen. */\nfunction shellQuote(value: string) {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction CurlCommand({\n method = 'GET',\n url,\n headers = [],\n body,\n redact = [/^authorization$/i, /^cookie$/i, /api[-_]?key/i, /^x-.*-token$/i],\n mask = '••••••••',\n insecure = false,\n title = 'curl',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'title'> & {\n method?: string\n url: string\n headers?: CurlHeader[]\n /** Sent with `--data`. An object is JSON-encoded. */\n body?: string | object\n /** Header names whose values are masked. */\n redact?: RegExp[]\n mask?: string\n /** Adds `-k`. Worth showing when it is genuinely in play. */\n insecure?: boolean\n title?: string\n}) {\n const command = useMemo(() => {\n const parts = [`curl -X ${method.toUpperCase()}`]\n if (insecure) parts.push('-k')\n parts.push(shellQuote(url))\n\n for (const header of headers) {\n // Masked before quoting, so the mask itself is quoted safely.\n const secret = redact.some((pattern) => pattern.test(header.name))\n parts.push(`-H ${shellQuote(`${header.name}: ${secret ? mask : header.value}`)}`)\n }\n\n if (body !== undefined) {\n const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2)\n parts.push(`--data ${shellQuote(text)}`)\n }\n\n // Backslash continuations: a 400-character line cannot be reviewed.\n return parts.join(' \\\\\\n ')\n }, [method, url, headers, body, redact, mask, insecure])\n\n return (\n <div data-slot=\"curl-command\" className={className} {...props}>\n <CodeBlock code={command} language=\"bash\" title={title} />\n </div>\n )\n}\n\nexport { CurlCommand, shellQuote }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "data-grid",
3
+ "type": "registry:ui",
4
+ "title": "Data Grid",
5
+ "description": "Sorting and selection over Table, driven by a column definition. The sort is stable and never mutates your array, so re-sorting on an equal column leaves settled rows where they were.",
6
+ "category": "Display",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "checkbox",
12
+ "empty",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "table"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/data-grid.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Empty } from '@/components/ui/empty'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A sortable, selectable table driven by a column definition.\n *\n * `Table` stays the presentational layer; this adds the behaviour that every\n * table grows eventually and that everyone re-implements slightly differently.\n *\n * Sorting is stable and non-destructive: rows are indexed, compared, and the\n * original order breaks ties, so re-sorting on an equal column never shuffles\n * rows that looked settled. Sorting also never mutates the caller's array.\n */\nexport type Column<Row> = {\n key: string\n header: ReactNode\n /** Cell contents. Defaults to `row[key]`. */\n render?: (row: Row) => ReactNode\n /** Sort key. Supply for anything that is not directly comparable. */\n sortValue?: (row: Row) => string | number\n sortable?: boolean\n align?: 'start' | 'end' | 'center'\n /** Hide below the `sm` breakpoint, for columns that are nice-to-have. */\n hideOnMobile?: boolean\n width?: string\n}\n\ntype SortState = { key: string; direction: 'asc' | 'desc' } | null\n\nconst ALIGN = {\n start: 'text-start',\n end: 'text-end',\n center: 'text-center',\n} as const\n\nfunction DataGrid<Row extends Record<string, unknown>>({\n columns,\n rows,\n rowKey,\n defaultSort = null,\n selectable = false,\n selected,\n onSelectedChange,\n onRowClick,\n empty,\n emptyLabel = 'No rows',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n columns: Column<Row>[]\n rows: Row[]\n rowKey: (row: Row) => string\n defaultSort?: SortState\n selectable?: boolean\n selected?: string[]\n onSelectedChange?: (ids: string[]) => void\n onRowClick?: (row: Row) => void\n empty?: ReactNode\n /** Tooltip on the empty state. */\n emptyLabel?: string\n}) {\n const [sort, setSort] = useState<SortState>(defaultSort)\n const [internalSelected, setInternalSelected] = useState<string[]>([])\n const selection = selected ?? internalSelected\n\n const sorted = useMemo(() => {\n if (!sort) return rows\n const column = columns.find((c) => c.key === sort.key)\n if (!column) return rows\n\n const value = (row: Row) =>\n column.sortValue?.(row) ?? (row[column.key] as string | number)\n\n // Index-carrying copy: the original position is the tiebreak, which is what\n // makes the sort stable across repeated clicks.\n return rows\n .map((row, index) => ({ row, index }))\n .sort((a, b) => {\n const left = value(a.row)\n const right = value(b.row)\n if (left === right) return a.index - b.index\n const order = left < right ? -1 : 1\n return sort.direction === 'asc' ? order : -order\n })\n .map((entry) => entry.row)\n }, [rows, columns, sort])\n\n const allKeys = sorted.map(rowKey)\n const allSelected = allKeys.length > 0 && allKeys.every((k) => selection.includes(k))\n const someSelected = selection.length > 0 && !allSelected\n\n function setSelection(next: string[]) {\n if (selected === undefined) setInternalSelected(next)\n onSelectedChange?.(next)\n }\n\n function toggleSort(key: string) {\n setSort((current) =>\n current?.key !== key\n ? { key, direction: 'asc' }\n : current.direction === 'asc'\n ? { key, direction: 'desc' }\n : null,\n )\n }\n\n // `Table` already draws its own bordered, rounded container. Wrapping it in\n // another one gave a double border with two different radii — so the empty\n // state supplies its own box, and the populated grid just styles Table's.\n if (sorted.length === 0) {\n return (\n <div\n data-slot=\"data-grid\"\n className={cn(surface, radius.panel, 'w-full', className)}\n {...props}\n >\n {empty ?? (\n <Empty title={emptyLabel} description=\"Nothing matches the current filters.\" />\n )}\n </div>\n )\n }\n\n return (\n <div data-slot=\"data-grid\" className={cn('w-full', className)} {...props}>\n <Table>\n <TableHeader>\n <TableRow>\n {selectable && (\n <TableHead className=\"w-10\">\n <Checkbox\n aria-label={allSelected ? 'Clear selection' : 'Select all rows'}\n checked={allSelected}\n indeterminate={someSelected}\n onChange={() => setSelection(allSelected ? [] : allKeys)}\n />\n </TableHead>\n )}\n {columns.map((column) => {\n const active = sort?.key === column.key\n const Icon = !active\n ? ChevronsUpDown\n : sort.direction === 'asc'\n ? ArrowUp\n : ArrowDown\n\n return (\n <TableHead\n key={column.key}\n style={column.width ? { width: column.width } : undefined}\n aria-sort={\n active\n ? sort.direction === 'asc'\n ? 'ascending'\n : 'descending'\n : column.sortable\n ? 'none'\n : undefined\n }\n className={cn(\n ALIGN[column.align ?? 'start'],\n column.hideOnMobile && 'hidden sm:table-cell',\n )}\n >\n {column.sortable ? (\n <button\n type=\"button\"\n onClick={() => toggleSort(column.key)}\n className={cn(\n 'hover:text-foreground -mx-1 inline-flex items-center gap-1 px-1 py-0.5',\n radius.xs,\n interactive,\n focusRing,\n active && 'text-foreground',\n )}\n >\n {column.header}\n <Icon className=\"size-3 shrink-0 opacity-70\" aria-hidden=\"true\" />\n </button>\n ) : (\n column.header\n )}\n </TableHead>\n )\n })}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {sorted.map((row) => {\n const key = rowKey(row)\n const isSelected = selection.includes(key)\n\n return (\n <TableRow\n key={key}\n data-selected={isSelected || undefined}\n onClick={onRowClick ? () => onRowClick(row) : undefined}\n className={cn(\n isSelected && 'bg-accent/50',\n onRowClick && 'cursor-pointer',\n )}\n >\n {selectable && (\n <TableCell className=\"w-10\">\n <Checkbox\n aria-label={`Select row ${key}`}\n checked={isSelected}\n onClick={(event) => event.stopPropagation()}\n onChange={() =>\n setSelection(\n isSelected\n ? selection.filter((id) => id !== key)\n : [...selection, key],\n )\n }\n />\n </TableCell>\n )}\n {columns.map((column) => (\n <TableCell\n key={column.key}\n className={cn(\n ALIGN[column.align ?? 'start'],\n column.hideOnMobile && 'hidden sm:table-cell',\n )}\n >\n {column.render?.(row) ?? String(row[column.key] ?? '')}\n </TableCell>\n ))}\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n </div>\n )\n}\n\nexport { DataGrid }\nexport type { Column as DataGridColumn }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "date-picker",
3
+ "type": "registry:ui",
4
+ "title": "Date Picker",
5
+ "description": "Calendar in a Popover with a trigger that reads like an Input. Every Calendar capability passes through — range, presets, times, multiple months — so there is one date implementation, not two.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "calendar",
12
+ "lib-date",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "popover"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/date-picker.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { CalendarDays } from 'lucide-react'\nimport {\n Calendar,\n type CalendarPreset,\n type WeekStart,\n} from '@/components/ui/calendar'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport {\n formatDate,\n formatRange,\n formatTime,\n type DateRange,\n} from '@/lib/date'\nimport { fieldBase, fieldSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/** The same three treatments Input and Select offer. */\nconst FIELD_VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n} as const\n\n/**\n * Calendar in a Popover, with a trigger that reads like an Input.\n *\n * Composed from existing parts rather than written fresh, so it inherits the\n * popper's flipping, the popover's dismissal and the field's sizing for free.\n * Every Calendar capability is passed through — range, presets, times, multiple\n * months — so there is only one date implementation to reason about.\n */\ntype Common = {\n placeholder?: string\n locale?: string\n fromDate?: Date\n toDate?: Date\n disabled?: boolean\n disabledDate?: (date: Date) => boolean\n size?: keyof typeof fieldSize\n /** Matches Input and Select, so a form can be uniform. */\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n numberOfMonths?: number\n weekStartsOn?: WeekStart\n captionLayout?: 'label' | 'dropdown'\n showTime?: boolean\n timeStep?: number\n footer?: ReactNode\n /** Keep the popover open after a pick — useful with times. */\n closeOnSelect?: boolean\n className?: string\n}\n\ntype SingleProps = Common & {\n mode?: 'single'\n value?: Date\n defaultValue?: Date\n onValueChange?: (date: Date | undefined) => void\n presets?: never\n}\n\ntype RangeProps = Common & {\n mode: 'range'\n value?: DateRange\n defaultValue?: DateRange\n onValueChange?: (range: DateRange | undefined) => void\n presets?: CalendarPreset[]\n}\n\nexport type DatePickerProps = (SingleProps | RangeProps) &\n Omit<ComponentProps<'button'>, 'value' | 'defaultValue' | 'onSelect' | 'mode'>\n\n/**\n * Destructuring a discriminated union leaves the variant-only keys in `...rest`,\n * which would then be spread onto a `<button>` — `value` as a Date is not a\n * valid button attribute. Reading through this shape strips them cleanly.\n */\ntype AnyDatePicker = Common &\n Omit<ComponentProps<'button'>, 'value' | 'defaultValue' | 'onSelect' | 'mode'> & {\n mode?: 'single' | 'range'\n value?: Date | DateRange\n defaultValue?: Date | DateRange\n onValueChange?: (value: never) => void\n presets?: CalendarPreset[]\n }\n\nfunction DatePicker(props: DatePickerProps) {\n const {\n mode: _mode,\n value: _value,\n defaultValue: _defaultValue,\n onValueChange: _onValueChange,\n presets: _presets,\n className,\n placeholder = props.mode === 'range' ? 'Pick a range' : 'Pick a date',\n locale = 'en-GB',\n fromDate,\n toDate,\n disabled,\n disabledDate,\n size = 'md',\n variant = 'default',\n error,\n numberOfMonths = props.mode === 'range' ? 2 : 1,\n weekStartsOn = 1,\n captionLayout = 'label',\n showTime = false,\n timeStep = 5,\n footer,\n // With a time picker the popover must stay open — closing on the day click\n // would give no chance to set the hour.\n closeOnSelect = !showTime,\n ...rest\n } = props as AnyDatePicker\n\n const range = props.mode === 'range'\n const controlled = props.value !== undefined\n const [own, setOwn] = useState<Date | DateRange | undefined>(props.defaultValue)\n const selected = controlled ? props.value : own\n const [open, setOpen] = useState(false)\n\n function emit(next: Date | DateRange | undefined) {\n if (!controlled) setOwn(next)\n ;(props.onValueChange as (v: typeof next) => void)?.(next)\n\n // A range is only finished once both ends exist.\n const complete = range ? Boolean((next as DateRange)?.to) : Boolean(next)\n if (closeOnSelect && complete) setOpen(false)\n }\n\n const label = range\n ? formatRange(selected as DateRange | undefined, locale)\n : selected\n ? formatDate(selected as Date, locale) +\n (showTime ? ` · ${formatTime(selected as Date, locale)}` : '')\n : ''\n\n const calendar = range ? (\n <Calendar\n mode=\"range\"\n selected={selected as DateRange | undefined}\n onSelect={emit}\n presets={props.presets}\n locale={locale}\n fromDate={fromDate}\n toDate={toDate}\n disabled={disabledDate}\n numberOfMonths={numberOfMonths}\n weekStartsOn={weekStartsOn}\n captionLayout={captionLayout}\n showTime={showTime}\n timeStep={timeStep}\n footer={footer}\n />\n ) : (\n <Calendar\n selected={selected as Date | undefined}\n onSelect={emit}\n locale={locale}\n fromDate={fromDate}\n toDate={toDate}\n disabled={disabledDate}\n numberOfMonths={numberOfMonths}\n weekStartsOn={weekStartsOn}\n captionLayout={captionLayout}\n showTime={showTime}\n timeStep={timeStep}\n footer={footer}\n />\n )\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={placeholder}\n className={cn(\n fieldBase,\n fieldSize[size],\n 'text-start',\n FIELD_VARIANT[variant],\n error && 'border-destructive',\n 'disabled:pointer-events-none disabled:opacity-50',\n className,\n )}\n {...rest}\n >\n <CalendarDays className=\"text-muted-foreground\" />\n <span className={cn('flex-1 truncate', !label && 'text-muted-foreground/70')}>\n {label || placeholder}\n </span>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-auto p-3\">\n {calendar}\n </PopoverContent>\n </Popover>\n )\n}\n\nexport { DatePicker }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "date-range-compare",
3
+ "type": "registry:ui",
4
+ "title": "Date Range Compare",
5
+ "description": "A metric against the same metric last period, with the basis named. \"+18%\" is meaningless until you know against what — and against a partial period it is meaningless full stop.",
6
+ "category": "Analytics",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "fmt",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "select",
15
+ "sparkline"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/date-range-compare.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, Minus } from 'lucide-react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Select } from '@/components/ui/select'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A metric across two periods, with the comparison made explicit.\n *\n * The comparison basis is named, always. \"+12%\" against what — the previous\n * period, or the same period last year? Those routinely disagree in sign for\n * anything seasonal, and a delta with no stated basis is not a fact.\n *\n * A partial current period is flagged. Comparing four days of this week against\n * seven of last week produces a catastrophic-looking drop that is purely an\n * artefact, and it is the most common misreading in any analytics dashboard.\n */\nexport type ComparisonBasis = 'previous' | 'year' | 'custom'\n\nconst BASIS_LABEL: Record<ComparisonBasis, string> = {\n previous: 'previous period',\n year: 'same period last year',\n custom: 'selected period',\n}\n\nfunction DateRangeCompare({\n label,\n value,\n previous,\n basis = 'previous',\n onBasisChange,\n format = 'number',\n currency = 'USD',\n locale = 'en-GB',\n history,\n /** True while the current period is still running. */\n partial = false,\n goodDirection = 'up',\n rangeLabel,\n noComparisonLabel = 'No comparison',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n label: ReactNode\n value: number\n previous?: number\n basis?: ComparisonBasis\n onBasisChange?: (basis: ComparisonBasis) => void\n format?: 'number' | 'currency' | 'percent' | 'duration'\n currency?: string\n locale?: string\n history?: number[]\n partial?: boolean\n goodDirection?: 'up' | 'down' | 'none'\n rangeLabel?: ReactNode\n /** Shown when no previous value is given. */\n noComparisonLabel?: ReactNode\n}) {\n const delta = useMemo(() => {\n if (previous === undefined || previous === 0) return undefined\n return (value - previous) / Math.abs(previous)\n }, [value, previous])\n\n const rising = (delta ?? 0) > 0\n const falling = (delta ?? 0) < 0\n const good =\n goodDirection === 'none' || delta === undefined || delta === 0\n ? undefined\n : (rising && goodDirection === 'up') || (falling && goodDirection === 'down')\n\n const Arrow = delta === undefined || delta === 0 ? Minus : rising ? ArrowUp : ArrowDown\n\n return (\n <div\n data-slot=\"date-range-compare\"\n className={cn(surface, radius.surface, 'flex flex-col gap-2 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-2\">\n <span className=\"text-muted-foreground text-xs font-medium\">{label}</span>\n {onBasisChange && (\n <Select\n size=\"xs\"\n variant=\"ghost\"\n value={basis}\n onValueChange={(next) => onBasisChange(next as ComparisonBasis)}\n options={[\n { value: 'previous', label: 'vs previous' },\n { value: 'year', label: 'vs last year' },\n ]}\n className=\"w-auto\"\n triggerClassName=\"w-auto\"\n />\n )}\n </div>\n\n <span className=\"text-2xl font-semibold tabular-nums\">\n <Fmt type={format} value={value} currency={currency} locale={locale} />\n </span>\n\n <div className=\"flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs\">\n {delta !== undefined ? (\n <span\n className={cn(\n 'inline-flex items-center gap-0.5 font-medium tabular-nums',\n good === true && 'text-[var(--green-soft-foreground)]',\n good === false && 'text-[var(--destructive-soft-foreground)]',\n good === undefined && 'text-muted-foreground',\n )}\n >\n <Arrow className=\"size-3.5\" aria-hidden=\"true\" />\n {Math.abs(delta * 100).toFixed(1)}%\n </span>\n ) : (\n <span className=\"text-muted-foreground\">{noComparisonLabel}</span>\n )}\n\n {/* Never a bare percentage: name what it is measured against. */}\n <span className=\"text-muted-foreground/80\">vs {BASIS_LABEL[basis]}</span>\n\n {previous !== undefined && (\n <span className=\"text-muted-foreground/60 tabular-nums\">\n (<Fmt type={format} value={previous} currency={currency} locale={locale} />)\n </span>\n )}\n </div>\n\n {history && history.length > 1 && (\n <Sparkline\n values={history}\n variant=\"area\"\n color={good === false ? 'var(--destructive)' : 'var(--blue)'}\n className=\"h-10\"\n />\n )}\n\n {rangeLabel && (\n <span className=\"text-muted-foreground/70 text-xs\">{rangeLabel}</span>\n )}\n\n {/* Four days against seven is a fake collapse. */}\n {partial && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n The current period is still running — this comparison is against a\n complete one.\n </p>\n )}\n </div>\n )\n}\n\nexport { DateRangeCompare }\n"
22
+ }
23
+ ]
24
+ }