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,25 @@
1
+ {
2
+ "name": "transaction-status",
3
+ "type": "registry:ui",
4
+ "title": "Transaction Status",
5
+ "description": "A transaction from submitted to final. Confirmations are progress toward a threshold, not a tick at one block — chains reorganise, and \"done\" at one confirmation teaches a habit that eventually costs money.",
6
+ "category": "Crypto",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "fmt",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "progress",
15
+ "spinner",
16
+ "wallet-address"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/transaction-status.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, ExternalLink, TriangleAlert, X } from 'lucide-react'\nimport { Progress } from '@/components/ui/progress'\nimport { Spinner } from '@/components/ui/spinner'\nimport { WalletAddress } from '@/components/ui/wallet-address'\nimport { Fmt } from '@/components/ui/fmt'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A transaction's progress from submitted to final.\n *\n * Confirmations are shown as progress toward a threshold, not as a tick the\n * moment the transaction is mined. One confirmation is not settlement — chains\n * reorganise — and an interface that says \"done\" at one block is teaching a\n * habit that eventually costs someone money.\n *\n * A reverted transaction is a distinct state from a failed submission: it was\n * mined, it consumed gas, and it is on-chain. Collapsing the two hides the fact\n * that the user paid for it.\n */\nexport type TxState = 'pending' | 'mined' | 'confirmed' | 'reverted' | 'dropped'\n\nconst STATE = {\n pending: { label: 'Pending', tone: 'text-[var(--amber-soft-foreground)]' },\n mined: { label: 'Mined', tone: 'text-[var(--blue-soft-foreground)]' },\n confirmed: { label: 'Confirmed', tone: 'text-[var(--green-soft-foreground)]' },\n reverted: { label: 'Reverted', tone: 'text-[var(--destructive-soft-foreground)]' },\n dropped: { label: 'Dropped', tone: 'text-muted-foreground' },\n} as const\n\nfunction TransactionStatus({\n hash,\n state,\n confirmations = 0,\n required = 12,\n href,\n summary,\n submittedAt,\n now,\n gasUsed,\n error,\n explorerLabel = 'View transaction on block explorer',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n hash: string\n state: TxState\n confirmations?: number\n /** Blocks before this counts as settled. */\n required?: number\n href?: string\n summary?: ReactNode\n submittedAt?: Date\n now?: Date\n gasUsed?: ReactNode\n error?: ReactNode\n /** Accessible name for the explorer link. */\n explorerLabel?: string\n}) {\n const meta = STATE[state]\n const settled = state === 'confirmed'\n const failed = state === 'reverted' || state === 'dropped'\n const progress = Math.min(confirmations / Math.max(required, 1), 1)\n\n return (\n <div\n data-slot=\"transaction-status\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-2.5\">\n <span className={cn('mt-0.5 shrink-0', meta.tone)} aria-hidden=\"true\">\n {state === 'pending' ? (\n <Spinner size=\"xs\" label=\"Pending\" />\n ) : settled ? (\n <Check className=\"size-4\" />\n ) : state === 'reverted' ? (\n <X className=\"size-4\" />\n ) : state === 'dropped' ? (\n <TriangleAlert className=\"size-4\" />\n ) : (\n <Check className=\"size-4\" />\n )}\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <p className={cn('text-sm font-medium', meta.tone)}>{meta.label}</p>\n {summary && (\n <p className=\"text-muted-foreground mt-0.5 text-sm\">{summary}</p>\n )}\n </div>\n\n {href && (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n aria-label={explorerLabel}\n className={cn('text-muted-foreground hover:text-foreground shrink-0', radius.xs, focusRing)}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n )}\n </div>\n\n {/* Progress toward settlement, not a tick at one block. */}\n {!failed && (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"text-muted-foreground flex items-baseline justify-between gap-2 text-xs\">\n <span>{settled ? 'Settled' : 'Confirmations'}</span>\n <span className=\"tabular-nums\">\n {Math.min(confirmations, required)} / {required}\n </span>\n </div>\n <Progress value={progress * 100} className=\"h-1.5\" />\n </div>\n )}\n\n {error && (\n <p className=\"text-[var(--destructive-soft-foreground)] text-xs\">{error}</p>\n )}\n\n <div className=\"text-muted-foreground/80 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs\">\n <WalletAddress address={hash} chars={10} size=\"sm\" />\n {submittedAt && (\n <span>\n submitted <Fmt type=\"relative\" value={submittedAt} now={now} />\n </span>\n )}\n {gasUsed && <span>gas {gasUsed}</span>}\n </div>\n </div>\n )\n}\n\nexport { TransactionStatus }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "tree",
3
+ "type": "registry:ui",
4
+ "title": "Tree",
5
+ "description": "A collapsible hierarchy with real keyboard navigation — arrow keys move by what is visible, one tab stop for the whole tree, and ARIA that says where each row sits in its branch.",
6
+ "category": "Navigation",
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/tree.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import {\n useCallback,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A collapsible hierarchy with full keyboard navigation.\n *\n * The generalisation of `FileTree` — anything nested: a JSON document, a\n * dependency graph, a causal chain.\n *\n * Two things are worth knowing. Navigation runs over a *flattened* list of the\n * currently visible nodes, because arrow keys move by what is on screen, not\n * by tree depth: pressing Down on the last child of a collapsed-sibling branch\n * has to land on the next visible row, wherever that sits in the hierarchy.\n *\n * And the tree owns exactly one tab stop (`tabIndex` roves with the active\n * row), per the ARIA practices — a tree of two hundred nodes that puts every\n * row in the tab order is unusable with a keyboard.\n */\nexport type TreeNode = {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** Right-aligned annotation — a count, a size, a status. */\n meta?: ReactNode\n children?: TreeNode[]\n disabled?: boolean\n}\n\ntype FlatNode = {\n node: TreeNode\n level: number\n parentId?: string\n /** Position within its own sibling group, for the flat-DOM ARIA pattern. */\n posinset: number\n setsize: number\n}\n\n/** Depth-first walk of what is currently visible. */\nfunction flatten(\n nodes: TreeNode[],\n expanded: Set<string>,\n level = 0,\n parentId?: string,\n): FlatNode[] {\n return nodes.flatMap((node, index) => {\n const row: FlatNode = {\n node,\n level,\n parentId,\n posinset: index + 1,\n setsize: nodes.length,\n }\n const open = node.children?.length && expanded.has(node.id)\n return open\n ? [row, ...flatten(node.children ?? [], expanded, level + 1, node.id)]\n : [row]\n })\n}\n\nfunction Tree({\n nodes,\n defaultExpanded = [],\n expanded: expandedProp,\n onExpandedChange,\n selected,\n onSelect,\n guides = true,\n className,\n ...props\n}: Omit<ComponentProps<'ul'>, 'onSelect'> & {\n nodes: TreeNode[]\n defaultExpanded?: string[]\n expanded?: string[]\n onExpandedChange?: (expanded: string[]) => void\n selected?: string\n onSelect?: (id: string) => void\n /** Indent guide lines. */\n guides?: boolean\n}) {\n const controlled = expandedProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<string[]>(defaultExpanded)\n const expandedList = controlled ? expandedProp : uncontrolled\n const expanded = useMemo(() => new Set(expandedList), [expandedList])\n\n const rows = useMemo(() => flatten(nodes, expanded), [nodes, expanded])\n const [active, setActive] = useState(0)\n const rowRefs = useRef<(HTMLDivElement | null)[]>([])\n\n const setExpanded = useCallback(\n (next: Set<string>) => {\n const list = [...next]\n if (!controlled) setUncontrolled(list)\n onExpandedChange?.(list)\n },\n [controlled, onExpandedChange],\n )\n\n const toggle = useCallback(\n (id: string, open?: boolean) => {\n const next = new Set(expanded)\n const shouldOpen = open ?? !next.has(id)\n if (shouldOpen) next.add(id)\n else next.delete(id)\n setExpanded(next)\n },\n [expanded, setExpanded],\n )\n\n const focusRow = (index: number) => {\n const clamped = Math.max(0, Math.min(index, rows.length - 1))\n setActive(clamped)\n rowRefs.current[clamped]?.focus()\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLUListElement>) {\n const row = rows[active]\n if (!row) return\n const hasChildren = Boolean(row.node.children?.length)\n const isOpen = expanded.has(row.node.id)\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault()\n focusRow(active + 1)\n break\n case 'ArrowUp':\n event.preventDefault()\n focusRow(active - 1)\n break\n case 'ArrowRight':\n event.preventDefault()\n // Open a closed branch; step into an open one.\n if (hasChildren && !isOpen) toggle(row.node.id, true)\n else if (hasChildren) focusRow(active + 1)\n break\n case 'ArrowLeft':\n event.preventDefault()\n // Close an open branch; otherwise climb to the parent.\n if (hasChildren && isOpen) toggle(row.node.id, false)\n else if (row.parentId) {\n const parent = rows.findIndex((r) => r.node.id === row.parentId)\n if (parent !== -1) focusRow(parent)\n }\n break\n case 'Home':\n event.preventDefault()\n focusRow(0)\n break\n case 'End':\n event.preventDefault()\n focusRow(rows.length - 1)\n break\n case 'Enter':\n case ' ':\n event.preventDefault()\n if (row.node.disabled) break\n if (hasChildren) toggle(row.node.id)\n else onSelect?.(row.node.id)\n break\n }\n }\n\n return (\n <ul\n role=\"tree\"\n data-slot=\"tree\"\n onKeyDown={onKeyDown}\n className={cn('flex list-none flex-col outline-none', className)}\n {...props}\n >\n {rows.map((row, index) => {\n const hasChildren = Boolean(row.node.children?.length)\n const isOpen = expanded.has(row.node.id)\n const isSelected = selected === row.node.id\n\n return (\n <li\n key={row.node.id}\n role=\"treeitem\"\n aria-expanded={hasChildren ? isOpen : undefined}\n aria-selected={isSelected}\n aria-level={row.level + 1}\n // Rows are siblings in the DOM, so depth alone cannot say \"third of\n // five in this branch\". These carry that.\n aria-posinset={row.posinset}\n aria-setsize={row.setsize}\n aria-disabled={row.node.disabled || undefined}\n >\n <div\n ref={(node) => {\n rowRefs.current[index] = node\n }}\n // One tab stop for the whole tree; it roves with the active row.\n tabIndex={index === active ? 0 : -1}\n onFocus={() => setActive(index)}\n onClick={() => {\n if (row.node.disabled) return\n setActive(index)\n if (hasChildren) toggle(row.node.id)\n else onSelect?.(row.node.id)\n }}\n style={{ paddingInlineStart: `${row.level * 16 + 6}px` }}\n className={cn(\n 'flex h-7 cursor-pointer items-center gap-1.5 pe-2 text-sm select-none',\n radius.control,\n interactive,\n focusRing,\n \"[&_svg:not([class*='size-'])]:size-3.5\",\n row.node.disabled && 'pointer-events-none opacity-50',\n isSelected\n ? 'bg-accent text-accent-foreground font-medium'\n : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',\n guides && row.level > 0 && 'relative',\n )}\n >\n <span\n aria-hidden=\"true\"\n className={cn(\n 'flex size-4 shrink-0 items-center justify-center',\n !hasChildren && 'invisible',\n )}\n >\n <ChevronRight\n className={cn(\n 'transition-transform duration-150 ease-out motion-reduce:transition-none',\n isOpen && 'rotate-90',\n )}\n />\n </span>\n\n {row.node.icon && (\n <span className=\"shrink-0\">{row.node.icon}</span>\n )}\n <span className=\"min-w-0 flex-1 truncate\">{row.node.label}</span>\n {row.node.meta && (\n <span className=\"text-muted-foreground/70 shrink-0 text-xs tabular-nums\">\n {row.node.meta}\n </span>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { Tree }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "two-factor-setup",
3
+ "type": "registry:ui",
4
+ "title": "Two-Factor Setup",
5
+ "description": "Scan, verify, save recovery codes. The recovery step is a step, not a footnote: an authenticator lost with no codes saved is an account lost, and burying them behind a link is why that happens.",
6
+ "category": "Auth",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "copy-button",
13
+ "input-otp",
14
+ "lib-styles",
15
+ "lib-utils",
16
+ "stepper"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/two-factor-setup.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, ShieldCheck, TriangleAlert } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { InputOTP } from '@/components/ui/input-otp'\nimport { Stepper } from '@/components/ui/stepper'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Enrolling an authenticator app, end to end.\n *\n * Recovery codes come last and the flow does not finish until they have been\n * seen. Enabling 2FA without them is how people lock themselves out of their\n * own accounts permanently — the support cost of skipping this step is larger\n * than the cost of the step.\n *\n * The secret is shown alongside the QR code, not instead of it. Desktop\n * password managers cannot scan a screen, and a QR-only flow forces people onto\n * a second device they may not have.\n */\nexport type TwoFactorStep = 'scan' | 'verify' | 'recovery' | 'done'\n\nfunction TwoFactorSetup({\n step,\n onStepChange,\n qr,\n secret,\n recoveryCodes,\n onVerify,\n onFinish,\n error,\n issuer = 'Astralyx',\n account,\n title = 'Two-factor authentication',\n stepLabels = { scan: 'Scan', verify: 'Verify', recovery: 'Recovery codes' },\n scanInstruction = 'Scan this with your authenticator app.',\n qrPlaceholder = 'QR code',\n secretLabel = 'Or enter this key',\n copySecretLabel = 'Copy setup key',\n continueLabel = 'Continue',\n verifyInstruction = 'Enter the six-digit code from your app.',\n codeLabel = 'Verification code',\n verifyLabel = 'Verify',\n backLabel = 'Back',\n recoveryWarning = 'Save these somewhere safe. Each one works once, and they are the only way back in if you lose the device.',\n copyCodesLabel = 'Copy recovery codes',\n savedLabel = 'I have saved these codes',\n finishLabel = 'Finish',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children' | 'title'> & {\n step: TwoFactorStep\n onStepChange?: (step: TwoFactorStep) => void\n /** The QR image or a rendered component. */\n qr?: ReactNode\n /** Base32 secret, for manual entry. */\n secret?: string\n recoveryCodes?: string[]\n onVerify?: (code: string) => void\n onFinish?: () => void\n error?: ReactNode\n issuer?: string\n account?: ReactNode\n title?: ReactNode\n /** Names of the three progress steps. */\n stepLabels?: { scan: ReactNode; verify: ReactNode; recovery: ReactNode }\n scanInstruction?: ReactNode\n /** Stand-in shown when no `qr` is supplied. */\n qrPlaceholder?: ReactNode\n secretLabel?: ReactNode\n copySecretLabel?: string\n continueLabel?: ReactNode\n verifyInstruction?: ReactNode\n /** Accessible name for the six-digit field. */\n codeLabel?: string\n verifyLabel?: ReactNode\n backLabel?: ReactNode\n recoveryWarning?: ReactNode\n copyCodesLabel?: string\n savedLabel?: ReactNode\n finishLabel?: ReactNode\n}) {\n const [code, setCode] = useState('')\n const [saved, setSaved] = useState(false)\n\n const index = { scan: 0, verify: 1, recovery: 2, done: 2 }[step]\n\n return (\n <div\n data-slot=\"two-factor-setup\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex items-center gap-2\">\n <ShieldCheck className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{title}</span>\n </div>\n\n <Stepper\n current={index}\n steps={[\n { id: 'scan', label: stepLabels.scan },\n { id: 'verify', label: stepLabels.verify },\n { id: 'recovery', label: stepLabels.recovery },\n ]}\n />\n\n {step === 'scan' && (\n <div className=\"flex flex-col gap-3\">\n <p className=\"text-muted-foreground text-sm\">{scanInstruction}</p>\n\n <div className=\"flex flex-wrap items-center gap-4\">\n <div className={cn('bg-secondary flex size-40 items-center justify-center', radius.control)}>\n {qr ?? <span className=\"text-muted-foreground/60 text-xs\">{qrPlaceholder}</span>}\n </div>\n\n {/* Shown alongside the QR: a desktop password manager cannot scan\n a screen, and QR-only forces a second device. */}\n {secret && (\n <div className=\"flex min-w-0 flex-col gap-1.5\">\n <span className=\"text-muted-foreground text-xs\">{secretLabel}</span>\n <span className=\"flex items-center gap-1\">\n <code className=\"bg-secondary rounded-md px-2 py-1 font-mono text-xs break-all\">\n {secret}\n </code>\n <CopyButton value={secret} label={copySecretLabel} />\n </span>\n {account && (\n <span className=\"text-muted-foreground/70 text-xs\">\n {issuer} · {account}\n </span>\n )}\n </div>\n )}\n </div>\n\n <Button className=\"self-start\" onClick={() => onStepChange?.('verify')}>\n {continueLabel}\n </Button>\n </div>\n )}\n\n {step === 'verify' && (\n <div className=\"flex flex-col gap-3\">\n <p className=\"text-muted-foreground text-sm\">{verifyInstruction}</p>\n <InputOTP length={6} value={code} onValueChange={setCode} aria-label={codeLabel} />\n {error && (\n <p className=\"text-[var(--destructive-soft-foreground)] text-xs\">{error}</p>\n )}\n <div className=\"flex gap-2\">\n <Button disabled={code.length < 6} onClick={() => onVerify?.(code)}>\n {verifyLabel}\n </Button>\n <Button variant=\"secondary\" onClick={() => onStepChange?.('scan')}>\n {backLabel}\n </Button>\n </div>\n </div>\n )}\n\n {(step === 'recovery' || step === 'done') && (\n <div className=\"flex flex-col gap-3\">\n <p className=\"flex items-start gap-1.5 text-sm\">\n <TriangleAlert\n className=\"mt-0.5 size-4 shrink-0 text-[var(--amber-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <span className=\"text-muted-foreground\">{recoveryWarning}</span>\n </p>\n\n <ol className={cn('bg-secondary/60 grid grid-cols-2 gap-x-4 gap-y-1 p-3 sm:grid-cols-3', radius.control)}>\n {(recoveryCodes ?? []).map((recoveryCode, i) => (\n <li key={recoveryCode} className=\"flex gap-2 font-mono text-xs\">\n <span className=\"text-muted-foreground/50 w-4 text-end tabular-nums\">{i + 1}</span>\n {recoveryCode}\n </li>\n ))}\n </ol>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n {recoveryCodes && (\n <CopyButton value={recoveryCodes.join('\\n')} label={copyCodesLabel} showLabel />\n )}\n <label className=\"flex items-center gap-2 text-xs\">\n <input\n type=\"checkbox\"\n checked={saved}\n onChange={(event) => setSaved(event.target.checked)}\n className=\"accent-[var(--primary)]\"\n />\n {savedLabel}\n </label>\n </div>\n\n {/* The flow does not finish until they have been seen. */}\n <Button disabled={!saved} className=\"self-start\" onClick={onFinish}>\n <Check />\n {finishLabel}\n </Button>\n </div>\n )}\n </div>\n )\n}\n\nexport { TwoFactorSetup }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "typing-indicator",
3
+ "type": "registry:ui",
4
+ "title": "Typing Indicator",
5
+ "description": "Who is currently typing. The animation is decorative and hidden from assistive tech; the sentence beside it is the actual content, and it is what gets announced.",
6
+ "category": "Messaging",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "avatar",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/typing-indicator.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { cn } from '@/lib/utils'\n\n/**\n * Who is currently typing.\n *\n * The wording is built from the count rather than concatenated by the caller,\n * because the grammar changes at every step: one name, two names joined by\n * \"and\", then \"and 3 others\". Callers that build this string themselves get\n * \"Ada and are typing\" the moment the list is empty.\n *\n * The dots animate — the one place a pure-colour transition cannot carry the\n * meaning, since \"someone is typing right now\" is inherently about liveness.\n * They stop under `prefers-reduced-motion`, where the text still says it.\n */\nfunction TypingIndicator({\n names,\n showAvatars = false,\n avatars,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n names: string[]\n showAvatars?: boolean\n avatars?: ReactNode\n}) {\n if (names.length === 0) return null\n\n // Grammar by count — the reason this is not a template string in the caller.\n const text =\n names.length === 1\n ? `${names[0]} is typing`\n : names.length === 2\n ? `${names[0]} and ${names[1]} are typing`\n : `${names[0]}, ${names[1]} and ${names.length - 2} ${\n names.length - 2 === 1 ? 'other' : 'others'\n } are typing`\n\n return (\n <div\n data-slot=\"typing-indicator\"\n aria-live=\"polite\"\n className={cn('text-muted-foreground flex items-center gap-2 text-xs', className)}\n {...props}\n >\n {showAvatars &&\n (avatars ?? (\n <span className=\"flex -space-x-1.5\">\n {names.slice(0, 3).map((name) => (\n <Avatar key={name} size=\"xs\" name={name} className=\"ring-background ring-2\" />\n ))}\n </span>\n ))}\n\n <span>{text}</span>\n\n <span aria-hidden=\"true\" className=\"flex items-center gap-0.5\">\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n className=\"bg-muted-foreground/60 size-1 animate-bounce rounded-full [corner-shape:round] motion-reduce:animate-none\"\n style={{ animationDelay: `${index * 140}ms` }}\n />\n ))}\n </span>\n </div>\n )\n}\n\nexport { TypingIndicator }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "typography",
3
+ "type": "registry:ui",
4
+ "title": "Typography",
5
+ "description": "Prose styling for content you did not author element by element — rendered markdown, a CMS body, a changelog. Headings start at h2, since the page already has its h1.",
6
+ "category": "Display",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/typography.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * Prose styling for content you did not author element by element — rendered\n * markdown, a CMS body, a changelog.\n *\n * Descendant selectors rather than styled components, because the content\n * arrives as plain HTML from a markdown renderer and there is nothing to swap.\n *\n * Vertical rhythm is set with `space-y`-style adjacent-sibling margins rather\n * than a margin on every element. A margin on each one collapses differently\n * depending on what precedes it, which is why prose blocks tend to have too\n * much space after headings and too little between list items.\n *\n * Headings deliberately start at `h2`. A prose block is page *content*, and the\n * page already has its `h1` — a body that ships its own would give the document\n * two, which is the most common heading-order failure there is.\n */\nconst PROSE = [\n 'text-sm leading-relaxed text-foreground',\n\n // Rhythm: gaps between siblings, nothing leading or trailing.\n '[&>*+*]:mt-4',\n '[&>h2+*]:mt-2 [&>h3+*]:mt-2 [&>h4+*]:mt-1.5',\n '[&>*+h2]:mt-8 [&>*+h3]:mt-6 [&>*+h4]:mt-5',\n\n '[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:tracking-tight',\n '[&_h3]:text-base [&_h3]:font-semibold [&_h3]:tracking-tight',\n '[&_h4]:text-sm [&_h4]:font-semibold',\n\n '[&_p]:text-muted-foreground [&_p]:text-pretty',\n '[&_strong]:text-foreground [&_strong]:font-semibold',\n '[&_em]:italic',\n\n '[&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-border hover:[&_a]:decoration-current',\n\n '[&_ul]:ms-5 [&_ul]:list-disc [&_ol]:ms-5 [&_ol]:list-decimal',\n '[&_li]:text-muted-foreground [&_li+li]:mt-1',\n '[&_li>ul]:mt-1 [&_li>ol]:mt-1',\n\n '[&_blockquote]:border-border [&_blockquote]:text-muted-foreground [&_blockquote]:border-s-2 [&_blockquote]:ps-4 [&_blockquote]:italic',\n\n '[&_code]:bg-muted [&_code]:rounded-sm [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]',\n // Literal, not built from `radius.control`: a class assembled by\n // concatenation is invisible to Tailwind's scanner and generates nothing.\n '[&_pre]:bg-muted [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:p-3',\n '[&_pre_code]:bg-transparent [&_pre_code]:p-0',\n\n '[&_hr]:border-border [&_hr]:my-6',\n '[&_img]:rounded-lg',\n\n '[&_table]:w-full [&_table]:text-sm',\n '[&_th]:border-border [&_th]:border-b [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-medium',\n '[&_td]:border-border/60 [&_td]:border-b [&_td]:px-3 [&_td]:py-2',\n].join(' ')\n\nfunction Typography({\n className,\n size = 'default',\n ...props\n}: ComponentProps<'div'> & { size?: 'sm' | 'default' | 'lg' }) {\n return (\n <div\n data-slot=\"typography\"\n className={cn(\n PROSE,\n size === 'sm' && 'text-xs',\n size === 'lg' && 'text-base',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport { Typography, PROSE }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "upload-list",
3
+ "type": "registry:ui",
4
+ "title": "Upload List",
5
+ "description": "Files in flight, with per-file progress and a retry for the ones that failed. A failed upload keeps its row rather than vanishing — silently dropping it is how people lose work.",
6
+ "category": "Media",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "attachment-preview",
12
+ "button",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "progress"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/upload-list.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { RotateCcw, X } from 'lucide-react'\nimport { AttachmentPreview, type Attachment } from '@/components/ui/attachment-preview'\nimport { Button } from '@/components/ui/button'\nimport { Progress } from '@/components/ui/progress'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A batch of uploads with an aggregate bar.\n *\n * Overall progress is weighted by file size, not by count. Five thumbnails and\n * one video are not six equal sixths, and a per-file average sits at 83% while\n * the only file that matters has barely started.\n *\n * Failures stay in the list with a retry. Dropping them silently is how a user\n * ends up believing an upload finished; the failed row is the notification.\n */\nfunction UploadList({\n uploads,\n onRetry,\n onRemove,\n onCancelAll,\n cancelAllLabel = 'Cancel remaining',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n uploads: Attachment[]\n onRetry?: (id: string) => void\n onRemove?: (id: string) => void\n onCancelAll?: () => void\n cancelAllLabel?: ReactNode\n}) {\n const active = uploads.filter((u) => u.progress !== undefined && u.progress < 1 && !u.error)\n const failed = uploads.filter((u) => u.error)\n const done = uploads.filter((u) => u.progress === undefined || u.progress >= 1).length\n\n // Weighted by bytes: five thumbnails and one video are not six equal sixths.\n const totalBytes = uploads.reduce((sum, u) => sum + (u.size ?? 0), 0)\n const uploadedBytes = uploads.reduce(\n (sum, u) => sum + (u.size ?? 0) * (u.error ? 0 : (u.progress ?? 1)),\n 0,\n )\n const overall = totalBytes > 0 ? uploadedBytes / totalBytes : done / Math.max(uploads.length, 1)\n\n if (uploads.length === 0) return null\n\n return (\n <div\n data-slot=\"upload-list\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-3', className)}\n {...props}\n >\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2 text-xs\">\n <span className=\"font-medium\">\n {active.length > 0\n ? `Uploading ${active.length} of ${uploads.length}`\n : failed.length > 0\n ? `${failed.length} failed`\n : `${uploads.length} uploaded`}\n </span>\n <span className=\"text-muted-foreground tabular-nums\">\n {Math.round(overall * 100)}%\n </span>\n </div>\n <Progress value={overall * 100} className=\"h-1.5\" />\n </div>\n\n <ul className=\"flex list-none flex-col gap-2\">\n {uploads.map((upload) => (\n <li key={upload.id} className=\"flex items-center gap-2\">\n <AttachmentPreview\n attachment={upload}\n onRemove={onRemove}\n compact\n className=\"min-w-0 flex-1\"\n />\n {/* Failures keep a retry rather than disappearing. */}\n {upload.error && onRetry && (\n <Button\n variant=\"secondary\"\n size=\"icon-xs\"\n aria-label={`Retry ${upload.name}`}\n onClick={() => onRetry(upload.id)}\n >\n <RotateCcw />\n </Button>\n )}\n </li>\n ))}\n </ul>\n\n {active.length > 0 && onCancelAll && (\n <Button variant=\"ghost\" size=\"xs\" className=\"self-start\" onClick={onCancelAll}>\n <X />\n {cancelAllLabel}\n </Button>\n )}\n </div>\n )\n}\n\nexport { UploadList }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "uptime-strip",
3
+ "type": "registry:ui",
4
+ "title": "Uptime Strip",
5
+ "description": "A run of per-period bars: uptime history or a contribution heatmap. One primitive for both, since they are the same picture — buckets coloured by state or by intensity.",
6
+ "category": "Operations",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils",
10
+ "tooltip"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/uptime-strip.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps } from 'react'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\n\n/**\n * A run of per-period bars: uptime history, or a contribution heatmap.\n *\n * One primitive for both, because they are the same picture — a sequence of\n * buckets coloured by intensity or state. The difference is only whether the\n * scale is categorical (up, degraded, down) or continuous (a count).\n *\n * Bars flex rather than taking a fixed width, so ninety days compress on a\n * phone instead of scrolling. A missing bucket renders as an explicit gap\n * rather than being dropped, which keeps the axis honest.\n */\nexport type UptimeBucket = {\n /** Label for the tooltip — a date, usually. */\n label: string\n /** Categorical state. Ignored when `value` is given. */\n status?: 'up' | 'degraded' | 'down' | 'none'\n /** Continuous intensity, 0 to 1, for a heatmap. */\n value?: number\n detail?: string\n}\n\nconst STATUS_TONE = {\n up: 'bg-[var(--green)]',\n degraded: 'bg-[var(--amber)]',\n down: 'bg-[var(--destructive)]',\n none: 'bg-border',\n} as const\n\n/** Five steps is enough to read a gradient and few enough to stay distinct. */\nfunction intensityTone(value: number) {\n if (value <= 0) return 'bg-border'\n if (value < 0.25) return 'bg-[var(--green)]/25'\n if (value < 0.5) return 'bg-[var(--green)]/45'\n if (value < 0.75) return 'bg-[var(--green)]/70'\n return 'bg-[var(--green)]'\n}\n\nfunction UptimeStrip({\n buckets,\n height = 28,\n rounded = true,\n label,\n summary,\n className,\n ...props\n}: ComponentProps<'div'> & {\n buckets: UptimeBucket[]\n height?: number\n rounded?: boolean\n label?: string\n /** Right-aligned caption — an uptime percentage, usually. */\n summary?: string\n}) {\n return (\n <div data-slot=\"uptime-strip\" className={cn('flex flex-col gap-1.5', className)} {...props}>\n {(label || summary) && (\n <div className=\"flex items-baseline justify-between gap-3\">\n {label && <span className=\"text-sm font-medium\">{label}</span>}\n {summary && (\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {summary}\n </span>\n )}\n </div>\n )}\n\n <div\n className=\"flex items-stretch gap-[2px]\"\n style={{ height }}\n role=\"img\"\n aria-label={\n label ? `${label} history${summary ? `, ${summary}` : ''}` : 'History'\n }\n >\n {buckets.map((bucket, index) => (\n <Tooltip\n key={index}\n content={\n <span className=\"flex flex-col\">\n <span>{bucket.label}</span>\n {bucket.detail && (\n <span className=\"opacity-70\">{bucket.detail}</span>\n )}\n </span>\n }\n >\n <span\n tabIndex={0}\n className={cn(\n 'min-w-[3px] flex-1 transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'focus-visible:ring-ring/50 outline-none focus-visible:ring-2',\n rounded && 'rounded-[2px]',\n bucket.value !== undefined\n ? intensityTone(bucket.value)\n : STATUS_TONE[bucket.status ?? 'none'],\n )}\n />\n </Tooltip>\n ))}\n </div>\n </div>\n )\n}\n\nexport { UptimeStrip }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "user-menu",
3
+ "type": "registry:ui",
4
+ "title": "User Menu",
5
+ "description": "The account button for a header or sidebar footer. The identity is repeated inside the panel, because on a collapsed rail the trigger is a bare avatar.",
6
+ "category": "Product",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "avatar",
12
+ "badge",
13
+ "dropdown-menu",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/user-menu.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The account button in a header or sidebar footer.\n *\n * Composed from `DropdownMenu` rather than reimplementing a popover, so the\n * keyboard behaviour and dismissal come from the component that already gets\n * them right.\n *\n * The identity is repeated inside the panel. On a collapsed sidebar the trigger\n * is a bare avatar, and a menu that opens with no indication of *whose* account\n * it belongs to is a real hazard on shared machines.\n *\n * Metrics follow the shared menu grid: `gap-2` and a square 8px inset, the same\n * as `menuItem` and `SidebarMenuButton`. With a 32px avatar that gives a 48px\n * row, so the trigger lines up with the rows it sits among rather than being a\n * few pixels off from all of them.\n */\nexport type UserMenuAction = {\n id: string\n label: ReactNode\n icon?: ReactNode\n shortcut?: string\n destructive?: boolean\n separatorBefore?: boolean\n onSelect?: () => void\n}\n\nfunction UserMenu({\n name,\n email,\n plan,\n avatar,\n actions,\n compact = false,\n align = 'start',\n className,\n ...props\n}: Omit<ComponentProps<'button'>, 'onSelect'> & {\n name: string\n email?: string\n plan?: ReactNode\n avatar?: ReactNode\n actions: UserMenuAction[]\n /** Avatar only — for a collapsed rail. */\n compact?: boolean\n align?: 'start' | 'center' | 'end'\n}) {\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n data-slot=\"user-menu-trigger\"\n className={cn(\n 'hover:bg-accent flex items-center gap-2 p-2 text-start',\n // Full width only when there is text to fill it; a collapsed\n // trigger is a square around the avatar.\n compact ? 'w-auto justify-center' : 'w-full',\n radius.control,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n {avatar ?? <Avatar size=\"sm\" name={name} className=\"shrink-0\" />}\n\n {!compact && (\n <>\n <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span className=\"block truncate text-sm leading-tight font-medium\">\n {name}\n </span>\n {(email || plan) && (\n <span className=\"text-muted-foreground block truncate text-xs leading-tight\">\n {email ?? plan}\n </span>\n )}\n </span>\n <ChevronDown className=\"text-muted-foreground size-4 shrink-0\" />\n </>\n )}\n {compact && <span className=\"sr-only\">{name}</span>}\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align={align} className=\"w-56\">\n {/* Repeated inside: on a collapsed rail the trigger is a bare avatar. */}\n {/* DropdownMenuLabel is muted by default — the name is the heading\n here, so it opts back into the foreground colour. */}\n <DropdownMenuLabel className=\"flex flex-col gap-0.5 py-2\">\n <span className=\"text-foreground block truncate text-sm font-medium\">\n {name}\n </span>\n {email && (\n <span className=\"block truncate text-xs font-normal\">{email}</span>\n )}\n {plan && (\n <span className=\"mt-1 flex\">\n <Badge size=\"sm\">{plan}</Badge>\n </span>\n )}\n </DropdownMenuLabel>\n <DropdownMenuSeparator />\n\n {actions.map((action) => (\n <div key={action.id}>\n {action.separatorBefore && <DropdownMenuSeparator />}\n <DropdownMenuItem\n onSelect={action.onSelect}\n className={cn(action.destructive && 'text-[var(--destructive-soft-foreground)]')}\n >\n {action.icon}\n <span className=\"flex-1\">{action.label}</span>\n {action.shortcut && (\n <span className=\"text-muted-foreground font-mono text-xs\">\n {action.shortcut}\n </span>\n )}\n </DropdownMenuItem>\n </div>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\nexport { UserMenu }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "utm-builder",
3
+ "type": "registry:ui",
4
+ "title": "UTM Builder",
5
+ "description": "Builds a tagged campaign URL. Values are lower-cased with underscores for spaces, because analytics tools treat Summer Sale, summer sale and summer_sale as three separate campaigns and nobody reconciles them later.",
6
+ "category": "Affiliate",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "copy-button",
12
+ "input",
13
+ "label",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/utm-builder.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Builds a tagged campaign URL.\n *\n * Values are lower-cased and spaces become underscores, because analytics tools\n * treat `Summer Sale`, `summer sale` and `summer_sale` as three different\n * campaigns. Cleaning at the point the link is made is the only reliable place\n * — nobody reconciles them afterwards.\n *\n * Existing query parameters on the base URL are preserved rather than\n * overwritten. Pasting a link that already carries a referral code and having\n * it silently dropped is how attribution disappears.\n *\n * Encoding is done by `URL`, not by hand. A campaign name with an ampersand\n * quietly truncates every parameter after it when the string is concatenated.\n */\ntype UtmField = {\n key: string\n label: string\n placeholder: string\n required?: boolean\n}\n\nconst FIELDS: UtmField[] = [\n { key: 'utm_source', label: 'Source', placeholder: 'newsletter', required: true },\n { key: 'utm_medium', label: 'Medium', placeholder: 'email', required: true },\n { key: 'utm_campaign', label: 'Campaign', placeholder: 'summer_sale', required: true },\n { key: 'utm_term', label: 'Term', placeholder: 'running+shoes' },\n { key: 'utm_content', label: 'Content', placeholder: 'header_link' },\n]\n\n/** Lower-case, underscores for spaces — three spellings become one campaign. */\nfunction normalise(value: string) {\n return value.trim().toLowerCase().replace(/\\s+/g, '_')\n}\n\nfunction UtmBuilder({\n baseUrl: baseProp,\n onUrlChange,\n destinationLabel = 'Destination URL',\n destinationPlaceholder = 'https://example.com/pricing',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n baseUrl?: string\n onUrlChange?: (url: string) => void\n destinationLabel?: ReactNode\n destinationPlaceholder?: string\n}) {\n const [base, setBase] = useState(baseProp ?? '')\n const [values, setValues] = useState<Record<string, string>>({})\n\n const { url, error } = useMemo(() => {\n if (!base.trim()) return { url: '', error: undefined }\n try {\n // `URL` handles encoding and keeps existing parameters intact.\n const parsed = new URL(base.includes('://') ? base : `https://${base}`)\n for (const field of FIELDS) {\n const value = normalise(values[field.key] ?? '')\n if (value) parsed.searchParams.set(field.key, value)\n }\n return { url: parsed.toString(), error: undefined }\n } catch {\n return { url: '', error: 'That does not look like a valid URL.' }\n }\n }, [base, values])\n\n const missing = FIELDS.filter(\n (field) => field.required && !normalise(values[field.key] ?? ''),\n )\n\n return (\n <div\n data-slot=\"utm-builder\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"utm-base\">{destinationLabel}</Label>\n <Input\n id=\"utm-base\"\n value={base}\n placeholder={destinationPlaceholder}\n error={Boolean(error)}\n onChange={(event) => setBase(event.target.value)}\n />\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {FIELDS.map((field) => (\n <div key={field.key} className=\"flex flex-col gap-1.5\">\n <Label htmlFor={`utm-${field.key}`} className=\"font-mono text-xs\">\n {field.key}\n {field.required && <span className=\"text-muted-foreground\"> *</span>}\n </Label>\n <Input\n id={`utm-${field.key}`}\n size=\"sm\"\n placeholder={field.placeholder}\n value={values[field.key] ?? ''}\n onChange={(event) =>\n setValues((current) => ({ ...current, [field.key]: event.target.value }))\n }\n />\n </div>\n ))}\n </div>\n\n {error && (\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 {error}\n </p>\n )}\n\n {missing.length > 0 && !error && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n {missing.map((field) => field.label).join(', ')} needed for attribution to work.\n </p>\n )}\n\n <div className={cn('bg-secondary/60 flex items-start gap-2 p-3', radius.control)}>\n <code className=\"min-w-0 flex-1 font-mono text-xs break-all\">\n {url || 'Your tagged URL will appear here'}\n </code>\n {url && (\n <CopyButton\n value={url}\n label=\"Copy tagged URL\"\n onClick={() => onUrlChange?.(url)}\n />\n )}\n </div>\n </div>\n )\n}\n\nexport { UtmBuilder, normalise as normaliseUtm }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "validator-list",
3
+ "type": "registry:ui",
4
+ "title": "Validator List",
5
+ "description": "Validators with commission, uptime and voting power. Flags membership of the smallest set controlling a third of the stake, and sorts ascending by default — the conventional descending sort pushes delegators toward whoever is already largest.",
6
+ "category": "Crypto",
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/validator-list.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, ShieldAlert } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Validators to delegate to, with commission, uptime and voting power.\n *\n * Cumulative voting power is the column nobody ships and everybody needs. A\n * chain where the top third of validators control a third of the stake is one\n * where those validators can halt it, and a list sorted by size actively pushes\n * delegators toward the validators that make that worse. Flagging membership of\n * the top-third set is the honest version.\n *\n * Sorted by voting power ascending by default, deliberately — the conventional\n * descending sort concentrates stake in whoever is already largest.\n */\nexport type Validator = {\n id: string\n name: ReactNode\n avatar?: ReactNode\n /** Percentage, e.g. 5 for 5%. */\n commission: number\n /** Percentage. */\n uptime?: number\n /** Share of total stake, 0–1. */\n votingPower: number\n stake?: ReactNode\n jailed?: boolean\n onSelect?: () => void\n}\n\ntype SortKey = 'votingPower' | 'commission' | 'uptime'\n\n/**\n * A sortable column header.\n *\n * At module scope, not inside `ValidatorList`. A component declared during\n * render is a fresh type each time, so React remounts the `<th>` on every\n * sort — and the button you just clicked loses focus mid-interaction.\n */\nfunction Header({\n label,\n sortKey,\n sort,\n onSort,\n}: {\n label: string\n sortKey: SortKey\n sort: { key: SortKey; desc: boolean }\n onSort: (key: SortKey) => void\n}) {\n return (\n <th className=\"px-3 py-2 text-end\">\n <button\n type=\"button\"\n onClick={() => onSort(sortKey)}\n className={cn(\n 'text-muted-foreground hover:text-foreground -mx-1 inline-flex items-center gap-1 px-1 text-xs font-medium',\n radius.xs,\n focusRing,\n sort.key === sortKey && 'text-foreground',\n )}\n >\n {label}\n {sort.key === sortKey && (sort.desc ? <ArrowDown className=\"size-3\" /> : <ArrowUp className=\"size-3\" />)}\n </button>\n </th>\n )\n}\n\nfunction ValidatorList({\n validators,\n onSelect,\n validatorHeader = 'Validator',\n commissionHeader = 'Commission',\n uptimeHeader = 'Uptime',\n votingPowerHeader = 'Voting power',\n jailedLabel = 'Jailed',\n concentrationLabel = 'Top third',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n validators: Validator[]\n onSelect?: (id: string) => void\n validatorHeader?: ReactNode\n commissionHeader?: string\n uptimeHeader?: string\n votingPowerHeader?: string\n /** Badge on a jailed validator. */\n jailedLabel?: ReactNode\n /** Badge on a validator inside the concentrated top third of stake. */\n concentrationLabel?: ReactNode\n}) {\n // Ascending by power: a descending default pushes stake toward the largest.\n const [sort, setSort] = useState<{ key: SortKey; desc: boolean }>({\n key: 'votingPower',\n desc: false,\n })\n\n const { rows, cartelIds } = useMemo(() => {\n // The smallest set of validators controlling a third of the stake could\n // halt the chain between them.\n const byPower = [...validators].sort((a, b) => b.votingPower - a.votingPower)\n const cartel = new Set<string>()\n let cumulative = 0\n for (const v of byPower) {\n if (cumulative >= 1 / 3) break\n cartel.add(v.id)\n cumulative += v.votingPower\n }\n\n const sorted = [...validators].sort((a, b) => {\n const av = a[sort.key] ?? 0\n const bv = b[sort.key] ?? 0\n return sort.desc ? Number(bv) - Number(av) : Number(av) - Number(bv)\n })\n return { rows: sorted, cartelIds: cartel }\n }, [validators, sort])\n\n const handleSort = (key: SortKey) =>\n setSort((current) => ({ key, desc: current.key === key ? !current.desc : true }))\n\n return (\n <div\n data-slot=\"validator-list\"\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\">\n {validatorHeader}\n </th>\n <Header label={commissionHeader} sortKey=\"commission\" sort={sort} onSort={handleSort} />\n <Header label={uptimeHeader} sortKey=\"uptime\" sort={sort} onSort={handleSort} />\n <Header label={votingPowerHeader} sortKey=\"votingPower\" sort={sort} onSort={handleSort} />\n </tr>\n </thead>\n\n <tbody>\n {rows.map((validator) => {\n const concentrating = cartelIds.has(validator.id)\n return (\n <tr\n key={validator.id}\n onClick={onSelect ? () => onSelect(validator.id) : undefined}\n className={cn(\n 'border-border/60 border-b last:border-b-0',\n onSelect && 'hover:bg-accent/40 cursor-pointer',\n validator.jailed && 'opacity-60',\n )}\n >\n <td className=\"px-3 py-2\">\n <span className=\"flex items-center gap-2\">\n {validator.avatar ?? <Avatar size=\"xs\" name={String(validator.name)} />}\n <span className=\"min-w-0 truncate font-medium\">{validator.name}</span>\n {validator.jailed && (\n <Badge size=\"sm\" color=\"destructive\">\n {jailedLabel}\n </Badge>\n )}\n {concentrating && !validator.jailed && (\n <Badge size=\"sm\" color=\"amber\">\n <ShieldAlert />\n {concentrationLabel}\n </Badge>\n )}\n </span>\n </td>\n\n <td className=\"px-3 py-2 text-end tabular-nums\">\n {validator.commission.toFixed(1)}%\n </td>\n\n <td\n className={cn(\n 'px-3 py-2 text-end tabular-nums',\n validator.uptime !== undefined && validator.uptime < 98\n ? 'text-[var(--amber-soft-foreground)]'\n : 'text-muted-foreground',\n )}\n >\n {validator.uptime === undefined ? '—' : `${validator.uptime.toFixed(2)}%`}\n </td>\n\n <td className=\"px-3 py-2 text-end tabular-nums\">\n {(validator.votingPower * 100).toFixed(2)}%\n </td>\n </tr>\n )\n })}\n </tbody>\n </table>\n </div>\n )\n}\n\nexport { ValidatorList }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "video-player",
3
+ "type": "registry:ui",
4
+ "title": "Video Player",
5
+ "description": "A player with a scrubber, quality and subtitle menus. Controls are real buttons on a real timeline, so the keyboard reaches every one of them — a div-based scrubber never does.",
6
+ "category": "Media",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "dropdown-menu",
13
+ "lib-styles",
14
+ "lib-utils"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/video-player.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport {\n Maximize,\n Pause,\n Play,\n Settings,\n Volume2,\n VolumeX,\n} from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A video player with a real scrubber, quality and subtitle menus.\n *\n * The scrubber is an `input[type=range]`, not a div with a drag handler. That\n * one decision buys arrow-key seeking, Home and End, screen-reader\n * announcement of the position and a working focus ring — all of which a\n * hand-rolled track has to reimplement and usually does not.\n *\n * Time is formatted from the media's own duration, so an hour-long video shows\n * `1:02:15` and a clip shows `0:42` rather than `00:00:42`. Padding every clip\n * to hours is the giveaway of a player that formats blindly.\n *\n * Subtitles default to off but the menu is always present. A player that hides\n * the menu when only one track exists makes people believe there are none.\n */\nexport type VideoTrack = { id: string; label: ReactNode }\n\nfunction formatTime(seconds: number, showHours: boolean) {\n if (!Number.isFinite(seconds)) return showHours ? '0:00:00' : '0:00'\n const s = Math.floor(seconds % 60)\n const m = Math.floor((seconds / 60) % 60)\n const h = Math.floor(seconds / 3600)\n const pad = (n: number) => String(n).padStart(2, '0')\n return showHours ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_PROGRESS_LABEL: (at: string, total: string) => string = (at, total) => `${at} of ${total}`\n\nfunction VideoPlayer({\n src,\n poster,\n qualities,\n quality,\n onQualityChange,\n subtitles,\n subtitle,\n onSubtitleChange,\n title,\n playLabel = 'Play',\n pauseLabel = 'Pause',\n muteLabel = 'Mute',\n unmuteLabel = 'Unmute',\n seekLabel = 'Seek',\n settingsLabel = 'Playback settings',\n fullscreenLabel = 'Full screen',\n qualityLabel = 'Quality',\n subtitlesLabel = 'Subtitles',\n subtitlesOffLabel = 'Off',\n progressLabel = DEFAULT_PROGRESS_LABEL,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'title'> & {\n src: string\n poster?: string\n qualities?: VideoTrack[]\n quality?: string\n onQualityChange?: (id: string) => void\n subtitles?: VideoTrack[]\n subtitle?: string | null\n onSubtitleChange?: (id: string | null) => void\n title?: ReactNode\n playLabel?: string\n pauseLabel?: string\n muteLabel?: string\n unmuteLabel?: string\n /** Accessible name for the scrubber. */\n seekLabel?: string\n settingsLabel?: string\n fullscreenLabel?: string\n /** Heading over the quality menu. */\n qualityLabel?: ReactNode\n /** Heading over the subtitle menu. */\n subtitlesLabel?: ReactNode\n /** The \"no subtitles\" choice, which is a real selection. */\n subtitlesOffLabel?: ReactNode\n /** Spoken position, given the formatted current time and duration. */\n progressLabel?: (at: string, total: string) => string\n}) {\n const videoRef = useRef<HTMLVideoElement>(null)\n const [playing, setPlaying] = useState(false)\n const [muted, setMuted] = useState(false)\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n\n useEffect(() => {\n const video = videoRef.current\n if (!video) return\n const onTime = () => setTime(video.currentTime)\n const onMeta = () => setDuration(video.duration)\n const onPlay = () => setPlaying(true)\n const onPause = () => setPlaying(false)\n\n video.addEventListener('timeupdate', onTime)\n video.addEventListener('loadedmetadata', onMeta)\n video.addEventListener('play', onPlay)\n video.addEventListener('pause', onPause)\n return () => {\n video.removeEventListener('timeupdate', onTime)\n video.removeEventListener('loadedmetadata', onMeta)\n video.removeEventListener('play', onPlay)\n video.removeEventListener('pause', onPause)\n }\n }, [])\n\n // Only pad to hours when the media actually runs that long.\n const showHours = duration >= 3600\n\n return (\n <div\n data-slot=\"video-player\"\n className={cn('bg-[var(--sidebar)] relative overflow-hidden', radius.surface, className)}\n {...props}\n >\n <video\n ref={videoRef}\n src={src}\n poster={poster}\n className=\"aspect-video w-full\"\n onClick={() => (playing ? videoRef.current?.pause() : videoRef.current?.play())}\n />\n\n <div className=\"flex flex-col gap-2 p-3\">\n {/* A real range input: arrow keys, Home/End and announcement for free. */}\n <input\n type=\"range\"\n min={0}\n max={duration || 0}\n step={0.1}\n value={time}\n aria-label={seekLabel}\n aria-valuetext={progressLabel(formatTime(time, showHours), formatTime(duration, showHours))}\n onChange={(event) => {\n const next = Number(event.target.value)\n setTime(next)\n if (videoRef.current) videoRef.current.currentTime = next\n }}\n className={cn('w-full accent-[var(--primary)]', focusRing, radius.xs)}\n />\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={playing ? pauseLabel : playLabel}\n className=\"text-[var(--sidebar-foreground)] hover:bg-white/10\"\n onClick={() => (playing ? videoRef.current?.pause() : videoRef.current?.play())}\n >\n {playing ? <Pause /> : <Play />}\n </Button>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={muted ? unmuteLabel : muteLabel}\n className=\"text-[var(--sidebar-foreground)] hover:bg-white/10\"\n onClick={() => {\n const next = !muted\n setMuted(next)\n if (videoRef.current) videoRef.current.muted = next\n }}\n >\n {muted ? <VolumeX /> : <Volume2 />}\n </Button>\n\n <span className=\"text-[var(--sidebar-foreground)]/80 ms-1 font-mono text-xs tabular-nums\">\n {formatTime(time, showHours)} / {formatTime(duration, showHours)}\n </span>\n\n {title && (\n <span className=\"text-[var(--sidebar-foreground)]/70 ms-3 min-w-0 flex-1 truncate text-xs\">\n {title}\n </span>\n )}\n\n <div className=\"ms-auto flex items-center gap-1\">\n {/* Always present, even with one track — hiding it makes people\n believe there are no subtitles at all. */}\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={settingsLabel}\n className=\"text-[var(--sidebar-foreground)] hover:bg-white/10\"\n >\n <Settings />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-44\">\n {qualities && qualities.length > 0 && (\n <>\n <DropdownMenuLabel>{qualityLabel}</DropdownMenuLabel>\n {qualities.map((track) => (\n <DropdownMenuItem\n key={track.id}\n onSelect={() => onQualityChange?.(track.id)}\n className={cn(track.id === quality && 'font-medium')}\n >\n {track.label}\n </DropdownMenuItem>\n ))}\n </>\n )}\n\n <DropdownMenuLabel>{subtitlesLabel}</DropdownMenuLabel>\n <DropdownMenuItem\n onSelect={() => onSubtitleChange?.(null)}\n className={cn(!subtitle && 'font-medium')}\n >\n {subtitlesOffLabel}\n </DropdownMenuItem>\n {(subtitles ?? []).map((track) => (\n <DropdownMenuItem\n key={track.id}\n onSelect={() => onSubtitleChange?.(track.id)}\n className={cn(track.id === subtitle && 'font-medium')}\n >\n {track.label}\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={fullscreenLabel}\n className=\"text-[var(--sidebar-foreground)] hover:bg-white/10\"\n onClick={() => videoRef.current?.requestFullscreen?.()}\n >\n <Maximize />\n </Button>\n </div>\n </div>\n </div>\n </div>\n )\n}\n\nexport { VideoPlayer, formatTime }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "virtual-list",
3
+ "type": "registry:ui",
4
+ "title": "Virtual List",
5
+ "description": "Renders only the rows in view. DataGrid and LogViewer both render every row they are given, which is fine until a few thousand and then stalls the main thread.",
6
+ "category": "Navigation",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "components/ui/virtual-list.tsx",
14
+ "type": "registry:ui",
15
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * Renders only the rows in view.\n *\n * `DataGrid` and `LogViewer` both render every row they are given, which is\n * fine until a few thousand and then stalls the main thread on every update.\n * This is the layer they can sit on.\n *\n * Fixed row height by design. Variable heights need measurement, a cache and a\n * correction pass for the scroll position when an estimate turns out wrong —\n * an order of magnitude more code, and wrong estimates make the scrollbar jump\n * under the user's hand. A fixed height keeps the maths exact.\n *\n * `overscan` renders a few rows beyond the viewport so a fast scroll does not\n * expose blank space before the next paint.\n */\nfunction VirtualList<Item>({\n items,\n rowHeight,\n renderItem,\n overscan = 4,\n height = 400,\n onEndReached,\n endThreshold = 200,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children' | 'height'> & {\n items: Item[]\n rowHeight: number\n renderItem: (item: Item, index: number) => ReactNode\n overscan?: number\n height?: number | string\n /** Fires once per approach to the end — for infinite scrolling. */\n onEndReached?: () => void\n endThreshold?: number\n}) {\n const scrollRef = useRef<HTMLDivElement>(null)\n const [scrollTop, setScrollTop] = useState(0)\n const [viewport, setViewport] = useState(0)\n const firedAt = useRef(-1)\n\n // Measure the viewport instead of trusting the `height` prop, which may be a\n // percentage or a clamp.\n useEffect(() => {\n const node = scrollRef.current\n if (!node) return\n\n const observer = new ResizeObserver(([entry]) => {\n setViewport(entry.contentRect.height)\n })\n observer.observe(node)\n return () => observer.disconnect()\n }, [])\n\n const total = items.length * rowHeight\n const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)\n const visibleCount = Math.ceil((viewport || 0) / rowHeight) + overscan * 2\n const last = Math.min(items.length, first + visibleCount)\n const slice = items.slice(first, last)\n\n return (\n <div\n ref={scrollRef}\n data-slot=\"virtual-list\"\n style={{ height }}\n onScroll={(event) => {\n const node = event.currentTarget\n setScrollTop(node.scrollTop)\n\n if (!onEndReached) return\n const remaining = node.scrollHeight - node.scrollTop - node.clientHeight\n // Guard by item count, so it fires once per batch rather than on\n // every scroll event while sitting near the bottom.\n if (remaining < endThreshold && firedAt.current !== items.length) {\n firedAt.current = items.length\n onEndReached()\n }\n }}\n className={cn('relative overflow-y-auto', className)}\n {...props}\n >\n {/* Spacer gives the scrollbar the full height of the list. */}\n <div style={{ height: total }} className=\"relative\">\n <div\n style={{ transform: `translateY(${first * rowHeight}px)` }}\n className=\"absolute inset-x-0 top-0\"\n >\n {slice.map((item, index) => (\n <div key={first + index} style={{ height: rowHeight }}>\n {renderItem(item, first + index)}\n </div>\n ))}\n </div>\n </div>\n </div>\n )\n}\n\nexport { VirtualList }\n"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "visually-hidden",
3
+ "type": "registry:ui",
4
+ "title": "Visually Hidden",
5
+ "description": "Hidden from sight, present for screen readers. The clip technique — not display:none or visibility:hidden, both of which remove the element from the accessibility tree entirely.",
6
+ "category": "Utility",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-utils",
10
+ "primitive-slot"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/visually-hidden.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import type { ComponentProps } from 'react'\nimport { Slot } from '@/components/primitives/slot'\nimport { cn } from '@/lib/utils'\n\n/**\n * Hidden from sight, present for screen readers.\n *\n * The clip-based technique, not `display: none` or `visibility: hidden` —\n * both of those remove the element from the accessibility tree, which defeats\n * the entire purpose. It is also not `opacity: 0`, which leaves the element\n * taking up layout.\n *\n * Tailwind's `sr-only` does exactly this; the component exists so intent is\n * legible in the markup, and so `asChild` can apply it to an element that\n * already carries its own classes.\n */\nfunction VisuallyHidden({\n className,\n asChild = false,\n ...props\n}: ComponentProps<'span'> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'span'\n\n return (\n <Comp data-slot=\"visually-hidden\" className={cn('sr-only', className)} {...props} />\n )\n}\n\nexport { VisuallyHidden }\n"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "wallet-address",
3
+ "type": "registry:ui",
4
+ "title": "Wallet Address",
5
+ "description": "An address shortened for display but never for copying. The truncation floor is security-relevant: attackers generate lookalikes sharing the first and last characters, so `chars` cannot go below six.",
6
+ "category": "Crypto",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "copy-button",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/wallet-address.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ExternalLink } from 'lucide-react'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An address, shortened for display but never for copying.\n *\n * The truncation is the security-relevant part. Addresses are compared by their\n * ends, so an attacker generates a lookalike sharing the first and last few\n * characters — \"address poisoning\". Showing too few characters makes that\n * attack trivial, which is why the minimum here is 6 leading and 4 trailing and\n * `chars` cannot go below it.\n *\n * Copy always yields the full address. A UI that copies what it displays is how\n * funds get sent to a truncated string.\n *\n * A `name` (ENS or similar) replaces the address visually but never replaces it\n * for copying — names are re-registrable and are not identity.\n */\nexport type WalletAddressProps = Omit<ComponentProps<'span'>, 'children'> & {\n address: string\n /** ENS or label. Shown instead of the address; the address is still copied. */\n name?: ReactNode\n /** Leading characters. Clamped to a minimum of 6. */\n chars?: number\n copyable?: boolean\n /** Block explorer URL for this address. */\n href?: string\n avatar?: ReactNode\n mono?: boolean\n size?: 'sm' | 'default'\n /** Accessible name for the explorer link. */\n explorerLabel?: string\n copyLabel?: string\n}\n\n/** Middle ellipsis, with a floor that keeps lookalikes distinguishable. */\nfunction shortenAddress(address: string, chars = 6) {\n const lead = Math.max(6, chars)\n const tail = 4\n if (address.length <= lead + tail + 1) return address\n return `${address.slice(0, lead)}…${address.slice(-tail)}`\n}\n\nfunction WalletAddress({\n address,\n name,\n chars = 6,\n copyable = true,\n href,\n avatar,\n mono = true,\n size = 'default',\n explorerLabel = 'View on block explorer',\n copyLabel = 'Copy address',\n className,\n ...props\n}: WalletAddressProps) {\n const shown = name ?? shortenAddress(address, chars)\n\n return (\n <span\n data-slot=\"wallet-address\"\n className={cn(\n 'inline-flex items-center gap-1.5',\n size === 'sm' ? 'text-xs' : 'text-sm',\n className,\n )}\n {...props}\n >\n {avatar}\n\n <span\n className={cn('truncate', mono && !name && 'font-mono')}\n // The full value on hover, so it can be verified without copying.\n title={address}\n >\n {shown}\n </span>\n\n {href && (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n aria-label={explorerLabel}\n className={cn(\n 'text-muted-foreground hover:text-foreground shrink-0',\n radius.xs,\n focusRing,\n )}\n >\n <ExternalLink className=\"size-3.5\" />\n </a>\n )}\n\n {/* Always the full address, never what is displayed. */}\n {copyable && <CopyButton value={address} label={copyLabel} />}\n </span>\n )\n}\n\nexport { WalletAddress, shortenAddress }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "wallet-connect",
3
+ "type": "registry:ui",
4
+ "title": "Wallet Connect",
5
+ "description": "Connect button and connected account in one control. Wrong-network outranks showing a balance — a figure from the wrong ledger is worse than none, because it looks authoritative.",
6
+ "category": "Crypto",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "dropdown-menu",
14
+ "lib-styles",
15
+ "lib-utils",
16
+ "wallet-address"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/wallet-connect.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { LogOut, TriangleAlert, Wallet } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { WalletAddress } from '@/components/ui/wallet-address'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Connect button and connected account, in one control.\n *\n * Wrong-network is a separate state from connected, and it takes priority over\n * showing a balance. A balance rendered while the wallet sits on an unexpected\n * chain is a number from the wrong ledger — worse than showing nothing, because\n * it looks authoritative.\n *\n * Connecting is a distinct state too: wallet prompts open in another window and\n * can sit unanswered for a long time, so the button has to stop inviting a\n * second click.\n */\nexport type WalletConnectProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n address?: string\n ensName?: ReactNode\n avatar?: ReactNode\n balance?: ReactNode\n chainName?: ReactNode\n /** True when the wallet is on a chain the app does not support. */\n wrongNetwork?: boolean\n connecting?: boolean\n onConnect?: () => void\n onDisconnect?: () => void\n onSwitchNetwork?: () => void\n explorerHref?: string\n connectLabel?: ReactNode\n wrongNetworkLabel?: ReactNode\n disconnectLabel?: ReactNode\n}\n\nfunction WalletConnect({\n address,\n ensName,\n avatar,\n balance,\n chainName,\n wrongNetwork = false,\n connecting = false,\n onConnect,\n onDisconnect,\n onSwitchNetwork,\n explorerHref,\n connectLabel = 'Connect wallet',\n wrongNetworkLabel = 'Wrong network',\n disconnectLabel = 'Disconnect',\n className,\n ...props\n}: WalletConnectProps) {\n // Disconnected — including while a wallet prompt is open elsewhere.\n if (!address) {\n return (\n <div data-slot=\"wallet-connect\" className={className} {...props}>\n <Button onClick={onConnect} disabled={connecting}>\n <Wallet />\n {connecting ? 'Check your wallet…' : connectLabel}\n </Button>\n </div>\n )\n }\n\n // Wrong network outranks everything: no balance from the wrong ledger.\n if (wrongNetwork) {\n return (\n <div data-slot=\"wallet-connect\" data-state=\"wrong-network\" className={className} {...props}>\n <Button color=\"destructive\" onClick={onSwitchNetwork}>\n <TriangleAlert />\n {wrongNetworkLabel}\n </Button>\n </div>\n )\n }\n\n return (\n <div data-slot=\"wallet-connect\" data-state=\"connected\" className={className} {...props}>\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n className={cn(\n 'bg-secondary hover:bg-accent flex items-center gap-2 py-1.5 pe-3 ps-2',\n radius.control,\n interactive,\n focusRing,\n )}\n >\n {avatar}\n <span className=\"flex min-w-0 flex-col items-start\">\n <span className=\"truncate text-sm font-medium\">\n {ensName ?? <WalletAddress address={address} copyable={false} />}\n </span>\n {balance && (\n <span className=\"text-muted-foreground truncate text-xs tabular-nums\">\n {balance}\n </span>\n )}\n </span>\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align=\"end\" className=\"w-64\">\n <DropdownMenuLabel className=\"flex flex-col gap-1.5 py-2\">\n <span className=\"text-foreground text-xs font-normal\">\n <WalletAddress address={address} href={explorerHref} chars={10} />\n </span>\n {chainName && (\n <span className=\"flex\">\n <Badge size=\"sm\">{chainName}</Badge>\n </span>\n )}\n </DropdownMenuLabel>\n\n <DropdownMenuSeparator />\n\n {onDisconnect && (\n <DropdownMenuItem\n onSelect={onDisconnect}\n className=\"text-[var(--destructive-soft-foreground)]\"\n >\n <LogOut />\n {disconnectLabel}\n </DropdownMenuItem>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n )\n}\n\nexport { WalletConnect }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "webhook-inspector",
3
+ "type": "registry:ui",
4
+ "title": "Webhook Inspector",
5
+ "description": "Inbound deliveries with payload and response. Signature status is its own field, never folded into the status code — a 200 with an invalid signature is a forged request your handler accepted.",
6
+ "category": "API",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "fmt",
14
+ "http-status",
15
+ "json-viewer",
16
+ "lib-styles",
17
+ "lib-utils"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "components/ui/webhook-inspector.tsx",
22
+ "type": "registry:ui",
23
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, RotateCcw } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { HttpStatus } from '@/components/ui/http-status'\nimport { JsonViewer, type Json } from '@/components/ui/json-viewer'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Inbound webhook deliveries, with payload and response.\n *\n * Both directions are kept: what arrived and what we answered. A webhook\n * failure is nearly always a disagreement between the two, and a log that\n * stores only the payload cannot tell you whether the sender was wrong or your\n * handler was.\n *\n * Signature verification status is its own field, separate from the response\n * code. A delivery can return 200 and have failed verification — that is a\n * forged request your handler accepted, and it must not be buried under a green\n * status.\n *\n * Retries are grouped under their original delivery rather than listed\n * separately, so five rows of the same event read as one problem rather than\n * five.\n */\nexport type WebhookDelivery = {\n id: string\n event: string\n at: Date\n status?: number\n durationMs?: number\n payload?: Json\n responseBody?: string\n /** Result of checking the sender's signature. */\n signature?: 'valid' | 'invalid' | 'missing' | 'unchecked'\n attempt?: number\n maxAttempts?: number\n}\n\nconst SIGNATURE = {\n valid: { label: 'signature ok', color: 'green' },\n invalid: { label: 'signature invalid', color: 'destructive' },\n missing: { label: 'no signature', color: 'amber' },\n unchecked: { label: 'signature unchecked', color: 'neutral' },\n} as const\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_ATTEMPT_LABEL: (attempt: number, max?: number) => ReactNode = (attempt, max) => `attempt ${attempt}${max ? ` of ${max}` : ''}`\n\nfunction WebhookInspector({\n deliveries,\n onReplay,\n now,\n locale = 'en-GB',\n emptyLabel = 'No deliveries yet',\n payloadLabel = 'Payload',\n responseLabel = 'Response',\n replayLabel = 'Replay',\n attemptLabel = DEFAULT_ATTEMPT_LABEL,\n forgedNote = 'This request was accepted with an invalid signature — it may not have come from the sender you expect.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n deliveries: WebhookDelivery[]\n onReplay?: (id: string) => void\n now?: Date\n locale?: string\n emptyLabel?: ReactNode\n payloadLabel?: ReactNode\n responseLabel?: ReactNode\n replayLabel?: ReactNode\n attemptLabel?: (attempt: number, max?: number) => ReactNode\n /** Shown when a delivery succeeded despite a bad signature. */\n forgedNote?: ReactNode\n}) {\n const [open, setOpen] = useState<string | null>(null)\n\n if (deliveries.length === 0) {\n return (\n <div className={cn(surface, radius.surface, className)} {...props}>\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"webhook-inspector\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <ul className=\"divide-border list-none divide-y\">\n {deliveries.map((delivery) => {\n const expanded = open === delivery.id\n const signature = delivery.signature ? SIGNATURE[delivery.signature] : undefined\n // 200 with a bad signature is a forged request you accepted.\n const forged =\n delivery.signature === 'invalid' && (delivery.status ?? 0) < 400\n\n return (\n <li key={delivery.id}>\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setOpen(expanded ? null : delivery.id)}\n className={cn('flex w-full items-center gap-3 p-3 text-start', interactive, focusRing)}\n >\n <ArrowDown className=\"text-muted-foreground size-3.5 shrink-0\" aria-hidden=\"true\" />\n\n <span className=\"min-w-0 flex-1\">\n <span className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-xs font-medium\">{delivery.event}</code>\n {/* Its own field — never folded into the status. */}\n {signature && (\n <Badge size=\"sm\" color={signature.color}>\n {signature.label}\n </Badge>\n )}\n {delivery.attempt !== undefined && delivery.attempt > 1 && (\n <Badge size=\"sm\" color=\"amber\">\n {attemptLabel(delivery.attempt, delivery.maxAttempts)}\n </Badge>\n )}\n </span>\n <span className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs\">\n <Fmt type=\"relative\" value={delivery.at} now={now} locale={locale} />\n {delivery.durationMs !== undefined && (\n <span className=\"tabular-nums\">{Math.round(delivery.durationMs)}ms</span>\n )}\n </span>\n </span>\n\n {delivery.status !== undefined && (\n <HttpStatus status={delivery.status} showPhrase={false} className=\"shrink-0\" />\n )}\n </button>\n\n {expanded && (\n <div className=\"bg-muted/20 border-border/60 flex flex-col gap-3 border-t p-3\">\n {forged && (\n <p className=\"text-xs text-[var(--destructive-soft-foreground)]\">{forgedNote}</p>\n )}\n\n {delivery.payload !== undefined && (\n <div className=\"flex flex-col gap-1.5\">\n <span className=\"text-muted-foreground text-xs font-medium\">{payloadLabel}</span>\n <JsonViewer value={delivery.payload} defaultExpandedDepth={2} />\n </div>\n )}\n\n {/* What we answered — the other half of every webhook bug. */}\n {delivery.responseBody !== undefined && (\n <div className=\"flex flex-col gap-1.5\">\n <span className=\"text-muted-foreground text-xs font-medium\">{responseLabel}</span>\n <pre className={cn('bg-secondary/60 overflow-x-auto p-2 font-mono text-xs', radius.control)}>\n {delivery.responseBody || '(empty)'}\n </pre>\n </div>\n )}\n\n {onReplay && (\n <Button\n size=\"xs\"\n variant=\"secondary\"\n className=\"self-start\"\n onClick={() => onReplay(delivery.id)}\n >\n <RotateCcw />\n {replayLabel}\n </Button>\n )}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { WebhookInspector }\n"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "websocket-frames",
3
+ "type": "registry:ui",
4
+ "title": "WebSocket Frames",
5
+ "description": "A frame log with the delta between frames. Protocol bugs are about the gap — a heartbeat that stopped arriving is obvious as a 30-second delta and invisible as a list of clock times.",
6
+ "category": "Runtime",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "fmt",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "select"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/websocket-frames.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, Circle } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Select } from '@/components/ui/select'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A WebSocket frame log, by direction.\n *\n * Direction is carried by an arrow, not by colour alone. A frame log is scanned\n * for \"what did we send just before it broke\", and that scan fails the moment\n * someone screenshots it into a monochrome ticket. Rows stay flush: indenting\n * one direction ragged the left edge of the payload column, which is the column\n * being read.\n *\n * Timestamps show the delta from the previous frame as well as the absolute\n * time. Protocol bugs are about the gap between frames — a heartbeat that\n * stopped arriving is visible as a 30-second delta and invisible as a list of\n * clock times.\n *\n * Control frames (ping, pong, close) are filterable and off by default in the\n * data view. A ping every 15 seconds buries the four frames that matter.\n */\nexport type WsFrame = {\n id: string\n direction: 'sent' | 'received'\n /** `text` and `binary` are data; the rest are control frames. */\n opcode?: 'text' | 'binary' | 'ping' | 'pong' | 'close'\n data: string\n at: Date\n /** Payload size. Falls back to the data length. */\n bytes?: number\n}\n\nconst CONTROL = new Set(['ping', 'pong', 'close'])\n\nfunction WebSocketFrames({\n frames,\n showControl: showControlProp,\n locale = 'en-GB',\n emptyLabel = 'No frames',\n filterLabel = 'Frames shown',\n allLabel = 'All frames',\n dataLabel = 'Data only',\n sentLabel = 'sent',\n receivedLabel = 'received',\n className,\n ...props\n}: ComponentProps<'div'> & {\n frames: WsFrame[]\n /** Include ping/pong/close. Off by default — they bury the real traffic. */\n showControl?: boolean\n locale?: string\n emptyLabel?: ReactNode\n /** Accessible name for the filter. */\n filterLabel?: string\n allLabel?: string\n dataLabel?: string\n sentLabel?: string\n receivedLabel?: string\n}) {\n const [own, setOwn] = useState(showControlProp ? 'all' : 'data')\n const mode = showControlProp === undefined ? own : showControlProp ? 'all' : 'data'\n const [open, setOpen] = useState<string | null>(null)\n\n const rows = useMemo(() => {\n const visible =\n mode === 'all' ? frames : frames.filter((f) => !CONTROL.has(f.opcode ?? 'text'))\n // Delta from the previous *visible* frame — the gap is the signal.\n return visible.map((frame, index) => ({\n frame,\n delta: index === 0 ? undefined : frame.at.getTime() - visible[index - 1].at.getTime(),\n }))\n }, [frames, mode])\n\n return (\n <div\n data-slot=\"websocket-frames\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {rows.length} / {frames.length}\n </span>\n <Select\n size=\"sm\"\n className=\"ms-auto w-36\"\n aria-label={filterLabel}\n value={mode}\n onValueChange={setOwn}\n options={[\n { value: 'data', label: dataLabel },\n { value: 'all', label: allLabel },\n ]}\n />\n </div>\n\n {rows.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"divide-border/60 max-h-96 list-none divide-y overflow-y-auto\">\n {rows.map(({ frame, delta }) => {\n const sent = frame.direction === 'sent'\n const control = CONTROL.has(frame.opcode ?? 'text')\n const expanded = open === frame.id\n const size = frame.bytes ?? frame.data.length\n\n return (\n <li key={frame.id}>\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setOpen(expanded ? null : frame.id)}\n className={cn('flex w-full items-start gap-2.5 p-3 text-start', interactive, focusRing)}\n >\n {/* Arrow and position, never colour alone. */}\n {sent ? (\n <ArrowUp className=\"text-muted-foreground mt-0.5 size-3.5 shrink-0\" aria-label={sentLabel} />\n ) : (\n <ArrowDown className=\"text-muted-foreground mt-0.5 size-3.5 shrink-0\" aria-label={receivedLabel} />\n )}\n\n <span className=\"min-w-0 flex-1\">\n <span\n className={cn(\n 'block truncate font-mono text-xs',\n control && 'text-muted-foreground/70 italic',\n )}\n >\n {frame.data || '(empty)'}\n </span>\n <span className=\"text-muted-foreground/70 mt-0.5 flex flex-wrap gap-x-2 text-xs\">\n <Fmt type=\"date\" value={frame.at} format=\"HH:mm:ss.SSS\" locale={locale} />\n {/* The gap between frames is where protocol bugs live. */}\n {delta !== undefined && (\n <span className={cn('tabular-nums', delta > 30_000 && 'text-[var(--amber-soft-foreground)]')}>\n +{delta < 1000 ? `${delta}ms` : `${(delta / 1000).toFixed(1)}s`}\n </span>\n )}\n <span className=\"tabular-nums\">\n <Fmt type=\"bytes\" value={size} locale={locale} />\n </span>\n </span>\n </span>\n\n {control && (\n <Badge size=\"sm\" color=\"neutral\" className=\"shrink-0\">\n <Circle className=\"fill-current\" />\n {frame.opcode}\n </Badge>\n )}\n </button>\n\n {expanded && (\n <pre className=\"bg-muted/30 border-border/60 overflow-x-auto border-t p-3 font-mono text-xs whitespace-pre-wrap\">\n {frame.data}\n </pre>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { WebSocketFrames }\n"
22
+ }
23
+ ]
24
+ }