@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,75 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ /** How long the transient "copied" state stays true before it resets. */
6
+ export const COPY_FEEDBACK_MS = 1500;
7
+
8
+ export interface UseCopyToClipboardOptions {
9
+ /** ms the `copied` flag stays true. Default: {@link COPY_FEEDBACK_MS}. */
10
+ resetAfterMs?: number;
11
+ }
12
+
13
+ export interface CopyToClipboard {
14
+ /** True for `resetAfterMs` after a successful copy. */
15
+ copied: boolean;
16
+ /** Writes `text` to the clipboard. Resolves `false` when it could not. */
17
+ copy: (text: string) => Promise<boolean>;
18
+ }
19
+
20
+ /**
21
+ * Copy-to-clipboard with a transient confirmation flag.
22
+ *
23
+ * One implementation for the whole library — the copy button in
24
+ * `@elabs-ai/components-editor` and `CopyableValue` share it, so the
25
+ * timing, the unmount cleanup and the "no clipboard here" answer are defined
26
+ * once instead of re-derived per component.
27
+ *
28
+ * `navigator.clipboard` is absent under SSR, in jsdom, and in any insecure
29
+ * (non-`https`, non-`localhost`) context. That is reported as `false` rather
30
+ * than thrown: a copy affordance failing is a papercut, not a crash, and the
31
+ * caller decides whether to say anything about it. The deprecated
32
+ * `document.execCommand("copy")` fallback is deliberately not used.
33
+ */
34
+ export function useCopyToClipboard(options?: UseCopyToClipboardOptions): CopyToClipboard {
35
+ const resetAfterMs = options?.resetAfterMs ?? COPY_FEEDBACK_MS;
36
+ const [copied, setCopied] = useState(false);
37
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+
39
+ // A component that unmounts inside the feedback window must not have its
40
+ // state set afterwards, and a second copy must restart the window, not race
41
+ // the first one's reset.
42
+ useEffect(
43
+ () => () => {
44
+ if (timerRef.current !== null) {
45
+ clearTimeout(timerRef.current);
46
+ }
47
+ },
48
+ [],
49
+ );
50
+
51
+ const copy = useCallback(
52
+ async (text: string): Promise<boolean> => {
53
+ if (typeof navigator === "undefined" || !navigator.clipboard) {
54
+ return false;
55
+ }
56
+ try {
57
+ await navigator.clipboard.writeText(text);
58
+ } catch {
59
+ return false;
60
+ }
61
+ setCopied(true);
62
+ if (timerRef.current !== null) {
63
+ clearTimeout(timerRef.current);
64
+ }
65
+ timerRef.current = setTimeout(() => {
66
+ timerRef.current = null;
67
+ setCopied(false);
68
+ }, resetAfterMs);
69
+ return true;
70
+ },
71
+ [resetAfterMs],
72
+ );
73
+
74
+ return { copied, copy };
75
+ }
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ /** Returns true when the viewport is below the mobile breakpoint (768px). */
6
+ export function useIsMobile(): boolean {
7
+ const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
8
+ useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
11
+ mql.addEventListener("change", onChange);
12
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
13
+ return () => mql.removeEventListener("change", onChange);
14
+ }, []);
15
+ return !!isMobile;
16
+ }
@@ -0,0 +1,266 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Button } from "./components/button";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/card";
6
+ import { RevealGroup } from "./components/reveal";
7
+
8
+ /**
9
+ * Foundation/Motion — demonstrates and VERIFIES the token-driven motion gate.
10
+ *
11
+ * The gate is a single CSS multiplier (`--motion-factor`) that scales every
12
+ * duration token (`--t-fast`/`--t-base`/...). It is set by, in precedence order,
13
+ * the user's `data-motion-pref` (toolbar "Motion" global), the OS
14
+ * `prefers-reduced-motion` setting, and the per-theme default. Flip the **Motion**
15
+ * toolbar control (System / Reduce / Full) and the **theme** control to sweep all
16
+ * combinations. The play functions below assert the gate mechanically.
17
+ */
18
+
19
+ /** Live readout of the resolved gate values (updates when data-motion-pref changes). */
20
+ function GateReadout() {
21
+ const probeRef = useRef<HTMLSpanElement>(null);
22
+ const [factor, setFactor] = useState("—");
23
+ const [effective, setEffective] = useState("—");
24
+
25
+ useEffect(() => {
26
+ const read = () => {
27
+ const root = document.documentElement;
28
+ setFactor(getComputedStyle(root).getPropertyValue("--motion-factor").trim() || "1");
29
+ if (probeRef.current) {
30
+ setEffective(getComputedStyle(probeRef.current).transitionDuration);
31
+ }
32
+ };
33
+ read();
34
+ const observer = new MutationObserver(read);
35
+ observer.observe(document.documentElement, {
36
+ attributes: true,
37
+ attributeFilter: ["data-motion-pref"],
38
+ });
39
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
40
+ mql.addEventListener("change", read);
41
+ return () => {
42
+ observer.disconnect();
43
+ mql.removeEventListener("change", read);
44
+ };
45
+ }, []);
46
+
47
+ return (
48
+ <div className="text-muted-foreground flex flex-col gap-1 text-body">
49
+ {/* Probe: drives a real property off the gated token so its computed value
50
+ reflects the effective duration the gate produces. */}
51
+ <span
52
+ ref={probeRef}
53
+ data-testid="motion-probe"
54
+ style={{ transitionProperty: "opacity", transitionDuration: "var(--t-base)" }}
55
+ aria-hidden
56
+ className="sr-only"
57
+ />
58
+ <span>
59
+ <span className="text-foreground font-medium">--motion-factor:</span> {factor}
60
+ </span>
61
+ <span>
62
+ <span className="text-foreground font-medium">effective --t-base:</span> {effective}
63
+ </span>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function MotionDemo() {
69
+ const [open, setOpen] = useState(false);
70
+ const [runId, setRunId] = useState(0);
71
+ return (
72
+ <div className="flex w-[28rem] max-w-full flex-col gap-6">
73
+ <Card>
74
+ <CardHeader>
75
+ <CardTitle>Micro-interactions</CardTitle>
76
+ <CardDescription>
77
+ Flip the <strong>Motion</strong> toolbar (System / Reduce / Full) and the theme — every
78
+ transition below is gated by <code>--motion-factor</code>.
79
+ </CardDescription>
80
+ </CardHeader>
81
+ <CardContent className="flex flex-col gap-5">
82
+ {/* Hover-lift: Card's `interactive` variant glides up + shadow on hover. */}
83
+ <div className="grid grid-cols-3 gap-3">
84
+ {["Hover", "these", "cards"].map((label) => (
85
+ <Card
86
+ key={label}
87
+ interactive
88
+ className="flex h-16 items-center justify-center text-body shadow-none"
89
+ >
90
+ {label}
91
+ </Card>
92
+ ))}
93
+ </div>
94
+
95
+ {/* Press: scale feedback (gated --t-fast, from the Button base recipe). */}
96
+ <div className="flex flex-wrap gap-3">
97
+ <Button>Press me</Button>
98
+ <Button variant="secondary">Secondary</Button>
99
+ <Button variant="outline" onClick={() => setOpen((o) => !o)} aria-expanded={open}>
100
+ {open ? "Collapse" : "Expand"}
101
+ </Button>
102
+ </div>
103
+
104
+ {/* Expand/collapse: grid-rows trick, animated over the gated --t-base. */}
105
+ <div
106
+ className="grid transition-[grid-template-rows] duration-base ease-standard"
107
+ style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
108
+ >
109
+ <div className="overflow-hidden">
110
+ <p className="text-muted-foreground text-body">
111
+ Expands over <code>--t-base</code>; snaps near-instantly when motion is reduced (but
112
+ never a literal 0s, so transitionend still fires).
113
+ </p>
114
+ </div>
115
+ </div>
116
+
117
+ {/* Staggered entrance — the clearest on/off tell. Replay remounts the list. */}
118
+ <div className="flex items-center justify-between">
119
+ <span className="text-muted-foreground text-body">Staggered entrance</span>
120
+ <Button size="sm" variant="ghost" onClick={() => setRunId((n) => n + 1)}>
121
+ Replay
122
+ </Button>
123
+ </div>
124
+ <RevealGroup key={runId} className="flex flex-col gap-2" appear="up" staggerMs={60}>
125
+ {["First row", "Second row", "Third row", "Fourth row"].map((row) => (
126
+ <div key={row} className="bg-surface-muted rounded-md border px-3 py-2 text-body">
127
+ {row}
128
+ </div>
129
+ ))}
130
+ </RevealGroup>
131
+ </CardContent>
132
+ </Card>
133
+ <GateReadout />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ const meta = {
139
+ title: "Foundations/Motion",
140
+ tags: ["autodocs"],
141
+ parameters: { layout: "centered" },
142
+ } satisfies Meta;
143
+
144
+ export default meta;
145
+ type Story = StoryObj<typeof meta>;
146
+
147
+ /** Visual demo — drive the toolbar "Motion" + theme controls to sweep states. */
148
+ export const Playground: Story = {
149
+ render: () => <MotionDemo />,
150
+ };
151
+
152
+ /**
153
+ * Reads the EFFECTIVE transition duration of a probe element under explicit
154
+ * `data-motion-pref="full"`. Explicit "full" overrides any OS reduce request, so
155
+ * this is environment-independent: it must resolve to the full ~0.18s base.
156
+ */
157
+ export const GateFullMotion: Story = {
158
+ render: () => <MotionDemo />,
159
+ play: async ({ canvasElement }) => {
160
+ const root = document.documentElement;
161
+ const previous = root.getAttribute("data-motion-pref");
162
+ try {
163
+ root.setAttribute("data-motion-pref", "full");
164
+ const probe = canvasElement.querySelector<HTMLElement>('[data-testid="motion-probe"]')!;
165
+ // getComputedStyle forces a synchronous style flush, so the new gate value
166
+ // is reflected immediately.
167
+ const seconds = parseFloat(getComputedStyle(probe).transitionDuration);
168
+ await expect(seconds).toBeGreaterThanOrEqual(0.15);
169
+ } finally {
170
+ if (previous) root.setAttribute("data-motion-pref", previous);
171
+ else root.removeAttribute("data-motion-pref");
172
+ }
173
+ },
174
+ };
175
+
176
+ /**
177
+ * Verifies the gate actually GATES: under `data-motion-pref="reduced"` the same
178
+ * probe collapses to a tiny fraction of the full duration (≥100× smaller) while
179
+ * staying strictly > 0 (the --motion-min floor — a literal 0s would never fire
180
+ * transitionend and would strand Radix's unmount-suspension).
181
+ */
182
+ export const GateReducedMotion: Story = {
183
+ render: () => <MotionDemo />,
184
+ play: async ({ canvasElement }) => {
185
+ const root = document.documentElement;
186
+ const previous = root.getAttribute("data-motion-pref");
187
+ const probe = canvasElement.querySelector<HTMLElement>('[data-testid="motion-probe"]')!;
188
+ try {
189
+ root.setAttribute("data-motion-pref", "full");
190
+ const full = parseFloat(getComputedStyle(probe).transitionDuration);
191
+ root.setAttribute("data-motion-pref", "reduced");
192
+ const reduced = parseFloat(getComputedStyle(probe).transitionDuration);
193
+
194
+ await expect(full).toBeGreaterThanOrEqual(0.15); // full motion intact
195
+ await expect(reduced).toBeLessThan(full * 0.5); // gate clearly engaged
196
+ await expect(reduced).toBeLessThan(0.05); // effectively off
197
+ await expect(reduced).toBeGreaterThanOrEqual(0); // never negative / NaN
198
+ } finally {
199
+ if (previous) root.setAttribute("data-motion-pref", previous);
200
+ else root.removeAttribute("data-motion-pref");
201
+ }
202
+ },
203
+ };
204
+
205
+ /**
206
+ * Guardrail for the load-bearing `--motion-min` floor: even at the worst case
207
+ * (`--motion-factor` = 0) the effective duration must stay strictly > 0s. A
208
+ * literal 0s never fires `transitionend`, which would strand Radix's CSS
209
+ * unmount-suspension (overlays would never unmount). Locks the `max(…, --motion-min)`
210
+ * in themes.css against a future "simplify" that drops the floor.
211
+ */
212
+ export const GateFloorNeverZero: Story = {
213
+ render: () => (
214
+ <span
215
+ data-testid="floor-probe"
216
+ aria-hidden
217
+ style={{
218
+ transitionProperty: "opacity",
219
+ transitionDuration: "max(calc(var(--duration-base) * 0), var(--motion-min))",
220
+ }}
221
+ />
222
+ ),
223
+ play: async ({ canvasElement }) => {
224
+ const probe = canvasElement.querySelector<HTMLElement>('[data-testid="floor-probe"]')!;
225
+ const seconds = parseFloat(getComputedStyle(probe).transitionDuration);
226
+ await expect(seconds).toBeGreaterThan(0);
227
+ },
228
+ };
229
+
230
+ /**
231
+ * Proves the Tailwind UTILITIES (not just the raw CSS vars) are generated and
232
+ * resolve to the tuned, gated scale (fast ≈0.16s, base ≈0.26s, slow ≈0.38s), and
233
+ * that `ease-standard` is the standard cubic-bezier — these are what components
234
+ * actually use. If a long-running dev server hasn't picked up the @theme
235
+ * additions, these resolve to defaults/none and this fails.
236
+ */
237
+ export const UtilitiesGenerated: Story = {
238
+ render: () => (
239
+ <div>
240
+ <span data-testid="util-fast" className="transition-opacity duration-fast ease-standard" />
241
+ <span data-testid="util-base" className="transition-opacity duration-base ease-standard" />
242
+ <span data-testid="util-slow" className="transition-opacity duration-slow ease-standard" />
243
+ </div>
244
+ ),
245
+ play: async ({ canvasElement }) => {
246
+ const dur = (id: string) =>
247
+ parseFloat(
248
+ getComputedStyle(canvasElement.querySelector<HTMLElement>(`[data-testid="${id}"]`)!)
249
+ .transitionDuration,
250
+ );
251
+ const fast = dur("util-fast");
252
+ const base = dur("util-base");
253
+ const slow = dur("util-slow");
254
+ // Tuned scale (full motion): fast 0.16s < base 0.26s < slow 0.38s — and clearly
255
+ // in the "smooth" range, not the original snappy 0.12/0.18/0.28.
256
+ await expect(fast).toBeGreaterThanOrEqual(0.14);
257
+ await expect(base).toBeGreaterThan(fast);
258
+ await expect(base).toBeGreaterThanOrEqual(0.24);
259
+ await expect(slow).toBeGreaterThan(base);
260
+ await expect(slow).toBeGreaterThanOrEqual(0.34);
261
+ const tf = getComputedStyle(
262
+ canvasElement.querySelector<HTMLElement>('[data-testid="util-fast"]')!,
263
+ ).transitionTimingFunction;
264
+ await expect(tf).toContain("cubic-bezier"); // ease-standard applied
265
+ },
266
+ };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * taste-profile.test.tsx — the RENDERED half of the taste profile (#108).
3
+ *
4
+ * The type/default surface is locked in `packages/tokens/src/taste-profile.test.ts`;
5
+ * this file exercises the real `<ThemeProvider>` in a DOM, which `@…-tokens`
6
+ * cannot do (its vitest environment is `node` by design — no React, no jsdom).
7
+ * `@…-ui` already ships the jsdom + Testing Library setup and legitimately
8
+ * depends on tokens (one-way dep graph), so the provider's live behaviour is
9
+ * proven here rather than mocked anywhere.
10
+ */
11
+ import { useState } from "react";
12
+ import { describe, expect, it, beforeEach } from "vitest";
13
+ import { render, screen } from "@testing-library/react";
14
+ import userEvent from "@testing-library/user-event";
15
+ import {
16
+ DEFAULT_TASTE_PROFILE,
17
+ DEFAULT_THEME,
18
+ BUILT_IN_THEME_META,
19
+ ThemeProvider,
20
+ useDecoration,
21
+ useDensity,
22
+ useMotionPreference,
23
+ useTasteProfile,
24
+ type DecorationLevel,
25
+ } from "@elabs-ai/components-tokens";
26
+
27
+ /** A probe that prints the live profile and drives every axis from context. */
28
+ function Probe() {
29
+ const { profile, setRegister } = useTasteProfile();
30
+ const { setDensity } = useDensity();
31
+ const { setDecoration } = useDecoration();
32
+ const { setMotionPreference } = useMotionPreference();
33
+ const [level, setLevel] = useState<DecorationLevel>(0);
34
+ return (
35
+ <div>
36
+ <output data-testid="profile">{JSON.stringify(profile)}</output>
37
+ <button onClick={() => setRegister("brand")}>brand</button>
38
+ <button onClick={() => setDensity("compact")}>compact</button>
39
+ {/* stands in for a real app's motion control (Settings → Motion) */}
40
+ <button onClick={() => setMotionPreference("full")}>full motion</button>
41
+ <button
42
+ onClick={() => {
43
+ const next = ((level + 6) % 12) as DecorationLevel;
44
+ setLevel(next);
45
+ setDecoration(next);
46
+ }}
47
+ >
48
+ decorate
49
+ </button>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ const profile = () => JSON.parse(screen.getByTestId("profile").textContent ?? "{}");
55
+
56
+ describe("useTasteProfile", () => {
57
+ beforeEach(() => {
58
+ window.localStorage.clear();
59
+ for (const attr of ["data-register", "data-density", "data-decoration"]) {
60
+ document.documentElement.removeAttribute(attr);
61
+ }
62
+ });
63
+
64
+ it("returns the restrained defaults inside a bare ThemeProvider", async () => {
65
+ render(
66
+ <ThemeProvider>
67
+ <Probe />
68
+ </ThemeProvider>,
69
+ );
70
+ expect(profile()).toEqual(DEFAULT_TASTE_PROFILE);
71
+ // The register is an inspectable seam on the root — no CSS keys off it.
72
+ expect(document.documentElement.getAttribute("data-register")).toBe("product");
73
+ });
74
+
75
+ it("setRegister writes data-register and persists it", async () => {
76
+ render(
77
+ <ThemeProvider>
78
+ <Probe />
79
+ </ThemeProvider>,
80
+ );
81
+ await userEvent.click(screen.getByRole("button", { name: "brand" }));
82
+ expect(profile().register).toBe("brand");
83
+ expect(document.documentElement.getAttribute("data-register")).toBe("brand");
84
+ expect(window.localStorage.getItem("brand-ui-taste-register")).toBe("brand");
85
+ });
86
+
87
+ it("rehydrates the persisted register on mount", () => {
88
+ window.localStorage.setItem("brand-ui-taste-register", "brand");
89
+ render(
90
+ <ThemeProvider>
91
+ <Probe />
92
+ </ThemeProvider>,
93
+ );
94
+ expect(profile().register).toBe("brand");
95
+ });
96
+
97
+ it("ignores a junk persisted register and falls back to the default", () => {
98
+ window.localStorage.setItem("brand-ui-taste-register", "marketing");
99
+ render(
100
+ <ThemeProvider>
101
+ <Probe />
102
+ </ThemeProvider>,
103
+ );
104
+ expect(profile().register).toBe("product");
105
+ });
106
+
107
+ it("tracks live changes on the density axis", async () => {
108
+ render(
109
+ <ThemeProvider>
110
+ <Probe />
111
+ </ThemeProvider>,
112
+ );
113
+ await userEvent.click(screen.getByRole("button", { name: "compact" }));
114
+ expect(profile().density).toBe("compact");
115
+ expect(document.documentElement.getAttribute("data-density")).toBe("compact");
116
+ });
117
+
118
+ it("expressiveness IS the effective decoration level (one dial, two names)", async () => {
119
+ render(
120
+ <ThemeProvider>
121
+ <Probe />
122
+ </ThemeProvider>,
123
+ );
124
+ expect(profile().expressiveness).toBe(0);
125
+ await userEvent.click(screen.getByRole("button", { name: "decorate" }));
126
+ expect(profile().expressiveness).toBe(6);
127
+ expect(document.documentElement.getAttribute("data-decoration")).toBe("6");
128
+ });
129
+
130
+ it("expressiveness follows the THEME's own decoration default when unset", () => {
131
+ render(
132
+ <ThemeProvider defaultTheme={DEFAULT_THEME}>
133
+ <Probe />
134
+ </ThemeProvider>,
135
+ );
136
+ // With no override the profile must report the theme's EFFECTIVE level (its
137
+ // registry default), never the (null) override. Read from the registry so a
138
+ // retuned theme can't silently make this vacuous.
139
+ expect(profile().expressiveness).toBe(
140
+ BUILT_IN_THEME_META[DEFAULT_THEME as "light" | "dark"].decorationLevel ?? 0,
141
+ );
142
+ });
143
+
144
+ it("honours an explicit defaultRegister", () => {
145
+ render(
146
+ <ThemeProvider defaultRegister="brand">
147
+ <Probe />
148
+ </ThemeProvider>,
149
+ );
150
+ expect(profile().register).toBe("brand");
151
+ });
152
+ });
153
+
154
+ /**
155
+ * The scaffold contract (#109): an app-spec's `taste` block becomes ThemeProvider
156
+ * props, and NOTHING else — no hardcoded density/expressiveness in component
157
+ * source. These lock that a spec-driven provider actually writes the dials, for
158
+ * both shipped presets.
159
+ */
160
+ describe("a spec-driven ThemeProvider writes the profile onto the root", () => {
161
+ beforeEach(() => {
162
+ window.localStorage.clear();
163
+ for (const attr of ["data-register", "data-density", "data-decoration", "data-motion-pref"]) {
164
+ document.documentElement.removeAttribute(attr);
165
+ }
166
+ });
167
+
168
+ it("product / calm — the restrained default omits the identity dials", () => {
169
+ render(
170
+ <ThemeProvider
171
+ defaultRegister="product"
172
+ defaultDensity="comfortable"
173
+ defaultMotionPreference="system"
174
+ defaultDecoration={0}
175
+ >
176
+ <Probe />
177
+ </ThemeProvider>,
178
+ );
179
+ const root = document.documentElement;
180
+ expect(root.getAttribute("data-register")).toBe("product");
181
+ // comfortable + "system" are identity values: no attribute, so the first
182
+ // paint is pixel-identical to a provider-less build (zero-flash).
183
+ expect(root.getAttribute("data-density")).toBeNull();
184
+ expect(root.getAttribute("data-motion-pref")).toBeNull();
185
+ expect(root.getAttribute("data-decoration")).toBe("0");
186
+ expect(profile()).toEqual({
187
+ register: "product",
188
+ density: "comfortable",
189
+ motion: "system",
190
+ expressiveness: 0,
191
+ });
192
+ });
193
+
194
+ it("brand / expressive — every non-identity dial lands on the root", () => {
195
+ render(
196
+ <ThemeProvider
197
+ defaultRegister="brand"
198
+ defaultDensity="spacious"
199
+ defaultMotionPreference="reduced"
200
+ defaultDecoration={4}
201
+ >
202
+ <Probe />
203
+ </ThemeProvider>,
204
+ );
205
+ const root = document.documentElement;
206
+ expect(root.getAttribute("data-register")).toBe("brand");
207
+ expect(root.getAttribute("data-density")).toBe("spacious");
208
+ expect(root.getAttribute("data-motion-pref")).toBe("reduced");
209
+ // expressiveness IS decoration — one dial, one attribute.
210
+ expect(root.getAttribute("data-decoration")).toBe("4");
211
+ expect(profile()).toEqual({
212
+ register: "brand",
213
+ density: "spacious",
214
+ motion: "reduced",
215
+ expressiveness: 4,
216
+ });
217
+ });
218
+
219
+ /**
220
+ * The a11y half of #108 AC3. `[data-motion-pref="full"]` is the ONE state that
221
+ * keeps `--motion-factor: 1` under an OS `prefers-reduced-motion: reduce` and
222
+ * opts out of the third-party animation cap (packages/tokens/src/themes.css) —
223
+ * informed consent, given by a PERSON for themselves. So:
224
+ * · no scaffold preset ships it (stages.md), and the app-spec schema rejects a
225
+ * spec that defaults to it (scripts/check-app-spec.test.mjs);
226
+ * · `system` — what the presets DO ship — writes no attribute at all, which is
227
+ * precisely what lets the OS rule win.
228
+ */
229
+ it('the scaffold defaults ("system") write NO motion attribute, so the OS rule wins', () => {
230
+ render(
231
+ <ThemeProvider defaultRegister="brand" defaultMotionPreference="system" defaultDecoration={4}>
232
+ <Probe />
233
+ </ThemeProvider>,
234
+ );
235
+ expect(document.documentElement.getAttribute("data-motion-pref")).toBeNull();
236
+ expect(profile().motion).toBe("system");
237
+ });
238
+
239
+ it('"full" is reachable only as the person\'s own explicit choice', async () => {
240
+ render(
241
+ <ThemeProvider>
242
+ <Probe />
243
+ </ThemeProvider>,
244
+ );
245
+ // Nothing an app is scaffolded with puts the override on the root…
246
+ expect(document.documentElement.getAttribute("data-motion-pref")).toBeNull();
247
+ // …only a motion control the person operates does.
248
+ await userEvent.click(screen.getByRole("button", { name: "full motion" }));
249
+ expect(document.documentElement.getAttribute("data-motion-pref")).toBe("full");
250
+ expect(profile().motion).toBe("full");
251
+ });
252
+ });