@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,21 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "textarea" 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
+ import * as React from "react"
6
+ import { cn } from "../lib/cn"
7
+
8
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
9
+ return (
10
+ <textarea
11
+ data-slot="textarea"
12
+ className={cn(
13
+ "flex field-sizing-content min-h-16 w-full rounded-sm border border-input bg-transparent px-3 py-2 text-md shadow-xs transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-out) outline-none placeholder:text-muted-foreground 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",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Textarea }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * TwoUX composition: light and dark themes. Light is the default; dark and "follow the system" are a choice the person makes.
3
+ * The theme lives on <html data-theme>, so every role token (light-dark() pairs in twoux.css) switches at once, with no provider.
4
+ * The choice is remembered in localStorage. Add THEME_SCRIPT to <head> so the page doesn't flash light before React loads.
5
+ */
6
+ import { Monitor, Moon, Sun } from "lucide-react"
7
+ import * as React from "react"
8
+ import { Button } from "./button"
9
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./dropdown-menu"
10
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
11
+
12
+ export type ThemeChoice = "light" | "dark" | "system"
13
+ const KEY = "twoux-theme"
14
+ const listeners = new Set<() => void>()
15
+
16
+ /** Inline in <head>, before the stylesheet: applies the saved theme before the first paint. */
17
+ export const THEME_SCRIPT = `try{var t=localStorage.getItem("${KEY}");if(t==="dark"||t==="system")document.documentElement.dataset.theme=t}catch(e){}`
18
+
19
+ const read = (): ThemeChoice => {
20
+ if (typeof document === "undefined") return "light"
21
+ const t = document.documentElement.dataset.theme
22
+ return t === "dark" || t === "system" ? t : "light"
23
+ }
24
+ const prefersDark = () => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
25
+
26
+ export function setTheme(choice: ThemeChoice) {
27
+ const root = document.documentElement
28
+ if (choice === "light") delete root.dataset.theme
29
+ else root.dataset.theme = choice
30
+ try { if (choice === "light") localStorage.removeItem(KEY); else localStorage.setItem(KEY, choice) } catch { /* private mode: the choice lasts this visit */ }
31
+ listeners.forEach((l) => l())
32
+ }
33
+
34
+ /** The person's choice, and what it resolves to now ("system" follows the OS and updates when it changes). */
35
+ export function useTheme() {
36
+ const [choice, setChoice] = React.useState<ThemeChoice>(read)
37
+ const [osDark, setOsDark] = React.useState(prefersDark)
38
+ React.useEffect(() => {
39
+ const sync = () => setChoice(read())
40
+ listeners.add(sync)
41
+ const mq = window.matchMedia("(prefers-color-scheme: dark)")
42
+ const onOs = () => setOsDark(mq.matches)
43
+ mq.addEventListener("change", onOs)
44
+ return () => { listeners.delete(sync); mq.removeEventListener("change", onOs) }
45
+ }, [])
46
+ const resolved: "light" | "dark" = choice === "system" ? (osDark ? "dark" : "light") : choice
47
+ return { choice, resolved, setTheme }
48
+ }
49
+
50
+ const OPTIONS = [
51
+ { value: "light", label: "Light", icon: Sun },
52
+ { value: "dark", label: "Dark", icon: Moon },
53
+ { value: "system", label: "Match system", icon: Monitor },
54
+ ] as const
55
+
56
+ /** Icon button with a menu: Light, Dark, Match system. Put it in the app header or the account menu. */
57
+ export function ThemeSwitcher({ className }: { className?: string }) {
58
+ const { choice, resolved } = useTheme()
59
+ const Icon = resolved === "dark" ? Moon : Sun
60
+ return (
61
+ <DropdownMenu>
62
+ <Tooltip>
63
+ <TooltipTrigger asChild>
64
+ <DropdownMenuTrigger asChild>
65
+ <Button variant="ghost" size="icon-sm" aria-label="Theme" className={className}><Icon /></Button>
66
+ </DropdownMenuTrigger>
67
+ </TooltipTrigger>
68
+ <TooltipContent>Theme</TooltipContent>
69
+ </Tooltip>
70
+ <DropdownMenuContent align="end">
71
+ <DropdownMenuLabel>Theme</DropdownMenuLabel>
72
+ <DropdownMenuRadioGroup value={choice} onValueChange={(v) => setTheme(v as ThemeChoice)}>
73
+ {OPTIONS.map(({ value, label, icon: I }) => (
74
+ <DropdownMenuRadioItem key={value} value={value}><I />{label}</DropdownMenuRadioItem>
75
+ ))}
76
+ </DropdownMenuRadioGroup>
77
+ </DropdownMenuContent>
78
+ </DropdownMenu>
79
+ )
80
+ }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * TwoUX Timeline: scheduled work across a few tracks on one time ruler, such as releases and maintenance per service
3
+ * or hiring campaign phases per team. Adapted from Astro UXDS's timeline (ruler, tracks, spans and instants, a
4
+ * current-time line, zoom), rebuilt for dense B2B screens:
5
+ * - Status is a word (tooltip, accessible name, table view) as well as a tint, never colour alone.
6
+ * - The track label column is sticky; only the time area scrolls, inside its own box (reflows to 320px).
7
+ * - One tab stop (role="grid"): ← → move in time within a track, ↑ ↓ move to the nearest event on the next track,
8
+ * Home End to the first or last in the track, Enter or Space selects, + and − zoom, Esc clears.
9
+ * + and − only work while the timeline has focus (WCAG 2.1.4's "active only on focus" exception).
10
+ * - Selecting an event calls onSelect: the app opens the record in its SidePanel. The timeline never edits by drag.
11
+ * - "Now" is a labelled line with the time as text, not a coloured playhead.
12
+ * - Dates are UK format; the tooltip gives the full date and zone as Timestamp does.
13
+ * - Overlapping events in one track stack in lanes instead of covering each other.
14
+ * Wrap it in ChartFrame for title, the loading, error and no-match states, and the table view (timelineTable).
15
+ */
16
+ import * as React from "react"
17
+ import { cn } from "../lib/cn"
18
+ import { textW, useWidth, Tip } from "../lib/chart-kit"
19
+ import { useShortcutsEnabled } from "../lib/shortcuts"
20
+ import { Button } from "./button"
21
+ import { SegmentedControl, Segment } from "./segmented-control"
22
+
23
+ /* ---------------------------------------------------------------- types */
24
+
25
+ export type TimelineStatus = "ok" | "warn" | "bad" | "neutral"
26
+ export type TimelineZoom = "hour" | "day" | "week" | "month"
27
+ export type TimelineEvent = {
28
+ id: string
29
+ label: string
30
+ start: Date | string
31
+ /** Leave out for an instant (a deadline, a deploy, an interview). */
32
+ end?: Date | string
33
+ status?: TimelineStatus
34
+ /** The word for the status, such as "At risk". Defaults: On track, At risk, Blocked, Planned. */
35
+ statusLabel?: string
36
+ }
37
+ export type TimelineTrack = { id: string; label: string; events: TimelineEvent[] }
38
+
39
+ const STATUS_WORD: Record<TimelineStatus, string> = { ok: "On track", warn: "At risk", bad: "Blocked", neutral: "Planned" }
40
+ const BAR: Record<TimelineStatus, string> = {
41
+ ok: "bg-ok-bg border-ok-line",
42
+ warn: "bg-warn-bg border-warn-line",
43
+ bad: "bg-bad-bg border-bad-line",
44
+ neutral: "bg-neutral-bg border-neutral-line",
45
+ }
46
+ const MARK: Record<TimelineStatus, string> = { ok: "bg-ok", warn: "bg-warn", bad: "bg-bad", neutral: "bg-ink-3" }
47
+ const ZOOM_LABEL: Record<TimelineZoom, string> = { hour: "Hour", day: "Day", week: "Week", month: "Month" }
48
+ const ZOOMS: TimelineZoom[] = ["hour", "day", "week", "month"]
49
+
50
+ const H = 3_600_000, D = 24 * H
51
+ /** Pixels per millisecond at each zoom: 64px an hour, 120px a day, 168px a week, about 124px a month. */
52
+ const SCALE: Record<TimelineZoom, number> = { hour: 64 / H, day: 120 / D, week: 24 / D, month: 4 / D }
53
+
54
+ const toDate = (v: Date | string) => (typeof v === "string" ? new Date(v) : v)
55
+ const word = (e: TimelineEvent) => e.statusLabel ?? STATUS_WORD[e.status ?? "neutral"]
56
+
57
+ /* ---------------------------------------------------------------- dates (UK, as Timestamp) */
58
+
59
+ const dtf = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat("en-GB", o)
60
+ const fDay = dtf({ weekday: "short", day: "numeric", month: "short" })
61
+ const fDate = dtf({ day: "numeric", month: "short", year: "numeric" })
62
+ const fTime = dtf({ hour: "2-digit", minute: "2-digit" })
63
+ const fFull = dtf({ weekday: "short", day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" })
64
+ const fMonth = dtf({ month: "short", year: "numeric" })
65
+ const fDayMonth = dtf({ day: "numeric", month: "short" })
66
+ const midnight = (d: Date) => d.getHours() === 0 && d.getMinutes() === 0
67
+ /** "Wed 12 Nov, 09:00", or "Wed 12 Nov" at midnight. */
68
+ export const timelineDate = (d: Date) => (midnight(d) ? fDay.format(d) : `${fDay.format(d)}, ${fTime.format(d)}`)
69
+ function duration(ms: number) {
70
+ const days = Math.floor(ms / D), hours = Math.round((ms % D) / H)
71
+ if (days >= 14) return `${Math.round(ms / (7 * D))} weeks`
72
+ const p = (n: number, u: string) => `${n} ${u}${n === 1 ? "" : "s"}`
73
+ return [days && p(days, "day"), hours && p(hours, "hour")].filter(Boolean).join(" ") || "Under an hour"
74
+ }
75
+
76
+ /** Table view for ChartFrame: track, event, starts, ends, status. */
77
+ export const timelineTable = (tracks: TimelineTrack[]) => ({
78
+ columns: ["Track", "Event", "Starts", "Ends", "Status"],
79
+ rows: tracks.flatMap((t) =>
80
+ [...t.events].sort((a, b) => +toDate(a.start) - +toDate(b.start)).map((e) => [t.label, e.label, fFull.format(toDate(e.start)), e.end ? fFull.format(toDate(e.end)) : "Same time", word(e)]),
81
+ ),
82
+ })
83
+
84
+ /* ---------------------------------------------------------------- ticks */
85
+
86
+ function ticks(from: Date, to: Date, zoom: TimelineZoom) {
87
+ const out: { t: number; label: string; major: boolean }[] = []
88
+ const d = new Date(from)
89
+ if (zoom === "hour") d.setMinutes(0, 0, 0)
90
+ else d.setHours(0, 0, 0, 0)
91
+ if (zoom === "week") while (d.getDay() !== 1) d.setDate(d.getDate() - 1)
92
+ if (zoom === "month") d.setDate(1)
93
+ for (let i = 0; d <= to && i < 2000; i++) {
94
+ const t = d.getTime()
95
+ if (t >= from.getTime()) {
96
+ if (zoom === "hour") out.push({ t, label: d.getHours() === 0 ? fDay.format(d) : fTime.format(d), major: d.getHours() === 0 })
97
+ else if (zoom === "day") out.push({ t, label: fDay.format(d), major: d.getDay() === 1 })
98
+ else if (zoom === "week") out.push({ t, label: `${fDayMonth.format(d)}`, major: d.getDate() <= 7 })
99
+ else out.push({ t, label: fMonth.format(d), major: d.getMonth() === 0 })
100
+ }
101
+ if (zoom === "hour") d.setHours(d.getHours() + 1)
102
+ else if (zoom === "day") d.setDate(d.getDate() + 1)
103
+ else if (zoom === "week") d.setDate(d.getDate() + 7)
104
+ else d.setMonth(d.getMonth() + 1)
105
+ }
106
+ return out
107
+ }
108
+
109
+ /* ---------------------------------------------------------------- component */
110
+
111
+ type Placed = { e: TimelineEvent; track: TimelineTrack; ti: number; s: number; en: number | null; x: number; w: number; lane: number; inside: boolean }
112
+
113
+ const RULER = 32, LANE = 28, PAD = 4
114
+
115
+ export function Timeline({
116
+ tracks,
117
+ start,
118
+ end,
119
+ now,
120
+ zoom: zoomProp,
121
+ defaultZoom = "day",
122
+ onZoomChange,
123
+ zooms = ZOOMS,
124
+ selectedId,
125
+ onSelect,
126
+ emptyText,
127
+ "aria-label": label,
128
+ className,
129
+ }: {
130
+ tracks: TimelineTrack[]
131
+ /** The visible period. Events outside it are left out; events crossing an edge are cut at the edge. */
132
+ start: Date | string
133
+ end: Date | string
134
+ /** Draws a labelled Now line when it falls inside the period. */
135
+ now?: Date | string
136
+ zoom?: TimelineZoom
137
+ defaultZoom?: TimelineZoom
138
+ onZoomChange?: (z: TimelineZoom) => void
139
+ /** The zoom levels offered, two to four. */
140
+ zooms?: TimelineZoom[]
141
+ /** The event whose record is open in the side panel. */
142
+ selectedId?: string | null
143
+ /** Click, Enter or Space on an event. Open the record in a SidePanel. */
144
+ onSelect?: (event: TimelineEvent, track: TimelineTrack) => void
145
+ emptyText?: string
146
+ "aria-label": string
147
+ className?: string
148
+ }) {
149
+ const from = toDate(start), to = toDate(end)
150
+ const nowD = now ? toDate(now) : null
151
+ const nowIn = !!nowD && nowD >= from && nowD <= to
152
+ const shortcutsOn = useShortcutsEnabled()
153
+ const [zInner, setZInner] = React.useState<TimelineZoom>(zoomProp ?? defaultZoom)
154
+ const wanted = zoomProp ?? zInner
155
+ const zoom = zooms.includes(wanted) ? wanted : zooms[0] ?? "day"
156
+ const [wrap, width] = useWidth<HTMLDivElement>()
157
+ const scroller = React.useRef<HTMLDivElement>(null)
158
+ const grid = React.useRef<HTMLDivElement>(null)
159
+ const id = React.useId()
160
+ const [active, setActive] = React.useState<string | null>(null)
161
+ const [hover, setHover] = React.useState<string | null>(null)
162
+ const [focused, setFocused] = React.useState(false)
163
+ const [scrollX, setScrollX] = React.useState(0)
164
+ const [selInner, setSelInner] = React.useState<string | null>(null)
165
+ const selected = selectedId !== undefined ? selectedId : selInner
166
+
167
+ const labelW = width && width < 480 ? 112 : 176
168
+ const viewW = Math.max(0, width - labelW)
169
+ const span = Math.max(1, to.getTime() - from.getTime())
170
+ const k = Math.max(SCALE[zoom], viewW / span)
171
+ const timeW = Math.round(span * k)
172
+ const X = (t: number) => (t - from.getTime()) * k
173
+
174
+ // Place events: clip to the period, then stack overlaps in lanes.
175
+ const rows = tracks.map((track, ti) => {
176
+ const placed: Placed[] = []
177
+ const laneEnd: number[] = []
178
+ const evs = track.events
179
+ .map((e) => ({ e, s: toDate(e.start).getTime(), en: e.end ? toDate(e.end).getTime() : null }))
180
+ .filter(({ s, en }) => (en ?? s) >= from.getTime() && s <= to.getTime())
181
+ .sort((a, b) => a.s - b.s)
182
+ for (const { e, s, en } of evs) {
183
+ const x = Math.max(0, X(s))
184
+ const w = en === null ? 0 : Math.max(6, X(Math.min(en, to.getTime())) - x)
185
+ const inside = en !== null && w >= textW(e.label) + 16
186
+ const occ = en === null ? x + 14 + textW(e.label) + 8 : inside ? x + w + 4 : x + w + textW(e.label) + 12
187
+ let lane = laneEnd.findIndex((r) => r <= x)
188
+ if (lane < 0) { lane = laneEnd.length; laneEnd.push(0) }
189
+ laneEnd[lane] = occ
190
+ placed.push({ e, track, ti, s, en, x, w, lane, inside })
191
+ }
192
+ return { track, placed, lanes: Math.max(1, laneEnd.length) }
193
+ })
194
+ const tops: number[] = []
195
+ rows.reduce((y, r) => { tops.push(y); return y + r.lanes * LANE + PAD * 2 }, RULER)
196
+ const all = rows.flatMap((r) => r.placed)
197
+ const find = (eid: string | null) => all.find((p) => p.e.id === eid) ?? null
198
+ const cur = find(active ?? hover)
199
+ const cellId = (eid: string) => `${id}-e-${eid}`
200
+
201
+ // Keep the time at the centre of the view when zooming; start with Now (or the first event) in view.
202
+ const centre = React.useRef<number | null>(null)
203
+ const setZoom = (z: TimelineZoom) => {
204
+ const el = scroller.current
205
+ if (el) centre.current = from.getTime() + (el.scrollLeft + viewW / 2) / k
206
+ if (zoomProp === undefined) setZInner(z)
207
+ onZoomChange?.(z)
208
+ }
209
+ const started = React.useRef(false)
210
+ React.useLayoutEffect(() => {
211
+ const el = scroller.current
212
+ if (!el || !viewW) return
213
+ if (centre.current !== null) { el.scrollLeft = X(centre.current) - viewW / 2; centre.current = null }
214
+ else if (!started.current) {
215
+ const t = nowIn ? nowD!.getTime() : all[0]?.s
216
+ if (t !== undefined) el.scrollLeft = X(t) - viewW / 4
217
+ }
218
+ started.current = true
219
+ setScrollX(el.scrollLeft)
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [k, viewW])
222
+
223
+ const reveal = (p: Placed) => {
224
+ const el = scroller.current
225
+ if (!el) return
226
+ const a = p.x, b = p.x + Math.max(p.w, 14)
227
+ if (a < el.scrollLeft + 8) el.scrollLeft = a - 24
228
+ else if (b > el.scrollLeft + viewW - 8) el.scrollLeft = Math.min(a - 24, b - viewW + 24)
229
+ }
230
+ const move = (p: Placed | null | undefined) => { if (p) { setActive(p.e.id); setHover(null); reveal(p) } }
231
+ const select = (p: Placed) => {
232
+ if (selectedId === undefined) setSelInner(p.e.id)
233
+ onSelect?.(p.e, p.track)
234
+ }
235
+
236
+ const onKey = (e: React.KeyboardEvent) => {
237
+ if (e.altKey || e.metaKey) return
238
+ const a = find(active)
239
+ const inTrack = a ? rows[a.ti].placed : []
240
+ const i = a ? inTrack.indexOf(a) : -1
241
+ const nearest = (dir: 1 | -1) => {
242
+ for (let t = (a?.ti ?? -1) + dir; t >= 0 && t < rows.length; t += dir) {
243
+ const list = rows[t].placed
244
+ if (list.length) return list.reduce((best, p) => (Math.abs(p.s - (a?.s ?? 0)) < Math.abs(best.s - (a?.s ?? 0)) ? p : best))
245
+ }
246
+ return null
247
+ }
248
+ const k_ = e.key
249
+ if (k_ === "+" || k_ === "=" || k_ === "-" || k_ === "−") {
250
+ if (e.ctrlKey || !shortcutsOn) return
251
+ e.preventDefault()
252
+ const zi = zooms.indexOf(zoom)
253
+ const next = zooms[Math.min(zooms.length - 1, Math.max(0, zi + (k_ === "+" || k_ === "=" ? -1 : 1)))]
254
+ if (next && next !== zoom) setZoom(next)
255
+ return
256
+ }
257
+ if (!all.length) return
258
+ if (!a && ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"].includes(k_)) {
259
+ e.preventDefault()
260
+ const visible = all.find((p) => p.x >= (scroller.current?.scrollLeft ?? 0)) ?? all[0]
261
+ move(e.ctrlKey && k_ === "End" ? all[all.length - 1] : visible)
262
+ return
263
+ }
264
+ if (k_ === "ArrowRight") { e.preventDefault(); move(inTrack[i + 1]) }
265
+ else if (k_ === "ArrowLeft") { e.preventDefault(); move(inTrack[i - 1]) }
266
+ else if (k_ === "ArrowDown") { e.preventDefault(); move(nearest(1)) }
267
+ else if (k_ === "ArrowUp") { e.preventDefault(); move(nearest(-1)) }
268
+ else if (k_ === "Home") { e.preventDefault(); move(e.ctrlKey ? all[0] : inTrack[0]) }
269
+ else if (k_ === "End") { e.preventDefault(); move(e.ctrlKey ? rows.flatMap((r) => r.placed).at(-1) : inTrack[inTrack.length - 1]) }
270
+ else if ((k_ === "Enter" || k_ === " ") && a) { e.preventDefault(); select(a) }
271
+ else if (k_ === "Escape" && (active || hover)) { e.stopPropagation(); setActive(null); setHover(null) }
272
+ }
273
+
274
+ const goNow = () => {
275
+ const el = scroller.current
276
+ if (el && nowD) el.scrollTo({ left: X(nowD.getTime()) - viewW / 2, behavior: "smooth" })
277
+ }
278
+
279
+ const tk = ticks(from, to, zoom)
280
+ const range = `${fDate.format(from)} to ${fDate.format(to)}`
281
+ const nowText = nowD ? `Now ${zoom === "month" || zoom === "week" ? fDayMonth.format(nowD) : fTime.format(nowD)}` : ""
282
+ const describe = (p: Placed) =>
283
+ `${p.e.label}, ${p.track.label}, ${p.en === null ? timelineDate(new Date(p.s)) : `${timelineDate(new Date(p.s))} to ${timelineDate(new Date(p.en))}`}, ${word(p.e)}`
284
+ const vars = (o: Record<string, string | number>) => Object.fromEntries(Object.entries(o).map(([key, v]) => [key, typeof v === "number" ? `${v}px` : v])) as React.CSSProperties
285
+
286
+ return (
287
+ <div ref={wrap} className={cn("relative grid w-full gap-2", className)}>
288
+ <div className="flex flex-wrap items-center justify-between gap-2">
289
+ <p className="text-sm text-ink-2">{range}</p>
290
+ <div className="flex flex-wrap items-center gap-control">
291
+ {nowIn && <Button variant="ghost" size="sm" onClick={goNow}>Go to now</Button>}
292
+ {zooms.length > 1 && (
293
+ <SegmentedControl size="sm" value={zoom} onValueChange={(v) => setZoom(v as TimelineZoom)} aria-label="Zoom">
294
+ {zooms.map((z) => <Segment key={z} value={z}>{ZOOM_LABEL[z]}</Segment>)}
295
+ </SegmentedControl>
296
+ )}
297
+ </div>
298
+ </div>
299
+
300
+ <div ref={scroller} onScroll={(e) => setScrollX(e.currentTarget.scrollLeft)} className="overflow-x-auto overscroll-x-contain rounded-xs border border-line">
301
+ <div
302
+ ref={grid}
303
+ role="grid"
304
+ tabIndex={0}
305
+ aria-label={label}
306
+ aria-describedby={`${id}-d`}
307
+ aria-activedescendant={active ? cellId(active) : undefined}
308
+ onKeyDown={onKey}
309
+ onFocus={() => setFocused(true)}
310
+ onBlur={() => { setFocused(false); setActive(null) }}
311
+ onMouseLeave={() => setHover(null)}
312
+ className="relative w-(--tw) min-w-full outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
313
+ style={vars({ "--tw": labelW + timeW, "--lw": labelW, "--sl": labelW + 8 })}
314
+ >
315
+ <span id={`${id}-d`} className="sr-only">
316
+ {`${tracks.length} tracks, ${all.length} events, ${range}.${nowIn ? ` Now is ${timelineDate(nowD!)}.` : ""} Arrow keys move between events, Enter opens one, plus and minus zoom.`}
317
+ </span>
318
+
319
+ {/* Grid lines and the Now line sit under the events. */}
320
+ <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-(--lw) right-0">
321
+ {tk.map((t) => (
322
+ <span key={t.t} className={cn("absolute inset-y-0 left-(--x) w-px", t.major ? "bg-line-strong" : "bg-line")} style={vars({ "--x": Math.round(X(t.t)) })} />
323
+ ))}
324
+ {nowIn && <span className="absolute inset-y-0 left-(--x) w-0.5 -translate-x-1/2 bg-ink" style={vars({ "--x": X(nowD!.getTime()) })} />}
325
+ </div>
326
+
327
+ {/* Ruler */}
328
+ <div aria-hidden="true" className="relative flex h-8 border-b border-line">
329
+ <div className="sticky left-0 z-20 flex w-(--lw) shrink-0 items-center border-r border-line bg-surface-2 px-2 text-sm font-medium text-ink-2">Track</div>
330
+ <div className="relative flex-1 bg-surface-2">
331
+ {tk.map((t) => (
332
+ <span key={t.t} className={cn("tabular absolute top-0 left-(--x) flex h-8 items-center whitespace-nowrap pl-2 text-xs", t.major ? "font-medium text-ink-2" : "text-ink-3")} style={vars({ "--x": Math.round(X(t.t)) })}>{t.label}</span>
333
+ ))}
334
+ {nowIn && (
335
+ <span className="tabular absolute top-1.5 left-(--x) z-10 -translate-x-1/2 whitespace-nowrap rounded-xs bg-ink px-1 text-xs font-medium text-surface" style={vars({ "--x": X(nowD!.getTime()) })}>{nowText}</span>
336
+ )}
337
+ </div>
338
+ </div>
339
+
340
+ {rows.map(({ track, placed, lanes }) => (
341
+ <div key={track.id} role="row" className="relative flex border-b border-line last:border-b-0 h-(--rh)" style={vars({ "--rh": lanes * LANE + PAD * 2 })}>
342
+ <div role="rowheader" className="sticky left-0 z-20 flex w-(--lw) shrink-0 items-start border-r border-line bg-surface px-2 pt-2 text-sm font-medium text-ink">
343
+ <span className="truncate">{track.label}</span>
344
+ </div>
345
+ <div className="relative flex-1">
346
+ {placed.map((p) => {
347
+ const isActive = active === p.e.id, isSel = selected === p.e.id, st = p.e.status ?? "neutral"
348
+ return (
349
+ <div
350
+ key={p.e.id}
351
+ id={cellId(p.e.id)}
352
+ role="gridcell"
353
+ aria-label={describe(p)}
354
+ aria-selected={isSel}
355
+ onMouseEnter={() => setHover(p.e.id)}
356
+ onClick={() => { grid.current?.focus({ preventScroll: true }); setActive(p.e.id); select(p) }}
357
+ className={cn(
358
+ "absolute top-(--y) left-(--x) z-10 flex h-6 cursor-pointer items-center gap-control text-sm text-ink",
359
+ isActive && focused && "rounded-xs outline-2 outline-offset-1 outline-primary",
360
+ )}
361
+ style={vars({ "--x": p.x, "--y": PAD + p.lane * LANE + 2 })}
362
+ >
363
+ {p.en === null ? (
364
+ <>
365
+ <span aria-hidden="true" className={cn("ml-0 size-2.5 shrink-0 -translate-x-1/2 rotate-45", MARK[st], isSel && "ring-2 ring-primary ring-offset-1 ring-offset-surface")} />
366
+ <span className={cn("-ml-1 whitespace-nowrap", isSel && "font-semibold")}>{p.e.label}</span>
367
+ </>
368
+ ) : (
369
+ <>
370
+ <span className={cn("flex h-6 w-(--w) shrink-0 items-center overflow-clip rounded-xs border px-2", BAR[st], isSel && "border-primary ring-1 ring-primary")} style={vars({ "--w": p.w })}>
371
+ {/* The label stays in view while the start of a long span is scrolled under the track column. */}
372
+ {p.inside && <span className={cn("sticky left-(--sl) truncate", isSel && "font-semibold")}>{p.e.label}</span>}
373
+ </span>
374
+ {!p.inside && <span className={cn("whitespace-nowrap", isSel && "font-semibold")}>{p.e.label}</span>}
375
+ </>
376
+ )}
377
+ </div>
378
+ )
379
+ })}
380
+ </div>
381
+ </div>
382
+ ))}
383
+
384
+ {!all.length && (
385
+ <div className="sticky left-0 flex h-20 w-(--vw) items-center justify-center px-4 text-center text-sm text-ink-2" style={vars({ "--vw": width || 320 })}>
386
+ {emptyText ?? `Nothing scheduled between ${fDate.format(from)} and ${fDate.format(to)}`}
387
+ </div>
388
+ )}
389
+ </div>
390
+ </div>
391
+
392
+ {cur && (
393
+ <Tip
394
+ x={Math.min(width - 8, Math.max(labelW, labelW + cur.x - scrollX + Math.min(cur.w, 40)))}
395
+ y={tops[cur.ti] + PAD + (cur.lane + 1) * LANE + 34}
396
+ width={width}
397
+ title={cur.e.label}
398
+ rows={[
399
+ { label: "Track", value: cur.track.label },
400
+ { label: "Status", value: word(cur.e) },
401
+ { label: cur.en === null ? "At" : "Starts", value: timelineDate(new Date(cur.s)) },
402
+ ...(cur.en !== null ? [{ label: "Ends", value: timelineDate(new Date(cur.en)) }, { label: "Length", value: duration(cur.en - cur.s) }] : []),
403
+ ]}
404
+ />
405
+ )}
406
+ </div>
407
+ )
408
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "toggle-group" 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 * as React from "react"
8
+ import { type VariantProps } from "class-variance-authority"
9
+ import { cn } from "../lib/cn"
10
+ import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
11
+
12
+ import { toggleVariants } from "./toggle"
13
+
14
+ const ToggleGroupContext = React.createContext<
15
+ VariantProps<typeof toggleVariants> & {
16
+ spacing?: number
17
+ }
18
+ >({
19
+ size: "default",
20
+ variant: "default",
21
+ spacing: 0,
22
+ })
23
+
24
+ function ToggleGroup({
25
+ className,
26
+ variant,
27
+ size,
28
+ spacing = 0,
29
+ children,
30
+ ...props
31
+ }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
32
+ VariantProps<typeof toggleVariants> & {
33
+ spacing?: number
34
+ }) {
35
+ return (
36
+ <ToggleGroupPrimitive.Root
37
+ data-slot="toggle-group"
38
+ data-variant={variant}
39
+ data-size={size}
40
+ data-spacing={spacing}
41
+ style={{ "--gap": spacing } as React.CSSProperties}
42
+ className={cn(
43
+ "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-sm data-[spacing=default]:data-[variant=outline]:shadow-xs",
44
+ className
45
+ )}
46
+ {...props}
47
+ >
48
+ <ToggleGroupContext.Provider value={{ variant, size, spacing }}>
49
+ {children}
50
+ </ToggleGroupContext.Provider>
51
+ </ToggleGroupPrimitive.Root>
52
+ )
53
+ }
54
+
55
+ function ToggleGroupItem({
56
+ className,
57
+ children,
58
+ variant,
59
+ size,
60
+ ...props
61
+ }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
62
+ VariantProps<typeof toggleVariants>) {
63
+ const context = React.useContext(ToggleGroupContext)
64
+
65
+ return (
66
+ <ToggleGroupPrimitive.Item
67
+ data-slot="toggle-group-item"
68
+ data-variant={context.variant || variant}
69
+ data-size={context.size || size}
70
+ data-spacing={context.spacing}
71
+ className={cn(
72
+ toggleVariants({
73
+ variant: context.variant || variant,
74
+ size: context.size || size,
75
+ }),
76
+ "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
77
+ "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-sm data-[spacing=0]:last:rounded-r-sm data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
78
+ className
79
+ )}
80
+ {...props}
81
+ >
82
+ {children}
83
+ </ToggleGroupPrimitive.Item>
84
+ )
85
+ }
86
+
87
+ export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Generated from shadcn/ui new-york-v4 "toggle" by scripts/tailor-shadcn.mjs. Do not edit by hand.
3
+ * TwoUX changes:
4
+ * - Compact heights
5
+ * - On reads as chosen, not hovered: indigo tint and text, which stay while the pointer is over it
6
+ * - Icons 14px, in proportion with 13px text
7
+ * - Outline: light edge that darkens on hover, indigo edge when on
8
+ */
9
+ "use client"
10
+
11
+ import * as React from "react"
12
+ import { cva, type VariantProps } from "class-variance-authority"
13
+ import { cn } from "../lib/cn"
14
+ import { Toggle as TogglePrimitive } from "radix-ui"
15
+
16
+ const toggleVariants = cva(
17
+ "inline-flex items-center justify-center gap-2 rounded-sm text-base font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-out) outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:not-focus-visible:border-destructive-edge data-[state=on]:bg-primary-soft data-[state=on]:text-primary-ink data-[state=on]:hover:bg-primary-soft data-[state=on]:hover:text-primary-ink [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
18
+ {
19
+ variants: {
20
+ variant: {
21
+ default: "bg-transparent",
22
+ outline:
23
+ "border border-input bg-transparent shadow-xs hover:border-input-hover hover:bg-accent hover:text-accent-foreground data-[state=on]:border-primary/30 data-[state=on]:hover:border-primary/50",
24
+ },
25
+ size: {
26
+ default: "h-8 min-w-8 px-2",
27
+ sm: "h-7 min-w-7 px-1.5",
28
+ lg: "h-9 min-w-9 px-2.5",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ size: "default",
34
+ },
35
+ }
36
+ )
37
+
38
+ function Toggle({
39
+ className,
40
+ variant,
41
+ size,
42
+ ...props
43
+ }: React.ComponentProps<typeof TogglePrimitive.Root> &
44
+ VariantProps<typeof toggleVariants>) {
45
+ return (
46
+ <TogglePrimitive.Root
47
+ data-slot="toggle"
48
+ className={cn(toggleVariants({ variant, size, className }))}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ export { Toggle, toggleVariants }