@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,172 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useState, type FormEvent, type HTMLAttributes } from "react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Button } from "../button";
6
+ import { Input } from "../input";
7
+ import { useLocale } from "../locale-provider";
8
+ import { ModelPicker, type ModelPickerGroup, type ModelPickerStatus } from "../model-picker";
9
+ import { formatLastOpened, type Workspace } from "./workspace-picker-state";
10
+
11
+ export type { Workspace };
12
+
13
+ export interface WorkspacePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
14
+ /** Recent workspaces, most-relevant first — the caller owns ordering. */
15
+ workspaces: Workspace[];
16
+ /** The currently open workspace's id. */
17
+ currentId?: string;
18
+ /** Fires with the chosen workspace; the popover closes first. */
19
+ onSelect?: (workspace: Workspace) => void;
20
+ /**
21
+ * Free-text path entry, rendered in `ModelPicker`'s `footer` slot below the
22
+ * list. Fires with the raw string the user typed once it validates as
23
+ * non-empty — this component performs no filesystem access of any kind
24
+ * (presentation layer, D5); resolving, normalizing or checking the path is
25
+ * the caller's job.
26
+ */
27
+ onSubmitPath?: (path: string) => void;
28
+ /**
29
+ * Drives the same four-body state grid as `ModelPicker` — see
30
+ * `ModelPickerStatus`. Default `"ready"`, inherited from `ModelPicker`.
31
+ */
32
+ status?: ModelPickerStatus;
33
+ disabled?: boolean;
34
+ /** Accessible name for the trigger and the popover — what the control is FOR. */
35
+ "aria-label"?: string;
36
+ /** Controlled popover open state — see `ModelPicker`'s controlled contract. */
37
+ open?: boolean;
38
+ onOpenChange?: (open: boolean) => void;
39
+ }
40
+
41
+ /**
42
+ * Recent-workspace switcher — issue #111, decided in
43
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 7.
44
+ *
45
+ * A `ModelPicker` PRESET, not a new picker: `ModelPickerItem`'s
46
+ * `id`/`label`/`description`/`meta` shape is already a superset of what a
47
+ * workspace needs, so this component only supplies workspace-shaped
48
+ * defaults — it maps `path` into the truncating `description` slot, formats
49
+ * `lastOpenedAt` with `Intl.RelativeTimeFormat`, marks the current workspace
50
+ * in the row's TEXT (never colour alone — `ModelPicker`'s check glyph is
51
+ * `aria-hidden` and cannot carry that on its own), and renders the free-text
52
+ * path entry into `ModelPicker`'s `footer` slot so it sits below the list,
53
+ * un-filtered by the search query.
54
+ *
55
+ * Unlike `TeamSwitcher`, this has no `Sidebar` dependency — it works anywhere
56
+ * a picker is needed (a session launch card, a command palette), not only
57
+ * inside a sidebar.
58
+ */
59
+ export const WorkspacePicker = forwardRef<HTMLDivElement, WorkspacePickerProps>(
60
+ function WorkspacePicker(
61
+ {
62
+ workspaces,
63
+ currentId,
64
+ onSelect,
65
+ onSubmitPath,
66
+ status,
67
+ disabled,
68
+ className,
69
+ open,
70
+ onOpenChange,
71
+ "aria-label": ariaLabelProp,
72
+ ...props
73
+ },
74
+ ref,
75
+ ) {
76
+ const { t, locale } = useLocale();
77
+ const [path, setPath] = useState("");
78
+
79
+ const current = workspaces.find((workspace) => workspace.id === currentId);
80
+ const triggerLabel = current?.name ?? t("ui.workspacePicker.placeholder");
81
+ const ariaLabel = ariaLabelProp ?? t("ui.workspacePicker.label");
82
+
83
+ const group: ModelPickerGroup = {
84
+ key: "workspaces",
85
+ label: t("ui.workspacePicker.recent"),
86
+ items: workspaces.map((workspace) => {
87
+ const isCurrent = workspace.id === currentId;
88
+ const meta: string[] = [];
89
+ if (workspace.lastOpenedAt) {
90
+ meta.push(formatLastOpened(workspace.lastOpenedAt, locale));
91
+ }
92
+ // Marked in TEXT so the marker reaches the row's accessible name and
93
+ // survives greyscale (accessibility.md's colour-is-never-the-only-
94
+ // channel test) — ModelPicker's own check glyph is `aria-hidden` and
95
+ // cannot carry this alone.
96
+ if (isCurrent) meta.push(t("ui.workspacePicker.current"));
97
+ return {
98
+ id: workspace.id,
99
+ label: workspace.name,
100
+ description: workspace.path,
101
+ meta,
102
+ };
103
+ }),
104
+ };
105
+
106
+ const handleSelect = (item: { id: string }) => {
107
+ const workspace = workspaces.find((w) => w.id === item.id);
108
+ if (workspace) onSelect?.(workspace);
109
+ };
110
+
111
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
112
+ event.preventDefault();
113
+ if (path.trim() === "") return;
114
+ onSubmitPath?.(path);
115
+ setPath("");
116
+ };
117
+
118
+ return (
119
+ <ModelPicker
120
+ ref={ref}
121
+ data-slot="workspace-picker"
122
+ className={cn(className)}
123
+ groups={[group]}
124
+ value={currentId}
125
+ onSelect={handleSelect}
126
+ status={status}
127
+ triggerLabel={triggerLabel}
128
+ aria-label={ariaLabel}
129
+ searchPlaceholder={t("ui.workspacePicker.searchPlaceholder")}
130
+ disabled={disabled}
131
+ open={open}
132
+ onOpenChange={onOpenChange}
133
+ footer={
134
+ // Outside `CommandList` on purpose (ModelPicker's own contract): a
135
+ // persistent action row, never filtered by the search query and
136
+ // never part of cmdk's roving selection.
137
+ //
138
+ // `onKeyDown` stops propagation because cmdk binds ITS OWN keydown
139
+ // handler on the `Command` root and reacts to Enter/Home/End/
140
+ // ArrowUp/ArrowDown regardless of which descendant is focused —
141
+ // without this, pressing Enter here would move the list's
142
+ // highlight instead of submitting the path.
143
+ <form
144
+ data-slot="workspace-picker-path-form"
145
+ onSubmit={handleSubmit}
146
+ onKeyDown={(event) => event.stopPropagation()}
147
+ className="flex items-center gap-2 border-t p-2"
148
+ >
149
+ <Input
150
+ data-slot="workspace-picker-path-input"
151
+ value={path}
152
+ onChange={(event) => setPath(event.target.value)}
153
+ placeholder={t("ui.workspacePicker.pathPlaceholder")}
154
+ aria-label={t("ui.workspacePicker.pathLabel")}
155
+ className="h-8 min-w-0 flex-1"
156
+ />
157
+ <Button
158
+ type="submit"
159
+ size="sm"
160
+ variant="outline"
161
+ aria-disabled={path.trim() === ""}
162
+ data-slot="workspace-picker-path-submit"
163
+ >
164
+ {t("ui.workspacePicker.openPath")}
165
+ </Button>
166
+ </form>
167
+ }
168
+ {...props}
169
+ />
170
+ );
171
+ },
172
+ );
@@ -0,0 +1,26 @@
1
+ import { forwardRef } from "react";
2
+ import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
3
+
4
+ /**
5
+ * Empty list / zero-data-yet state. Three line-art rows read as "empty list"
6
+ * on their own silhouette — no accent mark, per the accent-semantics
7
+ * convention in `illustration-base.tsx` (#48 finding 1): a floating "add"
8
+ * badge here would repeat information the rows already supply, not add any.
9
+ */
10
+ export const EmptyListIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
11
+ function EmptyListIllustration({ size, className, ...props }, ref) {
12
+ return (
13
+ <svg
14
+ ref={ref}
15
+ {...illustrationSvgProps(size, className)}
16
+ data-slot="empty-list-illustration"
17
+ {...props}
18
+ >
19
+ <rect x="36" y="68" width="64" height="10" rx="5" />
20
+ <rect x="36" y="90" width="64" height="10" rx="5" />
21
+ <rect x="36" y="112" width="44" height="10" rx="5" />
22
+ </svg>
23
+ );
24
+ },
25
+ );
26
+ EmptyListIllustration.displayName = "EmptyListIllustration";
@@ -0,0 +1,60 @@
1
+ import { forwardRef } from "react";
2
+ import {
3
+ illustrationSvgProps,
4
+ illustrationAccent,
5
+ type IllustrationProps,
6
+ } from "./illustration-base";
7
+
8
+ /**
9
+ * Recoverable-failure state. A line-art document split into two halves with a
10
+ * visible gap between them — distinguishable from `NoAccessIllustration`/
11
+ * `OfflineIllustration` by silhouette alone (a split document reads as
12
+ * "broken" even in greyscale), per the WCAG 1.4.1 "never color-only" rule.
13
+ * The exclamation mark IS meaning-bearing (per the accent-semantics
14
+ * convention in `illustration-base.tsx`, #48 finding 1) — it turns "torn
15
+ * document" into "warning", information the silhouette alone doesn't carry —
16
+ * so it stays, in the upper-right quadrant of the right-hand half, clear of
17
+ * both documents' outlines.
18
+ *
19
+ * Redrawn for #24 fix round 1 (P1-3) — the previous cracked-card + tiny alert
20
+ * triangle read as a lightning bolt or a broken-image placeholder and the
21
+ * triangle's exclamation collapsed to an illegible blob at 4rem.
22
+ *
23
+ * Redrawn again for #48 finding 3 — the mark previously sat at (76-84,
24
+ * 54-95), exactly where the two document halves' torn edges converge, so it
25
+ * read as a knot of overlapping lines rather than a "!". Moved to
26
+ * (98-113, 44-85), 8-15 units clear of the nearest tear line at every
27
+ * y-coordinate it spans.
28
+ */
29
+ export const ErrorIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
30
+ function ErrorIllustration({ size, className, ...props }, ref) {
31
+ return (
32
+ <svg
33
+ ref={ref}
34
+ {...illustrationSvgProps(size, className)}
35
+ data-slot="error-illustration"
36
+ {...props}
37
+ >
38
+ <path d="M36,32 L83.06,32 L71.29,55.16 L92.47,69.06 L66.59,92.24 L76,120 L36,120 Z" />
39
+ <path d="M124,36 L124,124 L84,124 L74.59,96.24 L100.47,73.06 L79.29,59.16 L91.06,36 Z" />
40
+ <rect
41
+ x="104"
42
+ y="46"
43
+ width="8"
44
+ height="24"
45
+ rx="4"
46
+ style={{ fill: illustrationAccent("var(--destructive-text)") }}
47
+ stroke="none"
48
+ />
49
+ <circle
50
+ cx="108"
51
+ cy="80"
52
+ r="5"
53
+ style={{ fill: illustrationAccent("var(--destructive-text)") }}
54
+ stroke="none"
55
+ />
56
+ </svg>
57
+ );
58
+ },
59
+ );
60
+ ErrorIllustration.displayName = "ErrorIllustration";
@@ -0,0 +1,27 @@
1
+ import { forwardRef } from "react";
2
+ import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
3
+
4
+ /**
5
+ * First-run / onboarding state. A line-art planted flag — reads as "start
6
+ * here" on its own silhouette, distinct from `SuccessIllustration`. No
7
+ * accent mark, per the accent-semantics convention in `illustration-base.tsx`
8
+ * (#48 finding 1): a decorative sparkle here would repeat information the
9
+ * flag already supplies, not add any.
10
+ */
11
+ export const FirstRunIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
12
+ function FirstRunIllustration({ size, className, ...props }, ref) {
13
+ return (
14
+ <svg
15
+ ref={ref}
16
+ {...illustrationSvgProps(size, className)}
17
+ data-slot="first-run-illustration"
18
+ {...props}
19
+ >
20
+ <line x1="64" y1="122" x2="64" y2="46" />
21
+ <path d="M64,46 L100,54 L86,64 L100,74 L64,82 Z" strokeLinejoin="round" />
22
+ <line x1="36" y1="122" x2="90" y2="122" />
23
+ </svg>
24
+ );
25
+ },
26
+ );
27
+ FirstRunIllustration.displayName = "FirstRunIllustration";
@@ -0,0 +1,108 @@
1
+ import type { SVGProps } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /**
5
+ * Shared 160×160 canvas every illustration draws on. Keeping one constant
6
+ * means every illustration's hand-authored coordinates line up on the same
7
+ * grid — every subject's ink is drawn to fill an ~88×88 box centered at
8
+ * (80, 78) (#24 fix round 1, P1-1), so the seven read as one optically-sized
9
+ * family instead of varying 2.1× against each other.
10
+ */
11
+ export const ILLUSTRATION_VIEW_BOX = "0 0 160 160";
12
+
13
+ export interface IllustrationProps extends SVGProps<SVGSVGElement> {
14
+ /**
15
+ * Rendered width & height (any CSS length — pass a `rem` value to stay
16
+ * density/zoom-aware). Illustrations are drawn to read clearly from about
17
+ * `4rem` (64px) up to `10rem` (160px).
18
+ * @default "7rem"
19
+ */
20
+ size?: string | number;
21
+ }
22
+
23
+ /**
24
+ * The `<svg>`-level attributes every illustration shares: canvas, stroke
25
+ * defaults (`currentColor`, so it themes with whatever text color the caller
26
+ * or ambient context applies — e.g. `StatePanel` tints the error kind's slot
27
+ * `text-destructive`), rem-based sizing, and decorative a11y (`aria-hidden`,
28
+ * `role="presentation"`) — every illustration is decorative; the state's
29
+ * title/description text is the accessible content, never the artwork.
30
+ *
31
+ * Deliberately does NOT include `data-slot` — each illustration file declares
32
+ * its own `data-slot="<name>-illustration"` literal alongside this spread, so
33
+ * the stable-selector convention (`.claude/rules/component-api.md`) is
34
+ * visible per-component, not hidden behind a shared factory.
35
+ */
36
+ export function illustrationSvgProps(size: string | number = "7rem", className?: string) {
37
+ return {
38
+ viewBox: ILLUSTRATION_VIEW_BOX,
39
+ width: size,
40
+ height: size,
41
+ fill: "none",
42
+ stroke: "currentColor",
43
+ strokeWidth: 4,
44
+ strokeLinecap: "round" as const,
45
+ strokeLinejoin: "round" as const,
46
+ "aria-hidden": true as const,
47
+ role: "presentation" as const,
48
+ focusable: "false" as const,
49
+ className: cn("shrink-0", className),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Accent-semantics convention (#48 finding 1): an illustration draws at most
55
+ * ONE accent mark (via `illustrationAccent()` below), and it exists ONLY
56
+ * when it carries information the silhouette (the `currentColor`-stroked
57
+ * subject) does not already supply on its own — never as ornamental
58
+ * flourish wearing a meaning-bearing token. Applied across the seven
59
+ * shipped illustrations:
60
+ * - **Meaning-bearing (kept):** the "×" that turns a magnifying glass into
61
+ * "no results" (`NoResultsIllustration`), the keyhole that turns a padlock
62
+ * body into "restricted" (`NoAccessIllustration`), the checkmark that
63
+ * turns a bare ring into "success" (`SuccessIllustration`), the
64
+ * exclamation that turns a torn document into "warning" rather than
65
+ * merely "broken" (`ErrorIllustration`).
66
+ * - **Was decorative-only, removed (#48):** the "add" badge on
67
+ * `EmptyListIllustration` (three rows already read as "empty list" on
68
+ * their own), the sparkle on `FirstRunIllustration` (the planted flag
69
+ * already reads as "start here"), and the broken-link fragments on
70
+ * `OfflineIllustration` (a slashed cloud already reads as "disconnected" —
71
+ * the universal "no signal" idiom). Their accent-free silhouettes carry
72
+ * the full state alone, matching how `ErrorIllustration`'s silhouette
73
+ * already did before this convention was written down.
74
+ * Every illustration is `aria-hidden` regardless (the state's title/
75
+ * description text is the ACCESSIBLE channel — see `illustrationSvgProps`
76
+ * below), so this is a visual-consistency rule, not an a11y requirement on
77
+ * its own; don't regress the silhouette-carries-meaning property while
78
+ * redrawing any of the four kept accents above.
79
+ */
80
+
81
+ /**
82
+ * The custom property every illustration's single meaning-bearing accent
83
+ * reads through. `StatePanel` sets it on the illustration slot's wrapper for
84
+ * `kind="error"` (`var(--destructive-text)`) so the accent follows the
85
+ * panel's tint instead of staying pinned to one hue inside a re-colored slot
86
+ * (#24 fix round 1, P0-2) — without this, an illustration whose accent
87
+ * defaults to the brand hue renders a lime badge on a red error panel. Falls
88
+ * back to each illustration's own default hue when no ambient override is
89
+ * set.
90
+ */
91
+ export const ILLUSTRATION_ACCENT_VAR = "--illustration-accent";
92
+
93
+ /**
94
+ * Build the `style` value for an illustration's accent ink: honors the
95
+ * ambient `--illustration-accent` override when set, otherwise `fallback`.
96
+ * `fallback` must be the correct **mark rung** for the accent's hue — e.g.
97
+ * `var(--primary-text)` (NOT `var(--primary)`, a plate color that measures
98
+ * 1.42:1 on `--card` in the `light` theme — #24 fix round 1, P0-1; see
99
+ * `.claude/rules/styling-and-tokens.md` "Which status rung a graphical MARK
100
+ * reaches for"), `var(--success-text)` for a positive-outcome accent, or
101
+ * `var(--destructive-text)` for an error accent — the TEXT rung, deliberately
102
+ * distinct from the FILL rung (`--destructive`) `StatePanel` puts the
103
+ * SUBJECT on for `kind="error"`, so the two don't collapse onto one colour
104
+ * (#48 finding 2).
105
+ */
106
+ export function illustrationAccent(fallback: string): string {
107
+ return `var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`;
108
+ }
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import type { ComponentType } from "react";
3
+ import {
4
+ EmptyListIllustration,
5
+ NoResultsIllustration,
6
+ NoAccessIllustration,
7
+ ErrorIllustration,
8
+ OfflineIllustration,
9
+ SuccessIllustration,
10
+ FirstRunIllustration,
11
+ type IllustrationProps,
12
+ } from "./index";
13
+
14
+ /**
15
+ * Demo wrapper: illustrations are pure `currentColor` line art plus one
16
+ * meaning-bearing accent (`--primary-text` by default; `--success-text` on
17
+ * `SuccessIllustration`, `--destructive` on `ErrorIllustration`), so a story
18
+ * renders them inside a `text-muted-foreground` container — the same ambient
19
+ * color `StatePanel` applies via its icon/illustration slot. `StatePanel`
20
+ * retints the accent to `--destructive` for any illustration placed in a
21
+ * `kind="error"` slot via the `--illustration-accent` custom property (see
22
+ * `illustration-base.tsx`) — see `States/StatePanel`'s "Error (with
23
+ * illustration, #24 P0-2)" story for that pairing.
24
+ */
25
+ function Demo(props: IllustrationProps & { as: ComponentType<IllustrationProps> }) {
26
+ const { as: Illustration, ...rest } = props;
27
+ return (
28
+ <div className="text-muted-foreground">
29
+ <Illustration {...rest} />
30
+ </div>
31
+ );
32
+ }
33
+
34
+ const meta = {
35
+ title: "States/Illustrations",
36
+ component: Demo,
37
+ tags: ["autodocs"],
38
+ argTypes: {
39
+ size: {
40
+ control: "text",
41
+ description: "Rendered width/height (any CSS length). Legible ~4rem–10rem.",
42
+ },
43
+ },
44
+ } satisfies Meta<typeof Demo>;
45
+ export default meta;
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ export const EmptyList: Story = {
49
+ args: { as: EmptyListIllustration },
50
+ };
51
+
52
+ export const NoResults: Story = {
53
+ args: { as: NoResultsIllustration },
54
+ };
55
+
56
+ export const NoAccess: Story = {
57
+ args: { as: NoAccessIllustration },
58
+ };
59
+
60
+ export const Error: Story = {
61
+ args: { as: ErrorIllustration },
62
+ };
63
+
64
+ export const Offline: Story = {
65
+ args: { as: OfflineIllustration },
66
+ };
67
+
68
+ export const Success: Story = {
69
+ args: { as: SuccessIllustration },
70
+ };
71
+
72
+ export const FirstRun: Story = {
73
+ args: { as: FirstRunIllustration },
74
+ };
75
+
76
+ export const CustomSize: Story = {
77
+ name: "Custom size (10rem)",
78
+ args: { as: EmptyListIllustration, size: "10rem" },
79
+ };
80
+
81
+ export const AllStates: Story = {
82
+ name: "All seven, side by side",
83
+ render: () => (
84
+ <div className="flex flex-wrap gap-8 text-muted-foreground">
85
+ <EmptyListIllustration />
86
+ <NoResultsIllustration />
87
+ <NoAccessIllustration />
88
+ <ErrorIllustration />
89
+ <OfflineIllustration />
90
+ <SuccessIllustration />
91
+ <FirstRunIllustration />
92
+ </div>
93
+ ),
94
+ };
@@ -0,0 +1,98 @@
1
+ import type { CSSProperties } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { render } from "@testing-library/react";
4
+ import {
5
+ EmptyListIllustration,
6
+ NoResultsIllustration,
7
+ NoAccessIllustration,
8
+ ErrorIllustration,
9
+ OfflineIllustration,
10
+ SuccessIllustration,
11
+ FirstRunIllustration,
12
+ ILLUSTRATION_ACCENT_VAR,
13
+ illustrationAccent,
14
+ } from "./index";
15
+
16
+ // No literal hex anywhere in a rendered attribute — only `currentColor` or a
17
+ // `var(--…)` token reference (inline style or attribute) may carry color.
18
+ const RAW_HEX_RE = /#[0-9a-fA-F]{3,6}\b/;
19
+
20
+ const ILLUSTRATIONS = [
21
+ ["EmptyListIllustration", EmptyListIllustration, "empty-list-illustration"],
22
+ ["NoResultsIllustration", NoResultsIllustration, "no-results-illustration"],
23
+ ["NoAccessIllustration", NoAccessIllustration, "no-access-illustration"],
24
+ ["ErrorIllustration", ErrorIllustration, "error-illustration"],
25
+ ["OfflineIllustration", OfflineIllustration, "offline-illustration"],
26
+ ["SuccessIllustration", SuccessIllustration, "success-illustration"],
27
+ ["FirstRunIllustration", FirstRunIllustration, "first-run-illustration"],
28
+ ] as const;
29
+
30
+ describe("state illustrations", () => {
31
+ it.each(ILLUSTRATIONS)(
32
+ "%s renders a decorative, token-driven <svg>",
33
+ (_name, Component, slot) => {
34
+ const { container } = render(<Component />);
35
+ const svg = container.querySelector("svg");
36
+ expect(svg).not.toBeNull();
37
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
38
+ expect(svg).toHaveAttribute("aria-hidden", "true");
39
+ expect(svg).toHaveAttribute("role", "presentation");
40
+ expect(svg).toHaveAttribute("data-slot", slot);
41
+
42
+ // No raw hex anywhere in the tree's attributes (element attrs + style attr).
43
+ const offenders: string[] = [];
44
+ for (const el of Array.from(container.querySelectorAll("*"))) {
45
+ for (const attr of Array.from(el.attributes)) {
46
+ if (RAW_HEX_RE.test(attr.value)) {
47
+ offenders.push(`${el.tagName}[${attr.name}]="${attr.value}"`);
48
+ }
49
+ }
50
+ }
51
+ expect(offenders).toEqual([]);
52
+ },
53
+ );
54
+
55
+ it("accepts a custom `size` and applies it as width/height", () => {
56
+ const { container } = render(<EmptyListIllustration size="10rem" />);
57
+ const svg = container.querySelector("svg");
58
+ expect(svg).toHaveAttribute("width", "10rem");
59
+ expect(svg).toHaveAttribute("height", "10rem");
60
+ });
61
+
62
+ it("defaults to a rem-based size within the legible 64px–160px range", () => {
63
+ const { container } = render(<NoResultsIllustration />);
64
+ const svg = container.querySelector("svg");
65
+ const width = svg?.getAttribute("width") ?? "";
66
+ expect(width).toMatch(/rem$/);
67
+ });
68
+
69
+ it("merges a caller className without dropping the base classes", () => {
70
+ const { container } = render(<SuccessIllustration className="text-primary" />);
71
+ const svg = container.querySelector("svg");
72
+ expect(svg?.getAttribute("class")).toContain("text-primary");
73
+ });
74
+
75
+ // #12/#53 review (P2): `ILLUSTRATION_ACCENT_VAR` is the mechanism `StatePanel`
76
+ // uses internally to safely retint an illustration's meaning-bearing accent
77
+ // (see illustration-base.tsx). A consumer rendering an illustration OUTSIDE
78
+ // `StatePanel` needs the same seam without duplicating the private
79
+ // "--illustration-accent" string, so the barrel must export it.
80
+ it("exports ILLUSTRATION_ACCENT_VAR — the public retint seam for consumers outside StatePanel", () => {
81
+ expect(ILLUSTRATION_ACCENT_VAR).toBe("--illustration-accent");
82
+ // A consumer can set it inline exactly the way StatePanel does internally.
83
+ const { container } = render(
84
+ <div style={{ [ILLUSTRATION_ACCENT_VAR]: "var(--success-text)" } as CSSProperties}>
85
+ <SuccessIllustration />
86
+ </div>,
87
+ );
88
+ const wrapper = container.firstChild as HTMLElement;
89
+ expect(wrapper.style.getPropertyValue(ILLUSTRATION_ACCENT_VAR)).toBe("var(--success-text)");
90
+ });
91
+
92
+ it("exports illustrationAccent — the public helper for building an illustration's fallback stroke/fill color", () => {
93
+ const fallback = "var(--success-text)";
94
+ const result = illustrationAccent(fallback);
95
+ // illustrationAccent(fallback) returns var(--illustration-accent, fallback)
96
+ expect(result).toBe(`var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`);
97
+ });
98
+ });
@@ -0,0 +1,16 @@
1
+ export type { IllustrationProps } from "./illustration-base";
2
+ // The custom-property name a consumer sets to safely retint an illustration's
3
+ // meaning-bearing accent (see `StatePanel`'s own usage). Previously only the
4
+ // illustration components + `IllustrationProps` were exported here, so a
5
+ // consumer rendering an illustration OUTSIDE `StatePanel` had no way to reach
6
+ // this seam without duplicating the private string (#12/#53 review, P2).
7
+ // Relates to the wider "illustration customization" tracking issue #46 — this
8
+ // change exports the existing constant, it does not itself resolve #46.
9
+ export { ILLUSTRATION_ACCENT_VAR, illustrationAccent } from "./illustration-base";
10
+ export { EmptyListIllustration } from "./empty-list-illustration";
11
+ export { NoResultsIllustration } from "./no-results-illustration";
12
+ export { NoAccessIllustration } from "./no-access-illustration";
13
+ export { ErrorIllustration } from "./error-illustration";
14
+ export { OfflineIllustration } from "./offline-illustration";
15
+ export { SuccessIllustration } from "./success-illustration";
16
+ export { FirstRunIllustration } from "./first-run-illustration";
@@ -0,0 +1,45 @@
1
+ import { forwardRef } from "react";
2
+ import {
3
+ illustrationSvgProps,
4
+ illustrationAccent,
5
+ type IllustrationProps,
6
+ } from "./illustration-base";
7
+
8
+ /**
9
+ * Permission / access-denied state. A line-art padlock with a `--primary-text`
10
+ * keyhole — reads as "restricted", not "broken". The keyhole is meaning-bearing
11
+ * (per the accent-semantics convention in `illustration-base.tsx`, #48
12
+ * finding 1) — it's what distinguishes a padlock from a generic rounded box.
13
+ */
14
+ export const NoAccessIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
15
+ function NoAccessIllustration({ size, className, ...props }, ref) {
16
+ return (
17
+ <svg
18
+ ref={ref}
19
+ {...illustrationSvgProps(size, className)}
20
+ data-slot="no-access-illustration"
21
+ {...props}
22
+ >
23
+ <path d="M50,72 V52 A30,20 0 0 1 110,52 V72" />
24
+ <rect x="36" y="72" width="88" height="48" rx="10" />
25
+ <circle
26
+ cx="80"
27
+ cy="92"
28
+ r="6"
29
+ style={{ stroke: illustrationAccent("var(--primary-text)") }}
30
+ strokeWidth={3}
31
+ />
32
+ <rect
33
+ x="77"
34
+ y="98"
35
+ width="6"
36
+ height="12"
37
+ rx="2"
38
+ style={{ stroke: illustrationAccent("var(--primary-text)") }}
39
+ strokeWidth={3}
40
+ />
41
+ </svg>
42
+ );
43
+ },
44
+ );
45
+ NoAccessIllustration.displayName = "NoAccessIllustration";