@elabs-ai/components-ui 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,34 @@
1
+ import { forwardRef, type ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface SkipLinkProps extends ComponentProps<"a"> {
5
+ /** The id of the landmark to jump to. Defaults to "main-content". */
6
+ targetId?: string;
7
+ }
8
+
9
+ /**
10
+ * The first focusable element of an application: invisible until focused, then a
11
+ * token-styled pill pinned to the top-start corner. Give the target element
12
+ * `id={targetId}` and `tabIndex={-1}` so focus actually lands there.
13
+ */
14
+ export const SkipLink = forwardRef<HTMLAnchorElement, SkipLinkProps>(function SkipLink(
15
+ { targetId = "main-content", className, children, ...props },
16
+ ref,
17
+ ) {
18
+ return (
19
+ <a
20
+ ref={ref}
21
+ data-slot="skip-link"
22
+ href={`#${targetId}`}
23
+ className={cn(
24
+ "sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:start-4 focus-visible:top-4 focus-visible:z-50",
25
+ "focus-visible:rounded-md focus-visible:bg-card focus-visible:px-3 focus-visible:py-2 focus-visible:text-body focus-visible:text-foreground focus-visible:shadow-ring-md",
26
+ "focus-ring",
27
+ className,
28
+ )}
29
+ {...props}
30
+ >
31
+ {children ?? "Skip to main content"}
32
+ </a>
33
+ );
34
+ });
@@ -124,7 +124,7 @@ export const Slider = forwardRef<ElementRef<typeof SliderPrimitive.Root>, Slider
124
124
  aria-valuetext={ariaValueText}
125
125
  {...safeThumbProps}
126
126
  className={cn(
127
- "block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
127
+ "block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50",
128
128
  safeThumbProps?.className,
129
129
  )}
130
130
  />
@@ -109,7 +109,10 @@ export const DragThenReset: Story = {
109
109
  thumb.focus();
110
110
  await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
111
111
  const numberInput = canvas.getByRole("spinbutton", { name: "Temperature" });
112
- await expect(numberInput).toHaveValue(0.3);
112
+ // Text spinbutton (locale-formatted): string display + numeric aria-valuenow.
113
+ await expect(numberInput).toHaveValue("0.3");
114
+ await expect(numberInput).toHaveAttribute("aria-valuenow", "0.3");
115
+ await expect(thumb).toHaveAttribute("aria-valuenow", "0.3");
113
116
 
114
117
  const resetBtn = canvas.getByRole("button", { name: "Reset" });
115
118
  await expect(resetBtn).not.toBeDisabled();
@@ -10,7 +10,7 @@ describe("SliderNumber", () => {
10
10
  "aria-valuenow",
11
11
  "0.5",
12
12
  );
13
- expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue(0.5);
13
+ expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue("0.5");
14
14
  });
15
15
 
16
16
  it("rounds slider-driven and typed values identically (lockstep)", async () => {
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { useEffect, useState } from "react";
2
4
  import { Toaster as SonnerToaster, toast } from "sonner";
3
5
  import { resolveThemeIsDark } from "@elabs-ai/components-tokens";
@@ -0,0 +1,18 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { Spinner } from "./spinner";
5
+
6
+ describe("Spinner", () => {
7
+ it("renders with its default accessible label", () => {
8
+ render(<Spinner />);
9
+ expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
10
+ });
11
+
12
+ it("forwards its ref to the rendered svg", () => {
13
+ const ref = createRef<SVGSVGElement>();
14
+ render(<Spinner ref={ref} />);
15
+ expect(ref.current).toBeInstanceOf(SVGSVGElement);
16
+ expect(ref.current).toBe(screen.getByRole("status", { name: "Loading" }));
17
+ });
18
+ });
@@ -1,19 +1,29 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
1
4
  import { Loader2 } from "lucide-react";
2
5
  import { cn } from "../../lib/cn";
6
+ import { useLocale } from "../locale-provider";
3
7
 
4
8
  export interface SpinnerProps extends React.SVGProps<SVGSVGElement> {
5
- /** Accessible label; defaults to "Loading". */
9
+ /** Accessible label; defaults to the localized "Loading" microcopy. */
6
10
  label?: string;
7
11
  }
8
12
 
9
13
  /** Indeterminate spinner with an accessible label. */
10
- export function Spinner({ className, label = "Loading", ...props }: SpinnerProps) {
14
+ export const Spinner = forwardRef<SVGSVGElement, SpinnerProps>(function Spinner(
15
+ { className, label, ...props },
16
+ ref,
17
+ ) {
18
+ const { t } = useLocale();
11
19
  return (
12
20
  <Loader2
21
+ ref={ref}
22
+ data-slot="spinner"
13
23
  role="status"
14
- aria-label={label}
24
+ aria-label={label ?? t("ui.spinner.label")}
15
25
  className={cn("size-4 animate-spin text-muted-foreground", className)}
16
26
  {...props}
17
27
  />
18
28
  );
19
- }
29
+ });
@@ -1 +1,6 @@
1
- export { SplitPanel, type SplitPanelProps, type SplitPanelTone } from "./split-panel";
1
+ export {
2
+ SplitPanel,
3
+ splitPaneVariants,
4
+ type SplitPanelProps,
5
+ type SplitPanelTone,
6
+ } from "./split-panel";
@@ -4,7 +4,23 @@ import { SplitPanel } from "./split-panel";
4
4
  const meta = {
5
5
  title: "Layout/SplitPanel",
6
6
  component: SplitPanel,
7
- parameters: { layout: "fullscreen" },
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A fixed two-pane layout with per-pane surface tones — panes the user can drag to " +
14
+ "resize are `Layout/Resizable`, see " +
15
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
16
+ '`SplitPanel` sizes the start pane with a CSS grid track (`startSize`, e.g. `"320px"`, ' +
17
+ '`"40%"`, `"1fr"`) rather than a percentage, so layout is predictable in tests and SSR ' +
18
+ "with no client-side measurement. The three-tone `plain`/`muted`/`card` ground-offset " +
19
+ "system on `startTone`/`endTone` is exported as `splitPaneVariants` — apply it to a " +
20
+ "`ResizablePanel` for the identical tiering on a draggable layout.",
21
+ },
22
+ },
23
+ },
8
24
  } satisfies Meta<typeof SplitPanel>;
