@elabs-ai/components-ui 4.0.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 (449) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/index.d.ts +4497 -0
  4. package/dist/index.js +15192 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/cn.d.ts +10 -0
  7. package/dist/lib/cn.js +20 -0
  8. package/dist/lib/cn.js.map +1 -0
  9. package/package.json +106 -0
  10. package/src/blocks/sidebar-02/app-sidebar.tsx +160 -0
  11. package/src/blocks/sidebar-02/logo.tsx +18 -0
  12. package/src/blocks/sidebar-02/nav-main.tsx +13 -0
  13. package/src/blocks/sidebar-02/nav-notifications.tsx +7 -0
  14. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +23 -0
  15. package/src/blocks/sidebar-02/team-switcher.tsx +7 -0
  16. package/src/blocks/sidebar-04/app-sidebar.tsx +236 -0
  17. package/src/blocks/sidebar-04/mail-context.tsx +29 -0
  18. package/src/blocks/sidebar-04/nav-user.tsx +6 -0
  19. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +150 -0
  20. package/src/blocks/sidebar-05/app-sidebar.tsx +375 -0
  21. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +18 -0
  22. package/src/blocks/sidebar-05/team-switcher.tsx +6 -0
  23. package/src/blocks-comparison-table.stories.tsx +165 -0
  24. package/src/components/accordion/accordion.stories.tsx +71 -0
  25. package/src/components/accordion/accordion.test.tsx +18 -0
  26. package/src/components/accordion/accordion.tsx +49 -0
  27. package/src/components/accordion/index.ts +1 -0
  28. package/src/components/advanced-group/advanced-group.stories.tsx +143 -0
  29. package/src/components/advanced-group/advanced-group.test.tsx +81 -0
  30. package/src/components/advanced-group/advanced-group.tsx +111 -0
  31. package/src/components/advanced-group/index.ts +2 -0
  32. package/src/components/alert/alert.stories.tsx +156 -0
  33. package/src/components/alert/alert.test.tsx +57 -0
  34. package/src/components/alert/alert.tsx +91 -0
  35. package/src/components/alert/index.ts +8 -0
  36. package/src/components/alert-dialog/alert-dialog.stories.tsx +73 -0
  37. package/src/components/alert-dialog/alert-dialog.tsx +104 -0
  38. package/src/components/alert-dialog/index.ts +12 -0
  39. package/src/components/app-shell/app-shell.stories.tsx +61 -0
  40. package/src/components/app-shell/app-shell.tsx +31 -0
  41. package/src/components/app-shell/index.ts +1 -0
  42. package/src/components/app-sidebar/app-sidebar.stories.tsx +121 -0
  43. package/src/components/app-sidebar/app-sidebar.test.tsx +55 -0
  44. package/src/components/app-sidebar/app-sidebar.tsx +53 -0
  45. package/src/components/app-sidebar/index.ts +1 -0
  46. package/src/components/aspect-ratio/aspect-ratio.stories.tsx +46 -0
  47. package/src/components/aspect-ratio/aspect-ratio.tsx +3 -0
  48. package/src/components/aspect-ratio/index.ts +1 -0
  49. package/src/components/attribution-panel/attribution-panel.stories.tsx +83 -0
  50. package/src/components/attribution-panel/attribution-panel.test.tsx +123 -0
  51. package/src/components/attribution-panel/attribution-panel.tsx +191 -0
  52. package/src/components/attribution-panel/attribution-types.ts +44 -0
  53. package/src/components/attribution-panel/attributions.generated.ts +1422 -0
  54. package/src/components/attribution-panel/index.ts +3 -0
  55. package/src/components/avatar/avatar.stories.tsx +45 -0
  56. package/src/components/avatar/avatar.test.tsx +13 -0
  57. package/src/components/avatar/avatar.tsx +46 -0
  58. package/src/components/avatar/index.ts +1 -0
  59. package/src/components/badge/badge.stories.tsx +44 -0
  60. package/src/components/badge/badge.test.tsx +10 -0
  61. package/src/components/badge/badge.tsx +28 -0
  62. package/src/components/badge/index.ts +1 -0
  63. package/src/components/bento-grid/bento-grid.stories.tsx +165 -0
  64. package/src/components/bento-grid/bento-grid.test.tsx +119 -0
  65. package/src/components/bento-grid/bento-grid.tsx +339 -0
  66. package/src/components/bento-grid/index.ts +9 -0
  67. package/src/components/bounded-number/bounded-number.stories.tsx +118 -0
  68. package/src/components/bounded-number/bounded-number.test.tsx +73 -0
  69. package/src/components/bounded-number/bounded-number.tsx +110 -0
  70. package/src/components/bounded-number/index.ts +1 -0
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +47 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +17 -0
  73. package/src/components/breadcrumb/breadcrumb.tsx +86 -0
  74. package/src/components/breadcrumb/index.ts +9 -0
  75. package/src/components/button/button.stories.tsx +70 -0
  76. package/src/components/button/button.test.tsx +50 -0
  77. package/src/components/button/button.tsx +63 -0
  78. package/src/components/button/index.ts +1 -0
  79. package/src/components/button-group/button-group.stories.tsx +45 -0
  80. package/src/components/button-group/button-group.tsx +70 -0
  81. package/src/components/button-group/index.ts +6 -0
  82. package/src/components/calendar/calendar.stories.tsx +95 -0
  83. package/src/components/calendar/calendar.test.tsx +140 -0
  84. package/src/components/calendar/calendar.tsx +70 -0
  85. package/src/components/calendar/index.ts +1 -0
  86. package/src/components/card/card.stories.tsx +438 -0
  87. package/src/components/card/card.test.tsx +153 -0
  88. package/src/components/card/card.tsx +332 -0
  89. package/src/components/card/index.ts +13 -0
  90. package/src/components/carousel/carousel.stories.tsx +102 -0
  91. package/src/components/carousel/carousel.tsx +225 -0
  92. package/src/components/carousel/index.ts +8 -0
  93. package/src/components/change-review/change-review.stories.tsx +288 -0
  94. package/src/components/change-review/change-review.test.tsx +261 -0
  95. package/src/components/change-review/change-review.tsx +637 -0
  96. package/src/components/change-review/index.ts +23 -0
  97. package/src/components/checkbox/checkbox.stories.tsx +119 -0
  98. package/src/components/checkbox/checkbox.test.tsx +40 -0
  99. package/src/components/checkbox/checkbox.tsx +34 -0
  100. package/src/components/checkbox/index.ts +1 -0
  101. package/src/components/collapsible/collapsible.stories.tsx +62 -0
  102. package/src/components/collapsible/collapsible.tsx +22 -0
  103. package/src/components/collapsible/index.ts +1 -0
  104. package/src/components/collapsible-panel/index.ts +8 -0
  105. package/src/components/collapsible-panel/use-collapsible-panel.test.tsx +93 -0
  106. package/src/components/collapsible-panel/use-collapsible-panel.ts +159 -0
  107. package/src/components/color-picker/color-picker.stories.tsx +93 -0
  108. package/src/components/color-picker/color-picker.test.tsx +79 -0
  109. package/src/components/color-picker/color-picker.tsx +341 -0
  110. package/src/components/color-picker/index.ts +1 -0
  111. package/src/components/combobox/combobox.stories.tsx +154 -0
  112. package/src/components/combobox/combobox.test.tsx +246 -0
  113. package/src/components/combobox/combobox.tsx +210 -0
  114. package/src/components/combobox/index.ts +1 -0
  115. package/src/components/command/command.stories.tsx +371 -0
  116. package/src/components/command/command.test.tsx +280 -0
  117. package/src/components/command/command.tsx +301 -0
  118. package/src/components/command/index.ts +13 -0
  119. package/src/components/confirm-dialog/confirm-dialog.stories.tsx +183 -0
  120. package/src/components/confirm-dialog/confirm-dialog.test.tsx +147 -0
  121. package/src/components/confirm-dialog/confirm-dialog.tsx +146 -0
  122. package/src/components/confirm-dialog/index.ts +2 -0
  123. package/src/components/context-menu/context-menu.stories.tsx +70 -0
  124. package/src/components/context-menu/context-menu.tsx +172 -0
  125. package/src/components/context-menu/index.ts +16 -0
  126. package/src/components/copyable-value/copyable-value.stories.tsx +80 -0
  127. package/src/components/copyable-value/copyable-value.test.tsx +128 -0
  128. package/src/components/copyable-value/copyable-value.tsx +101 -0
  129. package/src/components/copyable-value/index.ts +1 -0
  130. package/src/components/date-picker/date-picker.stories.tsx +42 -0
  131. package/src/components/date-picker/date-picker.tsx +49 -0
  132. package/src/components/date-picker/index.ts +1 -0
  133. package/src/components/date-range-picker/date-range-picker.stories.tsx +151 -0
  134. package/src/components/date-range-picker/date-range-picker.test.tsx +62 -0
  135. package/src/components/date-range-picker/date-range-picker.tsx +126 -0
  136. package/src/components/date-range-picker/index.ts +6 -0
  137. package/src/components/descriptions/descriptions.stories.tsx +43 -0
  138. package/src/components/descriptions/descriptions.test.tsx +41 -0
  139. package/src/components/descriptions/descriptions.tsx +95 -0
  140. package/src/components/descriptions/index.ts +6 -0
  141. package/src/components/dialog/dialog.stories.tsx +728 -0
  142. package/src/components/dialog/dialog.test.tsx +285 -0
  143. package/src/components/dialog/dialog.tsx +340 -0
  144. package/src/components/dialog/index.ts +18 -0
  145. package/src/components/dialog/use-dialog-dismiss-guard.test.tsx +80 -0
  146. package/src/components/dialog/use-dialog-dismiss-guard.ts +109 -0
  147. package/src/components/drawer/drawer.stories.tsx +76 -0
  148. package/src/components/drawer/drawer.tsx +85 -0
  149. package/src/components/drawer/index.ts +11 -0
  150. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +176 -0
  151. package/src/components/dropdown-menu/dropdown-menu.test.tsx +109 -0
  152. package/src/components/dropdown-menu/dropdown-menu.tsx +179 -0
  153. package/src/components/dropdown-menu/index.ts +17 -0
  154. package/src/components/empty-state/empty-state.stories.tsx +54 -0
  155. package/src/components/empty-state/empty-state.test.tsx +11 -0
  156. package/src/components/empty-state/empty-state.tsx +30 -0
  157. package/src/components/empty-state/index.ts +1 -0
  158. package/src/components/error-state/error-state.stories.tsx +51 -0
  159. package/src/components/error-state/error-state.tsx +27 -0
  160. package/src/components/error-state/index.ts +1 -0
  161. package/src/components/expand-dialog/expand-dialog.stories.tsx +246 -0
  162. package/src/components/expand-dialog/expand-dialog.test.tsx +185 -0
  163. package/src/components/expand-dialog/expand-dialog.tsx +387 -0
  164. package/src/components/expand-dialog/index.ts +11 -0
  165. package/src/components/field-row/field-row.stories.tsx +114 -0
  166. package/src/components/field-row/field-row.test.tsx +188 -0
  167. package/src/components/field-row/field-row.tsx +115 -0
  168. package/src/components/field-row/index.ts +1 -0
  169. package/src/components/file-upload/file-upload.stories.tsx +183 -0
  170. package/src/components/file-upload/file-upload.test.tsx +169 -0
  171. package/src/components/file-upload/file-upload.tsx +459 -0
  172. package/src/components/file-upload/index.ts +13 -0
  173. package/src/components/form/form.stories.tsx +71 -0
  174. package/src/components/form/form.tsx +141 -0
  175. package/src/components/form/index.ts +10 -0
  176. package/src/components/hover-card/hover-card.stories.tsx +57 -0
  177. package/src/components/hover-card/hover-card.tsx +25 -0
  178. package/src/components/hover-card/index.ts +1 -0
  179. package/src/components/icon-button/icon-button.stories.tsx +108 -0
  180. package/src/components/icon-button/icon-button.test.tsx +93 -0
  181. package/src/components/icon-button/icon-button.tsx +139 -0
  182. package/src/components/icon-button/index.ts +1 -0
  183. package/src/components/input/index.ts +1 -0
  184. package/src/components/input/input.stories.tsx +44 -0
  185. package/src/components/input/input.test.tsx +19 -0
  186. package/src/components/input/input.tsx +26 -0
  187. package/src/components/input-group/index.ts +11 -0
  188. package/src/components/input-group/input-group.stories.tsx +87 -0
  189. package/src/components/input-group/input-group.test.tsx +48 -0
  190. package/src/components/input-group/input-group.tsx +170 -0
  191. package/src/components/input-otp/index.ts +1 -0
  192. package/src/components/input-otp/input-otp.stories.tsx +66 -0
  193. package/src/components/input-otp/input-otp.tsx +68 -0
  194. package/src/components/kbd/index.ts +1 -0
  195. package/src/components/kbd/kbd.stories.tsx +45 -0
  196. package/src/components/kbd/kbd.tsx +20 -0
  197. package/src/components/key-value-editor/index.ts +1 -0
  198. package/src/components/key-value-editor/key-value-editor.stories.tsx +130 -0
  199. package/src/components/key-value-editor/key-value-editor.test.tsx +231 -0
  200. package/src/components/key-value-editor/key-value-editor.tsx +263 -0
  201. package/src/components/label/index.ts +1 -0
  202. package/src/components/label/label.stories.tsx +35 -0
  203. package/src/components/label/label.tsx +19 -0
  204. package/src/components/link-preview/index.ts +2 -0
  205. package/src/components/link-preview/link-preview.stories.tsx +178 -0
  206. package/src/components/link-preview/link-preview.test.tsx +123 -0
  207. package/src/components/link-preview/link-preview.tsx +194 -0
  208. package/src/components/list-editor/index.ts +1 -0
  209. package/src/components/list-editor/list-editor.stories.tsx +119 -0
  210. package/src/components/list-editor/list-editor.test.tsx +96 -0
  211. package/src/components/list-editor/list-editor.tsx +192 -0
  212. package/src/components/loading-state/index.ts +1 -0
  213. package/src/components/loading-state/loading-state.stories.tsx +38 -0
  214. package/src/components/loading-state/loading-state.tsx +26 -0
  215. package/src/components/locale-provider/index.ts +8 -0
  216. package/src/components/locale-provider/locale-provider.stories.tsx +83 -0
  217. package/src/components/locale-provider/locale-provider.test.tsx +238 -0
  218. package/src/components/locale-provider/locale-provider.tsx +187 -0
  219. package/src/components/locale-provider/messages.ts +300 -0
  220. package/src/components/match-highlight/index.ts +7 -0
  221. package/src/components/match-highlight/match-highlight.stories.tsx +146 -0
  222. package/src/components/match-highlight/match-highlight.test.tsx +202 -0
  223. package/src/components/match-highlight/match-highlight.tsx +145 -0
  224. package/src/components/mention-input/index.ts +28 -0
  225. package/src/components/mention-input/mention-input-mirror.stories.tsx +171 -0
  226. package/src/components/mention-input/mention-input.stories.tsx +463 -0
  227. package/src/components/mention-input/mention-input.test.tsx +617 -0
  228. package/src/components/mention-input/mention-input.tsx +1102 -0
  229. package/src/components/mention-input/mention-value.test.ts +253 -0
  230. package/src/components/mention-input/mention-value.ts +193 -0
  231. package/src/components/menubar/index.ts +17 -0
  232. package/src/components/menubar/menubar.stories.tsx +67 -0
  233. package/src/components/menubar/menubar.tsx +198 -0
  234. package/src/components/metric-card/index.ts +1 -0
  235. package/src/components/metric-card/metric-card.stories.tsx +224 -0
  236. package/src/components/metric-card/metric-card.test.tsx +198 -0
  237. package/src/components/metric-card/metric-card.tsx +229 -0
  238. package/src/components/model-picker/index.ts +11 -0
  239. package/src/components/model-picker/model-picker-state.ts +85 -0
  240. package/src/components/model-picker/model-picker.stories.tsx +128 -0
  241. package/src/components/model-picker/model-picker.test.tsx +241 -0
  242. package/src/components/model-picker/model-picker.tsx +270 -0
  243. package/src/components/nav-main/index.ts +1 -0
  244. package/src/components/nav-main/nav-main.tsx +147 -0
  245. package/src/components/nav-notifications/index.ts +5 -0
  246. package/src/components/nav-notifications/nav-notifications.test.tsx +15 -0
  247. package/src/components/nav-notifications/nav-notifications.tsx +70 -0
  248. package/src/components/nav-user/index.ts +1 -0
  249. package/src/components/nav-user/nav-user.test.tsx +29 -0
  250. package/src/components/nav-user/nav-user.tsx +114 -0
  251. package/src/components/navigation-menu/index.ts +10 -0
  252. package/src/components/navigation-menu/navigation-menu.stories.tsx +92 -0
  253. package/src/components/navigation-menu/navigation-menu.tsx +99 -0
  254. package/src/components/number-input/index.ts +1 -0
  255. package/src/components/number-input/number-input.stories.tsx +120 -0
  256. package/src/components/number-input/number-input.test.tsx +96 -0
  257. package/src/components/number-input/number-input.tsx +219 -0
  258. package/src/components/page-shell/index.ts +1 -0
  259. package/src/components/page-shell/page-shell.stories.tsx +105 -0
  260. package/src/components/page-shell/page-shell.test.tsx +57 -0
  261. package/src/components/page-shell/page-shell.tsx +62 -0
  262. package/src/components/pagination/index.ts +9 -0
  263. package/src/components/pagination/pagination.stories.tsx +57 -0
  264. package/src/components/pagination/pagination.tsx +77 -0
  265. package/src/components/popover/index.ts +1 -0
  266. package/src/components/popover/popover.stories.tsx +28 -0
  267. package/src/components/popover/popover.test.tsx +16 -0
  268. package/src/components/popover/popover.tsx +29 -0
  269. package/src/components/progress/index.ts +1 -0
  270. package/src/components/progress/progress.stories.tsx +80 -0
  271. package/src/components/progress/progress.test.tsx +35 -0
  272. package/src/components/progress/progress.tsx +50 -0
  273. package/src/components/radio-group/index.ts +1 -0
  274. package/src/components/radio-group/radio-group.stories.tsx +75 -0
  275. package/src/components/radio-group/radio-group.test.tsx +16 -0
  276. package/src/components/radio-group/radio-group.tsx +33 -0
  277. package/src/components/rating/index.ts +1 -0
  278. package/src/components/rating/rating.stories.tsx +111 -0
  279. package/src/components/rating/rating.test.tsx +41 -0
  280. package/src/components/rating/rating.tsx +226 -0
  281. package/src/components/resizable/index.ts +1 -0
  282. package/src/components/resizable/resizable.stories.tsx +25 -0
  283. package/src/components/resizable/resizable.tsx +40 -0
  284. package/src/components/reveal/index.ts +7 -0
  285. package/src/components/reveal/reveal.stories.tsx +90 -0
  286. package/src/components/reveal/reveal.test.tsx +43 -0
  287. package/src/components/reveal/reveal.tsx +95 -0
  288. package/src/components/revision-timeline/index.ts +10 -0
  289. package/src/components/revision-timeline/revision-timeline.stories.tsx +785 -0
  290. package/src/components/revision-timeline/revision-timeline.test.tsx +509 -0
  291. package/src/components/revision-timeline/revision-timeline.tsx +1107 -0
  292. package/src/components/scroll-area/index.ts +1 -0
  293. package/src/components/scroll-area/scroll-area.stories.tsx +45 -0
  294. package/src/components/scroll-area/scroll-area.tsx +43 -0
  295. package/src/components/section-header/index.ts +1 -0
  296. package/src/components/section-header/section-header.stories.tsx +20 -0
  297. package/src/components/section-header/section-header.tsx +36 -0
  298. package/src/components/segmented-field/index.ts +5 -0
  299. package/src/components/segmented-field/segmented-field.stories.tsx +121 -0
  300. package/src/components/segmented-field/segmented-field.test.tsx +163 -0
  301. package/src/components/segmented-field/segmented-field.tsx +235 -0
  302. package/src/components/select/index.ts +10 -0
  303. package/src/components/select/select.stories.tsx +106 -0
  304. package/src/components/select/select.test.tsx +98 -0
  305. package/src/components/select/select.tsx +230 -0
  306. package/src/components/separator/index.ts +2 -0
  307. package/src/components/separator/separator.stories.tsx +87 -0
  308. package/src/components/separator/separator.tsx +51 -0
  309. package/src/components/sheet/index.ts +12 -0
  310. package/src/components/sheet/sheet.stories.tsx +65 -0
  311. package/src/components/sheet/sheet.tsx +109 -0
  312. package/src/components/sidebar/index.ts +27 -0
  313. package/src/components/sidebar/sidebar.stories.tsx +69 -0
  314. package/src/components/sidebar/sidebar.test.tsx +43 -0
  315. package/src/components/sidebar/sidebar.tsx +688 -0
  316. package/src/components/skeleton/index.ts +1 -0
  317. package/src/components/skeleton/skeleton.stories.tsx +28 -0
  318. package/src/components/skeleton/skeleton.tsx +15 -0
  319. package/src/components/slider/index.ts +1 -0
  320. package/src/components/slider/slider.stories.tsx +211 -0
  321. package/src/components/slider/slider.test.tsx +168 -0
  322. package/src/components/slider/slider.tsx +136 -0
  323. package/src/components/slider-number/index.ts +1 -0
  324. package/src/components/slider-number/slider-number.stories.tsx +123 -0
  325. package/src/components/slider-number/slider-number.test.tsx +165 -0
  326. package/src/components/slider-number/slider-number.tsx +191 -0
  327. package/src/components/sonner/index.ts +1 -0
  328. package/src/components/sonner/sonner.stories.tsx +124 -0
  329. package/src/components/sonner/sonner.test.tsx +76 -0
  330. package/src/components/sonner/sonner.tsx +101 -0
  331. package/src/components/spinner/index.ts +1 -0
  332. package/src/components/spinner/spinner.stories.tsx +27 -0
  333. package/src/components/spinner/spinner.tsx +19 -0
  334. package/src/components/split-panel/index.ts +1 -0
  335. package/src/components/split-panel/split-panel.stories.tsx +60 -0
  336. package/src/components/split-panel/split-panel.test.tsx +38 -0
  337. package/src/components/split-panel/split-panel.tsx +95 -0
  338. package/src/components/state-panel/index.ts +1 -0
  339. package/src/components/state-panel/state-panel.stories.tsx +80 -0
  340. package/src/components/state-panel/state-panel.test.tsx +64 -0
  341. package/src/components/state-panel/state-panel.tsx +167 -0
  342. package/src/components/status-badge/index.ts +18 -0
  343. package/src/components/status-badge/status-badge.stories.tsx +132 -0
  344. package/src/components/status-badge/status-badge.test.tsx +189 -0
  345. package/src/components/status-badge/status-badge.tsx +330 -0
  346. package/src/components/switch/index.ts +1 -0
  347. package/src/components/switch/switch.stories.tsx +58 -0
  348. package/src/components/switch/switch.test.tsx +12 -0
  349. package/src/components/switch/switch.tsx +29 -0
  350. package/src/components/table/index.ts +10 -0
  351. package/src/components/table/table.stories.tsx +53 -0
  352. package/src/components/table/table.test.tsx +17 -0
  353. package/src/components/table/table.tsx +106 -0
  354. package/src/components/tabs/index.ts +1 -0
  355. package/src/components/tabs/tabs.stories.tsx +267 -0
  356. package/src/components/tabs/tabs.test.tsx +94 -0
  357. package/src/components/tabs/tabs.tsx +170 -0
  358. package/src/components/tag-input/index.ts +1 -0
  359. package/src/components/tag-input/tag-input.stories.tsx +120 -0
  360. package/src/components/tag-input/tag-input.test.tsx +97 -0
  361. package/src/components/tag-input/tag-input.tsx +253 -0
  362. package/src/components/team-switcher/index.ts +1 -0
  363. package/src/components/team-switcher/team-switcher.test.tsx +39 -0
  364. package/src/components/team-switcher/team-switcher.tsx +100 -0
  365. package/src/components/textarea/index.ts +1 -0
  366. package/src/components/textarea/textarea.stories.tsx +42 -0
  367. package/src/components/textarea/textarea.tsx +24 -0
  368. package/src/components/theme-switcher/index.ts +2 -0
  369. package/src/components/theme-switcher/theme-switcher.stories.tsx +146 -0
  370. package/src/components/theme-switcher/theme-switcher.test.tsx +253 -0
  371. package/src/components/theme-switcher/theme-switcher.tsx +280 -0
  372. package/src/components/theme-switcher/use-theme-transition.ts +62 -0
  373. package/src/components/timeline/index.ts +13 -0
  374. package/src/components/timeline/timeline.stories.tsx +99 -0
  375. package/src/components/timeline/timeline.test.tsx +196 -0
  376. package/src/components/timeline/timeline.tsx +188 -0
  377. package/src/components/toggle/index.ts +1 -0
  378. package/src/components/toggle/toggle.stories.tsx +116 -0
  379. package/src/components/toggle/toggle.test.tsx +119 -0
  380. package/src/components/toggle/toggle.tsx +64 -0
  381. package/src/components/toggle-group/index.ts +1 -0
  382. package/src/components/toggle-group/toggle-group.stories.tsx +110 -0
  383. package/src/components/toggle-group/toggle-group.tsx +59 -0
  384. package/src/components/toolbar/index.ts +14 -0
  385. package/src/components/toolbar/toolbar.stories.tsx +126 -0
  386. package/src/components/toolbar/toolbar.test.tsx +73 -0
  387. package/src/components/toolbar/toolbar.tsx +177 -0
  388. package/src/components/tooltip/index.ts +1 -0
  389. package/src/components/tooltip/tooltip.stories.tsx +67 -0
  390. package/src/components/tooltip/tooltip.tsx +27 -0
  391. package/src/components/top-nav/index.ts +1 -0
  392. package/src/components/top-nav/top-nav.stories.tsx +18 -0
  393. package/src/components/top-nav/top-nav.tsx +34 -0
  394. package/src/components/transfer/index.ts +2 -0
  395. package/src/components/transfer/transfer.stories.tsx +162 -0
  396. package/src/components/transfer/transfer.test.tsx +177 -0
  397. package/src/components/transfer/transfer.tsx +481 -0
  398. package/src/components/tree/index.ts +6 -0
  399. package/src/components/tree/tree.stories.tsx +618 -0
  400. package/src/components/tree/tree.test.tsx +699 -0
  401. package/src/components/tree/tree.tsx +1149 -0
  402. package/src/components/tree/use-tree-keyboard.ts +314 -0
  403. package/src/components/tree-select/index.ts +1 -0
  404. package/src/components/tree-select/tree-select.stories.tsx +127 -0
  405. package/src/components/tree-select/tree-select.test.tsx +40 -0
  406. package/src/components/tree-select/tree-select.tsx +137 -0
  407. package/src/components/typography/index.ts +31 -0
  408. package/src/components/typography/prose.test.tsx +48 -0
  409. package/src/components/typography/prose.tsx +194 -0
  410. package/src/components/typography/typography.stories.tsx +244 -0
  411. package/src/components/typography/typography.test.tsx +140 -0
  412. package/src/components/typography/typography.tsx +126 -0
  413. package/src/components/view-toolbar/index.ts +10 -0
  414. package/src/components/view-toolbar/view-toolbar.stories.tsx +342 -0
  415. package/src/components/view-toolbar/view-toolbar.test.tsx +262 -0
  416. package/src/components/view-toolbar/view-toolbar.tsx +320 -0
  417. package/src/components/virtual-select/index.ts +2 -0
  418. package/src/components/virtual-select/use-virtual-listbox.ts +132 -0
  419. package/src/components/virtual-select/virtual-select.stories.tsx +203 -0
  420. package/src/components/virtual-select/virtual-select.test.tsx +106 -0
  421. package/src/components/virtual-select/virtual-select.tsx +361 -0
  422. package/src/components/wizard/index.ts +12 -0
  423. package/src/components/wizard/wizard.stories.tsx +148 -0
  424. package/src/components/wizard/wizard.test.tsx +67 -0
  425. package/src/components/wizard/wizard.tsx +354 -0
  426. package/src/decoration.stories.tsx +236 -0
  427. package/src/index.ts +174 -0
  428. package/src/lib/cn.test.ts +39 -0
  429. package/src/lib/cn.ts +31 -0
  430. package/src/lib/compact-number.ts +61 -0
  431. package/src/lib/document-address.test.ts +75 -0
  432. package/src/lib/document-address.ts +265 -0
  433. package/src/lib/download.ts +93 -0
  434. package/src/lib/file-icon.test.ts +45 -0
  435. package/src/lib/file-icon.ts +58 -0
  436. package/src/lib/file-kind.test.ts +98 -0
  437. package/src/lib/file-kind.ts +303 -0
  438. package/src/lib/file-source.test.ts +217 -0
  439. package/src/lib/file-source.ts +269 -0
  440. package/src/lib/merge-refs.test.ts +37 -0
  441. package/src/lib/merge-refs.ts +17 -0
  442. package/src/lib/streamdown-translations.ts +99 -0
  443. package/src/lib/use-copy-to-clipboard.ts +75 -0
  444. package/src/lib/use-mobile.ts +16 -0
  445. package/src/motion.stories.tsx +266 -0
  446. package/src/taste-profile.test.tsx +252 -0
  447. package/src/templates-object-detail-hub.stories.tsx +466 -0
  448. package/src/templates-screen-states.stories.tsx +306 -0
  449. package/src/templates-settings.stories.tsx +245 -0
