@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,176 @@
1
+ /**
2
+ * TwoUX compositions for small labels on records.
3
+ * Tag: a category people chose (quieter than a status badge; 4px corners, because pills mean filters).
4
+ * PersonAvatar + AvatarStack: initials on a fixed per-person colour that passes 4.5:1, +N with names.
5
+ * Timestamp: <time> with ISO datetime, relative or absolute, the full date with zone in a tooltip; date-only values (2026-12-07) show as a calendar date with no time or zone.
6
+ * FeatureLabel: the New or Beta label. Plain text in navigation; after a page title it opens a popover on request.
7
+ */
8
+ import { X } from "lucide-react"
9
+ import * as React from "react"
10
+ import { cn } from "../lib/cn"
11
+ import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "./avatar"
12
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
14
+
15
+ /* ---------------------------------------------------------------- feature label */
16
+
17
+ /**
18
+ * New or Beta, as words in the accent ink, never a colour, toast or banner. Without children it is plain text
19
+ * (use it inside nav items, which are already buttons). With children it is a button that opens a popover:
20
+ * what changed, for New; that results may change and how to send feedback, for Beta. Remove New after 30 days.
21
+ */
22
+ export function FeatureLabel({ kind, children, className }: { kind: "New" | "Beta"; children?: React.ReactNode; className?: string }) {
23
+ const cls = "inline-flex items-center text-xs font-medium text-primary-ink underline decoration-dotted decoration-primary-ink/60 underline-offset-3"
24
+ if (!children) return <span data-slot="feature-label" className={cn(cls, "no-underline", className)}>{kind}</span>
25
+ return (
26
+ <Popover>
27
+ <PopoverTrigger asChild>
28
+ <button type="button" data-slot="feature-label" className={cn(cls, "h-6 rounded-xs px-1 hover:bg-primary-soft", className)}>
29
+ {kind}<span className="sr-only">: what this means</span>
30
+ </button>
31
+ </PopoverTrigger>
32
+ <PopoverContent align="start" className="grid w-72 gap-2 text-base text-ink-2">{children}</PopoverContent>
33
+ </Popover>
34
+ )
35
+ }
36
+
37
+ /* ---------------------------------------------------------------- tag */
38
+
39
+ export function Tag({ children, color, onRemove, className }: { children: string; color?: string; onRemove?: () => void; className?: string }) {
40
+ return (
41
+ <span data-slot="tag" className={cn("inline-flex h-5.5 max-w-48 items-center gap-control rounded-xs border border-line-strong bg-surface px-control text-sm text-ink", className)}>
42
+ {color && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-(--tag)" style={{ "--tag": color } as React.CSSProperties} />}
43
+ <span className="truncate" title={children}>{children}</span>
44
+ {onRemove && (
45
+ <button type="button" onClick={onRemove} aria-label={`Remove ${children}`} className="relative -mr-0.5 grid size-3.5 shrink-0 place-items-center rounded-xs text-ink-3 after:absolute after:-inset-1 hover:bg-hover hover:text-ink">
46
+ <X className="size-3" aria-hidden="true" />
47
+ </button>
48
+ )}
49
+ </span>
50
+ )
51
+ }
52
+
53
+ /** Up to three tags, then +N with the rest in a tooltip, so table rows stay scannable. */
54
+ export function TagList({ tags, max = 3 }: { tags: string[]; max?: number }) {
55
+ const shown = tags.slice(0, max), rest = tags.slice(max)
56
+ return (
57
+ <span className="inline-flex items-center gap-1">
58
+ {shown.map((t) => <Tag key={t}>{t}</Tag>)}
59
+ {rest.length > 0 && (
60
+ <Tooltip>
61
+ <TooltipTrigger asChild><button type="button" className="tabular rounded-xs px-0.5 text-sm text-ink-3 hover:text-ink">+{rest.length}</button></TooltipTrigger>
62
+ <TooltipContent>{rest.join(", ")}</TooltipContent>
63
+ </Tooltip>
64
+ )}
65
+ </span>
66
+ )
67
+ }
68
+
69
+ /* ---------------------------------------------------------------- people */
70
+
71
+ // 700 steps, each 5.8:1 or more with white initials. Green and red are left out: they mean status.
72
+ const PEOPLE = ["indigo", "teal", "magenta", "blue", "orange", "amber"]
73
+ /** The same person always gets the same colour. */
74
+ export const personColor = (name: string) => `var(--color-${PEOPLE[[...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % PEOPLE.length]}-700)`
75
+ export const initials = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join("")
76
+
77
+ export function PersonAvatar({ name, src, size = "default", className }: { name: string; src?: string; size?: "sm" | "default" | "lg"; className?: string }) {
78
+ return (
79
+ <Avatar size={size} className={className} style={{ "--avatar": personColor(name) } as React.CSSProperties}>
80
+ {src && <AvatarImage src={src} alt="" />}
81
+ <AvatarFallback aria-hidden="true">{initials(name)}</AvatarFallback>
82
+ <span className="sr-only">{name}</span>
83
+ </Avatar>
84
+ )
85
+ }
86
+
87
+ export function AvatarStack({ people, max = 3, label = "People" }: { people: { name: string; src?: string }[]; max?: number; label?: string }) {
88
+ const shown = people.slice(0, max), rest = people.slice(max)
89
+ return (
90
+ <AvatarGroup className="*:data-[slot=avatar]:transition-[margin] *:data-[slot=avatar]:duration-(--duration-base) *:data-[slot=avatar]:ease-(--ease-standard) hover:-space-x-0.5" aria-label={`${label}: ${people.map((p) => p.name).join(", ")}`} role="group">
91
+ {shown.map((p) => <PersonAvatar key={p.name} name={p.name} src={p.src} />)}
92
+ {rest.length > 0 && (
93
+ <Tooltip>
94
+ <TooltipTrigger asChild>
95
+ <button type="button" className="rounded-full"><AvatarGroupCount className="tabular">+{rest.length}</AvatarGroupCount></button>
96
+ </TooltipTrigger>
97
+ <TooltipContent>{rest.map((p) => p.name).join(", ")}</TooltipContent>
98
+ </Tooltip>
99
+ )}
100
+ </AvatarGroup>
101
+ )
102
+ }
103
+
104
+ /* ---------------------------------------------------------------- time */
105
+
106
+ const absolute = (d: Date) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }).format(d)
107
+ /** A calendar date with no time or zone: 7 December 2026, or 7 Dec 2026 when short. */
108
+ const calendarDate = (d: Date, short: boolean) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: short ? "short" : "long", year: "numeric" }).format(d)
109
+ /** Today, tomorrow, in 3 days, 2 days ago, counted in calendar days; past a week, the date. */
110
+ function relativeDay(d: Date, now: Date, short: boolean) {
111
+ const days = Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) / 86400000)
112
+ if (days === 0) return "today"
113
+ if (days === 1) return "tomorrow"
114
+ if (days === -1) return "yesterday"
115
+ const n = Math.abs(days)
116
+ if (n < 7) return days > 0 ? `in ${n}${short ? "d" : " days"}` : `${n}${short ? "d" : " days"} ago`
117
+ return calendarDate(d, true)
118
+ }
119
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
120
+ function relative(d: Date, now: Date, short: boolean) {
121
+ const s = Math.round((now.getTime() - d.getTime()) / 1000)
122
+ const future = s < 0, a = Math.abs(s)
123
+ const unit = (n: number, long: string, abbr: string) => (short ? `${n}${abbr}` : `${n} ${long}${n === 1 ? "" : "s"}`)
124
+ if (a < 60) return future ? "in under a minute" : "just now"
125
+ const v = a < 3600 ? unit(Math.floor(a / 60), "minute", "m") : a < 86400 ? unit(Math.floor(a / 3600), "hour", "h") : a < 86400 * 7 ? unit(Math.floor(a / 86400), "day", "d") : null
126
+ if (!v) return new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(d.getFullYear() !== now.getFullYear() && { year: "numeric" }) }).format(d)
127
+ return future ? `in ${v}` : `${v} ago`
128
+ }
129
+
130
+ export function Timestamp({ value, format = "relative", short = false, by, className }: { value: Date | string; format?: "relative" | "absolute" | "date" | "time"; short?: boolean; by?: React.ReactNode; className?: string }) {
131
+ // "time" is the time of day to the second (15:52:56), for live feeds such as trades, where every row is from today.
132
+ // A date with no time (2026-12-07: a deadline, a start date) is a calendar day in local time. new Date() would read it
133
+ // as midnight UTC, show a time and zone that mean nothing, and land on the day before west of London.
134
+ const day = typeof value === "string" ? value.match(DATE_ONLY) : null
135
+ const d = day ? new Date(+day[1], +day[2] - 1, +day[3]) : typeof value === "string" ? new Date(value) : value
136
+ const dateOnly = !!day || format === "date"
137
+ const [now, setNow] = React.useState(() => new Date())
138
+ React.useEffect(() => {
139
+ if (format !== "relative") return
140
+ const t = setInterval(() => setNow(new Date()), 60000) // ticks over once a minute, never announced
141
+ return () => clearInterval(t)
142
+ }, [format])
143
+ const full = dateOnly ? calendarDate(d, false) : absolute(d)
144
+ const text = format === "time" ? d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : format === "relative" ? (dateOnly ? relativeDay(d, now, short) : relative(d, now, short)) : dateOnly ? calendarDate(d, short) : full
145
+ // The full date shows on hover or keyboard focus only. Focus moved by code (a popover or dialog opening, focus
146
+ // returning after a click) is not :focus-visible, so it never opens the tooltip unasked.
147
+ const timeRef = React.useRef<HTMLTimeElement>(null)
148
+ const hovered = React.useRef(false)
149
+ const [open, setOpen] = React.useState(false)
150
+ const onOpenChange = (o: boolean) => {
151
+ const el = timeRef.current
152
+ if (o && el && document.activeElement === el && !hovered.current && !el.matches(":focus-visible")) return
153
+ setOpen(o)
154
+ }
155
+ return (
156
+ <span className={cn("text-ink-2", className)}>
157
+ <Tooltip open={open} onOpenChange={onOpenChange}>
158
+ <TooltipTrigger asChild>
159
+ <time
160
+ ref={timeRef}
161
+ dateTime={day ? (value as string) : dateOnly ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` : d.toISOString()}
162
+ tabIndex={format === "relative" ? 0 : undefined}
163
+ aria-label={format === "relative" ? `${text}, ${full}` : undefined}
164
+ onPointerEnter={() => { hovered.current = true }}
165
+ onPointerLeave={() => { hovered.current = false }}
166
+ className="tabular rounded-xs"
167
+ >
168
+ {text}
169
+ </time>
170
+ </TooltipTrigger>
171
+ {(format === "relative" || format === "time") && <TooltipContent>{full}</TooltipContent>}
172
+ </Tooltip>
173
+ {by && <span> by {by}</span>}
174
+ </span>
175
+ )
176
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * TwoUX composition: small motion helpers for changes shadcn doesn't animate. Overlays, menus, sheets and toasts keep
3
+ * shadcn's own motion; these cover what happens inside a screen: an icon or label that changes state, a count that moves,
4
+ * a figure that updates, AI text that streams, a card that changes height.
5
+ * Timings use the motion tokens in twoux.css: entrances ease with --ease-entrance, exits are shorter and use
6
+ * --ease-exit, and only Count overshoots (--ease-pop). Theme tempo scales them all. All of it stops under
7
+ * prefers-reduced-motion (twoux.css sets every duration to 0), and nothing animates on first render.
8
+ */
9
+ import * as React from "react"
10
+ import { cn } from "../lib/cn"
11
+
12
+ const useChanged = <T,>(value: T) => {
13
+ const first = React.useRef(true)
14
+ const prev = React.useRef(value)
15
+ const changed = !first.current && !Object.is(prev.current, value)
16
+ React.useEffect(() => { first.current = false; prev.current = value }, [value])
17
+ return changed
18
+ }
19
+
20
+ /**
21
+ * Swap: crossfades between states of one control, such as Copy → Copied or Save → Saving… → Saved.
22
+ * Change `swapKey` when the content changes. The old content leaves while the new one arrives in the same spot,
23
+ * so the width never jumps. kind="icon" scales and unblurs; kind="text" rises 4px.
24
+ */
25
+ export function Swap({ swapKey, kind = "text", children, className }: { swapKey: React.Key; kind?: "icon" | "text"; children: React.ReactNode; className?: string }) {
26
+ const [items, setItems] = React.useState([{ key: swapKey, node: children, leaving: false }])
27
+ const mounted = React.useRef(false)
28
+ React.useEffect(() => {
29
+ if (!mounted.current) { mounted.current = true; return }
30
+ setItems((cur) => {
31
+ if (cur[cur.length - 1].key === swapKey) return cur.map((i) => (i.key === swapKey ? { ...i, node: children } : i))
32
+ return [...cur.filter((i) => i.key !== swapKey).map((i) => ({ ...i, leaving: true })), { key: swapKey, node: children, leaving: false }]
33
+ })
34
+ // Leaving content is removed when its exit animation ends (so theme tempo applies); this is only a safety net.
35
+ const t = window.setTimeout(() => setItems((cur) => cur.filter((i) => !i.leaving)), 1000)
36
+ return () => window.clearTimeout(t)
37
+ }, [swapKey, children])
38
+ const drop = (key: React.Key) => setItems((cur) => cur.filter((i) => !(i.leaving && i.key === key)))
39
+ const enter = kind === "icon" ? "animate-swap-in-icon" : "animate-swap-in-text"
40
+ const leave = kind === "icon" ? "animate-swap-out-icon" : "animate-swap-out-text"
41
+ return (
42
+ <span data-slot="swap" className={cn("inline-grid [&>*]:col-start-1 [&>*]:row-start-1", kind === "icon" && "place-items-center", className)}>
43
+ {items.map((i, n) => (
44
+ <span
45
+ key={i.key}
46
+ aria-hidden={i.leaving || undefined}
47
+ onAnimationEnd={i.leaving ? (e) => { if (e.target === e.currentTarget) drop(i.key) } : undefined}
48
+ className={cn("inline-flex items-center gap-control", n > 0 || items.length > 1 ? (i.leaving ? leave : enter) : "")}
49
+ >
50
+ {i.node}
51
+ </span>
52
+ ))}
53
+ </span>
54
+ )
55
+ }
56
+
57
+ /** Count: a small number (tab, sidebar, inbox) that pops when it changes, so a new item is noticed without a toast. */
58
+ export function Count({ value, className }: { value: number; className?: string }) {
59
+ const changed = useChanged(value)
60
+ return (
61
+ <span key={changed ? value : "static"} data-slot="count" className={cn("tabular inline-block", changed && "animate-count-pop", className)}>
62
+ {value}
63
+ </span>
64
+ )
65
+ }
66
+
67
+ /**
68
+ * RollingNumber: digits roll to their new value (figures on stat cards when a filter changes).
69
+ * Takes the formatted string, so units and separators stay as written ("1,204", "23 days", "£4.2k").
70
+ * Screen readers get the final value only.
71
+ */
72
+ export function RollingNumber({ value, className }: { value: string; className?: string }) {
73
+ return (
74
+ <span data-slot="rolling-number" className={cn("tabular inline-flex", className)}>
75
+ <span className="sr-only">{value}</span>
76
+ <span aria-hidden="true" className="inline-flex">
77
+ {[...value].map((ch, i) =>
78
+ /\d/.test(ch) ? (
79
+ <span key={`${value.length}-${i}`} className="inline-block h-lh overflow-hidden">
80
+ <span className="flex translate-y-(--y) flex-col transition-transform duration-(--duration-long) ease-(--ease-standard)" style={{ "--y": `calc(${ch} * -1lh)` } as React.CSSProperties}>
81
+ {"0123456789".split("").map((d) => <span key={d} className="h-lh">{d}</span>)}
82
+ </span>
83
+ </span>
84
+ ) : (
85
+ <span key={`${value.length}-${i}`} className="whitespace-pre">{ch}</span>
86
+ ),
87
+ )}
88
+ </span>
89
+ </span>
90
+ )
91
+ }
92
+
93
+ /** ShimmerText: a light sweep across a status line while work is in progress ("Thinking", "Working…"). Text only, never a block. */
94
+ export function ShimmerText({ children, active = true, className }: { children: React.ReactNode; active?: boolean; className?: string }) {
95
+ return (
96
+ <span data-slot="shimmer-text" className={cn(active && "animate-text-shimmer bg-linear-to-r from-ink-3 via-ink to-ink-3 bg-size-[200%_100%] bg-clip-text text-transparent", className)}>
97
+ {children}
98
+ </span>
99
+ )
100
+ }
101
+
102
+ /**
103
+ * StreamingText: AI text that arrives in chunks. Each new word fades in, so the eye follows the edge of the answer
104
+ * instead of the whole block reflowing. Pass the full text so far; finished words never animate again.
105
+ */
106
+ export function StreamingText({ text, className }: { text: string; className?: string }) {
107
+ const words = text.split(/(\s+)/)
108
+ const seen = React.useRef(0)
109
+ const from = seen.current
110
+ React.useEffect(() => { seen.current = words.length })
111
+ return (
112
+ <span data-slot="streaming-text" className={className}>
113
+ {words.map((w, i) => (
114
+ <span key={i} className={cn(i >= from && from > 0 && "animate-word-in")}>{w}</span>
115
+ ))}
116
+ </span>
117
+ )
118
+ }
119
+
120
+ /** AnimateHeight: a container that eases to its content's new height (a board item resized, a card that expands). */
121
+ export function AnimateHeight({ children, className }: { children: React.ReactNode; className?: string }) {
122
+ const inner = React.useRef<HTMLDivElement>(null)
123
+ const [h, setH] = React.useState<number | undefined>(undefined)
124
+ React.useLayoutEffect(() => {
125
+ const el = inner.current
126
+ if (!el) return
127
+ const ro = new ResizeObserver(() => setH(el.offsetHeight))
128
+ ro.observe(el)
129
+ return () => ro.disconnect()
130
+ }, [])
131
+ return (
132
+ <div data-slot="animate-height" className={cn("overflow-hidden transition-[height] duration-(--duration-slow) ease-(--ease-standard)", h !== undefined && "h-(--h)", className)} style={h === undefined ? undefined : ({ "--h": `${h}px` } as React.CSSProperties)}>
133
+ <div ref={inner}>{children}</div>
134
+ </div>
135
+ )
136
+ }