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,19 @@
1
+ {
2
+ "name": "http-status",
3
+ "type": "registry:ui",
4
+ "title": "HTTP Status",
5
+ "description": "A status code with its reason phrase. Colour follows the class, never the individual code — grading a 503 as worse than a 500 invents a severity ordering that does not exist.",
6
+ "category": "API",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "badge",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/http-status.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { cn } from '@/lib/utils'\n\n/**\n * An HTTP status code with its meaning.\n *\n * The number alone is only legible to people who have memorised the table, and\n * the ones that matter most are the ones nobody memorises — 409, 422, 429 are\n * each a different action for the reader. The reason phrase is shown beside the\n * code rather than behind a tooltip.\n *\n * Colour follows the class, not the individual code: every 2xx is the same\n * green, every 5xx the same red. Grading within a class invents a severity\n * ordering that does not exist — a 503 is not \"worse\" than a 500, it is\n * different.\n *\n * 3xx is treated as informational rather than as success. A redirect is not a\n * completed request, and colouring it green hides a redirect loop.\n */\nconst PHRASES: Record<number, string> = {\n 200: 'OK', 201: 'Created', 202: 'Accepted', 204: 'No Content',\n 301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified', 307: 'Temporary Redirect', 308: 'Permanent Redirect',\n 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found',\n 405: 'Method Not Allowed', 409: 'Conflict', 410: 'Gone', 413: 'Payload Too Large',\n 418: \"I'm a teapot\", 422: 'Unprocessable Content', 429: 'Too Many Requests',\n 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway',\n 503: 'Service Unavailable', 504: 'Gateway Timeout',\n}\n\n/** Colour by class. Grading inside a class implies an ordering that is not real. */\nfunction toneFor(status: number) {\n if (status >= 500) return 'destructive'\n if (status >= 400) return 'amber'\n if (status >= 300) return 'blue'\n if (status >= 200) return 'green'\n return 'neutral'\n}\n\nfunction HttpStatus({\n status,\n phrase,\n showPhrase = true,\n size = 'sm',\n className,\n ...props\n}: Omit<ComponentProps<typeof Badge>, 'color' | 'children'> & {\n status: number\n /** Overrides the built-in reason phrase. */\n phrase?: string\n showPhrase?: boolean\n size?: 'sm' | 'default'\n}) {\n const text = phrase ?? PHRASES[status]\n\n return (\n <Badge\n data-slot=\"http-status\"\n data-status={status}\n color={toneFor(status)}\n size={size}\n className={cn('font-mono tabular-nums', className)}\n {...props}\n >\n {status}\n {showPhrase && text && <span className=\"font-sans font-normal\">{text}</span>}\n </Badge>\n )\n}\n\nexport { HttpStatus, PHRASES as httpPhrases, toneFor as httpStatusTone }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "incident-card",
3
+ "type": "registry:ui",
4
+ "title": "Incident Card",
5
+ "description": "An incident: severity, state, owner, age. Severity is a left edge rather than a tinted surface, because a list of ten red cards is unreadable and the stripe scales to a queue.",
6
+ "category": "Operations",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "avatar",
12
+ "badge",
13
+ "fmt",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/incident-card.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { AlertTriangle, Clock, User } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An incident: severity, state, who owns it, how long it has been open.\n *\n * Severity uses a left edge rather than a tinted background. A card whose whole\n * surface is red draws the eye to the card instead of to its content, and a\n * list of ten of them is unreadable — the stripe scales to a queue.\n *\n * Duration is computed from `startedAt` and an optional `resolvedAt`, so an\n * open incident's age advances on its own instead of being a stale string\n * someone has to remember to refresh.\n */\nexport type Severity = 'sev1' | 'sev2' | 'sev3' | 'sev4'\nexport type IncidentState = 'investigating' | 'identified' | 'monitoring' | 'resolved'\n\nconst SEVERITY = {\n sev1: { label: 'SEV1', color: 'destructive', edge: 'bg-[var(--destructive)]' },\n sev2: { label: 'SEV2', color: 'amber', edge: 'bg-[var(--amber)]' },\n sev3: { label: 'SEV3', color: 'blue', edge: 'bg-[var(--blue)]' },\n sev4: { label: 'SEV4', color: 'neutral', edge: 'bg-border' },\n} as const\n\nconst STATE_LABEL = {\n investigating: 'Investigating',\n identified: 'Identified',\n monitoring: 'Monitoring',\n resolved: 'Resolved',\n} as const\n\nfunction IncidentCard({\n title,\n severity = 'sev3',\n state = 'investigating',\n startedAt,\n resolvedAt,\n assignee,\n services,\n summary,\n now,\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'title'> & {\n title: ReactNode\n severity?: Severity\n state?: IncidentState\n startedAt: Date\n resolvedAt?: Date\n assignee?: string\n services?: string[]\n summary?: ReactNode\n /** Reference point for the age. Defaults to now. */\n now?: Date\n}) {\n const level = SEVERITY[severity]\n const end = resolvedAt ?? now ?? new Date()\n const seconds = Math.max(0, (end.getTime() - startedAt.getTime()) / 1000)\n\n return (\n <article\n data-slot=\"incident-card\"\n data-severity={severity}\n className={cn(\n surface,\n radius.surface,\n 'relative overflow-hidden ps-4',\n state === 'resolved' && 'opacity-75',\n className,\n )}\n {...props}\n >\n <span\n aria-hidden=\"true\"\n className={cn('absolute inset-y-0 start-0 w-1.5', level.edge)}\n />\n\n <div className=\"flex flex-col gap-2 p-4\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\" color={level.color}>\n <AlertTriangle />\n {level.label}\n </Badge>\n <Badge size=\"sm\" color={state === 'resolved' ? 'green' : 'neutral'}>\n {STATE_LABEL[state]}\n </Badge>\n <span className=\"text-muted-foreground ms-auto inline-flex items-center gap-1 text-xs tabular-nums\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n <Fmt type=\"duration\" value={seconds} />\n </span>\n </div>\n\n <h3 className=\"text-sm font-medium text-pretty\">{title}</h3>\n\n {summary && <p className=\"text-muted-foreground text-sm\">{summary}</p>}\n\n <div className=\"text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs\">\n <span>\n Started <Fmt type=\"relative\" value={startedAt} now={now} />\n </span>\n\n {services && services.length > 0 && (\n <span className=\"flex flex-wrap gap-1\">\n {services.map((service) => (\n <Badge key={service} size=\"sm\">\n {service}\n </Badge>\n ))}\n </span>\n )}\n\n {assignee && (\n <span className=\"ms-auto inline-flex items-center gap-1.5\">\n <User className=\"size-3.5 sr-only\" aria-hidden=\"true\" />\n <Avatar size=\"xs\" name={assignee} />\n {assignee}\n </span>\n )}\n </div>\n </div>\n </article>\n )\n}\n\nexport { IncidentCard }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "input-file",
3
+ "type": "registry:ui",
4
+ "title": "Input File",
5
+ "description": "A file picker shaped like an Input, for a form row. The native control is replaced rather than restyled — its button text is not settable and its layout is unreachable, so matching the field metrics means driving a hidden input.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/input-file.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useId, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Paperclip, Upload, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport {\n disabledState,\n fieldBase,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file picker shaped like an Input, for use in a form row.\n *\n * The counterpart to `Dropzone`, not a replacement: this is the control you put\n * on a settings form beside a Label, where a 120px drop target would be absurd.\n * Dropzone is for the case where dropping is the primary gesture.\n *\n * The native control is replaced rather than restyled. `input[type=file]` gives\n * you a UA button whose text is not settable and whose layout is not reachable\n * except through a vendor pseudo-element, so matching the kit's field metrics\n * means driving a hidden input from our own trigger.\n */\ntype InputFileProps = Omit<ComponentProps<'input'>, 'size' | 'type' | 'value'> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Text shown when nothing is picked. */\n placeholder?: string\n buttonLabel?: ReactNode\n /** Show an X to clear the selection. */\n clearable?: boolean\n /** Accessible name for that clear button. */\n clearLabel?: string\n onFiles?: (files: File[]) => void\n}\n\nconst VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent hover:bg-accent',\n} as const\n\nfunction InputFile({\n size = 'md',\n variant = 'default',\n error = false,\n placeholder = 'No file selected',\n buttonLabel = 'Browse',\n clearLabel = 'Clear selection',\n clearable = true,\n multiple = false,\n disabled = false,\n className,\n onFiles,\n onChange,\n ...props\n}: InputFileProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [files, setFiles] = useState<File[]>([])\n const id = useId()\n\n const summary =\n files.length === 0\n ? placeholder\n : files.length === 1\n ? files[0].name\n : `${files.length} files`\n\n function update(next: File[]) {\n setFiles(next)\n onFiles?.(next)\n }\n\n return (\n <div\n data-slot=\"input-file\"\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT[variant],\n error && 'border-destructive',\n // The wrapper is the focus surface: focus lands on the hidden input, so\n // the ring has to be drawn from `focus-within` on the box around it.\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n <Paperclip className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n\n <span\n id={id}\n className={cn(\n 'min-w-0 flex-1 truncate text-start',\n files.length === 0 && 'text-muted-foreground/70',\n )}\n >\n {summary}\n </span>\n\n {clearable && files.length > 0 && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={clearLabel}\n className=\"shrink-0\"\n onClick={() => {\n if (inputRef.current) inputRef.current.value = ''\n update([])\n }}\n >\n <X />\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n size={size === 'xs' || size === 'sm' ? 'xs' : 'sm'}\n className=\"shrink-0\"\n onClick={() => inputRef.current?.click()}\n >\n <Upload />\n {buttonLabel}\n </Button>\n\n <input\n ref={inputRef}\n type=\"file\"\n multiple={multiple}\n disabled={disabled}\n aria-labelledby={id}\n className={cn('sr-only', focusRing, disabledState, radius.control)}\n onChange={(event) => {\n update([...(event.target.files ?? [])])\n onChange?.(event)\n }}\n {...props}\n />\n </div>\n )\n}\n\nexport { InputFile }\nexport type { InputFileProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "input-otp",
3
+ "type": "registry:ui",
4
+ "title": "Input OTP",
5
+ "description": "A fixed-length code entry, one box per character. Every box is a real input so autofill can target it, and paste is distributed across all of them.",
6
+ "category": "Forms",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/input-otp.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import {\n useRef,\n useState,\n type ClipboardEvent,\n type ComponentProps,\n type KeyboardEvent,\n} from 'react'\nimport { fieldBase } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A fixed-length code entry, one box per character.\n *\n * Every box is a real input, so password managers and SMS autofill can target\n * them, and `inputMode=\"numeric\"` brings up the right keyboard. Paste is handled\n * on any box and distributed across the rest — pasting a code is the common\n * case, and per-box paste would drop all but one character.\n */\ntype InputOTPProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> & {\n length?: number\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n onComplete?: (value: string) => void\n disabled?: boolean\n /** Insert a gap after this many boxes, e.g. 3 for a 6-digit code. */\n groupAfter?: number\n pattern?: 'numeric' | 'alphanumeric'\n}\n\nfunction InputOTP({\n className,\n length = 6,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n onComplete,\n disabled = false,\n groupAfter,\n pattern = 'numeric',\n ...props\n}: InputOTPProps) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = (controlled ? valueProp : uncontrolled).slice(0, length)\n\n const inputs = useRef<(HTMLInputElement | null)[]>([])\n const allowed = pattern === 'numeric' ? /[^0-9]/g : /[^a-zA-Z0-9]/g\n\n function commit(next: string) {\n const clean = next.replace(allowed, '').slice(0, length)\n if (!controlled) setUncontrolled(clean)\n onValueChange?.(clean)\n if (clean.length === length) onComplete?.(clean)\n return clean\n }\n\n function setCharAt(index: number, char: string) {\n const chars = value.padEnd(length, ' ').split('')\n chars[index] = char || ' '\n return commit(chars.join('').replace(/ +$/, ''))\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLInputElement>, index: number) {\n if (event.key === 'Backspace') {\n event.preventDefault()\n if (value[index]) setCharAt(index, '')\n else if (index > 0) {\n setCharAt(index - 1, '')\n inputs.current[index - 1]?.focus()\n }\n } else if (event.key === 'ArrowLeft' && index > 0) {\n event.preventDefault()\n inputs.current[index - 1]?.focus()\n } else if (event.key === 'ArrowRight' && index < length - 1) {\n event.preventDefault()\n inputs.current[index + 1]?.focus()\n }\n }\n\n function onPaste(event: ClipboardEvent<HTMLInputElement>, index: number) {\n event.preventDefault()\n const pasted = event.clipboardData.getData('text').replace(allowed, '')\n if (!pasted) return\n\n const chars = value.padEnd(length, ' ').split('')\n for (let i = 0; i < pasted.length && index + i < length; i++) {\n chars[index + i] = pasted[i]\n }\n\n const next = commit(chars.join('').replace(/ +$/, ''))\n const focusAt = Math.min(index + pasted.length, length - 1)\n inputs.current[focusAt]?.focus()\n return next\n }\n\n return (\n <div\n data-slot=\"input-otp\"\n className={cn('flex items-center gap-2', className)}\n {...props}\n >\n {Array.from({ length }, (_, index) => (\n <div key={index} className=\"flex items-center gap-2\">\n <input\n ref={(node) => {\n inputs.current[index] = node\n }}\n type=\"text\"\n inputMode={pattern === 'numeric' ? 'numeric' : 'text'}\n autoComplete={index === 0 ? 'one-time-code' : 'off'}\n maxLength={1}\n disabled={disabled}\n aria-label={`Character ${index + 1} of ${length}`}\n value={value[index] ?? ''}\n onChange={(event) => {\n const char = event.target.value.slice(-1)\n if (!char) return setCharAt(index, '')\n setCharAt(index, char)\n if (index < length - 1) inputs.current[index + 1]?.focus()\n }}\n onKeyDown={(event) => onKeyDown(event, index)}\n onPaste={(event) => onPaste(event, index)}\n onFocus={(event) => event.target.select()}\n className={cn(\n fieldBase,\n // A field, so it sits on the control ladder: 48px, the xl rung,\n // with the matching radius. 44px was between two rungs.\n 'size-12 rounded-[var(--radius-control-xl)]',\n 'border-border bg-background justify-center border text-center font-mono text-base',\n 'focus:border-[var(--border-active)] focus:outline-none',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n />\n {groupAfter && index === groupAfter - 1 && index < length - 1 && (\n <span aria-hidden=\"true\" className=\"text-muted-foreground px-1\">\n &ndash;\n </span>\n )}\n </div>\n ))}\n </div>\n )\n}\n\nexport { InputOTP }\nexport type { InputOTPProps }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "input",
3
+ "type": "registry:ui",
4
+ "title": "Input",
5
+ "description": "A single-line text field, built on the headless Field primitive. Monochrome by design — it shares the Button size steps but none of its colour sets.",
6
+ "category": "Forms",
7
+ "dependencies": [
8
+ "class-variance-authority@^0.7.1",
9
+ "lucide-react@^1.39.0"
10
+ ],
11
+ "registryDependencies": [
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "primitive-field"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/input.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { X } from 'lucide-react'\nimport { Field } from '@/components/primitives/field'\nimport { fieldBase, fieldInput, fieldSize, focusRingInset } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Built on the headless `Field` primitive, so clicking the padding or an icon\n * focuses the control rather than doing nothing.\n *\n * Deliberately monochrome: an input is a surface you type on, not an accent, so\n * it uses only the theme's own background, border and foreground tokens and has\n * no `color` or `tint`. It also takes no focus ring and no border change — the\n * caret is the focus indicator. `error` is the single exception: the one state\n * that earns a colour.\n */\nconst inputVariants = cva(fieldBase, {\n variants: {\n variant: {\n /** Bordered field; the border only changes on focus and on error. */\n default: 'border-border bg-background border',\n /** Filled field, no border. */\n secondary: 'bg-secondary border border-transparent',\n /** No chrome at rest; fills in on hover. */\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n },\n size: {\n xs: fieldSize.xs,\n sm: fieldSize.sm,\n default: fieldSize.md,\n lg: fieldSize.lg,\n xl: fieldSize.xl,\n },\n // Declared after `variant` so it also wins the focus border — an errored\n // field stays red while you type in it.\n error: {\n true: 'border-destructive focus-within:border-destructive',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n error: false,\n },\n})\n\ntype InputProps = Omit<ComponentProps<'input'>, 'size' | 'style'> &\n VariantProps<typeof inputVariants> & {\n /** Rendered inside the field, before or after the text. */\n icon?: ReactNode\n iconPosition?: 'start' | 'end'\n /** Show a clear button on the trailing side once the field has content. */\n clearable?: boolean\n /** Fires after the clear button empties the field. */\n onClear?: () => void\n clearLabel?: string\n /** Styles the wrapper; `className` styles the `<input>` itself. */\n containerClassName?: string\n style?: ComponentProps<'div'>['style']\n }\n\nfunction Input({\n className,\n containerClassName,\n variant,\n size,\n error,\n icon,\n iconPosition = 'start',\n clearable = false,\n onClear,\n clearLabel = 'Clear',\n style,\n onChange,\n ...props\n}: InputProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n\n // Controlled callers own the value; uncontrolled ones need it tracked here so\n // the clear button knows whether there is anything to clear.\n const controlled = props.value !== undefined\n const [typed, setTyped] = useState(\n () => String(props.defaultValue ?? '') !== '',\n )\n const hasValue = controlled ? String(props.value ?? '') !== '' : typed\n\n const showClear =\n clearable && hasValue && !props.disabled && !props.readOnly\n\n function clear() {\n const element = inputRef.current\n if (!element) return\n\n // Assigning `.value` directly is invisible to React. Going through the\n // prototype setter and dispatching `input` makes React's own onChange fire,\n // so a controlled parent updates instead of silently reverting the field.\n const setter = Object.getOwnPropertyDescriptor(\n window.HTMLInputElement.prototype,\n 'value',\n )?.set\n\n setter?.call(element, '')\n element.dispatchEvent(new Event('input', { bubbles: true }))\n\n setTyped(false)\n element.focus()\n onClear?.()\n }\n\n const slot = icon ? <Field.Slot side={iconPosition}>{icon}</Field.Slot> : null\n\n return (\n <Field.Root\n data-slot=\"field\"\n className={cn(\n inputVariants({ variant, size, error }),\n containerClassName,\n )}\n style={style}\n >\n {iconPosition === 'start' && slot}\n\n <Field.Control\n ref={inputRef}\n aria-invalid={error || undefined}\n className={cn(fieldInput, className)}\n onChange={(event) => {\n if (!controlled) setTyped(event.target.value !== '')\n onChange?.(event)\n }}\n {...props}\n />\n\n {iconPosition === 'end' && slot}\n\n {showClear && (\n <button\n type=\"button\"\n aria-label={clearLabel}\n // Keep focus in the field: a press here must not blur it first.\n onMouseDown={(event) => event.preventDefault()}\n onClick={clear}\n className={cn(\n 'text-muted-foreground hover:text-foreground flex shrink-0 cursor-pointer rounded-full transition-colors',\n focusRingInset,\n )}\n >\n <X />\n </button>\n )}\n </Field.Root>\n )\n}\n\nexport { Input, inputVariants }\nexport type { InputProps }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "invoice-list",
3
+ "type": "registry:ui",
4
+ "title": "Invoice List",
5
+ "description": "Billing history. Amounts are right-aligned and tabular, which is the whole reason this is not a generic list — a money column that does not line up cannot be scanned.",
6
+ "category": "Finance",
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/invoice-list.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Download, FileText } 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 * Billing history.\n *\n * Amounts are right-aligned and tabular, which is the whole reason this is not\n * a generic list: a column of money that does not line up at the decimal point\n * cannot be scanned, and scanning is what a billing page is for.\n *\n * Currency is per-invoice rather than per-list. An account that changed plan\n * across a currency migration has both, and formatting the old ones with the\n * new symbol would be wrong in a way nobody notices until an audit.\n */\nexport type Invoice = {\n id: string\n number: string\n date: Date\n amount: number\n currency?: string\n status: 'paid' | 'open' | 'past_due' | 'refunded' | 'void'\n description?: ReactNode\n url?: string\n}\n\nconst STATUS = {\n paid: { label: 'Paid', color: 'green' },\n open: { label: 'Open', color: 'blue' },\n past_due: { label: 'Past due', color: 'destructive' },\n refunded: { label: 'Refunded', color: 'neutral' },\n void: { label: 'Void', color: 'neutral' },\n} as const\n\nfunction InvoiceList({\n invoices,\n onDownload,\n locale = 'en-GB',\n emptyLabel = 'No invoices yet',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n invoices: Invoice[]\n onDownload?: (invoice: Invoice) => void\n locale?: string\n emptyLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"invoice-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {invoices.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {invoices.map((invoice) => {\n const status = STATUS[invoice.status]\n\n return (\n <li\n key={invoice.id}\n className=\"flex flex-wrap items-center gap-x-3 gap-y-2 p-3\"\n >\n <FileText className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\">{invoice.number}</p>\n <p className=\"text-muted-foreground text-xs\">\n <Fmt type=\"date\" value={invoice.date} format=\"D MMMM YYYY\" locale={locale} />\n {invoice.description && <> · {invoice.description}</>}\n </p>\n </div>\n\n <Badge size=\"sm\" color={status.color}>\n {status.label}\n </Badge>\n\n {/* Tabular and a minimum width, so the column lines up for\n ordinary amounts — but `min-w`, not `w`: the currency prefix\n is locale-dependent (en-GB renders USD as \"US$\") and a large\n invoice would clip against a fixed width. */}\n <span className=\"min-w-24 shrink-0 text-end text-sm font-medium tabular-nums whitespace-nowrap\">\n <Fmt\n type=\"currency\"\n value={invoice.amount}\n currency={invoice.currency ?? 'USD'}\n locale={locale}\n />\n </span>\n\n {(invoice.url || onDownload) && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={`Download ${invoice.number}`}\n asChild={Boolean(invoice.url)}\n onClick={onDownload ? () => onDownload(invoice) : undefined}\n >\n {invoice.url ? (\n <a href={invoice.url} download>\n <Download />\n </a>\n ) : (\n <Download />\n )}\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { InvoiceList }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "ip-cluster",
3
+ "type": "registry:ui",
4
+ "title": "IP Cluster",
5
+ "description": "Accounts grouped by a shared address or device. The shape is the finding: one node fanning out to eight accounts is multi-accounting, eight accounts each on their own address is a shared ISP.",
6
+ "category": "Security",
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/ip-cluster.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Globe, Monitor, User } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Accounts grouped by a shared IP address or device.\n *\n * The shape it draws is the finding: one node fanning out to eight accounts is\n * multi-accounting; eight accounts each on their own address is a coincidence of\n * a shared ISP. A flat list of \"accounts that share an IP\" cannot tell those\n * apart, which is why this is a grouped view rather than a table.\n *\n * Group sizes are printed and the list sorts largest-first, because an analyst\n * opening this has a queue and needs the worst cluster at the top without\n * scanning.\n *\n * Residential and mobile addresses are marked. Carrier-grade NAT puts thousands\n * of unrelated subscribers behind one address, so an unmarked shared-IP finding\n * on a mobile network is close to meaningless — the label is what stops someone\n * banning a whole apartment block.\n */\nexport type ClusterMember = {\n id: string\n label: ReactNode\n detail?: ReactNode\n /** Marks the account under review, so it stands out in its own cluster. */\n focus?: boolean\n status?: 'active' | 'banned' | 'flagged'\n}\n\nexport type Cluster = {\n id: string\n /** The shared value — an IP, a device hash, a payout wallet. */\n value: ReactNode\n kind?: 'ip' | 'device' | 'account'\n /** Shared residential or mobile addresses are far weaker evidence. */\n network?: 'residential' | 'mobile' | 'datacenter' | 'vpn'\n location?: ReactNode\n members: ClusterMember[]\n}\n\nconst KIND_ICON = { ip: Globe, device: Monitor, account: User } as const\n\nconst NETWORK_TONE = {\n datacenter: { color: 'destructive', note: 'datacenter' },\n vpn: { color: 'destructive', note: 'VPN / proxy' },\n residential: { color: 'neutral', note: 'residential' },\n // Worth spelling out: CGNAT puts thousands of strangers on one address.\n mobile: { color: 'amber', note: 'mobile — shared by carrier NAT' },\n} as const\n\nconst STATUS_COLOR = { active: 'neutral', banned: 'destructive', flagged: 'amber' } as const\n\nfunction IpCluster({\n clusters,\n onSelect,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children' | 'onSelect'> & {\n clusters: Cluster[]\n onSelect?: (member: ClusterMember, cluster: Cluster) => void\n}) {\n // Worst cluster first — this is a queue, not a reference.\n const sorted = useMemo(\n () => [...clusters].sort((a, b) => b.members.length - a.members.length),\n [clusters],\n )\n\n return (\n <div data-slot=\"ip-cluster\" className={cn('flex flex-col gap-3', className)} {...props}>\n {sorted.map((cluster) => {\n const Icon = KIND_ICON[cluster.kind ?? 'ip']\n const network = cluster.network ? NETWORK_TONE[cluster.network] : undefined\n return (\n <section\n key={cluster.id}\n className={cn(surface, radius.surface, 'flex flex-col gap-2.5 p-3')}\n >\n <header className=\"flex flex-wrap items-center gap-2\">\n <Icon className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <code className=\"font-mono text-xs font-medium\">{cluster.value}</code>\n {network && (\n <Badge size=\"sm\" color={network.color}>\n {network.note}\n </Badge>\n )}\n {cluster.location && (\n <span className=\"text-muted-foreground text-xs\">{cluster.location}</span>\n )}\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n {cluster.members.length} account{cluster.members.length === 1 ? '' : 's'}\n </span>\n </header>\n\n <ul className=\"flex list-none flex-wrap gap-1.5\">\n {cluster.members.map((member) => {\n const content = (\n <>\n <span className=\"truncate\">{member.label}</span>\n {member.detail && (\n <span className=\"text-muted-foreground shrink-0\">{member.detail}</span>\n )}\n {member.status && member.status !== 'active' && (\n <Badge size=\"sm\" color={STATUS_COLOR[member.status]}>\n {member.status}\n </Badge>\n )}\n </>\n )\n const classes = cn(\n 'flex max-w-full items-center gap-1.5 px-2 py-1 text-xs',\n radius.control,\n member.focus\n ? 'bg-primary text-primary-foreground'\n : 'bg-secondary text-secondary-foreground',\n onSelect && !member.focus && 'hover:bg-accent',\n )\n\n return (\n <li key={member.id} className=\"min-w-0\">\n {onSelect ? (\n <button\n type=\"button\"\n className={cn(classes, 'text-start')}\n onClick={() => onSelect(member, cluster)}\n >\n {content}\n </button>\n ) : (\n <span className={classes}>{content}</span>\n )}\n </li>\n )\n })}\n </ul>\n </section>\n )\n })}\n </div>\n )\n}\n\nexport { IpCluster }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "json-viewer",
3
+ "type": "registry:ui",
4
+ "title": "JSON Viewer",
5
+ "description": "A collapsible view of a parsed value. Collapsed nodes show their size, because \"three entries or three thousand\" is the question you have before expanding one.",
6
+ "category": "Development",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/json-viewer.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { useState, type ComponentProps } from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A collapsible view of a parsed JSON value.\n *\n * Takes a value, not a string: the caller has already parsed it in every real\n * case (an API response, a config object), and re-serialising just to re-parse\n * loses `undefined`, dates and anything else that survives in memory.\n *\n * Collapsed nodes show their size, because \"does this array have three entries\n * or three thousand\" is the question you actually have before expanding one.\n */\ntype Json = string | number | boolean | null | Json[] | { [key: string]: Json }\n\nconst TYPE_TONE = {\n string: 'text-[var(--green-soft-foreground)]',\n number: 'text-[var(--blue-soft-foreground)]',\n boolean: 'text-[var(--violet-soft-foreground)]',\n null: 'text-muted-foreground',\n} as const\n\nfunction preview(value: Json): string {\n if (Array.isArray(value)) return `[] ${value.length} items`\n if (value && typeof value === 'object') {\n return `{} ${Object.keys(value).length} keys`\n }\n return String(value)\n}\n\nfunction JsonNode({\n name,\n value,\n depth,\n defaultExpandedDepth,\n}: {\n name?: string\n value: Json\n depth: number\n defaultExpandedDepth: number\n}) {\n const branch = value !== null && typeof value === 'object'\n const [open, setOpen] = useState(depth < defaultExpandedDepth)\n\n if (!branch) {\n const kind = value === null ? 'null' : (typeof value as keyof typeof TYPE_TONE)\n return (\n <div\n className=\"flex gap-2 py-0.5\"\n style={{ paddingInlineStart: `${depth * 14}px` }}\n >\n {name !== undefined && (\n <span className=\"text-foreground/80 shrink-0\">{name}:</span>\n )}\n <span className={cn('min-w-0 break-all', TYPE_TONE[kind] ?? '')}>\n {typeof value === 'string' ? `\"${value}\"` : String(value)}\n </span>\n </div>\n )\n }\n\n const entries: [string, Json][] = Array.isArray(value)\n ? value.map((item, index) => [String(index), item])\n : Object.entries(value)\n\n return (\n <div>\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() => setOpen((current) => !current)}\n style={{ paddingInlineStart: `${depth * 14}px` }}\n className={cn(\n 'hover:bg-accent/50 flex w-full items-center gap-1 py-0.5 text-start',\n radius.xs,\n interactive,\n focusRing,\n )}\n >\n <ChevronRight\n className={cn(\n 'size-3 shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n open && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n {name !== undefined && (\n <span className=\"text-foreground/80 shrink-0\">{name}:</span>\n )}\n <span className=\"text-muted-foreground truncate\">{preview(value)}</span>\n </button>\n\n {open &&\n entries.map(([key, child]) => (\n <JsonNode\n key={key}\n name={key}\n value={child}\n depth={depth + 1}\n defaultExpandedDepth={defaultExpandedDepth}\n />\n ))}\n </div>\n )\n}\n\nfunction JsonViewer({\n value,\n defaultExpandedDepth = 1,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'value'> & {\n value: Json\n /** Levels open on first render. */\n defaultExpandedDepth?: number\n}) {\n return (\n <div\n data-slot=\"json-viewer\"\n className={cn(\n 'border-border bg-card overflow-auto border p-3 font-mono text-xs',\n radius.surface,\n className,\n )}\n {...props}\n >\n <JsonNode value={value} depth={0} defaultExpandedDepth={defaultExpandedDepth} />\n </div>\n )\n}\n\nexport { JsonViewer }\nexport type { Json }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "jwt-inspector",
3
+ "type": "registry:ui",
4
+ "title": "JWT Inspector",
5
+ "description": "Decodes a token into header, payload and signature. Decoding is not verification and the component says so — a green tick without the signing key would be actively dangerous.",
6
+ "category": "Code Tools",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "fmt",
14
+ "json-viewer",
15
+ "lib-styles",
16
+ "lib-utils"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/jwt-inspector.tsx",
21
+ "type": "registry:ui",
22
+ "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 { Fmt } from '@/components/ui/fmt'\nimport { JsonViewer, type Json } from '@/components/ui/json-viewer'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Decodes a JWT into its header, payload and signature.\n *\n * Decoding is not verification, and the component says so rather than implying\n * a checked token. Signature verification needs the key and belongs on the\n * server; a green tick here would be actively dangerous.\n *\n * `exp` and `nbf` are resolved to real times, because a bare epoch integer is\n * the single most common thing people misread in a token — off-by-1000 between\n * seconds and milliseconds is an afternoon lost.\n *\n * `alg: none` is called out loudly. It is the classic JWT vulnerability and it\n * looks like an ordinary field in a JSON dump.\n *\n * The signature is masked by default. This gets screenshotted into tickets, and\n * a token with its signature is a live credential.\n */\ntype Decoded = {\n header: Record<string, unknown>\n payload: Record<string, unknown>\n signature: string\n} | null\n\n/** base64url → JSON. Padding is restored; `atob` requires it. */\nfunction decodeSegment(segment: string): Record<string, unknown> | null {\n try {\n const base64 = segment.replace(/-/g, '+').replace(/_/g, '/')\n const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)\n return JSON.parse(decodeURIComponent(escape(atob(padded))))\n } catch {\n return null\n }\n}\n\nfunction JwtInspector({\n token,\n now,\n revealSignature = false,\n headerLabel = 'Header',\n payloadLabel = 'Payload',\n signatureLabel = 'Signature',\n unverifiedNote = 'Decoded only — the signature has not been verified. That needs the signing key and belongs on the server.',\n invalidNote = 'That does not parse as a JWT. A token has three dot-separated base64url segments.',\n algNoneNote = 'alg is \"none\": this token asserts it is unsigned. Anything accepting it accepts a forgery.',\n expiredLabel = 'Expired',\n activeLabel = 'Active',\n notYetValidLabel = 'Not yet valid',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n token: string\n /** Reference instant for expiry. Defaults to now. */\n now?: Date\n /** Show the signature rather than masking it. */\n revealSignature?: boolean\n headerLabel?: ReactNode\n payloadLabel?: ReactNode\n signatureLabel?: ReactNode\n unverifiedNote?: ReactNode\n invalidNote?: ReactNode\n algNoneNote?: ReactNode\n expiredLabel?: ReactNode\n activeLabel?: ReactNode\n notYetValidLabel?: ReactNode\n}) {\n const [shown, setShown] = useState(revealSignature)\n\n const decoded = useMemo<Decoded>(() => {\n const parts = token.trim().split('.')\n if (parts.length !== 3) return null\n const header = decodeSegment(parts[0])\n const payload = decodeSegment(parts[1])\n if (!header || !payload) return null\n return { header, payload, signature: parts[2] }\n }, [token])\n\n const reference = now ?? new Date()\n\n // Seconds since epoch by spec — but tokens in the wild carry milliseconds,\n // so anything implausibly large is treated as ms rather than shown as a\n // date in the year 55000.\n const asDate = (value: unknown) => {\n if (typeof value !== 'number') return undefined\n return new Date(value > 1e11 ? value : value * 1000)\n }\n\n const exp = asDate(decoded?.payload.exp)\n const nbf = asDate(decoded?.payload.nbf) ?? asDate(decoded?.payload.iat)\n const expired = exp !== undefined && exp < reference\n const early = nbf !== undefined && nbf > reference\n const algNone = String(decoded?.header.alg ?? '').toLowerCase() === 'none'\n\n if (!decoded) {\n return (\n <div\n data-slot=\"jwt-inspector\"\n className={cn(surface, radius.surface, 'p-4', className)}\n {...props}\n >\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 return (\n <div\n data-slot=\"jwt-inspector\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\" className=\"font-mono\">\n {String(decoded.header.alg ?? '?')}\n </Badge>\n {exp && (\n <Badge size=\"sm\" color={expired ? 'destructive' : 'green'}>\n {expired ? expiredLabel : activeLabel}\n </Badge>\n )}\n {early && (\n <Badge size=\"sm\" color=\"amber\">\n {notYetValidLabel}\n </Badge>\n )}\n {exp && (\n <span className=\"text-muted-foreground text-xs\">\n {expired ? '' : 'expires '}\n <Fmt type=\"relative\" value={exp} now={reference} />\n {' · '}\n <Fmt type=\"date\" value={exp} format=\"DD MMM YYYY HH:mm\" />\n </span>\n )}\n </div>\n\n {/* The classic JWT vulnerability, which looks like an ordinary field. */}\n {algNone && (\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 {algNoneNote}\n </p>\n )}\n\n <div className=\"flex flex-col gap-1.5\">\n <span className=\"text-muted-foreground text-xs font-medium\">{headerLabel}</span>\n <JsonViewer value={decoded.header as Json} />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <span className=\"text-muted-foreground text-xs font-medium\">{payloadLabel}</span>\n <JsonViewer value={decoded.payload as Json} defaultExpandedDepth={2} />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <span className=\"text-muted-foreground flex items-center gap-2 text-xs font-medium\">\n {signatureLabel}\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={shown ? 'Hide signature' : 'Reveal signature'}\n aria-pressed={shown}\n onClick={() => setShown((current) => !current)}\n >\n {shown ? <EyeOff /> : <Eye />}\n </Button>\n </span>\n <code className={cn('bg-secondary/60 p-2 font-mono text-xs break-all', radius.control)}>\n {shown ? decoded.signature : '•'.repeat(Math.min(decoded.signature.length, 44))}\n </code>\n </div>\n\n <p className=\"text-muted-foreground border-border border-t pt-3 text-xs\">\n {unverifiedNote}\n </p>\n </div>\n )\n}\n\nexport { JwtInspector, decodeSegment as decodeJwtSegment }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "kanban",
3
+ "type": "registry:ui",
4
+ "title": "Kanban",
5
+ "description": "Columns of cards, movable between them. Drag-and-drop is pointer-only, so every card also moves with the arrow keys — a board you can only drag excludes keyboard users from its main action.",
6
+ "category": "Display",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "badge",
10
+ "lib-styles",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/kanban.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import { useState, type ComponentProps, type DragEvent, type ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Columns of cards, movable between them.\n *\n * Uses the HTML drag-and-drop API rather than pointer maths. It is the API that\n * gives a native drag image and works with the OS conventions, and reaching for\n * a pointer-based library is how a board ends up unusable on touch.\n *\n * Because drag-and-drop is pointer-only, every card also carries a keyboard\n * path: focus it and use the arrow keys to move it between columns. A board\n * where the only way to move work is dragging excludes anyone using a keyboard,\n * and that is the majority of the actions on a board people use daily.\n *\n * The board never reorders anything itself — `onMove` reports the intent and\n * the caller owns the data. Optimistically moving a card and then having the\n * server reject it is worse than a moment of latency.\n */\nexport type KanbanCard = {\n id: string\n title: ReactNode\n meta?: ReactNode\n accent?: string\n}\n\nexport type KanbanColumn = {\n id: string\n title: string\n cards: KanbanCard[]\n limit?: number\n}\n\nfunction Kanban({\n columns,\n onMove,\n renderCard,\n emptyLabel = 'Nothing here',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onDrop'> & {\n columns: KanbanColumn[]\n onMove?: (cardId: string, fromColumn: string, toColumn: string) => void\n renderCard?: (card: KanbanCard, column: KanbanColumn) => ReactNode\n /** Shown in an empty column. */\n emptyLabel?: ReactNode\n}) {\n const [dragging, setDragging] = useState<{ id: string; from: string } | null>(null)\n const [over, setOver] = useState<string | null>(null)\n\n function move(cardId: string, from: string, to: string) {\n if (from === to) return\n onMove?.(cardId, from, to)\n }\n\n function onDrop(event: DragEvent<HTMLElement>, columnId: string) {\n event.preventDefault()\n setOver(null)\n const id = event.dataTransfer.getData('text/plain') || dragging?.id\n const from = dragging?.from\n if (id && from) move(id, from, columnId)\n setDragging(null)\n }\n\n return (\n <div\n data-slot=\"kanban\"\n className={cn('flex gap-3 overflow-x-auto pb-2', className)}\n {...props}\n >\n {columns.map((column, columnIndex) => {\n const full = column.limit !== undefined && column.cards.length >= column.limit\n\n return (\n <section\n key={column.id}\n aria-label={column.title}\n onDragOver={(event) => {\n event.preventDefault()\n setOver(column.id)\n }}\n onDragLeave={() => setOver((current) => (current === column.id ? null : current))}\n onDrop={(event) => onDrop(event, column.id)}\n className={cn(\n 'bg-secondary flex w-72 shrink-0 flex-col gap-2 p-2',\n radius.surface,\n over === column.id && 'ring-primary/40 ring-2',\n )}\n >\n <header className=\"flex items-center gap-2 px-1\">\n <h3 className=\"min-w-0 flex-1 truncate text-xs font-medium tracking-wide uppercase\">\n {column.title}\n </h3>\n <Badge size=\"sm\" color={full ? 'amber' : 'neutral'}>\n {column.cards.length}\n {column.limit !== undefined && ` / ${column.limit}`}\n </Badge>\n </header>\n\n <ul className=\"flex list-none flex-col gap-2\">\n {column.cards.map((card) => (\n <li key={card.id}>\n <div\n draggable\n tabIndex={0}\n role=\"button\"\n aria-label={`${typeof card.title === 'string' ? card.title : 'Card'} in ${column.title}. Use the arrow keys to move between columns.`}\n onDragStart={(event) => {\n event.dataTransfer.setData('text/plain', card.id)\n event.dataTransfer.effectAllowed = 'move'\n setDragging({ id: card.id, from: column.id })\n }}\n onDragEnd={() => {\n setDragging(null)\n setOver(null)\n }}\n // The keyboard path: dragging is pointer-only, so arrows\n // move a focused card between columns.\n onKeyDown={(event) => {\n const direction =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!direction) return\n const target = columns[columnIndex + direction]\n if (!target) return\n event.preventDefault()\n move(card.id, column.id, target.id)\n }}\n className={cn(\n 'bg-card border-border relative cursor-grab border p-3 active:cursor-grabbing',\n radius.control,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'hover:border-foreground/25',\n dragging?.id === card.id && 'opacity-50',\n )}\n >\n {card.accent && (\n <span\n aria-hidden=\"true\"\n className=\"absolute inset-y-0 start-0 w-1 rounded-s-[inherit]\"\n style={{ backgroundColor: card.accent }}\n />\n )}\n\n {renderCard?.(card, column) ?? (\n <>\n <p className=\"text-sm font-medium text-pretty\">{card.title}</p>\n {card.meta && (\n <div className=\"text-muted-foreground mt-2 text-xs\">\n {card.meta}\n </div>\n )}\n </>\n )}\n </div>\n </li>\n ))}\n\n {column.cards.length === 0 && (\n <li className=\"text-muted-foreground/60 px-1 py-6 text-center text-xs\">\n {emptyLabel}\n </li>\n )}\n </ul>\n </section>\n )\n })}\n </div>\n )\n}\n\nexport { Kanban }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "kbd",
3
+ "type": "registry:ui",
4
+ "title": "Kbd",
5
+ "description": "A keyboard key. Splits on \"+\" so a chord can be written as one string.",
6
+ "category": "Display",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/kbd.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/** A keyboard key. Splits on `+` so a chord can be written as one string. */\nfunction Kbd({\n className,\n keys,\n children,\n ...props\n}: ComponentProps<'kbd'> & { keys?: string }) {\n const parts = keys?.split('+').map((key) => key.trim())\n\n return (\n <kbd\n data-slot=\"kbd\"\n className={cn(\n 'text-muted-foreground inline-flex items-center gap-1 font-mono text-xs',\n className,\n )}\n {...props}\n >\n {parts\n ? parts.map((key, index) => (\n <span\n key={index}\n className=\"border-border bg-muted rounded-md border px-1.5 py-0.5\"\n >\n {key}\n </span>\n ))\n : children}\n </kbd>\n )\n}\n\nexport { Kbd }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "label-picker",
3
+ "type": "registry:ui",
4
+ "title": "Label Picker",
5
+ "description": "Coloured repository labels, picked from a searchable list. Chip text is derived from the fill by luminance, since a label set imported from a forge only gives you a background colour.",
6
+ "category": "Development",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "input",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "popover"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/label-picker.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Plus, Search } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Repository labels: coloured chips, picked from a searchable list.\n *\n * Each label carries its own colour, so the chip is styled from data rather\n * than from a variant — which is why this is not just a Badge with a prop. The\n * text colour is derived from the fill rather than stored, since a label set\n * imported from a forge only gives you the background.\n */\nexport type LabelOption = {\n id: string\n name: string\n /** Any CSS colour. */\n color: string\n description?: string\n}\n\n/**\n * Pick black or white text for a background.\n *\n * `color-mix` cannot answer \"is this light or dark\", and `oklch()` relative\n * syntax cannot branch — so the decision is made here, from the sRGB\n * luminance of a hex value, and anything non-hex falls back to white.\n */\nfunction readableInk(color: string) {\n const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())\n if (!hex) return 'white'\n const value = parseInt(hex[1], 16)\n const r = (value >> 16) & 255\n const g = (value >> 8) & 255\n const b = value & 255\n // Rec. 709 luma, the standard weighting for perceived brightness.\n const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255\n return luma > 0.6 ? 'black' : 'white'\n}\n\nfunction LabelChip({\n label,\n className,\n ...props\n}: ComponentProps<'span'> & { label: LabelOption }) {\n return (\n <span\n data-slot=\"label-chip\"\n style={{ backgroundColor: label.color, color: readableInk(label.color) }}\n className={cn(\n 'inline-flex h-5 shrink-0 items-center gap-1 px-2 text-[10px] font-medium',\n radius.xs,\n className,\n )}\n {...props}\n >\n {label.name}\n </span>\n )\n}\n\nfunction LabelPicker({\n labels,\n selected,\n onSelectedChange,\n triggerLabel = 'Labels',\n searchPlaceholder = 'Filter labels',\n searchLabel = 'Filter labels',\n listLabel = 'Labels',\n emptyMessage = 'No labels match.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n labels: LabelOption[]\n selected: string[]\n onSelectedChange: (ids: string[]) => void\n triggerLabel?: string\n searchPlaceholder?: string\n /** Accessible name for the filter field. */\n searchLabel?: string\n /** Accessible name for the option list. */\n listLabel?: string\n emptyMessage?: ReactNode\n}) {\n const [query, setQuery] = useState('')\n\n const visible = labels.filter((label) =>\n label.name.toLowerCase().includes(query.toLowerCase()),\n )\n\n function toggle(id: string) {\n onSelectedChange(\n selected.includes(id)\n ? selected.filter((value) => value !== id)\n : [...selected, id],\n )\n }\n\n return (\n <div\n data-slot=\"label-picker\"\n className={cn('flex flex-wrap items-center gap-1.5', className)}\n {...props}\n >\n {labels\n .filter((label) => selected.includes(label.id))\n .map((label) => (\n <LabelChip key={label.id} label={label} />\n ))}\n\n <Popover>\n <PopoverTrigger asChild>\n <Button variant=\"secondary\" size=\"xs\">\n <Plus />\n {triggerLabel}\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-64 p-0\">\n <div className=\"border-border border-b p-2\">\n <Input\n size=\"sm\"\n variant=\"secondary\"\n icon={<Search />}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n\n <ul\n role=\"listbox\"\n aria-multiselectable=\"true\"\n aria-label={listLabel}\n className=\"max-h-56 list-none overflow-y-auto p-1\"\n >\n {visible.map((label) => {\n const isSelected = selected.includes(label.id)\n return (\n <li key={label.id}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={isSelected}\n onClick={() => toggle(label.id)}\n className={cn(\n 'hover:bg-accent flex w-full items-start gap-2 px-2 py-1.5 text-start',\n radius.control,\n interactive,\n focusRing,\n )}\n >\n <span\n aria-hidden=\"true\"\n style={{ backgroundColor: label.color }}\n className=\"mt-1 size-3 shrink-0 rounded-full [corner-shape:round]\"\n />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate text-sm\">{label.name}</span>\n {label.description && (\n <span className=\"text-muted-foreground block truncate text-xs\">\n {label.description}\n </span>\n )}\n </span>\n {isSelected && <Check className=\"mt-0.5 size-3.5 shrink-0\" />}\n </button>\n </li>\n )\n })}\n\n {visible.length === 0 && (\n <li className=\"text-muted-foreground px-2 py-3 text-center text-sm\">\n {emptyMessage}\n </li>\n )}\n </ul>\n </PopoverContent>\n </Popover>\n </div>\n )\n}\n\nexport { LabelChip, LabelPicker, readableInk }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "label",
3
+ "type": "registry:ui",
4
+ "title": "Label",
5
+ "description": "A form label. htmlFor is the point — clicking it focuses the control, and a screen reader announces the two together.",
6
+ "category": "Forms",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/label.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A form label. `htmlFor` is the point — clicking it focuses the control, and a\n * screen reader announces the two together.\n */\nfunction Label({\n className,\n required,\n children,\n ...props\n}: ComponentProps<'label'> & { required?: boolean }) {\n return (\n <label\n data-slot=\"label\"\n className={cn(\n 'flex items-center gap-1 text-sm leading-none font-medium select-none',\n 'has-[+_:disabled]:opacity-60',\n className,\n )}\n {...props}\n >\n {children}\n {required && (\n <span aria-hidden=\"true\" className=\"text-destructive\">\n *\n </span>\n )}\n </label>\n )\n}\n\nexport { Label }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "leaderboard",
3
+ "type": "registry:ui",
4
+ "title": "Leaderboard",
5
+ "description": "Ranked scores with movement since the last period, and the current user pinned even when they are nowhere near the top. A board you cannot find yourself on is a board you stop opening.",
6
+ "category": "Gaming",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "avatar",
12
+ "badge",
13
+ "lib-styles",
14
+ "lib-utils"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/leaderboard.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Minus, TrendingDown, TrendingUp } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A ranked table with the viewer pinned.\n *\n * The current user's row is always rendered, even at rank 4,812 — pinned to the\n * bottom with an ellipsis above it if they fall outside the visible page. A\n * leaderboard that simply omits you is the one thing people check it for.\n *\n * Ties share a rank and the next rank skips accordingly (1, 2, 2, 4), which is\n * standard competition ranking. Numbering rows sequentially silently breaks\n * ties in favour of whoever the sort happened to put first.\n */\nexport type LeaderboardEntry = {\n id: string\n name: ReactNode\n avatar?: ReactNode\n score: number\n /** Rank in the previous period, for the movement arrow. */\n previousRank?: number\n isCurrentUser?: boolean\n meta?: ReactNode\n}\n\ntype RankedRow = { entry: LeaderboardEntry; rank: number }\n\n/** Competition ranking: ties share a rank and the next one skips (1, 2, 2, 4). */\nfunction rankEntries(entries: LeaderboardEntry[]): RankedRow[] {\n const sorted = [...entries].sort((a, b) => b.score - a.score)\n const rows: RankedRow[] = []\n let rank = 0\n let lastScore: number | null = null\n\n for (const [index, entry] of sorted.entries()) {\n if (lastScore === null || entry.score !== lastScore) rank = index + 1\n lastScore = entry.score\n rows.push({ entry, rank })\n }\n return rows\n}\n\n/**\n * One row.\n *\n * Declared at module scope rather than inside `LeaderboardTable`: a component\n * defined during render is a new type on every render, so React unmounts and\n * remounts every row instead of updating it — losing focus and any transition\n * along with it.\n */\nfunction Row({\n row,\n pinned,\n format,\n currentUserLabel,\n}: {\n row: RankedRow\n pinned?: boolean\n format: (score: number) => ReactNode\n currentUserLabel: ReactNode\n}) {\n const movement =\n row.entry.previousRank === undefined ? 0 : row.entry.previousRank - row.rank\n const Icon = movement > 0 ? TrendingUp : movement < 0 ? TrendingDown : Minus\n\n return (\n <li\n className={cn(\n 'flex items-center gap-3 px-3 py-2',\n row.entry.isCurrentUser && 'bg-accent/50',\n pinned && 'border-border border-t',\n )}\n >\n <span className=\"text-muted-foreground w-8 shrink-0 text-end text-sm font-medium tabular-nums\">\n {row.rank}\n </span>\n\n {row.entry.avatar ?? <Avatar size=\"xs\" name={String(row.entry.name)} className=\"shrink-0\" />}\n\n <span className=\"min-w-0 flex-1\">\n <span className=\"flex items-center gap-2\">\n <span className=\"truncate text-sm font-medium\">{row.entry.name}</span>\n {row.entry.isCurrentUser && <Badge size=\"sm\">{currentUserLabel}</Badge>}\n </span>\n {row.entry.meta && (\n <span className=\"text-muted-foreground block truncate text-xs\">{row.entry.meta}</span>\n )}\n </span>\n\n {row.entry.previousRank !== undefined && (\n <span\n className={cn(\n 'flex shrink-0 items-center gap-0.5 text-xs tabular-nums',\n movement > 0 && 'text-[var(--green-soft-foreground)]',\n movement < 0 && 'text-[var(--destructive-soft-foreground)]',\n movement === 0 && 'text-muted-foreground/40',\n )}\n >\n <Icon className=\"size-3\" aria-hidden=\"true\" />\n {movement !== 0 && Math.abs(movement)}\n </span>\n )}\n\n <span className=\"min-w-16 shrink-0 text-end text-sm font-semibold tabular-nums\">\n {format(row.entry.score)}\n </span>\n </li>\n )\n}\n\nfunction LeaderboardTable({\n entries,\n limit,\n scoreLabel = 'Score',\n formatScore,\n locale = 'en-GB',\n rankHeader = '#',\n playerHeader = 'Player',\n currentUserLabel = 'You',\n className,\n ...props\n}: ComponentProps<'div'> & {\n entries: LeaderboardEntry[]\n /** Rows shown before the current user is pinned. */\n limit?: number\n scoreLabel?: ReactNode\n formatScore?: (score: number) => ReactNode\n locale?: string\n rankHeader?: ReactNode\n playerHeader?: ReactNode\n /** Badge on the viewer's own row. */\n currentUserLabel?: ReactNode\n\n}) {\n const num = new Intl.NumberFormat(locale)\n const format = formatScore ?? ((score: number) => num.format(score))\n\n const withRanks = rankEntries(entries)\n\n const visible = limit ? withRanks.slice(0, limit) : withRanks\n const me = withRanks.find((row) => row.entry.isCurrentUser)\n const meIsHidden = me && !visible.includes(me)\n\n return (\n <div\n data-slot=\"leaderboard\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border text-muted-foreground flex items-center gap-3 border-b p-3 text-xs font-medium\">\n <span className=\"w-8 text-end\">{rankHeader}</span>\n <span className=\"flex-1\">{playerHeader}</span>\n <span>{scoreLabel}</span>\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {visible.map((row) => (\n <Row key={row.entry.id} row={row} format={format} currentUserLabel={currentUserLabel} />\n ))}\n\n {/* Pinned: a leaderboard that omits you is the one thing you check. */}\n {meIsHidden && me && (\n <>\n <li className=\"text-muted-foreground/40 px-3 py-1 text-center text-xs\">···</li>\n <Row row={me} pinned format={format} currentUserLabel={currentUserLabel} />\n </>\n )}\n </ul>\n </div>\n )\n}\n\nexport { LeaderboardTable }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "ledger-table",
3
+ "type": "registry:ui",
4
+ "title": "Ledger Table",
5
+ "description": "Double-entry lines with a running balance. Debits and credits get their own columns and are totalled separately, because a ledger where the two do not agree is the finding.",
6
+ "category": "Finance",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "fmt",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/ledger-table.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A double-entry ledger with a running balance.\n *\n * The component checks that debits equal credits and says so when they do not.\n * That is the entire point of double-entry — an unbalanced ledger is a bug, and\n * a table that renders one without comment is worse than useless because it\n * looks authoritative.\n *\n * Amounts are integer minor units (cents), not floats. `0.1 + 0.2` is a\n * genuinely wrong number in a ledger, and running balances accumulate that\n * error line by line until the total is visibly off.\n *\n * The running balance is computed here rather than taken per row, so it cannot\n * disagree with the entries above it.\n */\nexport type LedgerEntry = {\n id: string\n date: Date\n description: ReactNode\n account?: ReactNode\n reference?: ReactNode\n /** Minor units. Exactly one of debit or credit per entry. */\n debit?: number\n credit?: number\n}\n\nfunction LedgerTable({\n entries,\n currency = 'USD',\n locale = 'en-GB',\n openingBalance = 0,\n showBalance = true,\n columnLabels = {\n date: 'Date',\n description: 'Description',\n account: 'Account',\n debit: 'Debit',\n credit: 'Credit',\n balance: 'Balance',\n },\n totalsLabel = 'Totals',\n className,\n ...props\n}: ComponentProps<'div'> & {\n entries: LedgerEntry[]\n currency?: string\n locale?: string\n /** Minor units. */\n openingBalance?: number\n showBalance?: boolean\n /** Column headings. */\n columnLabels?: {\n date?: ReactNode\n description?: ReactNode\n account?: ReactNode\n debit?: ReactNode\n credit?: ReactNode\n balance?: ReactNode\n }\n totalsLabel?: ReactNode\n}) {\n const { rows, debits, credits, closing } = useMemo(\n () =>\n entries.reduce(\n (acc, entry) => {\n const debit = entry.debit ?? 0\n const credit = entry.credit ?? 0\n // Debits increase, credits decrease — an asset-account convention.\n const balance = acc.closing + debit - credit\n return {\n rows: [...acc.rows, { ...entry, balance }],\n debits: acc.debits + debit,\n credits: acc.credits + credit,\n closing: balance,\n }\n },\n {\n rows: [] as (LedgerEntry & { balance: number })[],\n debits: 0,\n credits: 0,\n closing: openingBalance,\n },\n ),\n [entries, openingBalance],\n )\n\n const balanced = debits === credits\n const money = (minor: number) => (\n <Fmt type=\"currency\" value={minor / 100} currency={currency} locale={locale} decimals={2} />\n )\n\n return (\n <div\n data-slot=\"ledger-table\"\n className={cn(surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">{columnLabels.date}</th>\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">{columnLabels.description}</th>\n <th className=\"text-muted-foreground hidden px-3 py-2 text-start text-xs font-medium sm:table-cell\">{columnLabels.account}</th>\n <th className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium\">{columnLabels.debit}</th>\n <th className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium\">{columnLabels.credit}</th>\n {showBalance && (\n <th className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium\">{columnLabels.balance}</th>\n )}\n </tr>\n </thead>\n\n <tbody>\n {rows.map((row) => (\n <tr key={row.id} className=\"border-border/60 border-b\">\n <td className=\"text-muted-foreground px-3 py-2 whitespace-nowrap tabular-nums\">\n <Fmt type=\"date\" value={row.date} format=\"DD/MM/YY\" locale={locale} />\n </td>\n <td className=\"px-3 py-2\">\n <span className=\"block\">{row.description}</span>\n {row.reference && (\n <span className=\"text-muted-foreground/70 block font-mono text-xs\">\n {row.reference}\n </span>\n )}\n </td>\n <td className=\"text-muted-foreground hidden px-3 py-2 sm:table-cell\">\n {row.account ?? '—'}\n </td>\n <td className=\"px-3 py-2 text-end tabular-nums whitespace-nowrap\">\n {row.debit ? money(row.debit) : <span className=\"text-muted-foreground/40\">—</span>}\n </td>\n <td className=\"px-3 py-2 text-end tabular-nums whitespace-nowrap\">\n {row.credit ? money(row.credit) : <span className=\"text-muted-foreground/40\">—</span>}\n </td>\n {showBalance && (\n <td className=\"px-3 py-2 text-end font-medium tabular-nums whitespace-nowrap\">\n {money(row.balance)}\n </td>\n )}\n </tr>\n ))}\n </tbody>\n\n <tfoot>\n <tr className=\"border-border border-t-2\">\n <td colSpan={3} className=\"px-3 py-2 text-xs font-medium\">\n {totalsLabel}\n </td>\n <td className=\"px-3 py-2 text-end font-medium tabular-nums whitespace-nowrap\">\n {money(debits)}\n </td>\n <td className=\"px-3 py-2 text-end font-medium tabular-nums whitespace-nowrap\">\n {money(credits)}\n </td>\n {showBalance && (\n <td className=\"px-3 py-2 text-end font-semibold tabular-nums whitespace-nowrap\">\n {money(closing)}\n </td>\n )}\n </tr>\n </tfoot>\n </table>\n\n {/* An unbalanced ledger is a bug; rendering it silently is worse. */}\n {!balanced && (\n <p className=\"border-border flex items-start gap-1.5 border-t p-3 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n Debits and credits do not balance — out by {money(Math.abs(debits - credits))}.\n </p>\n )}\n </div>\n )\n}\n\nexport { LedgerTable }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "lib-date",
3
+ "type": "registry:lib",
4
+ "title": "date",
5
+ "dependencies": [],
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "lib/date.ts",
10
+ "type": "registry:lib",
11
+ "content": "/**\n * Date helpers for Calendar. Local-time only and deliberately dependency-free.\n *\n * Everything here works on a date's local Y/M/D. Using UTC would shift the\n * displayed day for anyone west of Greenwich after 00:00 UTC, which is the\n * classic off-by-one-day calendar bug.\n */\n\nexport function startOfDay(date: Date) {\n const copy = new Date(date)\n copy.setHours(0, 0, 0, 0)\n return copy\n}\n\nexport function isSameDay(a: Date, b: Date) {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n )\n}\n\nexport function isSameMonth(a: Date, b: Date) {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()\n}\n\nexport function addMonths(date: Date, count: number) {\n // Set the day to 1 first: adding a month to 31 January would otherwise land\n // in March, because 31 February does not exist and rolls over.\n const copy = new Date(date.getFullYear(), date.getMonth() + count, 1)\n const days = daysInMonth(copy)\n copy.setDate(Math.min(date.getDate(), days))\n return copy\n}\n\nexport function addDays(date: Date, count: number) {\n const copy = new Date(date)\n copy.setDate(copy.getDate() + count)\n return copy\n}\n\nexport function daysInMonth(date: Date) {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()\n}\n\n/**\n * The 6x7 grid for a month, including the leading and trailing days that fill\n * the first and last weeks. Always 42 cells, so the calendar never changes\n * height as you page through months.\n */\nexport function monthGrid(month: Date, weekStartsOn = 1) {\n const first = new Date(month.getFullYear(), month.getMonth(), 1)\n const shift = (first.getDay() - weekStartsOn + 7) % 7\n const start = addDays(first, -shift)\n\n return Array.from({ length: 42 }, (_, index) => addDays(start, index))\n}\n\nexport function weekdayNames(locale: string, weekStartsOn = 1) {\n const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n // 2024-01-07 was a Sunday, so index 0 lines up with getDay() === 0.\n return Array.from({ length: 7 }, (_, index) =>\n formatter.format(new Date(2024, 0, 7 + ((index + weekStartsOn) % 7))),\n )\n}\n\nexport function formatMonth(date: Date, locale: string) {\n return new Intl.DateTimeFormat(locale, {\n month: 'long',\n year: 'numeric',\n }).format(date)\n}\n\nexport function formatDate(date: Date, locale: string) {\n return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)\n}\n\nexport function isBetween(date: Date, from: Date, to: Date) {\n const time = startOfDay(date).getTime()\n const lo = Math.min(startOfDay(from).getTime(), startOfDay(to).getTime())\n const hi = Math.max(startOfDay(from).getTime(), startOfDay(to).getTime())\n return time >= lo && time <= hi\n}\n\n/* ------------------------------------------------------------------ ranges */\n\nexport type DateRange = { from?: Date; to?: Date }\n\nexport function startOfMonth(date: Date) {\n return new Date(date.getFullYear(), date.getMonth(), 1)\n}\n\n/** `weekStartsOn` is 0–6, Sunday through Saturday. */\nexport function startOfWeek(date: Date, weekStartsOn = 1) {\n const shift = (date.getDay() - weekStartsOn + 7) % 7\n return startOfDay(addDays(date, -shift))\n}\n\nexport function isBefore(a: Date, b: Date) {\n return startOfDay(a).getTime() < startOfDay(b).getTime()\n}\n\nexport function isAfter(a: Date, b: Date) {\n return startOfDay(a).getTime() > startOfDay(b).getTime()\n}\n\n/** Always six rows of seven, so paging months never changes the height. */\nexport function monthWeeks(month: Date, weekStartsOn = 1) {\n const first = startOfWeek(startOfMonth(month), weekStartsOn)\n return Array.from({ length: 6 }, (_, week) =>\n Array.from({ length: 7 }, (_, day) => addDays(first, week * 7 + day)),\n )\n}\n\n/** Put the two ends of a range in order, whichever way it was drawn. */\nexport function orderRange(a: Date, b: Date): DateRange {\n return isAfter(a, b) ? { from: b, to: a } : { from: a, to: b }\n}\n\n/**\n * Carry the clock from one date onto another.\n *\n * Picking a new day should not silently reset a time the user already chose,\n * which is exactly what `new Date(y, m, d)` would do.\n */\nexport function withTimeOf(date: Date, source?: Date) {\n if (!source) return date\n const next = new Date(date)\n next.setHours(source.getHours(), source.getMinutes(), 0, 0)\n return next\n}\n\nexport function setTime(date: Date, hours: number, minutes: number) {\n const next = new Date(date)\n next.setHours(hours, minutes, 0, 0)\n return next\n}\n\nexport function isRangeStart(date: Date, range?: DateRange) {\n return Boolean(range?.from && isSameDay(date, range.from))\n}\n\nexport function isRangeEnd(date: Date, range?: DateRange) {\n return Boolean(range?.to && isSameDay(date, range.to))\n}\n\nexport function isInRange(date: Date, range?: DateRange) {\n if (!range?.from || !range.to) return false\n return isBetween(date, range.from, range.to)\n}\n\nexport function formatRange(range: DateRange | undefined, locale: string) {\n if (!range?.from) return ''\n if (!range.to || isSameDay(range.from, range.to)) {\n return formatDate(range.from, locale)\n }\n return `${formatDate(range.from, locale)} – ${formatDate(range.to, locale)}`\n}\n\nexport function formatTime(date: Date, locale: string) {\n return new Intl.DateTimeFormat(locale, {\n hour: '2-digit',\n minute: '2-digit',\n }).format(date)\n}\n\n/** Ranges people ask for often enough to be worth a button. */\nexport function defaultPresets(): { label: string; range: DateRange }[] {\n const today = startOfDay(new Date())\n const back = (days: number) => addDays(today, -days)\n\n return [\n { label: 'Today', range: { from: today, to: today } },\n { label: 'Last 7 days', range: { from: back(6), to: today } },\n { label: 'Last 30 days', range: { from: back(29), to: today } },\n { label: 'This month', range: { from: startOfMonth(today), to: today } },\n {\n label: 'Last month',\n range: {\n from: startOfMonth(addMonths(today, -1)),\n to: addDays(startOfMonth(today), -1),\n },\n },\n ]\n}\n"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "lib-highlighter",
3
+ "type": "registry:lib",
4
+ "title": "highlighter",
5
+ "dependencies": [
6
+ "shiki@^4.4.3"
7
+ ],
8
+ "registryDependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "lib/highlighter.ts",
12
+ "type": "registry:lib",
13
+ "content": "import { createHighlighterCore, type HighlighterCore } from 'shiki/core'\nimport { createJavaScriptRegexEngine } from 'shiki/engine/javascript'\n\n/** Languages the CodeBlock can highlight. Add a lang here and in `LANGS`. */\nexport const LANGUAGES = [\n 'tsx',\n 'jsx',\n 'typescript',\n 'javascript',\n 'css',\n 'html',\n 'json',\n 'bash',\n 'sql',\n] as const\n\nexport type Language = (typeof LANGUAGES)[number]\n\nconst LANGS = [\n import('shiki/langs/tsx.mjs'),\n import('shiki/langs/jsx.mjs'),\n import('shiki/langs/typescript.mjs'),\n import('shiki/langs/javascript.mjs'),\n import('shiki/langs/css.mjs'),\n import('shiki/langs/html.mjs'),\n import('shiki/langs/json.mjs'),\n import('shiki/langs/bash.mjs'),\n import('shiki/langs/sql.mjs'),\n]\n\nlet highlighter: Promise<HighlighterCore> | undefined\n\n/**\n * Lazily create a single shared highlighter. Both themes are loaded at once so\n * a rendered block carries light *and* dark colors and needs no re-highlight\n * when the theme flips — see the `.shiki` rules in index.css.\n */\nexport function getHighlighter() {\n highlighter ??= createHighlighterCore({\n themes: [\n import('shiki/themes/github-light-default.mjs'),\n import('shiki/themes/github-dark-default.mjs'),\n ],\n langs: LANGS,\n engine: createJavaScriptRegexEngine(),\n })\n\n return highlighter\n}\n\nexport type HighlightOptions = {\n /** 1-based line numbers to mark with `data-highlighted`. */\n highlightLines?: number[]\n}\n\nexport async function highlight(\n code: string,\n lang: Language,\n { highlightLines }: HighlightOptions = {},\n) {\n const shiki = await getHighlighter()\n const marked = new Set(highlightLines)\n\n return shiki.codeToHtml(code, {\n lang,\n themes: { light: 'github-light-default', dark: 'github-dark-default' },\n defaultColor: false,\n colorReplacements: {\n '#ffffff': 'transparent',\n '#0d1117': 'transparent',\n },\n transformers: marked.size\n ? [\n {\n name: 'highlight-lines',\n line(node, line) {\n if (marked.has(line)) {\n node.properties['data-highlighted'] = ''\n }\n },\n },\n ]\n : undefined,\n })\n}\n"
14
+ }
15
+ ]
16
+ }