@twoux/ui 0.0.0-stage → 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 (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/SHADCN-DIFF.md +228 -0
  5. package/agent/AGENTS.md +358 -0
  6. package/agent/docs-md/accessibility.md +49 -0
  7. package/agent/docs-md/action-catalogue.md +81 -0
  8. package/agent/docs-md/actions.md +74 -0
  9. package/agent/docs-md/agent-activity-log.md +70 -0
  10. package/agent/docs-md/agent-permissions.md +82 -0
  11. package/agent/docs-md/agent-run.md +90 -0
  12. package/agent/docs-md/agents-on-behalf.md +74 -0
  13. package/agent/docs-md/ai-column.md +85 -0
  14. package/agent/docs-md/ai-origin-label.md +70 -0
  15. package/agent/docs-md/announcing-features.md +73 -0
  16. package/agent/docs-md/answer-actions.md +81 -0
  17. package/agent/docs-md/app-shell.md +114 -0
  18. package/agent/docs-md/artifact-card.md +89 -0
  19. package/agent/docs-md/ask-on-screen.md +72 -0
  20. package/agent/docs-md/assistant-page.md +100 -0
  21. package/agent/docs-md/assistant-panel.md +142 -0
  22. package/agent/docs-md/avatar.md +82 -0
  23. package/agent/docs-md/banner.md +106 -0
  24. package/agent/docs-md/board.md +163 -0
  25. package/agent/docs-md/breadcrumb.md +89 -0
  26. package/agent/docs-md/breakdown-table.md +97 -0
  27. package/agent/docs-md/bulk-action-bar.md +112 -0
  28. package/agent/docs-md/button.md +293 -0
  29. package/agent/docs-md/card.md +115 -0
  30. package/agent/docs-md/change-list.md +99 -0
  31. package/agent/docs-md/change-request.md +166 -0
  32. package/agent/docs-md/chart-area.md +87 -0
  33. package/agent/docs-md/chart-bar.md +100 -0
  34. package/agent/docs-md/chart-box-plot.md +88 -0
  35. package/agent/docs-md/chart-bullet.md +89 -0
  36. package/agent/docs-md/chart-candlestick.md +112 -0
  37. package/agent/docs-md/chart-card-line.md +120 -0
  38. package/agent/docs-md/chart-donut.md +90 -0
  39. package/agent/docs-md/chart-drill-down.md +72 -0
  40. package/agent/docs-md/chart-funnel.md +71 -0
  41. package/agent/docs-md/chart-heatmap.md +76 -0
  42. package/agent/docs-md/chart-histogram.md +81 -0
  43. package/agent/docs-md/chart-map.md +165 -0
  44. package/agent/docs-md/chart-mixed.md +88 -0
  45. package/agent/docs-md/chart-price.md +92 -0
  46. package/agent/docs-md/chart-radial-bars.md +109 -0
  47. package/agent/docs-md/chart-radial-lines.md +111 -0
  48. package/agent/docs-md/chart-sankey.md +104 -0
  49. package/agent/docs-md/chart-scatter.md +87 -0
  50. package/agent/docs-md/chart-share-bar.md +89 -0
  51. package/agent/docs-md/chart-treemap.md +98 -0
  52. package/agent/docs-md/chart-waterfall.md +93 -0
  53. package/agent/docs-md/checkbox.md +131 -0
  54. package/agent/docs-md/choosing-selection-control.md +71 -0
  55. package/agent/docs-md/citations-sources.md +79 -0
  56. package/agent/docs-md/citing-sources.md +63 -0
  57. package/agent/docs-md/clarify-before-acting.md +69 -0
  58. package/agent/docs-md/clarifying-question.md +92 -0
  59. package/agent/docs-md/code-block.md +174 -0
  60. package/agent/docs-md/colours.md +87 -0
  61. package/agent/docs-md/columns-menu.md +140 -0
  62. package/agent/docs-md/combobox.md +161 -0
  63. package/agent/docs-md/command-palette.md +126 -0
  64. package/agent/docs-md/confidence-note.md +87 -0
  65. package/agent/docs-md/configurable-dashboard.md +76 -0
  66. package/agent/docs-md/confirmation-dialog.md +93 -0
  67. package/agent/docs-md/connected-tools.md +80 -0
  68. package/agent/docs-md/connector-picker.md +127 -0
  69. package/agent/docs-md/consistency.md +53 -0
  70. package/agent/docs-md/content-density.md +61 -0
  71. package/agent/docs-md/conversation-history.md +78 -0
  72. package/agent/docs-md/create-record.md +80 -0
  73. package/agent/docs-md/csv-import.md +88 -0
  74. package/agent/docs-md/dashboard-items.md +78 -0
  75. package/agent/docs-md/data-export.md +61 -0
  76. package/agent/docs-md/data-grid.md +137 -0
  77. package/agent/docs-md/data-vis-colours.md +76 -0
  78. package/agent/docs-md/data-visualisation.md +76 -0
  79. package/agent/docs-md/date-picker.md +178 -0
  80. package/agent/docs-md/delete-record.md +75 -0
  81. package/agent/docs-md/dense-table.md +495 -0
  82. package/agent/docs-md/design-tokens.md +53 -0
  83. package/agent/docs-md/disabled-read-only.md +75 -0
  84. package/agent/docs-md/drag-and-drop.md +74 -0
  85. package/agent/docs-md/dropdown-menu.md +137 -0
  86. package/agent/docs-md/edit-record.md +73 -0
  87. package/agent/docs-md/empty-loading-error.md +78 -0
  88. package/agent/docs-md/empty-state.md +128 -0
  89. package/agent/docs-md/error-messages.md +75 -0
  90. package/agent/docs-md/error-state.md +123 -0
  91. package/agent/docs-md/export-menu.md +86 -0
  92. package/agent/docs-md/feedback-prompt.md +88 -0
  93. package/agent/docs-md/field-mapping.md +78 -0
  94. package/agent/docs-md/file-drop.md +110 -0
  95. package/agent/docs-md/filter-bar.md +138 -0
  96. package/agent/docs-md/filter-panel.md +103 -0
  97. package/agent/docs-md/filtering-search.md +79 -0
  98. package/agent/docs-md/follow-ups-starters.md +79 -0
  99. package/agent/docs-md/form-validation.md +95 -0
  100. package/agent/docs-md/generated-columns.md +65 -0
  101. package/agent/docs-md/generated-drafts-files.md +71 -0
  102. package/agent/docs-md/grouped-list.md +89 -0
  103. package/agent/docs-md/guide-cue.md +170 -0
  104. package/agent/docs-md/help-panel.md +103 -0
  105. package/agent/docs-md/help-system.md +74 -0
  106. package/agent/docs-md/iconography.md +49 -0
  107. package/agent/docs-md/inbox.md +108 -0
  108. package/agent/docs-md/inline-editing-validation.md +80 -0
  109. package/agent/docs-md/inline-input-in-answer.md +70 -0
  110. package/agent/docs-md/items-palette.md +116 -0
  111. package/agent/docs-md/keyboard-model.md +47 -0
  112. package/agent/docs-md/keyboard-navigation.md +68 -0
  113. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  114. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  115. package/agent/docs-md/labelling-ai-output.md +66 -0
  116. package/agent/docs-md/layout.md +47 -0
  117. package/agent/docs-md/list-table.md +254 -0
  118. package/agent/docs-md/loading-skeleton.md +91 -0
  119. package/agent/docs-md/managing-agents.md +71 -0
  120. package/agent/docs-md/message-composer.md +105 -0
  121. package/agent/docs-md/migrating.md +49 -0
  122. package/agent/docs-md/motion.md +93 -0
  123. package/agent/docs-md/navigation.md +76 -0
  124. package/agent/docs-md/notification-channels.md +65 -0
  125. package/agent/docs-md/notifications-decision.md +100 -0
  126. package/agent/docs-md/onboarding-step.md +114 -0
  127. package/agent/docs-md/order-book.md +108 -0
  128. package/agent/docs-md/page-header.md +96 -0
  129. package/agent/docs-md/pagination.md +88 -0
  130. package/agent/docs-md/pane-layout.md +105 -0
  131. package/agent/docs-md/period-comparison.md +66 -0
  132. package/agent/docs-md/plan-card.md +120 -0
  133. package/agent/docs-md/popover-rules.md +88 -0
  134. package/agent/docs-md/prompt-editor.md +90 -0
  135. package/agent/docs-md/propose-apply.md +65 -0
  136. package/agent/docs-md/provider-logo.md +97 -0
  137. package/agent/docs-md/radio.md +83 -0
  138. package/agent/docs-md/ranked-list.md +102 -0
  139. package/agent/docs-md/reasoning-progress.md +72 -0
  140. package/agent/docs-md/record-detail.md +71 -0
  141. package/agent/docs-md/regenerating-answers.md +70 -0
  142. package/agent/docs-md/run-status-bar.md +94 -0
  143. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  144. package/agent/docs-md/saved-views.md +149 -0
  145. package/agent/docs-md/saving-views.md +68 -0
  146. package/agent/docs-md/scheduler.md +258 -0
  147. package/agent/docs-md/secondary-panels.md +72 -0
  148. package/agent/docs-md/segmented-control.md +88 -0
  149. package/agent/docs-md/selection-bulk-actions.md +70 -0
  150. package/agent/docs-md/separator.md +75 -0
  151. package/agent/docs-md/setup-checklist.md +92 -0
  152. package/agent/docs-md/shape-elevation.md +55 -0
  153. package/agent/docs-md/sheet-grid.md +211 -0
  154. package/agent/docs-md/side-panel.md +134 -0
  155. package/agent/docs-md/sign-in.md +114 -0
  156. package/agent/docs-md/spacing.md +55 -0
  157. package/agent/docs-md/split-button.md +100 -0
  158. package/agent/docs-md/stat-compare.md +82 -0
  159. package/agent/docs-md/static-dashboard.md +75 -0
  160. package/agent/docs-md/status-badge.md +140 -0
  161. package/agent/docs-md/step-card.md +75 -0
  162. package/agent/docs-md/stepper.md +92 -0
  163. package/agent/docs-md/suggested-changes.md +91 -0
  164. package/agent/docs-md/switch.md +78 -0
  165. package/agent/docs-md/table-settings.md +81 -0
  166. package/agent/docs-md/tabs.md +85 -0
  167. package/agent/docs-md/tag-input.md +108 -0
  168. package/agent/docs-md/tag.md +79 -0
  169. package/agent/docs-md/task-builder.md +87 -0
  170. package/agent/docs-md/team-instructions.md +72 -0
  171. package/agent/docs-md/templates-gallery.md +85 -0
  172. package/agent/docs-md/text-input.md +160 -0
  173. package/agent/docs-md/thinking-steps.md +89 -0
  174. package/agent/docs-md/timeline.md +189 -0
  175. package/agent/docs-md/timestamp.md +84 -0
  176. package/agent/docs-md/timestamps.md +70 -0
  177. package/agent/docs-md/toast.md +117 -0
  178. package/agent/docs-md/tooltip.md +90 -0
  179. package/agent/docs-md/typography.md +62 -0
  180. package/agent/docs-md/undo-destructive.md +66 -0
  181. package/agent/docs-md/unsaved-changes.md +71 -0
  182. package/agent/docs-md/usage-meter.md +80 -0
  183. package/agent/docs-md/user-feedback.md +71 -0
  184. package/agent/docs-md/voice-and-tone.md +60 -0
  185. package/agent/docs-md/why-choice.md +93 -0
  186. package/agent/docs-md/workspace-onboarding.md +74 -0
  187. package/agent/docs-md/writing.md +48 -0
  188. package/agent/llms-full.txt +18369 -0
  189. package/agent/llms.txt +198 -0
  190. package/agent/skills/twoux-audit/SKILL.md +49 -0
  191. package/agent/skills/twoux-build/SKILL.md +39 -0
  192. package/agent/skills/twoux-design/SKILL.md +54 -0
  193. package/agent/skills/twoux-write/SKILL.md +32 -0
  194. package/bin/check.mjs +123 -0
  195. package/bin/docs.mjs +24 -0
  196. package/bin/guard.mjs +27 -0
  197. package/bin/init.mjs +113 -0
  198. package/bin/knowledge.mjs +87 -0
  199. package/bin/mcp.mjs +116 -0
  200. package/bin/theme.mjs +168 -0
  201. package/bin/twoux.mjs +25 -0
  202. package/package.json +80 -3
  203. package/registry/jobs.json +1028 -0
  204. package/registry/palette.json +770 -0
  205. package/registry/rules.json +913 -0
  206. package/src/components/agent-review.tsx +364 -0
  207. package/src/components/ai-basics.tsx +212 -0
  208. package/src/components/ai-work.tsx +304 -0
  209. package/src/components/ai.tsx +227 -0
  210. package/src/components/alert-dialog.tsx +203 -0
  211. package/src/components/alert.tsx +86 -0
  212. package/src/components/avatar.tsx +114 -0
  213. package/src/components/badge.tsx +59 -0
  214. package/src/components/board.tsx +397 -0
  215. package/src/components/breadcrumb.tsx +114 -0
  216. package/src/components/button-group.tsx +90 -0
  217. package/src/components/button.tsx +109 -0
  218. package/src/components/calendar.tsx +247 -0
  219. package/src/components/card.tsx +102 -0
  220. package/src/components/chart-map.tsx +336 -0
  221. package/src/components/charts-more.tsx +1331 -0
  222. package/src/components/charts-radial.tsx +328 -0
  223. package/src/components/charts.tsx +390 -0
  224. package/src/components/checkbox.tsx +40 -0
  225. package/src/components/code.tsx +139 -0
  226. package/src/components/collapsible.tsx +37 -0
  227. package/src/components/combobox.tsx +339 -0
  228. package/src/components/command-extras.tsx +11 -0
  229. package/src/components/command.tsx +196 -0
  230. package/src/components/data-grid.tsx +213 -0
  231. package/src/components/data-table.tsx +334 -0
  232. package/src/components/data-views.tsx +740 -0
  233. package/src/components/date-picker.tsx +686 -0
  234. package/src/components/dense-table.tsx +207 -0
  235. package/src/components/dialog.tsx +162 -0
  236. package/src/components/dropdown-menu.tsx +264 -0
  237. package/src/components/empty.tsx +114 -0
  238. package/src/components/feedback-prompt.tsx +80 -0
  239. package/src/components/field.tsx +254 -0
  240. package/src/components/fields.tsx +112 -0
  241. package/src/components/flow.tsx +840 -0
  242. package/src/components/guide-cue.tsx +273 -0
  243. package/src/components/inbox.tsx +113 -0
  244. package/src/components/input-group.tsx +176 -0
  245. package/src/components/input.tsx +26 -0
  246. package/src/components/kbd.tsx +34 -0
  247. package/src/components/label.tsx +27 -0
  248. package/src/components/labels.tsx +176 -0
  249. package/src/components/motion.tsx +136 -0
  250. package/src/components/onboarding.tsx +454 -0
  251. package/src/components/order-book.tsx +185 -0
  252. package/src/components/pane-layout.tsx +417 -0
  253. package/src/components/popover.tsx +96 -0
  254. package/src/components/provider-logos.data.ts +49 -0
  255. package/src/components/radio-group.tsx +52 -0
  256. package/src/components/scheduler.tsx +997 -0
  257. package/src/components/segmented-control.tsx +28 -0
  258. package/src/components/select.tsx +195 -0
  259. package/src/components/separator.tsx +31 -0
  260. package/src/components/sheet.tsx +148 -0
  261. package/src/components/shell.tsx +426 -0
  262. package/src/components/shortcut-sheet.tsx +83 -0
  263. package/src/components/skeleton.tsx +18 -0
  264. package/src/components/sonner.tsx +107 -0
  265. package/src/components/spinner.tsx +13 -0
  266. package/src/components/split-button.tsx +58 -0
  267. package/src/components/stepper.tsx +78 -0
  268. package/src/components/switch.tsx +43 -0
  269. package/src/components/table.tsx +126 -0
  270. package/src/components/tabs.tsx +118 -0
  271. package/src/components/tag-input.tsx +253 -0
  272. package/src/components/textarea.tsx +21 -0
  273. package/src/components/theme.tsx +80 -0
  274. package/src/components/timeline.tsx +408 -0
  275. package/src/components/toggle-group.tsx +87 -0
  276. package/src/components/toggle.tsx +54 -0
  277. package/src/components/tooltip.tsx +65 -0
  278. package/src/components/workspace-actions.tsx +294 -0
  279. package/src/components/workspace-import.tsx +465 -0
  280. package/src/components/workspace.tsx +1884 -0
  281. package/src/index.ts +80 -0
  282. package/src/lib/active-indicator.tsx +53 -0
  283. package/src/lib/chart-kit.tsx +72 -0
  284. package/src/lib/cn.ts +6 -0
  285. package/src/lib/shortcuts.tsx +61 -0
  286. package/src/lib/workspace.tsx +10 -0
  287. package/src/shadcn/alert-dialog.tsx +196 -0
  288. package/src/shadcn/alert.tsx +65 -0
  289. package/src/shadcn/avatar.tsx +108 -0
  290. package/src/shadcn/badge.tsx +47 -0
  291. package/src/shadcn/breadcrumb.tsx +108 -0
  292. package/src/shadcn/button-group.tsx +83 -0
  293. package/src/shadcn/button.tsx +63 -0
  294. package/src/shadcn/calendar.tsx +220 -0
  295. package/src/shadcn/card.tsx +91 -0
  296. package/src/shadcn/checkbox.tsx +31 -0
  297. package/src/shadcn/collapsible.tsx +33 -0
  298. package/src/shadcn/command.tsx +184 -0
  299. package/src/shadcn/dialog.tsx +158 -0
  300. package/src/shadcn/dropdown-menu.tsx +256 -0
  301. package/src/shadcn/empty.tsx +103 -0
  302. package/src/shadcn/field.tsx +248 -0
  303. package/src/shadcn/input-group.tsx +170 -0
  304. package/src/shadcn/input.tsx +20 -0
  305. package/src/shadcn/kbd.tsx +28 -0
  306. package/src/shadcn/label.tsx +23 -0
  307. package/src/shadcn/popover.tsx +88 -0
  308. package/src/shadcn/radio-group.tsx +44 -0
  309. package/src/shadcn/select.tsx +189 -0
  310. package/src/shadcn/separator.tsx +27 -0
  311. package/src/shadcn/sheet.tsx +142 -0
  312. package/src/shadcn/skeleton.tsx +13 -0
  313. package/src/shadcn/sonner.tsx +40 -0
  314. package/src/shadcn/switch.tsx +34 -0
  315. package/src/shadcn/table.tsx +115 -0
  316. package/src/shadcn/tabs.tsx +90 -0
  317. package/src/shadcn/textarea.tsx +17 -0
  318. package/src/shadcn/toggle-group.tsx +83 -0
  319. package/src/shadcn/toggle.tsx +46 -0
  320. package/src/shadcn/tooltip.tsx +56 -0
  321. package/src/styles/palette.css +127 -0
  322. package/src/styles/theme.css +35 -0
  323. package/src/styles/twoux.css +236 -0
  324. package/theme.json +14 -0
  325. package/README.md +0 -3
@@ -0,0 +1,390 @@
1
+ /**
2
+ * TwoUX charts: own SVG, so marks and colours follow TwoUX's validated data-viz rules.
3
+ * One frame for every chart (title, controls, legend, states, table view) and one cartesian engine
4
+ * for line, area (stacked), bar (grouped or stacked) and mixed bar + line, plus sparkline and stat compare.
5
+ * Donut, heatmap, funnel and scatter live in charts-more.tsx. One axis only; series colours in fixed order (c1…c8);
6
+ * text never in a series colour; every value reachable by keyboard and in a table.
7
+ */
8
+ import { AlertCircle, RotateCw, Table2 } from "lucide-react"
9
+ import { useWorkspace } from "../lib/workspace"
10
+ import * as React from "react"
11
+ import { RollingNumber } from "./motion"
12
+ import { cn } from "../lib/cn"
13
+ import { Button } from "./button"
14
+ import { Skeleton } from "./skeleton"
15
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table"
16
+
17
+ /* ---------------------------------------------------------------- shared */
18
+
19
+ const SERIES = ["c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8"] as const
20
+ /** Colour for the nth series: fixed order, never cycled. A 9th series must fold into “Other”. */
21
+ export const seriesColor = (i: number) => `var(--color-${SERIES[Math.min(i, SERIES.length - 1)]})`
22
+ const fmtDefault = (n: number) => n.toLocaleString("en-GB")
23
+
24
+ export type ChartStatus = "ready" | "loading" | "empty" | "no-match" | "error"
25
+ export type ChartSeries = {
26
+ id: string
27
+ label: string
28
+ /** One value per x category; null for a gap. */
29
+ data: (number | null)[]
30
+ /** Mixed charts only. */
31
+ type?: "bar" | "line"
32
+ }
33
+
34
+ function useWidth<T extends HTMLElement>() {
35
+ const ref = React.useRef<T>(null)
36
+ const [w, setW] = React.useState(0)
37
+ React.useLayoutEffect(() => {
38
+ const el = ref.current
39
+ if (!el) return
40
+ const ro = new ResizeObserver(([e]) => setW(Math.round(e.contentRect.width)))
41
+ ro.observe(el)
42
+ setW(el.clientWidth)
43
+ return () => ro.disconnect()
44
+ }, [])
45
+ return [ref, w] as const
46
+ }
47
+
48
+ /** Round, readable ticks from zero. */
49
+ export function niceTicks(max: number, count = 4) {
50
+ if (max <= 0) return [0, 1]
51
+ const raw = max / count
52
+ const mag = 10 ** Math.floor(Math.log10(raw))
53
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw
54
+ const top = Math.ceil(max / step) * step
55
+ return Array.from({ length: Math.round(top / step) + 1 }, (_, i) => +(i * step).toFixed(10))
56
+ }
57
+
58
+ /* ---------------------------------------------------------------- frame */
59
+
60
+ export function ChartFrame({
61
+ title,
62
+ description,
63
+ actions,
64
+ status = "ready",
65
+ onRetry,
66
+ onClearFilter,
67
+ emptyText = "No data for this period",
68
+ legend,
69
+ table,
70
+ height = 220,
71
+ level = 3,
72
+ frame: frameProp,
73
+ titleHidden = false,
74
+ view,
75
+ onViewChange,
76
+ className,
77
+ children,
78
+ }: {
79
+ title: string
80
+ /** "flush" drops the card's border, shadow and padding, for a chart that fills a region of a workspace.
81
+ * Defaults to "flush" inside AppShell frame="flush" and "card" everywhere else. */
82
+ frame?: "card" | "flush"
83
+ /** Keeps the title for screen readers only, when a pane header around the chart already shows it. */
84
+ titleHidden?: boolean
85
+ /** Controls chart or table from outside, such as a pane's menu. With titleHidden and no actions, the frame then draws no header at all. */
86
+ view?: "chart" | "table"
87
+ onViewChange?: (view: "chart" | "table") => void
88
+ description?: string
89
+ /** Heading level of the title. Use 2 when the chart sits straight under the page's h1, as on a dashboard. */
90
+ level?: 2 | 3 | 4
91
+ /** Period control and similar, top right. */
92
+ actions?: React.ReactNode
93
+ status?: ChartStatus
94
+ onRetry?: () => void
95
+ onClearFilter?: () => void
96
+ emptyText?: string
97
+ legend?: React.ReactNode
98
+ /** The same data as a table; a toggle shows it in place of the chart. */
99
+ table?: { columns: string[]; rows: (string | number)[][] }
100
+ height?: number
101
+ className?: string
102
+ children: React.ReactNode
103
+ }) {
104
+ const workspace = useWorkspace()
105
+ const frame = frameProp ?? (workspace ? "flush" : "card")
106
+ const [ownTable, setOwnTable] = React.useState(false)
107
+ const controlled = view !== undefined
108
+ const asTable = controlled ? view === "table" : ownTable
109
+ const setAsTable = (next: boolean) => (controlled ? onViewChange?.(next ? "table" : "chart") : setOwnTable(next))
110
+ const showHeader = !(titleHidden && controlled && !actions)
111
+ const id = React.useId()
112
+ return (
113
+ <section aria-labelledby={`${id}-t`} data-slot="chart" className={cn("grid bg-surface", frame === "flush" ? "content-start gap-2 p-region" : "gap-3 rounded-sm border border-line p-region shadow-card", className)}>
114
+ <header className={cn("flex flex-wrap items-start justify-between gap-2", titleHidden && "justify-end", !showHeader && "sr-only")}>
115
+ <div className={cn("grid min-w-0 gap-0.5", titleHidden && "sr-only")}>
116
+ {React.createElement(`h${level}`, { id: `${id}-t`, className: "text-md font-semibold text-ink" }, title)}
117
+ {description && <p className="text-sm text-ink-2">{description}</p>}
118
+ </div>
119
+ <div className="flex items-center gap-control">
120
+ {actions}
121
+ {table && status === "ready" && !controlled && (
122
+ <Button variant="ghost" size="icon-sm" aria-pressed={asTable} aria-label={asTable ? "Show chart" : "Show as table"} onClick={() => setAsTable(!asTable)}>
123
+ <Table2 />
124
+ </Button>
125
+ )}
126
+ </div>
127
+ </header>
128
+ {status === "loading" ? (
129
+ <div className="grid content-end gap-2" style={{ "--h": `${height}px` } as React.CSSProperties} aria-busy="true" aria-label={`Loading ${title}`}>
130
+ <div className="flex h-(--h) items-end gap-2">{[45, 70, 55, 85, 60, 75, 50, 90].map((h, i) => <Skeleton key={i} className="h-(--b) flex-1" style={{ "--b": `${h}%` } as React.CSSProperties} />)}</div>
131
+ </div>
132
+ ) : status !== "ready" ? (
133
+ <div className="grid place-items-center gap-2 rounded-xs border border-dashed border-line-strong text-center h-(--h)" style={{ "--h": `${height}px` } as React.CSSProperties}>
134
+ <div className="grid justify-items-center gap-2 px-4">
135
+ {status === "error" && <AlertCircle className="size-4 text-bad" aria-hidden="true" />}
136
+ <p className="text-base font-medium text-ink">{status === "error" ? `Couldn't load ${title.toLowerCase()}` : status === "no-match" ? "No data matches these filters" : emptyText}</p>
137
+ {status === "error" && onRetry && <Button variant="outline" size="sm" onClick={onRetry}><RotateCw />Try again</Button>}
138
+ {status === "no-match" && onClearFilter && <Button variant="outline" size="sm" onClick={onClearFilter}>Clear filters</Button>}
139
+ </div>
140
+ </div>
141
+ ) : asTable && table ? (
142
+ <div className="max-h-(--h) overflow-auto rounded-xs border border-line" style={{ "--h": `${height + 40}px` } as React.CSSProperties}>
143
+ <Table aria-label={`${title}, as a table`}>
144
+ <TableHeader><TableRow>{table.columns.map((c, i) => <TableHead key={c} className={i ? "text-right" : undefined}>{c}</TableHead>)}</TableRow></TableHeader>
145
+ <TableBody>{table.rows.map((r, i) => <TableRow key={i}>{r.map((v, j) => <TableCell key={j} className={j ? "tabular text-right" : undefined}>{typeof v === "number" ? fmtDefault(v) : v}</TableCell>)}</TableRow>)}</TableBody>
146
+ </Table>
147
+ </div>
148
+ ) : (
149
+ <>
150
+ {children}
151
+ {legend}
152
+ </>
153
+ )}
154
+ </section>
155
+ )
156
+ }
157
+
158
+ /** Legend: always shown for two or more series. Clicking a key hides or shows that series. */
159
+ export function ChartLegend({ items, hidden = [], onToggle }: { items: { id: string; label: string; color: string; shape?: "line" | "box" }[]; hidden?: string[]; onToggle?: (id: string) => void }) {
160
+ if (items.length < 2) return null
161
+ return (
162
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
163
+ {items.map((it) => {
164
+ const off = hidden.includes(it.id)
165
+ const key = (
166
+ <>
167
+ <span aria-hidden="true" className={cn("shrink-0 bg-(--c)", it.shape === "line" ? "h-0.5 w-3 rounded-full" : "size-2.5 rounded-xs")} style={{ "--c": it.color } as React.CSSProperties} />
168
+ <span className={cn("text-sm", off ? "text-ink-3 line-through" : "text-ink-2")}>{it.label}</span>
169
+ </>
170
+ )
171
+ return (
172
+ <li key={it.id}>
173
+ {onToggle ? (
174
+ <button type="button" aria-pressed={!off} onClick={() => onToggle(it.id)} className="relative inline-flex items-center gap-control rounded-xs after:absolute after:-inset-1 hover:[&>span:last-child]:text-ink">{key}</button>
175
+ ) : (
176
+ <span className="inline-flex items-center gap-control">{key}</span>
177
+ )}
178
+ </li>
179
+ )
180
+ })}
181
+ </ul>
182
+ )
183
+ }
184
+
185
+ /* ---------------------------------------------------------------- cartesian engine */
186
+
187
+ type Kind = "line" | "area" | "bar" | "mixed"
188
+
189
+ export function CartesianChart({
190
+ kind,
191
+ x,
192
+ series,
193
+ stacked = false,
194
+ height = 220,
195
+ yLabel,
196
+ format = fmtDefault,
197
+ threshold,
198
+ hidden = [],
199
+ "aria-label": label,
200
+ }: {
201
+ kind: Kind
202
+ /** Category labels along the x axis, e.g. weeks. */
203
+ x: string[]
204
+ series: ChartSeries[]
205
+ /** Area charts and bar charts can stack. */
206
+ stacked?: boolean
207
+ height?: number
208
+ /** Unit of the y axis, shown in the summary and tooltip, e.g. “applications”. */
209
+ yLabel?: string
210
+ format?: (n: number) => string
211
+ /** A reference line, such as a target. */
212
+ threshold?: { value: number; label: string }
213
+ /** Series ids hidden from the legend. Colours stay with their series. */
214
+ hidden?: string[]
215
+ "aria-label": string
216
+ }) {
217
+ const [wrap, width] = useWidth<HTMLDivElement>()
218
+ const [active, setActive] = React.useState<number | null>(null)
219
+ const pad = { t: 8, r: 8, b: 22, l: 40 }
220
+ const w = Math.max(0, width - pad.l - pad.r)
221
+ const h = height - pad.t - pad.b
222
+ const n = x.length
223
+ const stackOn = stacked && (kind === "area" || kind === "bar")
224
+
225
+ // Cumulative tops for stacking
226
+ const on = (s: ChartSeries) => !hidden.includes(s.id)
227
+ const stacks = series.map((_, si) => x.map((_, xi) => series.slice(0, si + 1).filter(on).reduce((a, s) => a + (s.data[xi] ?? 0), 0)))
228
+ const prevStack = (si: number, xi: number) => series.slice(0, si).filter(on).reduce((a, s) => a + (s.data[xi] ?? 0), 0)
229
+ const max = Math.max(threshold?.value ?? 0, ...(stackOn ? stacks[stacks.length - 1] ?? [0] : series.filter(on).flatMap((s) => s.data.map((v) => v ?? 0))))
230
+ const ticks = niceTicks(max)
231
+ const top = ticks[ticks.length - 1] || 1
232
+ const y = (v: number) => pad.t + h - (v / top) * h
233
+ const band = n ? w / n : 0
234
+ const cx = (i: number) => pad.l + band * i + band / 2
235
+
236
+ const barSeries = series.map((s, i) => ({ s, i })).filter(({ s }) => on(s) && (kind === "bar" || (kind === "mixed" && s.type !== "line")))
237
+ const lineSeries = series.map((s, i) => ({ s, i })).filter(({ s }) => on(s) && (kind === "line" || kind === "area" || (kind === "mixed" && s.type === "line")))
238
+ const groupW = Math.min(band * 0.7, 48)
239
+ const barW = stackOn ? groupW : Math.max(2, (groupW - (barSeries.length - 1) * 2) / Math.max(1, barSeries.length))
240
+
241
+ // Thin the x labels until they fit side by side (about 6.6px per character at 12px).
242
+ const labelEvery = Math.max(n > 12 ? Math.ceil(n / 8) : 1, Math.ceil((Math.max(0, ...x.map((l) => l.length)) * 6.6 + 8) / (band || 1)))
243
+ const path = (pts: [number, number][]) => pts.map(([px, py], i) => `${i ? "L" : "M"}${px.toFixed(1)},${py.toFixed(1)}`).join("")
244
+ // Missing values break the line into runs: a gap, never a drop to zero.
245
+ const gapPath = (pts: ([number, number] | null)[]) => {
246
+ let d = "", pen = false
247
+ for (const p of pts) {
248
+ if (!p) { pen = false; continue }
249
+ d += `${pen ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`
250
+ pen = true
251
+ }
252
+ return d
253
+ }
254
+ const lastOf = (d: (number | null)[]) => { for (let k = d.length - 1; k >= 0; k--) if (d[k] !== null) return { v: d[k] as number, at: x[k] }; return null }
255
+ const summary = `${label}. ${n} points from ${x[0]} to ${x[n - 1]}. ${series.filter(on).map((s) => { const l = lastOf(s.data); return `${s.label}: from ${s.data[0] === null ? "no data" : format(s.data[0] as number)} to ${l ? `${format(l.v)} at ${l.at}` : "no data"}` }).join("; ")}.`
256
+
257
+ const onKey = (e: React.KeyboardEvent) => {
258
+ if (e.key === "ArrowRight") { e.preventDefault(); setActive((a) => Math.min(n - 1, a === null ? 0 : a + 1)) }
259
+ else if (e.key === "ArrowLeft") { e.preventDefault(); setActive((a) => Math.max(0, a === null ? n - 1 : a - 1)) }
260
+ else if (e.key === "Home") { e.preventDefault(); setActive(0) }
261
+ else if (e.key === "End") { e.preventDefault(); setActive(n - 1) }
262
+ else if (e.key === "Escape") setActive(null)
263
+ }
264
+
265
+ return (
266
+ <div ref={wrap} className="relative w-full" style={{ "--h": `${height}px` } as React.CSSProperties}>
267
+ {width > 0 && (
268
+ <svg
269
+ width={width}
270
+ height={height}
271
+ role="group"
272
+ tabIndex={0}
273
+ aria-label={summary}
274
+ aria-roledescription="chart"
275
+ onKeyDown={onKey}
276
+ onBlur={() => setActive(null)}
277
+ onMouseLeave={() => setActive(null)}
278
+ className="block overflow-visible rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
279
+ >
280
+ {/* grid and y axis: recessive */}
281
+ {ticks.map((t) => (
282
+ <g key={t}>
283
+ <line x1={pad.l} x2={pad.l + w} y1={y(t)} y2={y(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} strokeWidth={1} />
284
+ <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{format(t)}</text>
285
+ </g>
286
+ ))}
287
+ {x.map((lbl, i) => i % labelEvery === 0 && (
288
+ <text key={lbl + i} x={cx(i)} y={height - 6} textAnchor="middle" className="fill-ink-3 text-xs">{lbl}</text>
289
+ ))}
290
+ {threshold && (
291
+ <g>
292
+ <line x1={pad.l} x2={pad.l + w} y1={y(threshold.value)} y2={y(threshold.value)} className="stroke-ink-3" strokeWidth={1} strokeDasharray="4 3" />
293
+ <text x={pad.l + w} y={y(threshold.value) - 4} textAnchor="end" className="fill-ink-2 text-xs">{threshold.label}</text>
294
+ </g>
295
+ )}
296
+ {/* bars: 4px rounded data end, 2px gaps */}
297
+ {barSeries.map(({ s, i }, bi) =>
298
+ s.data.map((v, xi) => {
299
+ if (v === null || v === 0) return null
300
+ const base = stackOn ? prevStack(i, xi) : 0
301
+ const yTop = y(base + v)
302
+ const hgt = y(base) - yTop - (stackOn && base > 0 ? 2 : 0)
303
+ const bx = stackOn ? cx(xi) - groupW / 2 : cx(xi) - groupW / 2 + bi * (barW + 2)
304
+ const r = Math.min(4, barW / 2, Math.max(0, hgt))
305
+ const d = `M${bx},${yTop + hgt}V${yTop + r}Q${bx},${yTop} ${bx + r},${yTop}H${bx + barW - r}Q${bx + barW},${yTop} ${bx + barW},${yTop + r}V${yTop + hgt}Z`
306
+ return <path key={s.id + xi} d={d} fill={seriesColor(i)} opacity={active === null || active === xi ? 1 : 0.55} />
307
+ }),
308
+ )}
309
+ {/* areas then lines: 2px, markers at the active point */}
310
+ {kind === "area" && lineSeries.map(({ s, i }) => {
311
+ // An unstacked area with gaps fills each run of values on its own.
312
+ if (!stackOn && s.data.some((v) => v === null)) {
313
+ const runs: number[][] = []
314
+ s.data.forEach((v, xi) => { if (v === null) return; const last = runs[runs.length - 1]; if (last && last[last.length - 1] === xi - 1) last.push(xi); else runs.push([xi]) })
315
+ return <g key={`a${s.id}`}>{runs.map((run) => <path key={run[0]} d={`${path(run.map((xi) => [cx(xi), y(s.data[xi] as number)]))}${path(run.map((xi) => [cx(xi), y(0)] as [number, number]).reverse()).replace("M", "L")}Z`} fill={seriesColor(i)} fillOpacity={0.18} />)}</g>
316
+ }
317
+ const topPts = x.map((_, xi) => [cx(xi), y(stackOn ? stacks[i][xi] : s.data[xi] ?? 0)] as [number, number])
318
+ const basePts = x.map((_, xi) => [cx(xi), y(stackOn ? prevStack(i, xi) : 0)] as [number, number]).reverse()
319
+ return <path key={`a${s.id}`} d={`${path(topPts)}${path(basePts).replace("M", "L")}Z`} fill={seriesColor(i)} fillOpacity={0.18} />
320
+ })}
321
+ {lineSeries.map(({ s, i }) => {
322
+ const pts = x.map((_, xi) => (kind === "area" && stackOn ? [cx(xi), y(stacks[i][xi])] : s.data[xi] === null || s.data[xi] === undefined ? null : [cx(xi), y(s.data[xi] as number)]) as [number, number] | null)
323
+ return <path key={`l${s.id}`} d={gapPath(pts)} fill="none" stroke={seriesColor(i)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
324
+ })}
325
+ {/* crosshair + hit areas wider than the marks */}
326
+ {active !== null && <line x1={cx(active)} x2={cx(active)} y1={pad.t} y2={pad.t + h} className="stroke-ink-3" strokeWidth={1} />}
327
+ {active !== null && lineSeries.map(({ s, i }) => {
328
+ const v = kind === "area" && stackOn ? stacks[i][active] : s.data[active]
329
+ return v === null ? null : <circle key={`m${s.id}`} cx={cx(active)} cy={y(v)} r={4} fill={seriesColor(i)} className="stroke-surface" strokeWidth={2} />
330
+ })}
331
+ {x.map((_, xi) => (
332
+ <rect key={`h${xi}`} x={pad.l + band * xi} y={pad.t} width={band} height={h} fill="transparent" onMouseEnter={() => setActive(xi)} />
333
+ ))}
334
+ </svg>
335
+ )}
336
+ {active !== null && width > 0 && (
337
+ <div
338
+ role="status"
339
+ className="pointer-events-none absolute top-1 z-10 grid min-w-36 gap-1 rounded-md border border-line bg-surface px-2 py-2 text-sm shadow-pop left-(--x)"
340
+ style={{ "--x": `${Math.min(Math.max(cx(active) + 12, 0), width - 160)}px` } as React.CSSProperties}
341
+ >
342
+ <span className="font-medium text-ink">{x[active]}</span>
343
+ {series.map((s, i) => !on(s) ? null : (
344
+ <span key={s.id} className="flex items-center gap-2">
345
+ <span aria-hidden="true" className="size-2 shrink-0 rounded-xs bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
346
+ <span className="text-ink-2">{s.label}</span>
347
+ <span className="tabular ml-auto pl-3 font-medium text-ink">{s.data[active] === null ? "No data" : format(s.data[active] as number)}{yLabel ? "" : ""}</span>
348
+ </span>
349
+ ))}
350
+ </div>
351
+ )}
352
+ </div>
353
+ )
354
+ }
355
+
356
+ /** Convenience: the table view for a cartesian chart. */
357
+ export const chartTable = (xLabel: string, x: string[], series: ChartSeries[]) => ({
358
+ columns: [xLabel, ...series.map((s) => s.label)],
359
+ rows: x.map((lbl, i) => [lbl, ...series.map((s) => s.data[i] ?? "")]),
360
+ })
361
+
362
+ /* ---------------------------------------------------------------- sparkline and stat */
363
+
364
+ export function Sparkline({ data, "aria-label": label, className }: { data: number[]; "aria-label": string; className?: string }) {
365
+ // A sparkline shows shape, so it scales to its own low and high, not from zero.
366
+ const max = Math.max(...data), min = Math.min(...data)
367
+ const pts = data.map((v, i) => `${(i / Math.max(1, data.length - 1)) * 100},${28 - ((v - min) / (max - min || 1)) * 26}`).join(" ")
368
+ return (
369
+ <svg viewBox="0 0 100 30" preserveAspectRatio="none" role="img" aria-label={label} className={cn("h-7 w-24", className)}>
370
+ <polyline points={pts} fill="none" stroke="var(--color-c1)" strokeWidth={2} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
371
+ </svg>
372
+ )
373
+ }
374
+
375
+ export function StatCompare({ label, value, change, period, good, spark, className }: { label: string; value: string; change: string; period: string; good?: boolean; spark?: number[]; className?: string }) {
376
+ const up = /^\+|↑/.test(change)
377
+ return (
378
+ <div data-slot="stat-compare" className={cn("grid gap-1", className)}>
379
+ <span className="text-sm text-ink-2">{label}</span>
380
+ <div className="flex items-end justify-between gap-3">
381
+ <RollingNumber value={value} className="text-2xl font-semibold text-ink" />
382
+ {spark && <Sparkline data={spark} aria-label={`${label} trend`} />}
383
+ </div>
384
+ <span className="text-sm text-ink-2">
385
+ <span className={cn("font-medium", good === undefined ? "text-ink" : good ? "text-ok" : "text-bad")}>{up ? "↑" : "↓"} {change.replace(/^[+\-↑↓]\s?/, "")}</span> vs {period}
386
+ {good !== undefined && <span className="sr-only">{good ? " (better)" : " (worse)"}</span>}
387
+ </span>
388
+ </div>
389
+ )
390
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "checkbox" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Edge at 3.59:1: nothing else identifies a checkbox (WCAG 1.4.11)
5
+ * - Invisible 26px hit area (WCAG 2.5.8)
6
+ * - The tick scales in instead of appearing
7
+ * - Indeterminate (some rows selected) shows an indigo box with a minus, not a tick on an empty box
8
+ */
9
+ "use client"
10
+
11
+ import * as React from "react"
12
+ import { cn } from "../lib/cn"
13
+ import { CheckIcon, MinusIcon } from "lucide-react"
14
+ import { Checkbox as CheckboxPrimitive } from "radix-ui"
15
+
16
+ function Checkbox({
17
+ className,
18
+ ...props
19
+ }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
20
+ return (
21
+ <CheckboxPrimitive.Root
22
+ data-slot="checkbox"
23
+ className={cn(
24
+ "group/checkbox peer relative size-4 shrink-0 after:absolute after:-inset-1.25 after:rounded-xs rounded-xs border border-control shadow-xs transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-out) outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:not-focus-visible:border-destructive-edge data-[state=checked]:border-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
25
+ className
26
+ )}
27
+ {...props}
28
+ >
29
+ <CheckboxPrimitive.Indicator
30
+ data-slot="checkbox-indicator"
31
+ className="grid place-content-center text-current animate-in zoom-in-50 fade-in-0 duration-150"
32
+ >
33
+ <CheckIcon className="size-3.5 text-current group-data-[state=indeterminate]/checkbox:hidden" />
34
+ <MinusIcon className="hidden size-3.5 text-current group-data-[state=indeterminate]/checkbox:block" />
35
+ </CheckboxPrimitive.Indicator>
36
+ </CheckboxPrimitive.Root>
37
+ )
38
+ }
39
+
40
+ export { Checkbox }
@@ -0,0 +1,139 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import { Check, Copy } from "lucide-react";
3
+ import { Swap } from "./motion";
4
+ import { type HTMLAttributes, type ReactNode, useState } from "react";
5
+ import { cn } from "../lib/cn";
6
+ import { Button } from "./button";
7
+
8
+ /* Inline code: a token, prop, file or key inside a sentence. Never breaks mid-token. */
9
+ export function Code({ className, ...props }: HTMLAttributes<HTMLElement>) {
10
+ return <code className={cn("whitespace-nowrap rounded-xs bg-surface-2 px-1 font-mono text-sm text-ink ring-1 ring-line ring-inset", className)} {...props} />;
11
+ }
12
+
13
+ /* ---------------- CodeBlock: structure follows shadcn/ui's code block ---------------- */
14
+
15
+ const blockVariants = cva("group/code relative overflow-hidden rounded-md border bg-surface-2", {
16
+ variants: {
17
+ variant: { default: "border-line", destructive: "border-bad-line bg-bad-bg/40" },
18
+ size: { default: "text-sm", sm: "text-xs" },
19
+ },
20
+ defaultVariants: { variant: "default", size: "default" },
21
+ });
22
+
23
+ export function CodeBlock({ variant, size, className, ...props }: HTMLAttributes<HTMLDivElement> & VariantProps<typeof blockVariants>) {
24
+ return <div data-size={size ?? "default"} className={cn(blockVariants({ variant, size }), className)} {...props} />;
25
+ }
26
+
27
+ export function CodeBlockHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
28
+ return <div className={cn("flex h-9 items-center gap-2 border-b border-line bg-surface pr-2 pl-3", className)} {...props} />;
29
+ }
30
+
31
+ export function CodeBlockTitle({ icon, className, children, ...props }: HTMLAttributes<HTMLDivElement> & { icon?: ReactNode }) {
32
+ return (
33
+ <div className={cn("flex min-w-0 flex-1 items-center gap-control text-sm text-ink-2 [&_svg]:size-3.5 [&_svg]:shrink-0", className)} {...props}>
34
+ {icon}
35
+ <span className="truncate font-mono">{children}</span>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /** Inside a header: sits at the end. Without a header: floats top-right and appears on hover or focus. */
41
+ export function CodeBlockActions({ className, floating, ...props }: HTMLAttributes<HTMLDivElement> & { floating?: boolean }) {
42
+ return (
43
+ <div
44
+ className={cn(
45
+ "flex items-center gap-0.5",
46
+ floating && "absolute top-1.5 right-1.5 z-10 rounded-sm bg-surface-2 opacity-0 transition-opacity duration-(--duration-fast) group-hover/code:opacity-100 group-focus-within/code:opacity-100 pointer-coarse:opacity-100",
47
+ className,
48
+ )}
49
+ {...props}
50
+ />
51
+ );
52
+ }
53
+
54
+ export function CodeBlockCopyButton({ value, className }: { value: string; className?: string }) {
55
+ const [copied, setCopied] = useState(false);
56
+ return (
57
+ <>
58
+ <Button
59
+ variant="ghost"
60
+ size="icon-xs"
61
+ className={className}
62
+ aria-label={copied ? "Copied" : "Copy code"}
63
+ title={copied ? "Copied" : "Copy"}
64
+ onClick={() =>
65
+ navigator.clipboard?.writeText(value).then(() => {
66
+ setCopied(true);
67
+ setTimeout(() => setCopied(false), 1500);
68
+ })
69
+ }
70
+ >
71
+ <Swap kind="icon" swapKey={copied ? "copied" : "copy"}>{copied ? <Check /> : <Copy />}</Swap>
72
+ </Button>
73
+ <span role="status" className="sr-only">{copied ? "Copied" : ""}</span>
74
+ </>
75
+ );
76
+ }
77
+
78
+ type LineVariant = "default" | "added" | "removed" | "highlighted";
79
+ const lineStyles: Record<LineVariant, string> = {
80
+ default: "",
81
+ added: "bg-ok-bg/70",
82
+ removed: "bg-bad-bg/70",
83
+ highlighted: "bg-primary-soft/70",
84
+ };
85
+ const markers: Record<LineVariant, string> = { default: " ", added: "+", removed: "−", highlighted: " " };
86
+
87
+ export interface CodeBlockContentProps extends HTMLAttributes<HTMLPreElement> {
88
+ /** The code, as a string. Each line is rendered separately so lines can be numbered and marked. */
89
+ code: string;
90
+ lineNumbers?: boolean;
91
+ startLine?: number;
92
+ /** Wrap long lines instead of scrolling. */
93
+ wrap?: boolean;
94
+ /** 1-based line numbers to mark. Added and removed lines also get + and − so the change is not shown by colour alone. */
95
+ highlight?: number[];
96
+ added?: number[];
97
+ removed?: number[];
98
+ }
99
+
100
+ export function CodeBlockContent({ code, lineNumbers, startLine = 1, wrap, highlight = [], added = [], removed = [], className, ...props }: CodeBlockContentProps) {
101
+ const lines = code.replace(/\n$/, "").split("\n");
102
+ const diff = added.length > 0 || removed.length > 0;
103
+ const gutter = String(startLine + lines.length - 1).length;
104
+ return (
105
+ <pre
106
+ // A scrollable region must be reachable by keyboard (WCAG 2.1.1).
107
+ tabIndex={0}
108
+ className={cn("m-0 max-w-full py-2 font-mono leading-relaxed text-ink outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary", wrap ? "whitespace-pre-wrap break-words" : "overflow-x-auto", className)}
109
+ {...props}
110
+ >
111
+ <code className={cn("grid", !wrap && "min-w-max")}>
112
+ {lines.map((text, i) => {
113
+ const n = i + 1;
114
+ const v: LineVariant = added.includes(n) ? "added" : removed.includes(n) ? "removed" : highlight.includes(n) ? "highlighted" : "default";
115
+ return (
116
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
117
+ <span key={i} className={cn("flex px-3.5", lineStyles[v])}>
118
+ {lineNumbers && (
119
+ <span aria-hidden="true" className={cn("mr-4 shrink-0 select-none text-right text-ink-3 tabular", gutter > 2 ? "min-w-8" : "min-w-5")}>
120
+ {startLine + i}
121
+ </span>
122
+ )}
123
+ {diff && (
124
+ <span aria-hidden={v === "default"} className={cn("mr-2 w-2 shrink-0 select-none", v === "added" && "text-ok", v === "removed" && "text-bad")}>
125
+ {markers[v]}
126
+ </span>
127
+ )}
128
+ <span className={cn("min-w-0", wrap && "wrap-anywhere", v === "removed" && "text-ink-2")}>{text || " "}</span>
129
+ </span>
130
+ );
131
+ })}
132
+ </code>
133
+ </pre>
134
+ );
135
+ }
136
+
137
+ export function CodeBlockFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
138
+ return <div className={cn("flex items-center gap-2 border-t border-line bg-surface px-3 py-2 text-sm text-ink-2", className)} {...props} />;
139
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "collapsible" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes: shared changes only (imports, role tokens for light and dark, type scale).
4
+ */
5
+ "use client"
6
+
7
+ import { Collapsible as CollapsiblePrimitive } from "radix-ui"
8
+
9
+ function Collapsible({
10
+ ...props
11
+ }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
12
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
13
+ }
14
+
15
+ function CollapsibleTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
18
+ return (
19
+ <CollapsiblePrimitive.CollapsibleTrigger
20
+ data-slot="collapsible-trigger"
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ function CollapsibleContent({
27
+ ...props
28
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
29
+ return (
30
+ <CollapsiblePrimitive.CollapsibleContent
31
+ data-slot="collapsible-content"
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }