@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -2,12 +2,14 @@ import { forwardRef, type ComponentProps } from "react";
2
2
  import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { buttonVariants } from "../button";
5
+ import { useLocale } from "../locale-provider";
5
6
 
6
7
  export function Pagination({ className, ...props }: ComponentProps<"nav">) {
8
+ const { t } = useLocale();
7
9
  return (
8
10
  <nav
9
11
  role="navigation"
10
- aria-label="pagination"
12
+ aria-label={t("ui.pagination.label")}
11
13
  className={cn("mx-auto flex w-full justify-center", className)}
12
14
  {...props}
13
15
  />
@@ -26,44 +28,50 @@ export const PaginationItem = forwardRef<HTMLLIElement, ComponentProps<"li">>(
26
28
  },
27
29
  );
28
30
  type PaginationLinkProps = { isActive?: boolean } & ComponentProps<"a">;
29
- export function PaginationLink({ className, isActive, ...props }: PaginationLinkProps) {
30
- return (
31
- <a
32
- aria-current={isActive ? "page" : undefined}
33
- className={cn(
34
- buttonVariants({ variant: isActive ? "outline" : "ghost", size: "icon" }),
35
- "cursor-pointer",
36
- className,
37
- )}
38
- {...props}
39
- />
40
- );
41
- }
31
+ export const PaginationLink = forwardRef<HTMLAnchorElement, PaginationLinkProps>(
32
+ function PaginationLink({ className, isActive, ...props }, ref) {
33
+ return (
34
+ <a
35
+ ref={ref}
36
+ aria-current={isActive ? "page" : undefined}
37
+ className={cn(
38
+ buttonVariants({ variant: isActive ? "outline" : "ghost", size: "icon" }),
39
+ "cursor-pointer",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ );
45
+ },
46
+ );
42
47
  export function PaginationPrevious({ className, ...props }: ComponentProps<"a">) {
48
+ const { t } = useLocale();
43
49
  return (
44
50
  <a
45
- aria-label="Go to previous page"
51
+ aria-label={t("ui.pagination.previous")}
46
52
  className={cn(buttonVariants({ variant: "ghost" }), "gap-1 ps-2.5 cursor-pointer", className)}
47
53
  {...props}
48
54
  >
49
55
  <ChevronLeft className="size-4" data-rtl-flip />
50
- <span>Previous</span>
56
+ <span>{t("previous")}</span>
51
57
  </a>
52
58
  );
53
59
  }
54
60
  export function PaginationNext({ className, ...props }: ComponentProps<"a">) {
61
+ const { t } = useLocale();
55
62
  return (
56
63
  <a
57
- aria-label="Go to next page"
64
+ aria-label={t("ui.pagination.next")}
58
65
  className={cn(buttonVariants({ variant: "ghost" }), "gap-1 pe-2.5 cursor-pointer", className)}
59
66
  {...props}
60
67
  >
61
- <span>Next</span>
68
+ <span>{t("next")}</span>
62
69
  <ChevronRight className="size-4" data-rtl-flip />
63
70
  </a>
64
71
  );
65
72
  }
66
73
  export function PaginationEllipsis({ className, ...props }: ComponentProps<"span">) {
74
+ const { t } = useLocale();
67
75
  return (
68
76
  <span
69
77
  aria-hidden
@@ -71,7 +79,7 @@ export function PaginationEllipsis({ className, ...props }: ComponentProps<"span
71
79
  {...props}
72
80
  >
73
81
  <MoreHorizontal className="size-4" />
74
- <span className="sr-only">More pages</span>
82
+ <span className="sr-only">{t("ui.pagination.morePages")}</span>
75
83
  </span>
76
84
  );
77
85
  }
@@ -14,6 +14,7 @@ export const PopoverContent = forwardRef<
14
14
  <PopoverPrimitive.Portal>
15
15
  <PopoverPrimitive.Content
16
16
  ref={ref}
17
+ data-slot="popover-content"
17
18
  align={align}
18
19
  sideOffset={sideOffset}
19
20
  className={cn(
@@ -36,11 +36,13 @@ export const Progress = forwardRef<ElementRef<typeof ProgressPrimitive.Root>, Pr
36
36
  return (
37
37
  <ProgressPrimitive.Root
38
38
  ref={ref}
39
+ data-slot="progress"
39
40
  value={value}
40
41
  className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
41
42
  {...props}
42
43
  >
43
44
  <ProgressPrimitive.Indicator
45
+ data-slot="progress-indicator"
44
46
  className={progressIndicatorVariants({ variant })}
45
47
  style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
46
48
  />
@@ -19,7 +19,7 @@ export const RadioGroupItem = forwardRef<
19
19
  ref={ref}
20
20
  className={cn(
21
21
  "aspect-square size-4 rounded-full border border-input text-primary shadow-sm transition-colors duration-fast ease-standard",
22
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
22
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
23
23
  "disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary",
24
24
  className,
25
25
  )}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import {
3
5
  forwardRef,
@@ -187,7 +189,7 @@ export const Rating = forwardRef<HTMLDivElement, RatingProps>(function Rating(
187
189
  aria-checked={checked}
188
190
  aria-label={`${index} ${index === 1 ? "star" : "stars"}`}
189
191
  tabIndex={i === tabStopIndex ? 0 : -1}
190
- className="rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
192
+ className="rounded-sm focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
191
193
  onClick={(e) =>
192
194
  commit(valueFromPointer(e as unknown as PointerEvent<HTMLButtonElement>, index))
193
195
  }
@@ -1,9 +1,27 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent } from "storybook/test";
3
+ import { cn } from "../../lib/cn";
4
+ import { splitPaneVariants } from "../split-panel";
2
5
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./resizable";
3
6
  const meta = {
4
7
  title: "Layout/Resizable",
5
8
  component: ResizablePanelGroup,
6
- parameters: { layout: "padded" },
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ layout: "padded",
12
+ docs: {
13
+ description: {
14
+ component:
15
+ "Panes the user can drag to resize — fixed two-pane layouts with per-pane surface " +
16
+ "tones are `Layout/SplitPanel`, see " +
17
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
18
+ "The `react-resizable-panels` primitive set (`ResizablePanelGroup`/`ResizablePanel`/ " +
19
+ "`ResizableHandle`): n panes, percentage sizing, persisted layout. Apply " +
20
+ "`splitPaneVariants({ tone })` (exported from `Layout/SplitPanel`) to a `ResizablePanel` " +
21
+ "for the same `plain`/`muted`/`card` ground-offset tiering — see the `Tiered` story.",
22
+ },
23
+ },
24
+ },
7
25
  } satisfies Meta<typeof ResizablePanelGroup>;
8
26
  export default meta;
9
27
  type Story = StoryObj<typeof meta>;
@@ -13,7 +31,7 @@ export const Default: Story = {
13
31
  <ResizablePanel defaultSize={40} className="flex items-center justify-center p-4 text-body">
14
32
  List
15
33
  </ResizablePanel>
16
- <ResizableHandle withHandle />
34
+ <ResizableHandle withHandle aria-label="Resize the list and detail panels" />
17
35
  <ResizablePanel
18
36
  defaultSize={60}
19
37
  className="flex items-center justify-center p-4 text-body text-muted-foreground"
@@ -22,4 +40,98 @@ export const Default: Story = {
22
40
  </ResizablePanel>
23
41
  </ResizablePanelGroup>
24
42
  ),
43
+ // #156 — the handle forwards `...props`, so `aria-label` reaches
44
+ // `PanelResizeHandle` and replaces the bare "separator, 50" announcement with a
45
+ // name that says which two regions it divides. Tabbing to it (rather than a
46
+ // direct `querySelector` focus call) exercises the same path a keyboard user
47
+ // takes, and the `ArrowRight` assertion locks that naming the handle didn't
48
+ // regress the keyboard resize behaviour that was already correct.
49
+ play: async ({ canvasElement }) => {
50
+ const handle = canvasElement.querySelector<HTMLElement>('[role="separator"]');
51
+ await expect(handle).not.toBeNull();
52
+
53
+ await userEvent.tab();
54
+ await expect(handle).toHaveFocus();
55
+ await expect(handle).toHaveAccessibleName("Resize the list and detail panels");
56
+
57
+ const panel = canvasElement.querySelector<HTMLElement>("[data-panel]");
58
+ const sizeBefore = panel!.getAttribute("data-panel-size");
59
+ await userEvent.keyboard("{ArrowRight}");
60
+ const sizeAfter = panel!.getAttribute("data-panel-size");
61
+ await expect(sizeAfter).not.toBe(sizeBefore);
62
+ },
63
+ };
64
+
65
+ /**
66
+ * The same `plain`/`muted`/`card` ground-offset tiering `SplitPanel` gives its
67
+ * `startTone`/`endTone`, composed onto draggable panels via the exported
68
+ * `splitPaneVariants` — so a user-resizable layout doesn't need a hand-rolled second
69
+ * copy of the tone classes. The play test drags the handle and asserts the panel's
70
+ * relative size (`data-panel-size`, react-resizable-panels' `flexGrow`-derived
71
+ * attribute) actually changed, not an exact pixel width — a headless drag is flaky
72
+ * against pixel deltas — and that both tone classes survive the resize.
73
+ */
74
+ export const Tiered: Story = {
75
+ render: () => (
76
+ <ResizablePanelGroup direction="horizontal" className="h-48 max-w-xl rounded-lg border">
77
+ <ResizablePanel
78
+ defaultSize={40}
79
+ className={cn(
80
+ splitPaneVariants({ tone: "muted" }),
81
+ "flex items-center justify-center p-4 text-body",
82
+ )}
83
+ >
84
+ List
85
+ </ResizablePanel>
86
+ <ResizableHandle withHandle aria-label="Resize the list and detail panels" />
87
+ <ResizablePanel
88
+ defaultSize={60}
89
+ className={cn(
90
+ splitPaneVariants({ tone: "card" }),
91
+ "flex items-center justify-center p-4 text-body text-muted-foreground",
92
+ )}
93
+ >
94
+ Detail
95
+ </ResizablePanel>
96
+ </ResizablePanelGroup>
97
+ ),
98
+ play: async ({ canvasElement }) => {
99
+ const panels = canvasElement.querySelectorAll<HTMLElement>("[data-panel]");
100
+ const [startPanel, endPanel] = Array.from(panels);
101
+ await expect(startPanel).toBeDefined();
102
+ await expect(endPanel).toBeDefined();
103
+ const sizeBefore = startPanel!.getAttribute("data-panel-size");
104
+
105
+ // Tones apply before the drag.
106
+ await expect(startPanel!.className).toMatch(/bg-surface-muted/);
107
+ await expect(endPanel!.className).toMatch(/bg-card/);
108
+ await expect(endPanel!.className).toMatch(/shadow-sm/);
109
+
110
+ // `ResizablePanel` sets `style="overflow: hidden"` inline (react-resizable-panels);
111
+ // an inline style always wins the cascade over `splitPaneVariants`' `overflow-auto`
112
+ // class, so the resolved overflow stays `hidden` here — the class isn't fighting
113
+ // anything visually, it's simply inert on this element. Locked so a future
114
+ // react-resizable-panels upgrade that drops the inline style doesn't silently
115
+ // flip scroll behaviour unnoticed.
116
+ await expect(getComputedStyle(startPanel!).overflow).toBe("hidden");
117
+
118
+ const handle = canvasElement.querySelector<HTMLElement>('[role="separator"]');
119
+ await expect(handle).not.toBeNull();
120
+ const rect = handle!.getBoundingClientRect();
121
+ const y = rect.top + rect.height / 2;
122
+ await userEvent.pointer([
123
+ { keys: "[MouseLeft>]", target: handle!, coords: { clientX: rect.left + 1, clientY: y } },
124
+ { coords: { clientX: rect.left + 80, clientY: y } },
125
+ { keys: "[/MouseLeft]" },
126
+ ]);
127
+
128
+ // The relative size changed — never assert an exact pixel width (flaky headless).
129
+ const sizeAfter = startPanel!.getAttribute("data-panel-size");
130
+ await expect(sizeAfter).not.toBe(sizeBefore);
131
+
132
+ // Tones still apply after the drag.
133
+ await expect(startPanel!.className).toMatch(/bg-surface-muted/);
134
+ await expect(endPanel!.className).toMatch(/bg-card/);
135
+ await expect(endPanel!.className).toMatch(/shadow-sm/);
136
+ },
25
137
  };
@@ -0,0 +1,76 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render } from "@testing-library/react";
4
+ import type * as ResizablePrimitiveModule from "react-resizable-panels";
5
+ import type { ImperativePanelGroupHandle } from "react-resizable-panels";
6
+
7
+ const capturedProps: Record<string, unknown>[] = [];
8
+
9
+ vi.mock("react-resizable-panels", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof ResizablePrimitiveModule>();
11
+ return {
12
+ ...actual,
13
+ PanelResizeHandle: (props: Record<string, unknown>) => {
14
+ capturedProps.push(props);
15
+ return actual.PanelResizeHandle(props as never);
16
+ },
17
+ };
18
+ });
19
+
20
+ // Imported AFTER the mock so the module picked up above is the one in use.
21
+ const { ResizableHandle, ResizablePanel, ResizablePanelGroup } = await import("./resizable");
22
+
23
+ describe("Resizable — handle hit area is expanded without changing the visible line", () => {
24
+ it("keeps the visible line at 1px (w-px) while widening the pointer hit area via hitAreaMargins", () => {
25
+ capturedProps.length = 0;
26
+ const { container } = render(
27
+ <ResizablePanelGroup direction="horizontal" className="h-48 max-w-xl">
28
+ <ResizablePanel defaultSize={40}>List</ResizablePanel>
29
+ <ResizableHandle aria-label="Resize" />
30
+ <ResizablePanel defaultSize={60}>Detail</ResizablePanel>
31
+ </ResizablePanelGroup>,
32
+ );
33
+ const handle = container.querySelector('[role="separator"]');
34
+ expect(handle).not.toBeNull();
35
+ // Visual line unchanged — still the 1px hairline, not a wider drawn box.
36
+ expect(handle!.className).toMatch(/\bw-px\b/);
37
+
38
+ // The DRAG hit area comes from the primitive's own pointer-distance
39
+ // config, not from CSS: at least 16px combined margin around the line
40
+ // (>= 8px each side), satisfying the ≥16px minimum without widening
41
+ // anything visible.
42
+ const props = capturedProps.at(-1);
43
+ const margins = props?.hitAreaMargins as { coarse: number; fine: number } | undefined;
44
+ expect(margins).toBeDefined();
45
+ expect(margins!.fine).toBeGreaterThanOrEqual(8);
46
+ expect(margins!.coarse).toBeGreaterThanOrEqual(8);
47
+ });
48
+
49
+ it("lets a caller override hitAreaMargins explicitly", () => {
50
+ capturedProps.length = 0;
51
+ render(
52
+ <ResizablePanelGroup direction="horizontal" className="h-48 max-w-xl">
53
+ <ResizablePanel defaultSize={40}>List</ResizablePanel>
54
+ <ResizableHandle aria-label="Resize" hitAreaMargins={{ coarse: 20, fine: 20 }} />
55
+ <ResizablePanel defaultSize={60}>Detail</ResizablePanel>
56
+ </ResizablePanelGroup>,
57
+ );
58
+ const props = capturedProps.at(-1);
59
+ expect(props?.hitAreaMargins).toEqual({ coarse: 20, fine: 20 });
60
+ });
61
+ });
62
+
63
+ describe("Resizable — forwardRef", () => {
64
+ it("ResizablePanelGroup forwards its ref to the imperative panel-group handle", () => {
65
+ const ref = createRef<ImperativePanelGroupHandle>();
66
+ render(
67
+ <ResizablePanelGroup ref={ref} direction="horizontal" className="h-48 max-w-xl">
68
+ <ResizablePanel defaultSize={40}>List</ResizablePanel>
69
+ <ResizableHandle aria-label="Resize" />
70
+ <ResizablePanel defaultSize={60}>Detail</ResizablePanel>
71
+ </ResizablePanelGroup>,
72
+ );
73
+ expect(ref.current).not.toBeNull();
74
+ expect(typeof ref.current?.getLayout).toBe("function");
75
+ });
76
+ });
@@ -1,30 +1,56 @@
1
+ import { forwardRef, type ForwardRefExoticComponent, type RefAttributes } from "react";
1
2
  import { GripVertical } from "lucide-react";
2
3
  import * as ResizablePrimitive from "react-resizable-panels";
3
4
  import { cn } from "../../lib/cn";
4
5
 
5
- export function ResizablePanelGroup({
6
- className,
7
- ...props
8
- }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
6
+ export const ResizablePanelGroup: ForwardRefExoticComponent<
7
+ React.ComponentProps<typeof ResizablePrimitive.PanelGroup> &
8
+ RefAttributes<ResizablePrimitive.ImperativePanelGroupHandle>
9
+ > = forwardRef<
10
+ ResizablePrimitive.ImperativePanelGroupHandle,
11
+ React.ComponentProps<typeof ResizablePrimitive.PanelGroup>
12
+ >(function ResizablePanelGroup({ className, ...props }, ref) {
9
13
  return (
10
14
  <ResizablePrimitive.PanelGroup
15
+ ref={ref}
11
16
  className={cn("flex size-full data-[panel-group-direction=vertical]:flex-col", className)}
12
17
  {...props}
13
18
  />
14
19
  );
15
- }
20
+ });
16
21
 
17
22
  export const ResizablePanel = ResizablePrimitive.Panel;
18
23
 
24
+ /**
25
+ * The visible line stays 1px (`w-px`); the DRAG hit area is expanded via the
26
+ * primitive's own `hitAreaMargins` (pointer-distance based, not a CSS trick),
27
+ * so pointer/touch users get a ≥16px target without widening what's drawn.
28
+ * Callers can still override `hitAreaMargins` explicitly.
29
+ */
30
+ const DEFAULT_HIT_AREA_MARGINS: ResizablePrimitive.PointerHitAreaMargins = {
31
+ coarse: 15,
32
+ fine: 8,
33
+ };
34
+
35
+ /**
36
+ * NOT wrapped in `forwardRef`: `react-resizable-panels`' `PanelResizeHandle`
37
+ * is a plain function component that hardcodes its own internal element ref
38
+ * and does not accept `ref` as a prop at all (neither in its types nor at
39
+ * runtime — verified: a `ref` passed to it resolves to `null`). There is no
40
+ * DOM node here for a wrapper ref to attach to without changing what render
41
+ * onto the actual drag surface, so adding `forwardRef` would be a dead prop.
42
+ */
19
43
  export function ResizableHandle({
20
44
  withHandle,
21
45
  className,
46
+ hitAreaMargins,
22
47
  ...props
23
48
  }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { withHandle?: boolean }) {
24
49
  return (
25
50
  <ResizablePrimitive.PanelResizeHandle
51
+ hitAreaMargins={hitAreaMargins ?? DEFAULT_HIT_AREA_MARGINS}
26
52
  className={cn(
27
- "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
53
+ "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-ring",
28
54
  "data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0",
29
55
  className,
30
56
  )}
@@ -17,10 +17,17 @@ const meta = {
17
17
  docs: {
18
18
  description: {
19
19
  component:
20
+ "The GIT-HISTORY rail; a generic ordered-steps rail is `Core/Timeline` — see " +
21
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
20
22
  "A presentational git-history timeline. Renders revisions (newest-first) with " +
21
23
  "day grouping, an SVG branch-graph lane gutter, churn indicators (+/−), " +
22
24
  "ref chips, and an accessible selection rail. The app computes lane indices " +
23
- "and edges; the component only renders what it receives.",
25
+ "and edges; the component only renders what it receives. " +
26
+ "It deliberately does not ride the shared `Timeline` rail: a rail node's colour " +
27
+ "there is the closed 7-state execution `Status`, while a node here is coloured by " +
28
+ "lane identity (the chart ramp), and a fork/merge join is a cross-row bezier that " +
29
+ "no item-local connector can draw — the four-point reason is in the component's " +
30
+ "docblock (RM-014).",
24
31
  },
25
32
  },
26
33
  },
@@ -264,6 +264,32 @@ describe("Merge rows", () => {
264
264
  expect(svg).not.toBeNull();
265
265
  });
266
266
 
267
+ // #387 — colour is never the only channel (WCAG 1.4.1). The lane graph paints
268
+ // every node from the same categorical chart ramp, so the ROW GLYPH is the cue
269
+ // that survives greyscale: the merge-marked row renders `GitMerge`, every other
270
+ // row `GitCommitHorizontal`. Asserted on the rendered `lucide-*` class (the
271
+ // actual shape signature) — the idiom flow-node.test.tsx uses. This is also the
272
+ // non-colour channel that justifies this component keeping its own node
273
+ // vocabulary instead of the shared `Timeline` rail's status-keyed dot (RM-014,
274
+ // #133) — see the component docblock.
275
+ //
276
+ // WHICH row is marked is deliberately NOT asserted here: the derivation counts
277
+ // edges by `to` (parents with >=2 children), so today the glyph lands on the
278
+ // fork point rather than on the commit whose message says "Merge". That routing
279
+ // question is out of scope for RM-014 and is reported separately; this lock is
280
+ // about the CHANNEL, and must keep holding whichever row ends up marked.
281
+ it("marks the merge row with a distinct glyph, not colour alone (#387)", () => {
282
+ render(<RevisionTimeline revisions={MERGE_REVISIONS} edges={MERGE_EDGES} groupBy="none" />);
283
+ const rows = Array.from(document.querySelectorAll("[data-revision-id]"));
284
+ expect(rows).toHaveLength(MERGE_REVISIONS.length);
285
+ const merge = rows.filter((r) => r.querySelector("svg.lucide-git-merge"));
286
+ const plain = rows.filter((r) => r.querySelector("svg.lucide-git-commit-horizontal"));
287
+ expect(merge).toHaveLength(1);
288
+ expect(plain).toHaveLength(rows.length - 1);
289
+ // No row carries both shapes, so the two are genuinely exclusive silhouettes.
290
+ expect(merge[0]!.querySelector("svg.lucide-git-commit-horizontal")).toBeNull();
291
+ });
292
+
267
293
  it("renders all revisions including the merge commit", () => {
268
294
  render(<RevisionTimeline revisions={MERGE_REVISIONS} edges={MERGE_EDGES} groupBy="none" />);
269
295
  expect(screen.getAllByRole("listitem")).toHaveLength(MERGE_REVISIONS.length);
@@ -12,6 +12,49 @@
12
12
  * The APP computes the graph layout (lane indices, edges). This component
13
13
  * only renders what it receives. Heavy graph-math stays out.
14
14
  *
15
+ * WHY THIS DOES NOT RIDE THE SHARED `Timeline` RAIL (RM-014, #133).
16
+ * `components/timeline/` is the canonical rail/node/connector spine (#190) and
17
+ * `AgentTimeline` rides it. This component deliberately does not. The decision
18
+ * was taken against the real code, so do not re-investigate it and do not
19
+ * "converge" the two without reopening #133 — four things block it:
20
+ *
21
+ * 1. The node channel already carries a DIFFERENT meaning there. On the
22
+ * shared rail a node's colour IS the closed 7-state execution `Status`:
23
+ * `NODE_STYLE` is locked to `STATUS_ROLE`/`statusBadgeVariants` (#392)
24
+ * and every status owns a unique non-colour signature (#387). A node
25
+ * here is coloured by LANE IDENTITY (`laneColor` → `--chart-1..5`, a
26
+ * categorical series ramp), and a revision has no execution status at
27
+ * all. Mapping lane → status would be a lie (lane 3 is not `failed`),
28
+ * and opening the rail node to a free-form colour would dissolve the two
29
+ * invariants those tests exist to defend.
30
+ * 2. A cross-lane join cannot be an item-local connector. The shared rail's
31
+ * connector is one `w-px` span inside each `<li>` at a fixed inline
32
+ * offset. A fork/merge join here is a cubic bezier from one row's centre
33
+ * to ANOTHER row's centre, several rows away and in a different lane —
34
+ * geometry that only exists in a coordinate space spanning the whole
35
+ * list (`rowCenterY` + `LaneGutter`). No per-item primitive can draw it,
36
+ * so the reusable unit would have to be the list, not the item.
37
+ * 3. Row geometry is load-bearing, not styling. Rows are fixed-height
38
+ * (`ROW_H_COMFORTABLE`/`ROW_H_COMPACT`) and day headers fixed at
39
+ * `DAY_HEADER_H` precisely because the SVG shares the list's coordinate
40
+ * space; the shared rail item is content-height (`pb-5 ps-7 last:pb-0`).
41
+ * Riding it means overriding every geometry decision it makes.
42
+ * 4. The row is a selection control, not a title slot. The whole row is one
43
+ * `<button aria-pressed>`, while the rail item's title slot is a span
44
+ * that unconditionally prefixes an `sr-only` status word — so every
45
+ * revision would announce a status it does not have.
46
+ *
47
+ * Teaching the rail all four means ~5 opt-in props, four of which turn OFF
48
+ * what the spine provides (its node, its connector, its geometry, its status
49
+ * announcement) and one of which moves `LaneGutter` into it — a behavioural
50
+ * mode fork (.claude/rules/component-api.md) paid for by every existing rail
51
+ * consumer. That relocates the duplication rather than removing it. The two
52
+ * are one silhouette ("a vertical list with dots and lines") speaking two
53
+ * grammars: an execution trace vs a commit DAG.
54
+ *
55
+ * Nothing visual is shared, but the fork-prevention gate still binds this
56
+ * file — which is why the GATE NOTES below are not optional.
57
+ *
15
58
  * GATE NOTES:
16
59
  * - No identifier starting with `Timeline` (timeline-fork class 1).
17
60
  * - Lanes/connectors drawn with SVG <line> / <path>, NOT `absolute w-px`
@@ -30,6 +73,7 @@ import {
30
73
  import { cva, type VariantProps } from "class-variance-authority";
31
74
  import { ChevronRight, GitBranch, GitCommitHorizontal, GitMerge, Plus, Minus } from "lucide-react";
32
75
  import { cn } from "../../lib/cn";
76
+ import { useLocale } from "../locale-provider";
33
77
 
34
78
  // ─── Public data types ────────────────────────────────────────────────────────
35
79
 
@@ -566,7 +610,7 @@ function BranchToggle({ branchName, onToggle }: { branchName: string; onToggle:
566
610
  "flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground",
567
611
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
568
612
  "hover:bg-accent hover:text-accent-foreground",
569
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
613
+ "focus-ring",
570
614
  )}
571
615
  >
572
616
  <BranchChevron expanded />
@@ -615,7 +659,7 @@ function CollapsedBranchRow({
615
659
  className={cn(
616
660
  "flex h-full w-full min-w-0 items-center gap-2 px-2 text-start",
617
661
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
618
- "rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
662
+ "rounded focus-ring",
619
663
  "hover:bg-accent hover:text-accent-foreground",
620
664
  )}
621
665
  >
@@ -695,7 +739,7 @@ function RevisionRow({ revision, isMerge, density, indent, collapseToggle }: Rev
695
739
  ? "flex h-full min-w-0 flex-1 items-center gap-2 px-2 text-start"
696
740
  : "flex h-full w-full min-w-0 items-center gap-2 px-2 text-start",
697
741
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
698
- "rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
742
+ "rounded focus-ring",
699
743
  "hover:bg-accent hover:text-accent-foreground",
700
744
  isSelected && "border-s-2 border-s-primary bg-accent text-accent-foreground",
701
745
  )}
@@ -894,6 +938,7 @@ export const RevisionTimeline = forwardRef<HTMLDivElement, RevisionTimelineProps
894
938
  },
895
939
  ref,
896
940
  ) {
941
+ const { t } = useLocale();
897
942
  const resolvedDensity: RevisionTimelineDensity = density ?? "comfortable";
898
943
 
899
944
  // Collapse/expand mode is opt-in: active only when `branches` are supplied.
@@ -1016,7 +1061,7 @@ export const RevisionTimeline = forwardRef<HTMLDivElement, RevisionTimelineProps
1016
1061
  const from = summaryOf.get(e.from) ?? e.from;
1017
1062
  const to = summaryOf.get(e.to) ?? e.to;
1018
1063
  if (from === to) continue; // collapsed into a single node
1019
- const key = `${from}${to}`;
1064
+ const key = `${from}\u0000${to}`;
1020
1065
  if (seen.has(key)) continue; // de-dup coincident remaps
1021
1066
  seen.add(key);
1022
1067
  out.push({ from, to });
@@ -1059,7 +1104,10 @@ export const RevisionTimeline = forwardRef<HTMLDivElement, RevisionTimelineProps
1059
1104
  {groups.map((group) => (
1060
1105
  <div key={group.dayKey}>
1061
1106
  {groupBy === "day" && <DayHeader label={group.dayLabel} />}
1062
- <ol role="list" aria-label={groupBy === "day" ? group.dayLabel : "Revisions"}>
1107
+ <ol
1108
+ role="list"
1109
+ aria-label={groupBy === "day" ? group.dayLabel : t("ui.revisionTimeline.label")}
1110
+ >
1063
1111
  {group.items.map((it) => {
1064
1112
  if (it.kind === "summary") {
1065
1113
  return (