@twoux/ui 0.0.0-stage → 0.1.1

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 (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * TwoUX compositions for getting started and connecting other services.
3
+ *
4
+ * ProviderLogo: the official mark of a service people sign in with or connect (Google, Microsoft, Slack…), on a white
5
+ * tile in both themes so dark marks stay visible. Only for identifying that service; never altered or recoloured.
6
+ * SignInOptions: provider buttons, single sign-on found from the email domain, and an email link.
7
+ * OnboardingStep: one step of first-run setup: why we ask, the fields, "Step 2 of 5", Back, Skip for optional steps.
8
+ * FileDrop: a drop zone with a button, the accepted types and size limit, and the error under it.
9
+ * ConnectorPicker: search, what is connected, everything else; each service says what it will let the product do.
10
+ * ConnectDialog: before connecting, the access asked for in plain words; then OAuth in a new window, or an API key with a test.
11
+ * SetupChecklist: the few things that make the product useful, in the sidebar, dismissible, never a tour.
12
+ * UsageMeter: credits or seats used against the limit, when it resets, and an upgrade link only when close to the limit.
13
+ */
14
+ import { AlertTriangle, Check, CheckCircle2, ExternalLink, KeyRound, Search, Upload, X } from "lucide-react"
15
+ import * as React from "react"
16
+ import { cn } from "../lib/cn"
17
+ import { Badge } from "./badge"
18
+ import { Button } from "./button"
19
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog"
20
+ import { Field, FieldDescription, FieldError, FieldLabel } from "./field"
21
+ import { Input } from "./input"
22
+ import { PROVIDERS, type ProviderId } from "./provider-logos.data"
23
+
24
+ export { PROVIDERS, type ProviderId }
25
+
26
+ /* ---------------------------------------------------------------- logo */
27
+
28
+ const LOGO_SIZE = { sm: "size-5 p-0.5", md: "size-7 p-1", lg: "size-9 p-1" } as const
29
+ const BARE_SIZE = { sm: "size-4", md: "size-6", lg: "size-8" } as const
30
+ /* Marks drawn in black or near-black. They vanish on a dark surface and may not be recoloured, so they always keep the white tile. */
31
+ const DARK_MARKS = new Set<ProviderId>(["apple", "github", "okta", "notion", "hubspot"])
32
+
33
+ /**
34
+ * The service's official mark on a white tile. Decorative next to its name; pass `label` when it stands alone.
35
+ * `tile={false}` shows a full-colour mark bare, e.g. inside a button; dark marks keep their tile regardless.
36
+ */
37
+ export function ProviderLogo({ provider, size = "md", label, tile = true, className }: { provider: ProviderId; size?: keyof typeof LOGO_SIZE; label?: string; tile?: boolean; className?: string }) {
38
+ const p = PROVIDERS[provider]
39
+ const tiled = tile || DARK_MARKS.has(provider)
40
+ return (
41
+ <span data-slot="provider-logo" data-tile={tiled || undefined} className={cn("inline-grid shrink-0 place-items-center", tiled ? cn("rounded-xs border border-line bg-white", LOGO_SIZE[size]) : BARE_SIZE[size], className)}>
42
+ <svg
43
+ viewBox={p.viewBox}
44
+ className="size-full"
45
+ role={label ? "img" : undefined}
46
+ aria-label={label}
47
+ aria-hidden={label ? undefined : true}
48
+ // Generated, trusted SVG from scripts/provider-logos.mjs
49
+ dangerouslySetInnerHTML={{ __html: p.body }}
50
+ />
51
+ </span>
52
+ )
53
+ }
54
+
55
+ /* ---------------------------------------------------------------- sign in */
56
+
57
+ const ssoDomain = (email: string) => /^[^@\s]+@([^@\s]+\.[^@\s]+)$/.exec(email.trim())?.[1]
58
+
59
+ /**
60
+ * Sign-in choices in one column: providers first, then email. When the email's domain uses single sign-on, the email
61
+ * button becomes "Continue with SSO". Errors appear under the field and say what to do.
62
+ */
63
+ export function SignInOptions({
64
+ providers = ["google", "microsoft"],
65
+ ssoDomains = [],
66
+ onProvider,
67
+ onEmail,
68
+ onSso,
69
+ busy,
70
+ error,
71
+ className,
72
+ }: {
73
+ providers?: ProviderId[]
74
+ /** Domains that must sign in with SSO, e.g. ["northwind.example"] */
75
+ ssoDomains?: string[]
76
+ onProvider: (p: ProviderId) => void
77
+ /** Send a sign-in link to this address */
78
+ onEmail: (email: string) => void
79
+ onSso?: (domain: string) => void
80
+ /** The choice in progress, so its button shows the spinner */
81
+ busy?: ProviderId | "email" | "sso"
82
+ /** e.g. "This email uses Google to sign in. Continue with Google instead." */
83
+ error?: string
84
+ className?: string
85
+ }) {
86
+ const [email, setEmail] = React.useState("")
87
+ const [fieldError, setFieldError] = React.useState("")
88
+ const domain = ssoDomain(email)
89
+ const sso = !!domain && ssoDomains.includes(domain)
90
+ const submit = (e: React.FormEvent) => {
91
+ e.preventDefault()
92
+ if (!ssoDomain(email)) { setFieldError("Enter your work email, for example sam@company.com."); return }
93
+ setFieldError("")
94
+ if (sso && onSso && domain) onSso(domain)
95
+ else onEmail(email.trim())
96
+ }
97
+ return (
98
+ <div data-slot="sign-in-options" className={cn("grid gap-3", className)}>
99
+ {/* One grid shared by every button (subgrid): space | mark | label | space. Mark and label sit centred as a block,
100
+ and every mark and label starts at the same x, because the label column is as wide as the longest label.
101
+ The first child is the mark, or Button's spinner while loading, so both take the mark's column. */}
102
+ <div className="grid grid-cols-[1fr_auto_auto_1fr] gap-2">
103
+ {providers.map((p) => (
104
+ <Button key={p} variant="outline" className="col-span-full grid grid-cols-subgrid items-center [&>:first-child]:col-start-2 [&>:first-child]:justify-self-center" loading={busy === p} onClick={() => onProvider(p)}>
105
+ {busy !== p && <ProviderLogo provider={p} size="sm" tile={false} />}
106
+ <span className="col-start-3 text-left">Continue with {PROVIDERS[p].label}</span>
107
+ </Button>
108
+ ))}
109
+ </div>
110
+ <div className="flex items-center gap-3 text-sm text-ink-3"><span className="h-px flex-1 bg-line" />or<span className="h-px flex-1 bg-line" /></div>
111
+ <form noValidate onSubmit={submit} className="grid gap-2">
112
+ <Field data-invalid={!!(fieldError || error) || undefined}>
113
+ <FieldLabel htmlFor="sign-in-email">Work email</FieldLabel>
114
+ <Input id="sign-in-email" type="email" autoComplete="email" value={email} aria-invalid={!!(fieldError || error) || undefined} onChange={(e) => { setEmail(e.target.value); setFieldError("") }} />
115
+ {sso && <FieldDescription>{domain} signs in with single sign-on.</FieldDescription>}
116
+ {(fieldError || error) && <FieldError>{fieldError || error}</FieldError>}
117
+ </Field>
118
+ <Button type="submit" className="w-full" loading={busy === "email" || busy === "sso"}>
119
+ {sso ? <><KeyRound />Continue with SSO</> : "Continue with email"}
120
+ </Button>
121
+ </form>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /* ---------------------------------------------------------------- onboarding step */
127
+
128
+ /**
129
+ * One step of first-run setup on a plain page (no gradients). Say why each question is asked; optional steps say
130
+ * "Optional" and offer Skip. Continue is never disabled: validate when pressed and put the error under the field.
131
+ */
132
+ export function OnboardingStep({
133
+ step,
134
+ total,
135
+ title,
136
+ description,
137
+ optional,
138
+ children,
139
+ onBack,
140
+ onSkip,
141
+ onContinue,
142
+ continueLabel = "Continue",
143
+ busy,
144
+ aside,
145
+ className,
146
+ }: {
147
+ /** 1-based */
148
+ step: number
149
+ total: number
150
+ title: string
151
+ description?: string
152
+ optional?: boolean
153
+ children: React.ReactNode
154
+ onBack?: () => void
155
+ onSkip?: () => void
156
+ onContinue: () => void
157
+ continueLabel?: string
158
+ busy?: boolean
159
+ /** A right-hand panel on wide screens, e.g. what a connection adds */
160
+ aside?: React.ReactNode
161
+ className?: string
162
+ }) {
163
+ const headingRef = React.useRef<HTMLHeadingElement>(null)
164
+ // Move focus to the step's heading when the step changes, so screen readers hear where they are.
165
+ React.useEffect(() => { headingRef.current?.focus() }, [step])
166
+ return (
167
+ <section data-slot="onboarding-step" aria-labelledby="onboarding-title" className={cn("grid w-full max-w-xl overflow-hidden rounded-md border border-line bg-surface shadow-card", aside && "max-w-4xl md:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)]", className)}>
168
+ <div className="flex min-h-0 flex-col gap-5 p-6 max-md:p-4">
169
+ <header className="grid gap-1">
170
+ <p className="text-sm text-ink-3">Step {step} of {total}{optional && " · Optional"}</p>
171
+ <h1 ref={headingRef} id="onboarding-title" tabIndex={-1} className="text-xl font-semibold text-ink outline-none">{title}</h1>
172
+ {description && <p className="text-base text-ink-2">{description}</p>}
173
+ </header>
174
+ <div className="min-h-0 flex-1">{children}</div>
175
+ <footer className="flex items-center gap-2 border-t border-line pt-4">
176
+ {onBack ? <Button variant="ghost" size="sm" onClick={onBack}>Back</Button> : <span />}
177
+ <span aria-hidden="true" className="flex flex-1 justify-center gap-1">
178
+ {Array.from({ length: total }, (_, i) => <span key={i} className={cn("h-1.5 rounded-full transition-all duration-(--duration-base) ease-(--ease-standard)", i + 1 === step ? "w-4 bg-primary" : i + 1 < step ? "w-1.5 bg-primary/50" : "w-1.5 bg-line-strong")} />)}
179
+ </span>
180
+ {optional && onSkip && <Button variant="ghost" size="sm" onClick={onSkip}>Skip</Button>}
181
+ <Button size="sm" loading={busy} onClick={onContinue}>{continueLabel}</Button>
182
+ </footer>
183
+ </div>
184
+ {aside && <aside className="border-l border-line bg-surface-2 p-6 max-md:hidden">{aside}</aside>}
185
+ </section>
186
+ )
187
+ }
188
+
189
+ /* ---------------------------------------------------------------- file drop */
190
+
191
+ /** Drop a file or choose one. Shows the accepted types and limit; the preview replaces the icon once chosen. */
192
+ export function FileDrop({
193
+ id,
194
+ label,
195
+ accept,
196
+ hint,
197
+ maxBytes,
198
+ preview,
199
+ onFile,
200
+ onRemove,
201
+ error,
202
+ className,
203
+ }: {
204
+ id: string
205
+ label: string
206
+ /** Types or extensions, e.g. "image/png,image/jpeg" or ".csv,text/csv" */
207
+ accept: string
208
+ /** e.g. "PNG or JPEG, up to 10 MB" */
209
+ hint: string
210
+ maxBytes?: number
211
+ /** URL of the chosen file's preview */
212
+ preview?: string
213
+ onFile: (file: File) => void
214
+ onRemove?: () => void
215
+ error?: string
216
+ className?: string
217
+ }) {
218
+ const input = React.useRef<HTMLInputElement>(null)
219
+ const [over, setOver] = React.useState(false)
220
+ const [localError, setLocalError] = React.useState("")
221
+ const take = (f?: File) => {
222
+ if (!f) return
223
+ // Match by extension (".csv") or type ("image/*", "text/csv"); some systems give CSVs an empty or spreadsheet type.
224
+ const ext = `.${f.name.split(".").pop()?.toLowerCase() ?? ""}`
225
+ const ok = accept.split(",").map((t) => t.trim().toLowerCase()).some((t) => (t.startsWith(".") ? ext === t : t.endsWith("/*") ? f.type.startsWith(t.slice(0, -1)) : f.type === t))
226
+ if (!ok) { setLocalError(`That file type isn't supported. Choose ${hint.split(",")[0]}.`); return }
227
+ if (maxBytes && f.size > maxBytes) { setLocalError(`That file is ${(f.size / 1e6).toFixed(1)} MB. Choose one up to ${(maxBytes / 1e6).toFixed(0)} MB.`); return }
228
+ setLocalError("")
229
+ onFile(f)
230
+ }
231
+ const shown = localError || error
232
+ return (
233
+ <Field data-invalid={!!shown || undefined} className={className}>
234
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
235
+ <div
236
+ data-slot="file-drop"
237
+ onDragOver={(e) => { e.preventDefault(); setOver(true) }}
238
+ onDragLeave={() => setOver(false)}
239
+ onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files[0]) }}
240
+ className={cn("flex items-center gap-3 rounded-sm border border-dashed p-3 transition-colors duration-(--duration-fast)", over ? "border-primary bg-primary-soft" : shown ? "border-destructive-edge" : "border-input")}
241
+ >
242
+ <span className="grid size-12 shrink-0 place-items-center overflow-hidden rounded-xs bg-surface-2">
243
+ {preview ? <img src={preview} alt="" className="size-full object-cover" /> : <Upload className="size-4 text-ink-3" aria-hidden="true" />}
244
+ </span>
245
+ <span className="grid min-w-0 flex-1">
246
+ <span className="text-base text-ink">{over ? "Drop to upload" : "Drop a file here"}</span>
247
+ <span className="text-sm text-ink-2">{hint}</span>
248
+ </span>
249
+ {preview && onRemove && <Button variant="ghost" size="sm" onClick={onRemove}>Remove</Button>}
250
+ <Button variant="outline" size="sm" onClick={() => input.current?.click()}>{preview ? "Replace" : "Upload"}</Button>
251
+ <input ref={input} id={id} type="file" accept={accept} className="sr-only" aria-invalid={!!shown || undefined} onChange={(e) => { take(e.target.files?.[0]); e.target.value = "" }} />
252
+ </div>
253
+ {shown && <FieldError>{shown}</FieldError>}
254
+ </Field>
255
+ )
256
+ }
257
+
258
+ /* ---------------------------------------------------------------- connectors */
259
+
260
+ export type Connector = {
261
+ id: string
262
+ provider: ProviderId
263
+ /** What connecting lets the product do, in plain words: "Read and send email" */
264
+ can: string[]
265
+ connected?: boolean
266
+ /** A connected service that needs attention */
267
+ problem?: string
268
+ /** e.g. "Included in your plan" */
269
+ note?: string
270
+ }
271
+
272
+ /** Search, then Connected and All services. Rows say what each connection lets the product do. */
273
+ export function ConnectorPicker({ connectors, onConnect, onManage, onActive, className }: { connectors: Connector[]; onConnect: (c: Connector) => void; onManage?: (c: Connector) => void; /** The row under the pointer or keyboard focus, e.g. to show what it adds beside the list */ onActive?: (c: Connector) => void; className?: string }) {
274
+ const [q, setQ] = React.useState("")
275
+ const match = (c: Connector) => `${PROVIDERS[c.provider].label} ${c.can.join(" ")}`.toLowerCase().includes(q.trim().toLowerCase())
276
+ const shown = connectors.filter(match)
277
+ const groups: [string, Connector[]][] = [["Connected", shown.filter((c) => c.connected)], [q ? "Results" : "All services", shown.filter((c) => !c.connected)]]
278
+ return (
279
+ <div data-slot="connector-picker" className={cn("grid gap-3", className)}>
280
+ <div className="relative">
281
+ <Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-3" aria-hidden="true" />
282
+ <Input type="search" aria-label={`Search ${connectors.length} services`} placeholder={`Search ${connectors.length} services`} value={q} onChange={(e) => setQ(e.target.value)} className="pl-8" />
283
+ </div>
284
+ {shown.length === 0 && <p className="py-4 text-center text-base text-ink-2">No service called “{q}”. Check the spelling, or connect it through webhooks or the API.</p>}
285
+ {groups.map(([title, list]) => list.length > 0 && (
286
+ <section key={title} aria-label={title} className="grid gap-0.5">
287
+ <h2 className="px-1 text-sm font-medium text-ink-2">{title} <span className="tabular text-ink-3">{list.length}</span></h2>
288
+ <ul className="grid">
289
+ {list.map((c) => (
290
+ <li key={c.id} onMouseEnter={() => onActive?.(c)} onFocus={() => onActive?.(c)} className="flex items-center gap-2 rounded-xs px-1 py-2 hover:bg-hover">
291
+ <ProviderLogo provider={c.provider} />
292
+ <span className="grid min-w-0 flex-1">
293
+ <span className="flex items-center gap-2 text-base font-medium text-ink">
294
+ {PROVIDERS[c.provider].label}
295
+ {/* Status sits in one place for every row, as a word in a badge; the right edge holds only actions. */}
296
+ {c.problem ? <Badge variant="warning" dot>Needs attention</Badge> : c.connected && <Badge variant="success" dot>Connected</Badge>}
297
+ </span>
298
+ <span className="truncate text-sm text-ink-2">{c.problem ?? c.note ?? c.can.join(" · ")}</span>
299
+ </span>
300
+ {c.connected ? (
301
+ c.problem ? <Button variant="outline" size="sm" onClick={() => onConnect(c)}>Reconnect</Button>
302
+ : onManage && <Button variant="outline" size="sm" onClick={() => onManage(c)}>Manage<span className="sr-only"> {PROVIDERS[c.provider].label}</span></Button>
303
+ ) : (
304
+ <Button variant="outline" size="sm" onClick={() => onConnect(c)}>Connect<span className="sr-only"> {PROVIDERS[c.provider].label}</span></Button>
305
+ )}
306
+ </li>
307
+ ))}
308
+ </ul>
309
+ </section>
310
+ ))}
311
+ </div>
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Before connecting: what access is asked for, in plain words, and who it applies to. OAuth opens the provider in a new
317
+ * window; an API key is tested before it is saved, and the error says what to check.
318
+ */
319
+ export function ConnectDialog({
320
+ connector,
321
+ open,
322
+ onOpenChange,
323
+ method = "oauth",
324
+ scope = "Only you",
325
+ product = "the product",
326
+ onAuthorise,
327
+ onTestKey,
328
+ onDone,
329
+ }: {
330
+ connector: Connector | null
331
+ open: boolean
332
+ onOpenChange: (o: boolean) => void
333
+ method?: "oauth" | "apikey"
334
+ /** Who the connection is shared with, e.g. "Everyone in TwoUX" */
335
+ scope?: string
336
+ /** Your product's name, for "This lets TwoUX:" */
337
+ product?: string
338
+ /** OAuth: open the provider's window; resolve when it returns */
339
+ onAuthorise?: () => Promise<void>
340
+ /** API key: resolve with an error message, or undefined when the key works */
341
+ onTestKey?: (key: string) => Promise<string | undefined>
342
+ onDone: () => void
343
+ }) {
344
+ const [key, setKey] = React.useState("")
345
+ const [state, setState] = React.useState<"idle" | "busy" | "ok">("idle")
346
+ const [error, setError] = React.useState("")
347
+ React.useEffect(() => { if (open) { setKey(""); setState("idle"); setError("") } }, [open])
348
+ if (!connector) return null
349
+ const name = PROVIDERS[connector.provider].label
350
+ const go = async () => {
351
+ if (method === "apikey") {
352
+ if (!key.trim()) { setError(`Paste the API key from your ${name} settings.`); return }
353
+ setState("busy")
354
+ const msg = await onTestKey?.(key.trim())
355
+ if (msg) { setError(msg); setState("idle"); return }
356
+ setState("ok")
357
+ } else {
358
+ setState("busy")
359
+ await onAuthorise?.()
360
+ setState("ok")
361
+ }
362
+ }
363
+ return (
364
+ <Dialog open={open} onOpenChange={onOpenChange}>
365
+ <DialogContent className="max-w-md">
366
+ <DialogHeader>
367
+ <DialogTitle className="flex items-center gap-2"><ProviderLogo provider={connector.provider} />Connect {name}</DialogTitle>
368
+ <DialogDescription>This lets {product}:</DialogDescription>
369
+ </DialogHeader>
370
+ <ul className="grid gap-2 text-base text-ink">
371
+ {connector.can.map((c) => <li key={c} className="flex gap-2"><Check className="mt-0.5 size-3.5 shrink-0 text-ok" aria-hidden="true" />{c}</li>)}
372
+ </ul>
373
+ <p className="text-sm text-ink-2">Shared with: {scope}. You can disconnect at any time in Settings, Connections.</p>
374
+ {method === "apikey" && state !== "ok" && (
375
+ <Field data-invalid={!!error || undefined}>
376
+ <FieldLabel htmlFor="connect-key">API key</FieldLabel>
377
+ <Input id="connect-key" type="password" autoComplete="off" value={key} aria-invalid={!!error || undefined} onChange={(e) => { setKey(e.target.value); setError("") }} />
378
+ {error ? <FieldError>{error}</FieldError> : <FieldDescription>The key is tested before it is saved. It is stored encrypted and never shown again.</FieldDescription>}
379
+ </Field>
380
+ )}
381
+ {state === "ok" && <p role="status" className="flex items-center gap-2 text-base text-ok"><CheckCircle2 className="size-4" aria-hidden="true" />{name} is connected.</p>}
382
+ <DialogFooter>
383
+ {state === "ok" ? (
384
+ <Button onClick={() => { onDone(); onOpenChange(false) }}>Done</Button>
385
+ ) : (
386
+ <>
387
+ <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
388
+ <Button loading={state === "busy"} onClick={go}>
389
+ {method === "oauth" ? <>Continue to {name}<ExternalLink /></> : "Test and connect"}
390
+ </Button>
391
+ </>
392
+ )}
393
+ </DialogFooter>
394
+ </DialogContent>
395
+ </Dialog>
396
+ )
397
+ }
398
+
399
+ /* ---------------------------------------------------------------- checklist */
400
+
401
+ export type SetupItem = { id: string; label: string; done: boolean; onStart?: () => void }
402
+
403
+ /** "Get set up" in the sidebar: a few steps that make the product useful, with progress. Dismissible; never a tour. */
404
+ export function SetupChecklist({ items, onDismiss, className }: { items: SetupItem[]; onDismiss?: () => void; className?: string }) {
405
+ const done = items.filter((i) => i.done).length
406
+ return (
407
+ <section aria-label="Get set up" data-slot="setup-checklist" className={cn("grid gap-2 rounded-sm border border-line bg-surface p-3", className)}>
408
+ <header className="flex items-center gap-2">
409
+ <h2 className="flex-1 text-base font-semibold text-ink">Get set up</h2>
410
+ <span className="tabular text-sm text-ink-2">{done} of {items.length}</span>
411
+ {onDismiss && <Button variant="ghost" size="icon-xs" aria-label="Hide setup checklist" onClick={onDismiss}><X /></Button>}
412
+ </header>
413
+ <span aria-hidden="true" className="h-1 overflow-hidden rounded-full bg-hover"><span className="block h-full w-(--p) rounded-full bg-primary transition-all duration-(--duration-slow) ease-(--ease-standard)" style={{ "--p": `${(done / items.length) * 100}%` } as React.CSSProperties} /></span>
414
+ <ul className="grid">
415
+ {items.map((i) => (
416
+ <li key={i.id} className="flex items-center gap-2 py-1">
417
+ <span className={cn("grid size-4 shrink-0 place-items-center rounded-full border", i.done ? "border-primary bg-primary text-primary-foreground" : "border-control")}>{i.done && <Check className="size-2.5" strokeWidth={3} aria-hidden="true" />}</span>
418
+ <span className={cn("min-w-0 flex-1 text-base", i.done ? "text-ink-3 line-through" : "text-ink")}>{i.label}<span className="sr-only">{i.done ? " (done)" : ""}</span></span>
419
+ {!i.done && i.onStart && <Button variant="outline" size="xs" onClick={i.onStart}>Start<span className="sr-only"> {i.label}</span></Button>}
420
+ </li>
421
+ ))}
422
+ </ul>
423
+ </section>
424
+ )
425
+ }
426
+
427
+ /* ---------------------------------------------------------------- usage */
428
+
429
+ /**
430
+ * Usage against a limit: "13.2k of 20k credits", when it resets, and what used it. The upgrade link appears only from
431
+ * 80% used, and the warning names what stops at the limit. Never a permanent bright button.
432
+ */
433
+ export function UsageMeter({ used, limit, unit, resets, atLimit, onDetails, onUpgrade, className }: { used: number; limit: number; unit: string; /** e.g. "1 Dec" */ resets?: string; /** What stops at the limit, e.g. "Agents pause until 1 Dec" */ atLimit?: string; onDetails?: () => void; onUpgrade?: () => void; className?: string }) {
434
+ const pct = Math.min(100, (used / limit) * 100)
435
+ const near = pct >= 80
436
+ const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toLocaleString("en-GB", { maximumFractionDigits: 1 })}k` : n.toLocaleString("en-GB"))
437
+ return (
438
+ <section aria-label={`${unit} used`} data-slot="usage-meter" className={cn("grid gap-2", className)}>
439
+ <div className="flex items-baseline gap-2 text-sm">
440
+ <span className="flex-1 font-medium text-ink">{unit.charAt(0).toUpperCase() + unit.slice(1)}</span>
441
+ <span className="tabular text-ink-2">{fmt(used)} of {fmt(limit)}</span>
442
+ </div>
443
+ <span role="meter" aria-valuemin={0} aria-valuemax={limit} aria-valuenow={used} aria-label={`${unit} used`} className="h-1.5 overflow-hidden rounded-full bg-hover">
444
+ <span className={cn("block h-full w-(--p) rounded-full transition-all duration-(--duration-slow) ease-(--ease-standard)", near ? "bg-warn" : "bg-primary")} style={{ "--p": `${pct}%` } as React.CSSProperties} />
445
+ </span>
446
+ {near && atLimit && <p className="flex items-start gap-control text-sm text-ink-2"><AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-warn" aria-hidden="true" />{Math.round(pct)}% used. {atLimit}.</p>}
447
+ <div className="flex flex-wrap items-center gap-x-3 text-sm">
448
+ {resets && <span className="text-ink-3">Resets {resets}</span>}
449
+ {onDetails && <Button variant="link" size="xs" onClick={onDetails}>What used them</Button>}
450
+ {near && onUpgrade && <Button variant="link" size="xs" onClick={onUpgrade}>Add more</Button>}
451
+ </div>
452
+ </section>
453
+ )
454
+ }
@@ -0,0 +1,185 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ArrowDown, ArrowUp, Minus, Plus } from "lucide-react"
5
+ import { cn } from "../lib/cn"
6
+ import { fmt } from "../lib/chart-kit"
7
+ import { Button } from "./button"
8
+ import { Skeleton } from "./skeleton"
9
+
10
+ /** One resting price level: the price and how much is offered or wanted at it. */
11
+ export type OrderLevel = { price: number; amount: number }
12
+
13
+ type Row = OrderLevel & { side: "sell" | "buy"; total: number }
14
+
15
+ /** Groups levels into steps: sell prices round up, buy prices round down, so a group never claims a better price than its orders. */
16
+ export function groupLevels(levels: OrderLevel[], step: number, side: "sell" | "buy"): OrderLevel[] {
17
+ if (!step) return levels
18
+ const map = new Map<number, number>()
19
+ for (const l of levels) {
20
+ const k = Number(((side === "sell" ? Math.ceil(l.price / step - 1e-9) : Math.floor(l.price / step + 1e-9)) * step).toFixed(10))
21
+ map.set(k, (map.get(k) ?? 0) + l.amount)
22
+ }
23
+ return [...map].map(([price, amount]) => ({ price, amount }))
24
+ }
25
+
26
+ /** Table view for ChartFrame: side, price, amount and running total, best prices first. */
27
+ export const orderBookTable = (asks: OrderLevel[], bids: OrderLevel[], format: (n: number) => string = fmt, formatAmount: (n: number) => string = fmt) => {
28
+ const run = (ls: OrderLevel[], side: string) => {
29
+ let t = 0
30
+ return ls.map((l) => [side, format(l.price), formatAmount(l.amount), formatAmount((t += l.amount))])
31
+ }
32
+ return {
33
+ columns: ["Side", "Price", "Amount", "Total"],
34
+ rows: [...run([...asks].sort((a, b) => a.price - b.price), "Sell"), ...run([...bids].sort((a, b) => b.price - a.price), "Buy")],
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Resting sell and buy orders either side of the spread. Sells sit above, best (lowest) next to the spread;
40
+ * buys sit below, best (highest) next to the spread. Each row's bar shows the running total against the deepest side,
41
+ * so walls stand out. Side is carried by position, the row's name and colour together, never colour alone.
42
+ * Choosing a row hands its price to the order form; rows keep their place as prices update, with no flashing.
43
+ */
44
+ export function OrderBook({
45
+ asks,
46
+ bids,
47
+ rows = 10,
48
+ format = fmt,
49
+ formatAmount = fmt,
50
+ priceLabel = "Price",
51
+ amountLabel = "Amount",
52
+ totalLabel = "Total",
53
+ step,
54
+ steps,
55
+ onStepChange,
56
+ formatStep = (n) => String(n),
57
+ last,
58
+ loading = false,
59
+ onSelectPrice,
60
+ className,
61
+ "aria-label": label,
62
+ }: {
63
+ asks: OrderLevel[]
64
+ bids: OrderLevel[]
65
+ /** Levels shown on each side. */
66
+ rows?: number
67
+ format?: (n: number) => string
68
+ formatAmount?: (n: number) => string
69
+ /** Column headings, with units: "Price (USD)", "Amount (BTC)". */
70
+ priceLabel?: string
71
+ amountLabel?: string
72
+ /** Running total heading, with its unit: "Total (BTC)". */
73
+ totalLabel?: string
74
+ /** Price grouping. Pass steps and onStepChange to show the − and + control. */
75
+ step?: number
76
+ steps?: number[]
77
+ onStepChange?: (step: number) => void
78
+ formatStep?: (n: number) => string
79
+ /** Last traded price, shown between the sides with its direction. */
80
+ last?: { price: number; direction: "up" | "down" }
81
+ loading?: boolean
82
+ /** Called with the chosen row's price and side, to fill the order form. */
83
+ onSelectPrice?: (price: number, side: "sell" | "buy") => void
84
+ className?: string
85
+ "aria-label": string
86
+ }) {
87
+ const sells = React.useMemo(() => {
88
+ const g = groupLevels(asks, step ?? 0, "sell").sort((a, b) => a.price - b.price).slice(0, rows)
89
+ let t = 0
90
+ return g.map((l) => ({ ...l, side: "sell" as const, total: (t += l.amount) })).reverse()
91
+ }, [asks, step, rows])
92
+ const buys = React.useMemo(() => {
93
+ const g = groupLevels(bids, step ?? 0, "buy").sort((a, b) => b.price - a.price).slice(0, rows)
94
+ let t = 0
95
+ return g.map((l) => ({ ...l, side: "buy" as const, total: (t += l.amount) }))
96
+ }, [bids, step, rows])
97
+ const deepest = Math.max(sells[0]?.total ?? 0, buys[buys.length - 1]?.total ?? 0, 1e-12)
98
+ const bestAsk = sells[sells.length - 1]?.price
99
+ const bestBid = buys[0]?.price
100
+ const spread = bestAsk !== undefined && bestBid !== undefined ? bestAsk - bestBid : null
101
+
102
+ // One tab stop; arrow keys move between rows, Enter or Space uses the price.
103
+ const all: Row[] = [...sells, ...buys]
104
+ const [focus, setFocus] = React.useState(0)
105
+ const refs = React.useRef<(HTMLButtonElement | null)[]>([])
106
+ const move = (i: number) => {
107
+ const n = Math.max(0, Math.min(all.length - 1, i))
108
+ setFocus(n)
109
+ refs.current[n]?.focus()
110
+ }
111
+ const onKey = (e: React.KeyboardEvent, i: number) => {
112
+ const to = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: all.length - 1, PageDown: i + 5, PageUp: i - 5 }[e.key]
113
+ if (to === undefined) return
114
+ e.preventDefault()
115
+ move(to)
116
+ }
117
+ const stepIdx = steps && step !== undefined ? steps.indexOf(step) : -1
118
+
119
+ const row = (r: Row, i: number) => (
120
+ <li key={`${r.side}-${r.price}`} className="relative">
121
+ <span aria-hidden="true" className={cn("absolute inset-y-px right-0 w-(--w) rounded-l-xs motion-safe:transition-[width] motion-safe:duration-(--duration-base) motion-safe:ease-(--ease-standard)", r.side === "sell" ? "bg-bad-bg" : "bg-ok-bg")} style={{ "--w": `${(r.total / deepest) * 100}%` } as React.CSSProperties} />
122
+ <button
123
+ ref={(el) => { refs.current[i] = el }}
124
+ type="button"
125
+ tabIndex={i === Math.min(focus, all.length - 1) ? 0 : -1}
126
+ disabled={!onSelectPrice}
127
+ onClick={() => { setFocus(i); onSelectPrice?.(r.price, r.side) }}
128
+ onKeyDown={(e) => onKey(e, i)}
129
+ aria-label={`${r.side === "sell" ? "Sell" : "Buy"} ${formatAmount(r.amount)} at ${format(r.price)}, total ${formatAmount(r.total)}`}
130
+ className="relative grid h-6 w-full grid-cols-3 items-center gap-2 rounded-xs px-2 text-right text-sm tabular outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-default"
131
+ >
132
+ <span className={cn("text-left font-medium", r.side === "sell" ? "text-bad" : "text-ok")}>{format(r.price)}</span>
133
+ <span className="text-ink">{formatAmount(r.amount)}</span>
134
+ <span className="text-ink-2">{formatAmount(r.total)}</span>
135
+ </button>
136
+ </li>
137
+ )
138
+
139
+ const skeleton = (key: string) => (
140
+ <ul key={key} className="grid gap-1 px-2 py-1" aria-hidden="true">
141
+ {Array.from({ length: rows }, (_, i) => <li key={i}><Skeleton className="h-5 w-full" /></li>)}
142
+ </ul>
143
+ )
144
+ const empty = (side: string) => <p className="px-2 py-3 text-sm text-ink-3">No {side} orders</p>
145
+
146
+ return (
147
+ <section aria-label={label} aria-busy={loading || undefined} className={cn("grid min-w-0 content-start", className)}>
148
+ {steps && onStepChange && step !== undefined && (
149
+ <div className="flex items-center justify-between gap-2 px-2 pb-2">
150
+ <span className="text-sm text-ink-2">Group by</span>
151
+ <div className="flex items-center gap-1">
152
+ <Button variant="ghost" size="icon-xs" aria-label="Smaller price groups" aria-disabled={stepIdx <= 0 || undefined} onClick={() => stepIdx > 0 && onStepChange(steps[stepIdx - 1])}><Minus /></Button>
153
+ <output className="min-w-14 text-center text-sm font-medium text-ink tabular" aria-live="polite">{formatStep(step)}</output>
154
+ <Button variant="ghost" size="icon-xs" aria-label="Larger price groups" aria-disabled={stepIdx >= steps.length - 1 || undefined} onClick={() => stepIdx < steps.length - 1 && onStepChange(steps[stepIdx + 1])}><Plus /></Button>
155
+ </div>
156
+ </div>
157
+ )}
158
+ <div className="grid grid-cols-3 gap-2 border-b border-line px-4 pb-1 text-right text-xs text-ink-3" aria-hidden="true">
159
+ <span className="text-left">{priceLabel}</span>
160
+ <span>{amountLabel}</span>
161
+ <span>{totalLabel}</span>
162
+ </div>
163
+ {loading ? skeleton("s") : (
164
+ <ul aria-label="Sell orders, best price last" className="grid content-end px-2 pt-1">
165
+ {sells.length ? sells.map((r, i) => row(r, i)) : <li>{empty("sell")}</li>}
166
+ </ul>
167
+ )}
168
+ <div className="flex items-center justify-between gap-2 border-y border-line px-4 py-2 text-sm tabular">
169
+ {last && !loading ? (
170
+ <span className={cn("flex items-center gap-1 font-semibold", last.direction === "up" ? "text-ok" : "text-bad")}>
171
+ {format(last.price)}
172
+ {last.direction === "up" ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
173
+ <span className="sr-only">Last trade, {last.direction === "up" ? "up" : "down"} on the one before</span>
174
+ </span>
175
+ ) : <span />}
176
+ <span className="text-ink-2">Spread {spread === null || loading ? "–" : format(spread)}</span>
177
+ </div>
178
+ {loading ? skeleton("b") : (
179
+ <ul aria-label="Buy orders, best price first" className="grid content-start px-2 pb-1">
180
+ {buys.length ? buys.map((r, i) => row(r, sells.length + i)) : <li>{empty("buy")}</li>}
181
+ </ul>
182
+ )}
183
+ </section>
184
+ )
185
+ }