9
25
  export default meta;
10
26
  type Story = StoryObj<typeof meta>;
@@ -28,7 +44,7 @@ export const MasterDetail: Story = {
28
44
  * the page itself is recessed below white; see research/structural-design 08 §H). A
29
45
  * `muted` list well is a light-theme-only enhancement (in dark, `--surface-muted` reads
30
46
  * lighter than `--card`), so this demo keeps the list `plain` to read correctly across
31
- * all three themes.
47
+ * every theme.
32
48
  */
33
49
  export const Tiered: Story = {
34
50
  render: () => (
@@ -19,6 +19,26 @@ describe("SplitPanel", () => {
19
19
  expect(endPane.className).toMatch(/border-s/);
20
20
  });
21
21
 
22
+ // Issue #163 — WCAG 1.4.11: with both tones "plain" (the shipped default),
23
+ // the hairline is the ONLY cue between the two panes (no fill/elevation
24
+ // difference), so it must be the strong, ≥3:1 rung — `border-strong ≥ 3:1
25
+ // on --card/--background` is proven for every theme in
26
+ // `@elabs-ai/components-tokens`'s `themes-contrast.test.ts`; this test locks
27
+ // that `SplitPanel` actually reaches for that token in this configuration,
28
+ // not just the bare `border-s`/`border-t` class the previous assertion
29
+ // above already passed on the unfixed code.
30
+ it("plain/plain divider uses the strong (≥3:1) border rung, not the subtle default", () => {
31
+ const { getByText } = render(<SplitPanel start={<>List</>} end={<>Detail</>} />);
32
+ expect(getByText("Detail").className).toMatch(/border-border-strong/);
33
+ });
34
+
35
+ it("muted/card tones keep the subtle divider rung — the fill difference already carries the boundary", () => {
36
+ const { getByText } = render(
37
+ <SplitPanel startTone="muted" endTone="card" start={<>List</>} end={<>Detail</>} />,
38
+ );
39
+ expect(getByText("Detail").className).not.toMatch(/border-border-strong/);
40
+ });
41
+
22
42
  it("applies the ground-offset tones: muted well + raised card", () => {
23
43
  const { getByText } = render(
24
44
  <SplitPanel
@@ -5,15 +5,20 @@ import { cn } from "../../lib/cn";
5
5
  /**
6
6
  * Per-pane surface tone — the ground-offset tiering axis (research 08 §H, #193).
7
7
  * `plain` keeps the ambient ground (default; non-breaking). `card` RAISES the pane as
8
- * a white `bg-card` + `shadow-sm` and is robust across all three themes — a card is
8
+ * a white `bg-card` + `shadow-sm` and is robust across every theme — a card is
9
9
  * lighter than the page ground in BOTH light and dark, so it always reads as raised.
10
10
  * `muted` (`bg-surface-muted`) reads as a recessed well ONLY in light themes; in dark
11
11
  * themes `--surface-muted` is lighter than `--card`, so a `muted` pane reads as a faint
12
12
  * raised tint, not a recess (dark-mode layering only goes UP — there is no surface below
13
13
  * the page ground). For a guaranteed cross-theme raise/recess contrast, raise the focus
14
14
  * pane with `card` and leave the other `plain`.
15
+ *
16
+ * Exported so the same tone system reaches a `Layout/Resizable` pane — a draggable
17
+ * `ResizablePanel` composes `splitPaneVariants({ tone })` for the identical ground-offset
18
+ * tiering `SplitPanel` gives its `startTone`/`endTone`, instead of a hand-rolled second
19
+ * copy of these three classes. See `Layout/Resizable`'s `Tiered` story.
15
20
  */
16
- const paneToneVariants = cva("min-h-0 min-w-0 overflow-auto", {
21
+ export const splitPaneVariants = cva("min-h-0 min-w-0 overflow-auto", {
17
22
  variants: {
18
23
  tone: {
19
24
  plain: "",
@@ -24,7 +29,7 @@ const paneToneVariants = cva("min-h-0 min-w-0 overflow-auto", {
24
29
  defaultVariants: { tone: "plain" },
25
30
  });
26
31
 
27
- export type SplitPanelTone = NonNullable<VariantProps<typeof paneToneVariants>["tone"]>;
32
+ export type SplitPanelTone = NonNullable<VariantProps<typeof splitPaneVariants>["tone"]>;
28
33
 
29
34
  export interface SplitPanelProps {
30
35
  start: ReactNode;
@@ -49,8 +54,12 @@ export interface SplitPanelProps {
49
54
 
50
55
  /**
51
56
  * Two-pane layout for master/detail, editor/preview and inspector workflows.
52
- * Static sizing by design (predictable in tests/SSR); for drag-to-resize wrap
53
- * with a resize hook or compose `react-resizable-panels` at the app level.
57
+ * Static sizing by design (predictable in tests/SSR) `startSize` is a CSS grid
58
+ * track (`"320px"`, `"40%"`, `"1fr"`), not a percentage. For panes the user can
59
+ * drag to resize, reach for `Layout/Resizable`'s `ResizablePanelGroup`/
60
+ * `ResizablePanel`/`ResizableHandle` instead and apply `splitPaneVariants({ tone })`
61
+ * to a panel for the same ground-offset tiering. See
62
+ * [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).
54
63
  *
55
64
  * For a master/detail surface where the detail should read as a raised card on a
56
65
  * recessed list, use `startTone="muted" endTone="card"` (ground-offset tiering).
@@ -80,11 +89,22 @@ export function SplitPanel({
80
89
  )}
81
90
  style={style}
82
91
  >
83
- <div className={cn(paneToneVariants({ tone: startTone }), startClassName)}>{start}</div>
92
+ <div className={cn(splitPaneVariants({ tone: startTone }), startClassName)}>{start}</div>
84
93
  <div
85
94
  className={cn(
86
- paneToneVariants({ tone: endTone }),
87
- divider && (isHorizontal ? "border-s" : "border-t"),
95
+ splitPaneVariants({ tone: endTone }),
96
+ divider &&
97
+ (isHorizontal ? "border-s" : "border-t") +
98
+ // #163 — with both panes "plain" there is no fill/elevation
99
+ // difference between them, so the hairline is the ONLY cue and
100
+ // must clear the 3:1 non-text bar (WCAG 1.4.11):
101
+ // `border-border-strong` (`border-strong ≥ 3:1 on --card/
102
+ // --background` is proven for every theme in
103
+ // @elabs-ai/components-tokens's themes-contrast.test.ts).
104
+ // With either tone "muted"/"card" the fill change already
105
+ // carries the boundary, so the subtle default rung stays —
106
+ // the redundant-cue exemption in styling-and-tokens.md.
107
+ (startTone === "plain" && endTone === "plain" ? " border-border-strong" : ""),
88
108
  endClassName,
89
109
  )}
90
110
  >
@@ -1,6 +1,11 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Button } from "../button";
3
3
  import { StatePanel } from "./state-panel";
4
+ import {
5
+ EmptyListIllustration,
6
+ NoAccessIllustration,
7
+ ErrorIllustration,
8
+ } from "../../illustrations";
4
9
 
5
10
  const meta = {
6
11
  title: "States/StatePanel",
@@ -78,3 +83,50 @@ export const EmptyNoIcon: Story = {
78
83
  description: "Items will appear here once available.",
79
84
  },
80
85
  };
86
+
87
+ export const WithIllustration: Story = {
88
+ name: "Empty (with illustration, #24)",
89
+ args: {
90
+ kind: "empty",
91
+ title: "No projects yet",
92
+ description: "Create your first project to get started.",
93
+ illustration: <EmptyListIllustration />,
94
+ actions: <Button size="sm">New project</Button>,
95
+ },
96
+ };
97
+
98
+ export const NoAccess: Story = {
99
+ name: "Empty (no-access illustration)",
100
+ args: {
101
+ // "No access" is a blocked/informational state, not a system failure —
102
+ // kind stays "empty" (no alarm-red "Error" eyebrow) rather than "error".
103
+ kind: "empty",
104
+ title: "You don’t have access",
105
+ description: "Ask a workspace admin to grant you permission.",
106
+ illustration: <NoAccessIllustration />,
107
+ actions: (
108
+ <Button size="sm" variant="outline">
109
+ Request access
110
+ </Button>
111
+ ),
112
+ },
113
+ };
114
+
115
+ export const ErrorWithIllustration: Story = {
116
+ name: "Error (with illustration, #24 P0-2)",
117
+ args: {
118
+ // The one panel `kind` that re-tints the illustration slot (`text-destructive`)
119
+ // — pairing it with an illustration is what surfaces whether the accent
120
+ // follows the tint or clashes against it. See P0-2 in the fix-round review:
121
+ // no shipped story previously rendered an illustration inside `kind="error"`.
122
+ kind: "error",
123
+ title: "Failed to load data",
124
+ description: "The server returned an unexpected response.",
125
+ illustration: <ErrorIllustration />,
126
+ actions: (
127
+ <Button size="sm" variant="outline">
128
+ Try again
129
+ </Button>
130
+ ),
131
+ },
132
+ };
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
3
  import { StatePanel } from "./state-panel";
4
+ import {
5
+ EmptyListIllustration,
6
+ ErrorIllustration,
7
+ ILLUSTRATION_ACCENT_VAR,
8
+ } from "../../illustrations";
4
9
 
5
10
  describe("StatePanel", () => {
6
11
  it("renders empty kind with title and description", () => {
@@ -24,6 +29,48 @@ describe("StatePanel", () => {
24
29
  expect(alert).toHaveAttribute("data-kind", "error");
25
30
  });
26
31
 
32
+ it("error eyebrow uses the destructive INK rung, not the fill rung (#40)", () => {
33
+ // The eyebrow is running TEXT on the panel's own `bg-destructive/5` wash, so
34
+ // it must reach for `text-destructive-text` (>= 4.5:1, the text-rung
35
+ // contract) — never the bare `text-destructive` fill rung, whose contract
36
+ // is only the 3:1 mark bar. See styling-and-tokens.md "Which status rung a
37
+ // graphical MARK reaches for" (#381).
38
+ render(<StatePanel kind="error" />);
39
+ const eyebrow = screen.getByText("Error");
40
+ expect(eyebrow.className).toContain("text-destructive-text");
41
+ expect(eyebrow.className).not.toMatch(/(?<!-)\btext-destructive\b(?!-)/);
42
+ });
43
+
44
+ it("error icon keeps the destructive FILL rung (a mark, not text)", () => {
45
+ // Inverse of the eyebrow lock above: an icon IS a mark, so it correctly
46
+ // stays on the fill rung — the fix must not "tidy" it onto -text too.
47
+ const { container } = render(<StatePanel kind="error" />);
48
+ const iconWrapper = container.querySelector("svg")?.parentElement;
49
+ expect(iconWrapper?.className ?? "").toMatch(/\btext-destructive\b/);
50
+ expect(iconWrapper?.className ?? "").not.toContain("text-destructive-text");
51
+ });
52
+
53
+ it("error rail carries the width cue (border-s-4) AND a destructive-family colour, not the neutral border-strong grey (#71)", () => {
54
+ // The rail's WIDTH (4px vs 1px) is the monochrome-survivable structural
55
+ // cue (per styling-and-tokens.md's border/border-strong decision test);
56
+ // its HUE should match the rest of the destructive family, not fall back
57
+ // to the neutral `border-strong` rung — that reads as a CSS-specificity
58
+ // bug, a 4px grey rail beside three red hairlines, not a deliberate
59
+ // accent (#71).
60
+ const { container } = render(<StatePanel kind="error" />);
61
+ const root = container.firstChild as HTMLElement;
62
+ expect(root.className).toMatch(/\bborder-s-4\b/);
63
+ expect(root.className).toMatch(/\bborder-s-destructive\b/);
64
+ expect(root.className).not.toMatch(/\bborder-s-border-strong\b/);
65
+ });
66
+
67
+ it("empty kind's rail carries neither the error width nor its destructive colour", () => {
68
+ const { container } = render(<StatePanel kind="empty" />);
69
+ const root = container.firstChild as HTMLElement;
70
+ expect(root.className).not.toMatch(/\bborder-s-4\b/);
71
+ expect(root.className).not.toMatch(/\bborder-s-destructive\b/);
72
+ });
73
+
27
74
  it("renders error kind with custom title", () => {
28
75
  render(<StatePanel kind="error" title="Custom error" description="Custom description" />);
29
76
  expect(screen.getByRole("alert")).toBeInTheDocument();
@@ -61,4 +108,82 @@ describe("StatePanel", () => {
61
108
  );
62
109
  expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
63
110
  });
111
+
112
+ it("renders an illustration without the size-10 icon clamp, decoratively (#24)", () => {
113
+ const { container } = render(
114
+ <StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
115
+ );
116
+ const svg = container.querySelector("svg");
117
+ expect(svg).not.toBeNull();
118
+ expect(svg).toHaveAttribute("aria-hidden", "true");
119
+ // The illustration's own wrapper must NOT carry the `[&_svg]:size-10`
120
+ // clamp the default icon slot uses — its rem sizing governs instead.
121
+ const wrapper = svg?.parentElement;
122
+ expect(wrapper?.className ?? "").not.toContain("size-10");
123
+ });
124
+
125
+ it("illustration path renders the title at the subtitle role, distinct from the icon path's text-body (#47)", () => {
126
+ // A 112px+ illustration needs a heavier title to group with — bumping the
127
+ // title to `text-subtitle` (styling-and-tokens.md's typography-scale
128
+ // roles) only on the illustration path, so the plain `icon` path (a
129
+ // fixed 40x40 glyph) stays byte-identical to before.
130
+ const { container: withIllustration } = render(
131
+ <StatePanel kind="empty" title="No items" illustration={<EmptyListIllustration />} />,
132
+ );
133
+ const titleWithIllustration = withIllustration.querySelector("h3");
134
+ expect(titleWithIllustration?.className ?? "").toContain("text-subtitle");
135
+ expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-body\b/);
136
+
137
+ const { container: withIcon } = render(
138
+ <StatePanel kind="empty" title="No items" icon={<span data-testid="i" />} />,
139
+ );
140
+ const titleWithIcon = withIcon.querySelector("h3");
141
+ expect(titleWithIcon?.className ?? "").toMatch(/\btext-body\b/);
142
+ expect(titleWithIcon?.className ?? "").not.toContain("text-subtitle");
143
+ });
144
+
145
+ it("error-kind illustration retints the accent to the TEXT rung, distinct from the retinted subject's FILL rung (#48)", () => {
146
+ // StatePanel wraps the illustration slot in `text-destructive` (the FILL
147
+ // rung — a mark, per styling-and-tokens.md "which status rung a
148
+ // graphical MARK reaches for") for kind="error". If the ambient
149
+ // `--illustration-accent` override it sets ALSO resolved to
150
+ // `--destructive`, the accent would collapse onto the subject the moment
151
+ // both read the same token (#48 finding 2). It must resolve to the TEXT
152
+ // rung instead — matching the convention every other illustration's own
153
+ // default fallback already uses (`--primary-text`, `--success-text`).
154
+ const { container } = render(<StatePanel kind="error" illustration={<ErrorIllustration />} />);
155
+ const svg = container.querySelector("svg");
156
+ const wrapper = svg?.parentElement as HTMLElement;
157
+ expect(wrapper.className).toMatch(/\btext-destructive\b/);
158
+ expect(wrapper.style.getPropertyValue(ILLUSTRATION_ACCENT_VAR)).toBe("var(--destructive-text)");
159
+ // Sanity: ErrorIllustration's own fallback (used when no ambient override
160
+ // is set) also reaches for the TEXT rung, not the FILL token the subject
161
+ // above just read.
162
+ const accentRect = container.querySelector("rect");
163
+ expect(accentRect?.getAttribute("style") ?? "").toContain("--destructive-text");
164
+ });
165
+
166
+ it("prefers illustration over icon when both are given", () => {
167
+ render(
168
+ <StatePanel
169
+ kind="empty"
170
+ title="Both given"
171
+ icon={<span data-testid="custom-icon">icon</span>}
172
+ illustration={<EmptyListIllustration />}
173
+ />,
174
+ );
175
+ expect(screen.queryByTestId("custom-icon")).toBeNull();
176
+ });
177
+
178
+ it("titleAs renders the requested heading level (#385)", () => {
179
+ render(<StatePanel kind="empty" title="No data" titleAs="h2" />);
180
+ const heading = screen.getByRole("heading", { level: 2, name: "No data" });
181
+ expect(heading.tagName).toBe("H2");
182
+ });
183
+
184
+ it("default titleAs is still h3 — unchanged output for every existing caller (#385)", () => {
185
+ render(<StatePanel kind="empty" title="No data" />);
186
+ const heading = screen.getByRole("heading", { level: 3, name: "No data" });
187
+ expect(heading.tagName).toBe("H3");
188
+ });
64
189
  });