@@ -0,0 +1,280 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback, useEffect, useState, type HTMLAttributes } from "react";
4
+ import { Check, Monitor, Moon, Sun } from "lucide-react";
5
+ import { useTheme, type ThemeDefinition, type ThemeName } from "@elabs-ai/components-tokens";
6
+
7
+ import { Button } from "../button";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "../dropdown-menu";
14
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
15
+ import { useThemeTransition, type ThemeTransitionEffect } from "./use-theme-transition";
16
+
17
+ const SYSTEM_STORAGE_KEY = "brand-ui-theme-system";
18
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
19
+
20
+ type ButtonSize = "sm" | "default" | "lg";
21
+ const ICON_SIZE: Record<ButtonSize, "icon-sm" | "icon" | "icon-lg"> = {
22
+ sm: "icon-sm",
23
+ default: "icon",
24
+ lg: "icon-lg",
25
+ };
26
+
27
+ /** A theme choice, or "system" to follow the OS color scheme. */
28
+ export type ThemePreference = ThemeName | "system";
29
+
30
+ export interface ThemeSwitcherProps extends Omit<HTMLAttributes<HTMLButtonElement>, "onChange"> {
31
+ /**
32
+ * Themes to offer, as names. ≤2 → toggle; >2 → dropdown.
33
+ *
34
+ * Defaults to the PROVIDER's registry (ADR 0029) — so an app that registers
35
+ * its own themes gets a working switcher with no prop at all. Pass this only
36
+ * to offer a subset, or to fix the order; names the provider doesn't know are
37
+ * dropped.
38
+ */
39
+ themes?: ThemeName[];
40
+ /** Force presentation; "auto" (default) picks toggle vs dropdown by theme count. */
41
+ mode?: "auto" | "toggle" | "dropdown";
42
+ /** Offer a "System" option that follows the OS color scheme. Default true. */
43
+ showSystem?: boolean;
44
+ /** Whole-screen reveal effect for the animated switch. Default "polygon". */
45
+ effect?: ThemeTransitionEffect;
46
+ /** Control size. Default "default". */
47
+ size?: ButtonSize;
48
+ /**
49
+ * Controlled preference (#366). When set, the switcher derives its "current"
50
+ * state — including "system" — from this prop instead of its own internal,
51
+ * `localStorage`-backed state, and reports every user pick via
52
+ * `onPreferenceChange` instead of writing its own storage key. Pass this pair
53
+ * when your app's actual source of truth is a different persisted preference
54
+ * (a settings API, a different storage key, a server-synced setting). Omit
55
+ * both for the default, uncontrolled behavior (unchanged).
56
+ */
57
+ preference?: ThemePreference;
58
+ /** Called with the new preference on every user pick, when `preference` is controlled. */
59
+ onPreferenceChange?: (preference: ThemePreference) => void;
60
+ }
61
+
62
+ function prefersDark(): boolean {
63
+ return typeof window !== "undefined" && window.matchMedia(DARK_QUERY).matches;
64
+ }
65
+
66
+ /** First theme in `list` matching `dark`, or the list's own first entry as a last resort. */
67
+ function pickByDarkness(list: readonly ThemeDefinition[], dark: boolean): ThemeName | undefined {
68
+ return (list.find((t) => t.dark === dark) ?? list[0])?.value;
69
+ }
70
+
71
+ /**
72
+ * Adaptive theme control. With ≤2 themes it renders a light/dark/system toggle
73
+ * (a single icon button that cycles); with >2 it auto-upgrades to a dropdown
74
+ * over the offered themes. Every switch animates the whole screen via
75
+ * `useThemeTransition` (respecting reduced motion). Icons follow each theme's
76
+ * `dark` flag, so it works for any theme — including one you authored.
77
+ *
78
+ * Uncontrolled by default (tracks its own "system" choice via `localStorage`).
79
+ * Pass `preference`/`onPreferenceChange` to drive it from an external store
80
+ * instead (#366) — see the `Controlled` story.
81
+ *
82
+ * **It renders the PROVIDER's registry** (ADR 0029). Register your themes on
83
+ * `<ThemeProvider themes={…}>` and this switcher offers them with no prop —
84
+ * which is also why `allowedThemes` (#355) is honoured for free: the provider
85
+ * has already narrowed what it exposes. The `themes` prop is now purely an
86
+ * additional, optional narrowing on top; a name the provider does not offer is
87
+ * dropped rather than rendered, so a disallowed or unregistered theme is never
88
+ * reachable via a menu item, the toggle, "System", or the OS
89
+ * `prefers-color-scheme` listener.
90
+ *
91
+ * The pre-ADR-0029 hard-coded `["light","dark"]` default is gone. Behaviour for
92
+ * an app on the default provider is unchanged — that registry IS light + dark.
93
+ */
94
+ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
95
+ function ThemeSwitcher(
96
+ {
97
+ themes,
98
+ mode = "auto",
99
+ showSystem = true,
100
+ effect = "polygon",
101
+ size = "default",
102
+ preference,
103
+ onPreferenceChange,
104
+ className,
105
+ ...props
106
+ },
107
+ ref,
108
+ ) {
109
+ const { theme, themeDefinitions, setTheme } = useTheme();
110
+ const switchTheme = useThemeTransition(effect);
111
+ const isControlled = preference !== undefined;
112
+ const [uncontrolledIsSystem, setUncontrolledIsSystem] = useState(false);
113
+ const isSystem = isControlled ? preference === "system" : uncontrolledIsSystem;
114
+
115
+ // The provider's registry is the universe; the prop only narrows it, in the
116
+ // prop's own order. A name the provider doesn't offer is DROPPED (it has no
117
+ // CSS block here and no label to render), and an empty intersection falls
118
+ // back to the whole registry rather than to a hard-coded pair — falling back
119
+ // to literals is what could resurrect a theme the provider disallowed.
120
+ const narrowed = themes
121
+ ? themes
122
+ .map((name) => themeDefinitions.find((d) => d.value === name))
123
+ .filter((d): d is ThemeDefinition => d !== undefined)
124
+ : themeDefinitions;
125
+ const offered: readonly ThemeDefinition[] = narrowed.length > 0 ? narrowed : themeDefinitions;
126
+ const safeThemes: ThemeName[] = offered.map((d) => d.value);
127
+ const labelOf = (name: ThemeName) => offered.find((d) => d.value === name)?.label ?? name;
128
+ const isDark = (name: ThemeName) => offered.find((d) => d.value === name)?.dark ?? false;
129
+
130
+ // `?? theme` (the active theme), never a "light"/"dark" literal: with an open
131
+ // registry those names may not exist at all in this app.
132
+ const lightTheme: ThemeName = pickByDarkness(offered, false) ?? theme;
133
+ const darkTheme: ThemeName = pickByDarkness(offered, true) ?? theme;
134
+
135
+ // The concrete theme to DISPLAY as current: the controlled preference when
136
+ // it names one, otherwise the theme actually applied via the provider.
137
+ const resolvedTheme: ThemeName =
138
+ isControlled && preference !== undefined && preference !== "system" ? preference : theme;
139
+
140
+ // Hydrate the "system" choice from localStorage and apply it on mount —
141
+ // uncontrolled mode only. Controlled mode's "system" state comes from the
142
+ // `preference` prop and never touches this component's own storage key.
143
+ useEffect(() => {
144
+ if (isControlled) return;
145
+ if (typeof window === "undefined") return;
146
+ const active = window.localStorage.getItem(SYSTEM_STORAGE_KEY) === "1";
147
+ setUncontrolledIsSystem(active);
148
+ if (active) setTheme(prefersDark() ? darkTheme : lightTheme);
149
+ // Mount-once hydration by design (mirrors the pre-#366 behavior); a
150
+ // component must not flip controlled/uncontrolled mid-lifecycle
151
+ // (.claude/rules/component-api.md), so `isControlled` cannot change here.
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, []);
154
+
155
+ // Keep the applied theme tracking the OS scheme while "system" is active —
156
+ // controlled or not. Depends on every value it reads (unlike the effect
157
+ // above) so it never captures a stale `lightTheme`/`darkTheme` once those
158
+ // become provider-derived (the staleness risk #384 flags).
159
+ useEffect(() => {
160
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
161
+ const mql = window.matchMedia(DARK_QUERY);
162
+ const onChange = () => {
163
+ const active = isControlled
164
+ ? preference === "system"
165
+ : window.localStorage.getItem(SYSTEM_STORAGE_KEY) === "1";
166
+ if (active) setTheme(prefersDark() ? darkTheme : lightTheme);
167
+ };
168
+ mql.addEventListener("change", onChange);
169
+ return () => mql.removeEventListener("change", onChange);
170
+ }, [isControlled, preference, darkTheme, lightTheme, setTheme]);
171
+
172
+ const pickTheme = useCallback(
173
+ (next: ThemeName) => {
174
+ if (isControlled) {
175
+ onPreferenceChange?.(next);
176
+ } else {
177
+ setUncontrolledIsSystem(false);
178
+ if (typeof window !== "undefined") window.localStorage.setItem(SYSTEM_STORAGE_KEY, "0");
179
+ }
180
+ switchTheme(next);
181
+ },
182
+ [switchTheme, isControlled, onPreferenceChange],
183
+ );
184
+
185
+ const pickSystem = useCallback(() => {
186
+ if (isControlled) {
187
+ onPreferenceChange?.("system");
188
+ } else {
189
+ setUncontrolledIsSystem(true);
190
+ if (typeof window !== "undefined") window.localStorage.setItem(SYSTEM_STORAGE_KEY, "1");
191
+ }
192
+ switchTheme(prefersDark() ? darkTheme : lightTheme);
193
+ }, [switchTheme, darkTheme, lightTheme, isControlled, onPreferenceChange]);
194
+
195
+ const TriggerIcon = isSystem ? Monitor : isDark(resolvedTheme) ? Moon : Sun;
196
+ const useDropdown = mode === "dropdown" || (mode === "auto" && safeThemes.length > 2);
197
+
198
+ // ---- Dropdown mode (>2 themes) ----
199
+ if (useDropdown) {
200
+ return (
201
+ <DropdownMenu>
202
+ <DropdownMenuTrigger asChild>
203
+ <Button
204
+ ref={ref}
205
+ variant="outline"
206
+ size={ICON_SIZE[size]}
207
+ aria-label="Theme"
208
+ className={className}
209
+ {...props}
210
+ >
211
+ <TriggerIcon />
212
+ </Button>
213
+ </DropdownMenuTrigger>
214
+ <DropdownMenuContent align="end">
215
+ {showSystem ? (
216
+ <DropdownMenuItem onSelect={() => pickSystem()}>
217
+ <Monitor />
218
+ <span>System</span>
219
+ {isSystem ? <Check className="ml-auto" /> : null}
220
+ </DropdownMenuItem>
221
+ ) : null}
222
+ {offered.map((d) => (
223
+ <DropdownMenuItem key={d.value} onSelect={() => pickTheme(d.value)}>
224
+ {d.dark ? <Moon /> : <Sun />}
225
+ <span>{d.label}</span>
226
+ {!isSystem && resolvedTheme === d.value ? <Check className="ml-auto" /> : null}
227
+ </DropdownMenuItem>
228
+ ))}
229
+ </DropdownMenuContent>
230
+ </DropdownMenu>
231
+ );
232
+ }
233
+
234
+ // ---- Toggle mode (≤2 themes): cycle light → dark → (system) ----
235
+ // These three are ROLES in the cycle, not theme names — `lightTheme` and
236
+ // `darkTheme` resolve each role to whatever this app actually registered.
237
+ const order: Array<"light" | "dark" | "system"> = showSystem
238
+ ? ["light", "dark", "system"]
239
+ : ["light", "dark"];
240
+ const current: "light" | "dark" | "system" = isSystem
241
+ ? "system"
242
+ : resolvedTheme === darkTheme
243
+ ? "dark"
244
+ : "light";
245
+ const next = order[(order.indexOf(current) + 1) % order.length];
246
+ const apply = () => {
247
+ if (next === "system") pickSystem();
248
+ else pickTheme(next === "dark" ? darkTheme : lightTheme);
249
+ };
250
+
251
+ // Announce the THEME'S OWN label, not the role: an app whose themes are
252
+ // "Daylight"/"Midnight" would otherwise announce "Theme: light" — naming a
253
+ // theme that does not exist in that app.
254
+ // `role` is optional only because `order[i]` is an indexed read under
255
+ // `noUncheckedIndexedAccess`; the modulo above makes it always present.
256
+ const roleLabel = (role: "light" | "dark" | "system" | undefined) =>
257
+ role === "system" ? "System" : labelOf(role === "dark" ? darkTheme : lightTheme);
258
+
259
+ return (
260
+ <TooltipProvider>
261
+ <Tooltip>
262
+ <TooltipTrigger asChild>
263
+ <Button
264
+ ref={ref}
265
+ variant="ghost"
266
+ size={ICON_SIZE[size]}
267
+ aria-label={`Theme: ${roleLabel(current)}. Activate to switch to ${roleLabel(next)}.`}
268
+ onClick={apply}
269
+ className={className}
270
+ {...props}
271
+ >
272
+ <TriggerIcon />
273
+ </Button>
274
+ </TooltipTrigger>
275
+ <TooltipContent>{roleLabel(current)}</TooltipContent>
276
+ </Tooltip>
277
+ </TooltipProvider>
278
+ );
279
+ },
280
+ );
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useReducedMotion, useTheme, type ThemeName } from "@elabs-ai/components-tokens";
5
+
6
+ /** Whole-screen reveal effects for the animated theme switch (View Transitions API). */
7
+ export type ThemeTransitionEffect = "polygon" | "circle" | "circle-blur" | "triangle";
8
+
9
+ type ViewTransitionDocument = Document & {
10
+ startViewTransition?: (callback: () => void | Promise<void>) => { finished: Promise<void> };
11
+ };
12
+
13
+ /**
14
+ * Returns a theme setter that animates the whole-screen switch via the View
15
+ * Transitions API (the chanhdai.com "theme toggle effect", adapted to brand-ui).
16
+ *
17
+ * - Direction-aware: sets `data-vt="to-dark|to-light"` on `<html>` from the
18
+ * target theme's `dark` flag, so the clip-path wipes the right way for ANY of
19
+ * the three themes (not just light/dark).
20
+ * - `data-vt-effect` selects the reveal shape (default "polygon").
21
+ * - Motion-aware: under `useReducedMotion()` (OS or in-app preference) — or when
22
+ * the browser lacks `startViewTransition` — it falls back to an instant,
23
+ * unanimated `setTheme`.
24
+ *
25
+ * The visual is pure CSS on `::view-transition-*` in `@elabs-ai/components-tokens` themes.css;
26
+ * this hook only orchestrates it.
27
+ */
28
+ export function useThemeTransition(effect: ThemeTransitionEffect = "polygon") {
29
+ const { theme, setTheme, themeDefinitions } = useTheme();
30
+ const reduced = useReducedMotion();
31
+
32
+ return useCallback(
33
+ (next: ThemeName) => {
34
+ if (next === theme) return;
35
+ if (typeof document === "undefined") {
36
+ setTheme(next);
37
+ return;
38
+ }
39
+ const doc = document as ViewTransitionDocument;
40
+ if (reduced || typeof doc.startViewTransition !== "function") {
41
+ setTheme(next);
42
+ return;
43
+ }
44
+ const root = document.documentElement;
45
+ // The direction of the reveal comes from the provider's REGISTRY entry
46
+ // for the incoming theme (ADR 0029) — a consumer theme animates correctly
47
+ // without registering here. `resolveThemeIsDark` is deliberately NOT used:
48
+ // it reads the CURRENT computed `color-scheme`, and `next` has not been
49
+ // applied yet, so it would report the OUTGOING theme's direction.
50
+ root.dataset.vt = themeDefinitions.find((d) => d.value === next)?.dark
51
+ ? "to-dark"
52
+ : "to-light";
53
+ root.dataset.vtEffect = effect;
54
+ const transition = doc.startViewTransition(() => setTheme(next));
55
+ transition.finished.finally(() => {
56
+ delete root.dataset.vt;
57
+ delete root.dataset.vtEffect;
58
+ });
59
+ },
60
+ [theme, setTheme, reduced, effect, themeDefinitions],
61
+ );
62
+ }
@@ -0,0 +1,13 @@
1
+ // NOTE: `TimelineStatus` (the editor's 3-state vocabulary) is deliberately NOT
2
+ // re-exported here — it already reaches the @elabs-ai/components-ui barrel via
3
+ // ./components/status-badge; exporting it twice would make the star-exports
4
+ // ambiguous.
5
+ export {
6
+ Timeline,
7
+ TimelineItem,
8
+ TimelineRoot,
9
+ type TimelineEntry,
10
+ type TimelineItemProps,
11
+ type TimelineProps,
12
+ type TimelineRootProps,
13
+ } from "./timeline";
@@ -0,0 +1,99 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+ import { Timeline, TimelineItem, TimelineRoot } from "./timeline";
4
+
5
+ const meta = {
6
+ title: "Core/Timeline",
7
+ component: Timeline,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ layout: "padded",
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "Ordered steps with a vertical connector and status-colored nodes — THE shared " +
15
+ "rail spine (#190). Two front doors over one rail: the array `items` API (the " +
16
+ "editor's `:::timeline` target, `done|active|pending`) and the compound " +
17
+ "`TimelineRoot`/`TimelineItem` parts, whose nodes take the canonical 7-state " +
18
+ "`Status` (status-badge). Token-driven and motion-free.",
19
+ },
20
+ },
21
+ },
22
+ argTypes: {
23
+ items: {
24
+ description:
25
+ "Array of timeline entries (array-API front door; maps `done|active|pending` status).",
26
+ control: false,
27
+ table: { category: "Content" },
28
+ },
29
+ className: {
30
+ description: "Extra Tailwind classes merged via cn().",
31
+ control: "text",
32
+ table: { category: "Appearance" },
33
+ },
34
+ },
35
+ } satisfies Meta<typeof Timeline>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ items: [
43
+ { title: "Draft", description: "Initial scope frozen", status: "done", timestamp: "Jun 4" },
44
+ {
45
+ title: "Review",
46
+ description: "With the migration team",
47
+ status: "active",
48
+ timestamp: "Jun 6",
49
+ },
50
+ { title: "Publish", status: "pending" },
51
+ ],
52
+ },
53
+ play: async ({ canvasElement }) => {
54
+ const canvas = within(canvasElement);
55
+ await waitFor(() => expect(canvas.getByText("Review")).toBeVisible());
56
+ expect(canvas.getAllByRole("listitem")).toHaveLength(3);
57
+ },
58
+ };
59
+
60
+ export const Compound: Story = {
61
+ args: { items: [] },
62
+ render: () => (
63
+ <TimelineRoot aria-label="Agent run">
64
+ <TimelineItem status="complete" timestamp="14:02">
65
+ Searched financial filings
66
+ </TimelineItem>
67
+ <TimelineItem status="complete" description="8 rows reconciled · 0 variances">
68
+ Queried finance.revenue
69
+ </TimelineItem>
70
+ <TimelineItem status="running">Computing QoQ deltas</TimelineItem>
71
+ <TimelineItem status="pending">Draft the board note</TimelineItem>
72
+ </TimelineRoot>
73
+ ),
74
+ play: async ({ canvasElement }) => {
75
+ const canvas = within(canvasElement);
76
+ await waitFor(() => expect(canvas.getByText("Computing QoQ deltas")).toBeVisible());
77
+ expect(canvas.getAllByRole("listitem")).toHaveLength(4);
78
+ },
79
+ };
80
+
81
+ export const CanonicalStatuses: Story = {
82
+ args: { items: [] },
83
+ render: () => (
84
+ <TimelineRoot aria-label="All canonical statuses">
85
+ <TimelineItem status="pending">Pending</TimelineItem>
86
+ <TimelineItem status="running">Running</TimelineItem>
87
+ <TimelineItem status="complete">Complete</TimelineItem>
88
+ <TimelineItem status="awaiting-approval">Awaiting approval</TimelineItem>
89
+ <TimelineItem status="denied">Denied</TimelineItem>
90
+ <TimelineItem status="failed">Failed</TimelineItem>
91
+ <TimelineItem status="skipped">Skipped</TimelineItem>
92
+ </TimelineRoot>
93
+ ),
94
+ play: async ({ canvasElement }) => {
95
+ const canvas = within(canvasElement);
96
+ expect(canvas.getAllByRole("listitem")).toHaveLength(7);
97
+ await waitFor(() => expect(canvas.getByText("Awaiting approval")).toBeVisible());
98
+ },
99
+ };
@@ -0,0 +1,196 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+
4
+ import { NODE_STYLE, Timeline, TimelineItem, TimelineRoot } from "./timeline";
5
+ import { STATUS_ROLE, STATUSES, statusBadgeVariants, type Status } from "../status-badge";
6
+
7
+ afterEach(cleanup);
8
+
9
+ // #392 — one vocabulary, one colour. `NODE_STYLE` (the rail dot) is a literal
10
+ // duplicate of the status→role mapping `STATUS_ROLE`/`statusBadgeVariants` own;
11
+ // this block is what stops the duplicate from silently drifting again (it did,
12
+ // for `running`: the dot rendered `--primary` while the badge rendered `--info`).
13
+ // Only the four CHROMATIC statuses are asserted — `pending`/`skipped`/`denied`
14
+ // are a deliberate, documented neutral divergence (a hollow dot vs a filled
15
+ // pill), not a bug: `NODE_STYLE.pending` uses `bg-background` (hollow, "not yet")
16
+ // where `statusBadgeVariants({status:"pending"})` uses a filled `bg-secondary`
17
+ // pill — a legitimate design choice a dot and a pill make differently.
18
+ describe("Timeline NODE_STYLE — one vocabulary, one colour (#392)", () => {
19
+ const CHROMATIC: Status[] = ["running", "complete", "awaiting-approval", "failed"];
20
+ const role = (classes: string) => classes.match(/\bbg-([a-z-]+?)(?:\/\d+)?\b/)?.[1];
21
+
22
+ it.each(CHROMATIC)(
23
+ "%s renders the same semantic role on the rail node and the badge",
24
+ (status) => {
25
+ expect(role(NODE_STYLE[status])).toBe(role(statusBadgeVariants({ status })));
26
+ expect(role(NODE_STYLE[status])).toBe(STATUS_ROLE[status]);
27
+ },
28
+ );
29
+
30
+ it("keeps the running ring halo on the same role as its fill", () => {
31
+ expect(NODE_STYLE.running).toMatch(/\bring-info\/25\b/);
32
+ expect(role(NODE_STYLE.running)).toBe("info");
33
+ });
34
+ });
35
+
36
+ // #387 — colour is never the only channel (WCAG 1.4.1). `NODE_STYLE` used to
37
+ // encode status in fill hue alone: `denied`/`skipped` were byte-identical, and
38
+ // `complete`/`awaiting-approval`/`failed` were shape-identical filled dots
39
+ // differing only by hue. An earlier version of this fix reached for
40
+ // `border-style` (dashed/dotted) on the vivid quartet too — a RENDERED check
41
+ // caught that it is invisible there (border colour == fill colour, so a dash
42
+ // gap reveals nothing) even though it read as "clearly different" in the
43
+ // class string. The vivid quartet (`running`/`complete`/`awaiting-approval`/
44
+ // `failed`) is differentiated by `ring-*` WIDTH instead (0/1/2/4 — a ring
45
+ // contrasts against the PAGE, not the fill, which is why it actually paints).
46
+ // `denied`/`skipped` keep `border-style` because THEIR border and fill
47
+ // genuinely differ in colour, where it does paint. See the class-map comment
48
+ // in `timeline.tsx` and the #387 rendered-proof zoomed/greyscale screenshots.
49
+ describe("Timeline NODE_STYLE — colour is never the only channel (#387)", () => {
50
+ const signature = (status: Status) => {
51
+ const classes = NODE_STYLE[status];
52
+ const fill = classes.match(/\bbg-[a-z-]+\b/)?.[0];
53
+ const style = classes.match(/\bborder-(dashed|dotted|double)\b/)?.[1] ?? "solid";
54
+ const ringWidth = classes.match(/\bring-(\d+)\b/)?.[1] ?? "0";
55
+ return `${fill}|${style}|ring-${ringWidth}`;
56
+ };
57
+
58
+ it("gives every status a unique non-colour (fill + border-style + ring-width) signature", () => {
59
+ const signatures = STATUSES.map(signature);
60
+ expect(new Set(signatures).size).toBe(STATUSES.length);
61
+ });
62
+
63
+ it("distinguishes denied, skipped and pending from one another (previously denied ≡ skipped)", () => {
64
+ expect(NODE_STYLE.denied).not.toBe(NODE_STYLE.skipped);
65
+ expect(signature("denied")).not.toBe(signature("skipped"));
66
+ expect(signature("pending")).not.toBe(signature("denied"));
67
+ expect(signature("pending")).not.toBe(signature("skipped"));
68
+ });
69
+
70
+ it("distinguishes complete, awaiting-approval and failed from one another (previously hue-only)", () => {
71
+ expect(signature("complete")).not.toBe(signature("awaiting-approval"));
72
+ expect(signature("complete")).not.toBe(signature("failed"));
73
+ expect(signature("awaiting-approval")).not.toBe(signature("failed"));
74
+ });
75
+
76
+ it("gives the vivid quartet (running/complete/awaiting-approval/failed) four distinct ring widths", () => {
77
+ const widths = ["running", "complete", "awaiting-approval", "failed"].map(
78
+ (s) => NODE_STYLE[s as Status].match(/\bring-(\d+)\b/)?.[1] ?? "0",
79
+ );
80
+ expect(new Set(widths).size).toBe(widths.length);
81
+ });
82
+
83
+ it("names the status for assistive tech — data-status alone isn't AT-visible", () => {
84
+ render(
85
+ <TimelineRoot>
86
+ <TimelineItem status="running">Computing QoQ deltas</TimelineItem>
87
+ <TimelineItem status="failed">Queried finance.revenue</TimelineItem>
88
+ <TimelineItem status="skipped">Draft the board note</TimelineItem>
89
+ </TimelineRoot>,
90
+ );
91
+ const [running, failed, skipped] = screen.getAllByRole("listitem");
92
+ expect(running).toHaveTextContent("Running: Computing QoQ deltas");
93
+ expect(failed).toHaveTextContent("Failed: Queried finance.revenue");
94
+ expect(skipped).toHaveTextContent("Skipped: Draft the board note");
95
+ });
96
+ });
97
+
98
+ describe("Timeline (array API — the editor-facing surface, #190)", () => {
99
+ it("renders one list item per timeline step", () => {
100
+ render(
101
+ <Timeline
102
+ items={[
103
+ { title: "Draft", status: "done" },
104
+ { title: "Review", status: "active", description: "in progress" },
105
+ { title: "Publish", status: "pending" },
106
+ ]}
107
+ />,
108
+ );
109
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
110
+ expect(screen.getByText("Review")).toBeInTheDocument();
111
+ expect(screen.getByText("in progress")).toBeInTheDocument();
112
+ });
113
+
114
+ it("maps the editor 3-state vocabulary onto the canonical Status", () => {
115
+ render(
116
+ <Timeline
117
+ items={[
118
+ { title: "Draft", status: "done" },
119
+ { title: "Review", status: "active" },
120
+ { title: "Publish" }, // status omitted → pending
121
+ ]}
122
+ />,
123
+ );
124
+ const [done, active, pending] = screen.getAllByRole("listitem");
125
+ expect(done).toHaveAttribute("data-status", "complete");
126
+ expect(active).toHaveAttribute("data-status", "running");
127
+ expect(pending).toHaveAttribute("data-status", "pending");
128
+ });
129
+ });
130
+
131
+ describe("TimelineRoot/TimelineItem (compound API)", () => {
132
+ it("composes items with canonical statuses and rich children", () => {
133
+ render(
134
+ <TimelineRoot data-testid="rail">
135
+ <TimelineItem status="complete" timestamp="Jun 4">
136
+ Searched filings
137
+ </TimelineItem>
138
+ <TimelineItem status="failed" description={<em>quota exceeded</em>}>
139
+ Queried finance.revenue
140
+ </TimelineItem>
141
+ </TimelineRoot>,
142
+ );
143
+ expect(screen.getByTestId("rail").tagName).toBe("OL");
144
+ const items = screen.getAllByRole("listitem");
145
+ expect(items).toHaveLength(2);
146
+ expect(items[0]).toHaveAttribute("data-status", "complete");
147
+ expect(items[1]).toHaveAttribute("data-status", "failed");
148
+ expect(screen.getByText("Jun 4")).toBeInTheDocument();
149
+ expect(screen.getByText("quota exceeded")).toBeInTheDocument();
150
+ });
151
+
152
+ it("renders the block detail slot under the node (#192 AgentStep host)", () => {
153
+ render(
154
+ <TimelineRoot>
155
+ <TimelineItem
156
+ status="complete"
157
+ detail={<div data-testid="detail">8 rows · 0 variances</div>}
158
+ >
159
+ Queried finance.revenue
160
+ </TimelineItem>
161
+ </TimelineRoot>,
162
+ );
163
+ expect(screen.getByTestId("detail")).toBeInTheDocument();
164
+ });
165
+
166
+ // #381 — the `awaiting-approval` node is a colour-ONLY mark (a ~10px dot whose
167
+ // border is the same tone, so there is no rescuing hairline). It must keep
168
+ // reaching for the status FILL rung, which is the rung the token contract
169
+ // guarantees ≥3:1 on every content surface (themes-contrast.test.ts). A future
170
+ // edit that swaps it for a wash (`bg-warning/10`) or the plate-ink rung
171
+ // (`text-warning-foreground`) would silently drop it back under 1.4.11.
172
+ it("draws the awaiting-approval node with the warning MARK rung (1.4.11)", () => {
173
+ render(
174
+ <TimelineRoot>
175
+ <TimelineItem status="awaiting-approval">Awaiting sign-off</TimelineItem>
176
+ </TimelineRoot>,
177
+ );
178
+ const node = screen.getByRole("listitem").querySelector(".bg-warning");
179
+ expect(node).not.toBeNull();
180
+ expect(node).toHaveClass("border-warning");
181
+ expect(node?.className).not.toMatch(/bg-warning\//);
182
+ });
183
+
184
+ it("merges className and spreads props on the li", () => {
185
+ render(
186
+ <TimelineRoot>
187
+ <TimelineItem className="custom-item" aria-label="step">
188
+ Step
189
+ </TimelineItem>
190
+ </TimelineRoot>,
191
+ );
192
+ const li = screen.getByRole("listitem");
193
+ expect(li).toHaveClass("custom-item");
194
+ expect(li).toHaveAttribute("aria-label", "step");
195
+ });
196
+ });