@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
@@ -7,6 +7,7 @@ import {
7
7
  useContext,
8
8
  useEffect,
9
9
  useMemo,
10
+ useRef,
10
11
  useState,
11
12
  type ComponentProps,
12
13
  type CSSProperties,
@@ -19,6 +20,7 @@ import { useIsMobile } from "../../lib/use-mobile";
19
20
  import { Button } from "../button";
20
21
  import { useCollapsiblePanel } from "../collapsible-panel";
21
22
  import { Input } from "../input";
23
+ import { useLocale } from "../locale-provider";
22
24
  import { Separator } from "../separator";
23
25
  import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
24
26
  import { Skeleton } from "../skeleton";
@@ -55,12 +57,36 @@ export const SidebarProvider = forwardRef<
55
57
  defaultOpen?: boolean;
56
58
  open?: boolean;
57
59
  onOpenChange?: (open: boolean) => void;
60
+ /**
61
+ * Drives the frame's inset treatment from an ANCESTOR the whole frame can
62
+ * see — `SidebarInset` (#342 fix) reads it as `data-variant` on this
63
+ * wrapper via `group-data-[variant=inset]/sidebar-wrapper:`, which reaches
64
+ * regardless of DOM order (unlike the legacy `peer-*` combinator, which
65
+ * only matches a Sidebar that comes AFTER). Optional and unset by
66
+ * default, so an existing caller that only sets `variant` on `Sidebar`
67
+ * is unaffected.
68
+ */
69
+ variant?: "sidebar" | "floating" | "inset";
70
+ /**
71
+ * Default `"app"` — today's behaviour, byte-identical. `"nested"` is for
72
+ * a `SidebarProvider` a compound component (e.g. `ContextRail`, ADR 0035
73
+ * §4) mounts internally so `useSidebar()` reports ITS OWN state to its
74
+ * own parts: it still provides `SidebarContext` and the two
75
+ * `--sidebar-width*` custom properties (which survive `display:
76
+ * contents`), but renders no frame box, no `data-slot`/`data-variant`/
77
+ * `data-state`, registers no global `⌘B`/`Ctrl+B` listener, and writes no
78
+ * `sidebar_state` cookie — so a nested rail can never be mistaken for a
79
+ * second app frame. See the emission table in ADR 0035 §4.
80
+ */
81
+ frame?: "app" | "nested";
58
82
  }
59
83
  >(function SidebarProvider(
60
84
  {
61
85
  defaultOpen = true,
62
86
  open: openProp,
63
87
  onOpenChange: setOpenProp,
88
+ variant,
89
+ frame = "app",
64
90
  className,
65
91
  style,
66
92
  children,
@@ -68,6 +94,7 @@ export const SidebarProvider = forwardRef<
68
94
  },
69
95
  ref,
70
96
  ) {
97
+ const isNested = frame === "nested";
71
98
  const isMobile = useIsMobile();
72
99
  const [openMobile, setOpenMobile] = useState(false);
73
100
 
@@ -78,27 +105,45 @@ export const SidebarProvider = forwardRef<
78
105
  const openState = typeof value === "function" ? value(open) : value;
79
106
  if (setOpenProp) setOpenProp(openState);
80
107
  else _setOpen(openState);
81
- if (typeof document !== "undefined") {
108
+ // A nested provider (ADR 0035 §4) is not the app frame, so it must not
109
+ // persist ITS state as if it were — only `frame="app"` (the default)
110
+ // owns the `sidebar_state` cookie.
111
+ if (!isNested && typeof document !== "undefined") {
82
112
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
83
113
  }
84
114
  },
85
- [setOpenProp, open],
115
+ [setOpenProp, open, isNested],
86
116
  );
87
117
 
88
118
  const toggleSidebar = useCallback(() => {
89
119
  return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o);
90
120
  }, [isMobile, setOpen]);
91
121
 
122
+ // `toggleSidebar` is a NEW function on every `open` change (it closes over
123
+ // `setOpen`, which itself closes over `open`) — a `useEffect` depending on
124
+ // it directly would tear down and re-add the global `keydown` listener on
125
+ // every single toggle. A ref holding the latest callback keeps the listener
126
+ // registration stable across toggles; only `isNested` (which never changes
127
+ // after mount) re-runs the effect.
128
+ const toggleSidebarRef = useRef(toggleSidebar);
92
129
  useEffect(() => {
130
+ toggleSidebarRef.current = toggleSidebar;
131
+ }, [toggleSidebar]);
132
+
133
+ useEffect(() => {
134
+ // Same reasoning as the cookie write above: the global keyboard shortcut
135
+ // belongs to the ONE app frame, not to every nested provider a compound
136
+ // component happens to mount.
137
+ if (isNested) return;
93
138
  const handleKeyDown = (event: KeyboardEvent) => {
94
139
  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
95
140
  event.preventDefault();
96
- toggleSidebar();
141
+ toggleSidebarRef.current();
97
142
  }
98
143
  };
99
144
  window.addEventListener("keydown", handleKeyDown);
100
145
  return () => window.removeEventListener("keydown", handleKeyDown);
101
- }, [toggleSidebar]);
146
+ }, [isNested]);
102
147
 
103
148
  const state = open ? "expanded" : "collapsed";
104
149
 
@@ -112,9 +157,26 @@ export const SidebarProvider = forwardRef<
112
157
  <TooltipProvider delayDuration={0}>
113
158
  <div
114
159
  ref={ref}
115
- data-slot="sidebar-wrapper"
160
+ // `data-slot`/`data-variant`/`data-state`/`group/sidebar-wrapper`
161
+ // (in the class list below) are ADR 0035 §8 refinement 3's
162
+ // `frame="app"` surface. `frame="nested"` (ADR 0035 §4, Task 9A)
163
+ // omits all four: a nested rail's own state must never drive the
164
+ // outer frame's geometry through the same group name or be mistaken
165
+ // for a second "sidebar wrapper" by a selector/test targeting the
166
+ // slot. The literal string "sidebar-wrapper" stays below (as a
167
+ // conditional value) so `pnpm data-slot:check` still sees this
168
+ // module's declaration.
169
+ data-slot={isNested ? undefined : "sidebar-wrapper"}
170
+ data-variant={isNested ? undefined : variant}
171
+ data-state={isNested ? undefined : state}
116
172
  style={
117
173
  {
174
+ // These two custom properties are the ONE thing a nested
175
+ // provider still emits (ADR 0035 §4) — they survive `display:
176
+ // contents` because custom properties inherit down the DOM
177
+ // tree regardless of the box an element generates, which is
178
+ // exactly how a nested rail publishes its width to whatever it
179
+ // wraps. Never move them onto a child.
118
180
  "--sidebar-width": SIDEBAR_WIDTH,
119
181
  "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
120
182
  ...style,
@@ -131,7 +193,28 @@ export const SidebarProvider = forwardRef<
131
193
  // for every element that inherits its colour (outline Buttons, list
132
194
  // rows). See the chrome<canvas elevation invariant in
133
195
  // .claude/rules/styling-and-tokens.md.
134
- "group/sidebar-wrapper flex min-h-svh w-full text-foreground has-data-[variant=inset]:bg-sidebar",
196
+ // Omitted under `frame="nested"` a nested provider is a pure
197
+ // context + custom-property carrier, not a second frame box.
198
+ !isNested && "group/sidebar-wrapper flex min-h-svh w-full text-foreground",
199
+ // The frame's OWN `variant` is authoritative once set (ADR 0035 §8
200
+ // refinement 4): resolved in JS, not by a CSS descendant match, so
201
+ // a nested rail three levels down that happens to render
202
+ // `variant="inset"` can never repaint THIS frame's ground merely
203
+ // because `:has()` is depth-unlimited. `has-data-[variant=inset]`
204
+ // stays as the fallback ONLY while this provider's own `variant`
205
+ // is unset, which is exactly every existing caller (this prop
206
+ // didn't exist before #342) — so they render exactly as today.
207
+ // Also omitted under `frame="nested"`, same reasoning as above.
208
+ !isNested &&
209
+ (variant === undefined
210
+ ? "has-data-[variant=inset]:bg-sidebar"
211
+ : variant === "inset" && "bg-sidebar"),
212
+ // `frame="nested"` renders no box of its own: `display: contents`
213
+ // (the literal Tailwind class, never an inline style or a
214
+ // concatenated name) removes this element from layout while
215
+ // keeping its children — and the custom properties above —
216
+ // reachable.
217
+ isNested && "contents",
135
218
  className,
136
219
  )}
137
220
  {...props}
@@ -155,6 +238,7 @@ export const Sidebar = forwardRef<
155
238
  ref,
156
239
  ) {
157
240
  const { isMobile, openMobile, setOpenMobile, open, setOpen } = useSidebar();
241
+ const { t } = useLocale();
158
242
 
159
243
  // The collapse mechanism (gap spacer + fixed slide) is the canonical
160
244
  // useCollapsiblePanel hook (#190, research 09 §B.2) — Sidebar passes its
@@ -196,12 +280,12 @@ export const Sidebar = forwardRef<
196
280
  data-slot="sidebar"
197
281
  data-mobile="true"
198
282
  side={side}
199
- className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
283
+ className="w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
200
284
  style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE } as CSSProperties}
201
285
  >
202
286
  <SheetHeader className="sr-only">
203
- <SheetTitle>Sidebar</SheetTitle>
204
- <SheetDescription>Displays the mobile sidebar.</SheetDescription>
287
+ <SheetTitle>{t("ui.sidebar.title")}</SheetTitle>
288
+ <SheetDescription>{t("ui.sidebar.mobileDescription")}</SheetDescription>
205
289
  </SheetHeader>
206
290
  <div className="flex h-full w-full flex-col">{children}</div>
207
291
  </SheetContent>
@@ -235,7 +319,16 @@ export const Sidebar = forwardRef<
235
319
  panel.containerClassName,
236
320
  variant === "floating" || variant === "inset"
237
321
  ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
238
- : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
322
+ : // `border-sidebar-border`, not the inherited canvas `--border`:
323
+ // this edge is CHROME, so it takes the chrome edge token — the
324
+ // same one the `floating` variant's inner surface and
325
+ // `SidebarSeparator` already use. Left to the canvas token it was
326
+ // a near-white hairline (light: L 0.88), invisible against the
327
+ // page but a bright line as soon as the rail sits on a
328
+ // `bg-sidebar` ground — which is exactly what an `inset` frame
329
+ // puts behind it, measured at the dashboard shell's right-hand
330
+ // `ContextRail`.
331
+ "group-data-[collapsible=icon]:w-(--sidebar-width-icon) border-sidebar-border group-data-[side=left]:border-r group-data-[side=right]:border-l",
239
332
  className,
240
333
  )}
241
334
  {...props}
@@ -254,7 +347,13 @@ export const Sidebar = forwardRef<
254
347
 
255
348
  export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeof Button>>(
256
349
  function SidebarTrigger({ className, onClick, ...props }, ref) {
257
- const { toggleSidebar } = useSidebar();
350
+ const { t } = useLocale();
351
+ const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
352
+ // `toggleSidebar` flips `openMobile` below the mobile breakpoint and `open`
353
+ // above it, so the state this button EXPOSES has to be read the same way —
354
+ // reporting the desktop `open` on a mobile viewport would announce the
355
+ // opposite of what the button does.
356
+ const expanded = isMobile ? openMobile : open;
258
357
  return (
259
358
  <Button
260
359
  ref={ref}
@@ -262,6 +361,18 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
262
361
  data-slot="sidebar-trigger"
263
362
  variant="ghost"
264
363
  size="icon"
364
+ // A disclosure control must EXPOSE the state it toggles (WCAG 4.1.2).
365
+ // This button's whole accessible name is the static "Toggle Sidebar"
366
+ // below, so without this attribute nothing tells a screen-reader user
367
+ // whether the rail is currently open — and no axe rule catches it,
368
+ // because a <button> has no REQUIRED expanded state.
369
+ //
370
+ // `aria-controls` is deliberately omitted, not forgotten: on mobile the
371
+ // sidebar renders into a `Sheet` that is not in the document while
372
+ // closed, so the attribute would point at an absent id — worse than
373
+ // leaving it off. Spread last, so a caller that really does own a
374
+ // stable target can still supply both.
375
+ aria-expanded={expanded}
265
376
  className={cn("size-7", className)}
266
377
  onClick={(event) => {
267
378
  onClick?.(event);
@@ -270,7 +381,7 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
270
381
  {...props}
271
382
  >
272
383
  <PanelLeftIcon />
273
- <span className="sr-only">Toggle Sidebar</span>
384
+ <span className="sr-only">{t("ui.sidebar.toggle")}</span>
274
385
  </Button>
275
386
  );
276
387
  },
@@ -278,16 +389,17 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
278
389
 
279
390
  export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button">>(
280
391
  function SidebarRail({ className, ...props }, ref) {
392
+ const { t } = useLocale();
281
393
  const { toggleSidebar } = useSidebar();
282
394
  return (
283
395
  <button
284
396
  ref={ref}
285
397
  data-sidebar="rail"
286
398
  data-slot="sidebar-rail"
287
- aria-label="Toggle Sidebar"
399
+ aria-label={t("ui.sidebar.toggle")}
288
400
  tabIndex={-1}
289
401
  onClick={toggleSidebar}
290
- title="Toggle Sidebar"
402
+ title={t("ui.sidebar.toggle")}
291
403
  className={cn(
292
404
  "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
293
405
  "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
@@ -300,22 +412,124 @@ export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button"
300
412
  },
301
413
  );
302
414
 
303
- export const SidebarInset = forwardRef<HTMLDivElement, ComponentProps<"main">>(
304
- function SidebarInset({ className, ...props }, ref) {
305
- return (
306
- <main
307
- ref={ref}
308
- data-slot="sidebar-inset"
309
- className={cn(
310
- "relative flex w-full flex-1 flex-col bg-background",
311
- "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
312
- className,
313
- )}
314
- {...props}
315
- />
316
- );
415
+ /**
416
+ * Which sides of the floating "inset" surface get a gutter, once an ancestor
417
+ * drives the treatment (`SidebarProvider variant="inset"`, or the legacy
418
+ * immediately-preceding `Sidebar variant="inset"`).
419
+ *
420
+ * - `"auto"` (default) — today's rule, unchanged: a gutter on every side
421
+ * except the leading edge (`m-2 ms-0`, because the classic layout puts the
422
+ * sidebar there), plus the leading edge's gutter returns (`ms-2`) once that
423
+ * sidebar collapses and its own gap closes.
424
+ * - `"none"` — no gutter margin (the unconditional radius/shadow below still
425
+ * apply).
426
+ * - An object — pick sides explicitly. `{ start: true, bottom: true }` is the
427
+ * "flush rail" geometry: a leading + bottom gutter only, no top, no
428
+ * trailing, because the trailing edge sits flush against a rail — a tab
429
+ * must touch the page it belongs to.
430
+ *
431
+ * PRECEDENCE (fix round 1, #342): a caller composing BOTH mechanisms at once
432
+ * — `SidebarProvider variant="inset"` AND a LEFT `Sidebar variant="inset"`,
433
+ * the shape a left-hand shell (e.g. the sidebar-02 rebuild) uses — never hits
434
+ * a class-order race, because the ancestor-scoped and legacy peer-scoped
435
+ * margin classes are BOTH derived from this same `gutter` value, so whenever
436
+ * both selectors match they emit identical declarations instead of competing
437
+ * ones; the resolved geometry is always exactly what `gutter` says, decided
438
+ * by this prop, never by the generated stylesheet's rule order.
439
+ */
440
+ export type SidebarInsetGutter =
441
+ | "auto"
442
+ | "none"
443
+ | { top?: boolean; bottom?: boolean; start?: boolean; end?: boolean };
444
+
445
+ export interface SidebarInsetProps extends ComponentProps<"main"> {
446
+ gutter?: SidebarInsetGutter;
447
+ }
448
+
449
+ // Each side is a COMPLETE literal utility string, one per SELECTOR SCOPE:
450
+ // `ancestor` (`group-data-…/sidebar-wrapper:`, reaches a `SidebarProvider
451
+ // variant="inset"` regardless of DOM order — the #342 fix) and `legacy`
452
+ // (`peer-data-…:`, reaches a `Sidebar variant="inset"` that immediately
453
+ // precedes this element — every caller before #342). Both scopes read the
454
+ // SAME `gutter` value below, which is what keeps them from ever disagreeing.
455
+ // Tailwind's content scanner only recognises literal class text in source —
456
+ // never a name assembled by concatenation/interpolation
457
+ // (.claude/rules/styling-and-tokens.md) — so the object form below picks
458
+ // among these literals, it never builds one.
459
+ const SIDEBAR_INSET_GUTTER_SIDE_CLASS = {
460
+ top: {
461
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mt-2",
462
+ legacy: "md:peer-data-[variant=inset]:mt-2",
317
463
  },
318
- );
464
+ bottom: {
465
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mb-2",
466
+ legacy: "md:peer-data-[variant=inset]:mb-2",
467
+ },
468
+ start: {
469
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:ms-2",
470
+ legacy: "md:peer-data-[variant=inset]:ms-2",
471
+ },
472
+ end: {
473
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:me-2",
474
+ legacy: "md:peer-data-[variant=inset]:me-2",
475
+ },
476
+ } as const;
477
+
478
+ // "auto" is its own complete literal pair, not composed from the map above,
479
+ // because it also carries the collapsed-state clause: the sidebar's own gap
480
+ // closes on collapse, so the inset's leading margin has to come back.
481
+ const SIDEBAR_INSET_GUTTER_AUTO_CLASS = {
482
+ ancestor:
483
+ "md:group-data-[variant=inset]/sidebar-wrapper:m-2 md:group-data-[variant=inset]/sidebar-wrapper:ms-0 md:group-data-[variant=inset]/sidebar-wrapper:group-data-[state=collapsed]/sidebar-wrapper:ms-2",
484
+ legacy:
485
+ "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
486
+ } as const;
487
+
488
+ function sidebarInsetGutterClassName(
489
+ gutter: SidebarInsetGutter,
490
+ scope: "ancestor" | "legacy",
491
+ ): string {
492
+ if (gutter === "auto") return SIDEBAR_INSET_GUTTER_AUTO_CLASS[scope];
493
+ if (gutter === "none") return "";
494
+ return cn(
495
+ gutter.top && SIDEBAR_INSET_GUTTER_SIDE_CLASS.top[scope],
496
+ gutter.bottom && SIDEBAR_INSET_GUTTER_SIDE_CLASS.bottom[scope],
497
+ gutter.start && SIDEBAR_INSET_GUTTER_SIDE_CLASS.start[scope],
498
+ gutter.end && SIDEBAR_INSET_GUTTER_SIDE_CLASS.end[scope],
499
+ );
500
+ }
501
+
502
+ export const SidebarInset = forwardRef<HTMLDivElement, SidebarInsetProps>(function SidebarInset(
503
+ { className, gutter = "auto", ...props },
504
+ ref,
505
+ ) {
506
+ return (
507
+ <main
508
+ ref={ref}
509
+ data-slot="sidebar-inset"
510
+ className={cn(
511
+ "relative flex w-full flex-1 flex-col bg-background",
512
+ // Ancestor-scoped (#342 fix): `group/sidebar-wrapper` spans the whole
513
+ // frame, so this reaches a right-hand or reordered `Sidebar` the old
514
+ // peer-* combinator could not (it only matches a sibling that comes
515
+ // AFTER). Reads `SidebarProvider`'s own `data-variant`/`data-state`.
516
+ // Radius/shadow are unconditional here — not gated by `gutter`.
517
+ "md:group-data-[variant=inset]/sidebar-wrapper:rounded-xl md:group-data-[variant=inset]/sidebar-wrapper:shadow-sm",
518
+ sidebarInsetGutterClassName(gutter, "ancestor"),
519
+ // Legacy peer rule — reaches a shell that sets `variant` only on
520
+ // `Sidebar` (every caller before #342). Radius/shadow unconditional
521
+ // here too; the margin is driven by the SAME `gutter` value as the
522
+ // ancestor rule above (fix round 1, #342), so a caller composing both
523
+ // mechanisms at once never hits a stylesheet-order race — whichever
524
+ // selector matches emits the identical declaration.
525
+ "md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
526
+ sidebarInsetGutterClassName(gutter, "legacy"),
527
+ className,
528
+ )}
529
+ {...props}
530
+ />
531
+ );
532
+ });
319
533
 
320
534
  export const SidebarInput = forwardRef<HTMLInputElement, ComponentProps<typeof Input>>(
321
535
  function SidebarInput({ className, ...props }, ref) {
@@ -416,8 +630,8 @@ export const SidebarGroupLabel = forwardRef<
416
630
  data-slot="sidebar-group-label"
417
631
  data-sidebar="group-label"
418
632
  className={cn(
419
- "flex h-8 shrink-0 items-center rounded-md px-2 text-meta font-medium text-sidebar-muted-foreground outline-none ring-sidebar-ring transition-[margin,opacity] duration-base ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
420
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
633
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-meta font-medium text-sidebar-muted-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-[opacity] duration-base ease-linear [&>svg]:size-4 [&>svg]:shrink-0",
634
+ "group-data-[collapsible=icon]:hidden",
421
635
  className,
422
636
  )}
423
637
  {...props}
@@ -436,7 +650,7 @@ export const SidebarGroupAction = forwardRef<
436
650
  data-slot="sidebar-group-action"
437
651
  data-sidebar="group-action"
438
652
  className={cn(
439
- "absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
653
+ "absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
440
654
  "group-data-[collapsible=icon]:hidden",
441
655
  className,
442
656
  )}
@@ -489,7 +703,7 @@ export const SidebarMenuItem = forwardRef<HTMLLIElement, ComponentProps<"li">>(
489
703
  );
490
704
 
491
705
  export const sidebarMenuButtonVariants = cva(
492
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-body text-sidebar-foreground outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
706
+ "peer/menu-button relative flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-body text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary data-[active=true]:before:pointer-events-none data-[active=true]:before:absolute data-[active=true]:before:inset-y-1.5 data-[active=true]:before:start-0 data-[active=true]:before:w-1 data-[active=true]:before:rounded-full data-[active=true]:before:bg-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
493
707
  {
494
708
  variants: {
495
709
  variant: {
@@ -573,7 +787,7 @@ export const SidebarMenuAction = forwardRef<
573
787
  data-slot="sidebar-menu-action"
574
788
  data-sidebar="menu-action"
575
789
  className={cn(
576
- "absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
790
+ "absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
577
791
  "group-data-[collapsible=icon]:hidden",
578
792
  showOnHover &&
579
793
  "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
@@ -617,9 +831,21 @@ export function SidebarMenuSkeleton({
617
831
  className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
618
832
  {...props}
619
833
  >
620
- {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
834
+ {/* `bg-sidebar-accent`, overriding `Skeleton`'s own `bg-muted`. `--muted`
835
+ is a CANVAS token: in the light theme it is a near-white
836
+ `oklch(0.968 …)` sitting on this sidebar's dark `oklch(0.3 …)` ground,
837
+ which measures 12.42:1 — the placeholder becomes the loudest thing on
838
+ a screen that has nothing loaded yet. The sidebar's own quiet rung
839
+ measures 1.26:1 on light and 1.29:1 on dark, i.e. a placeholder in
840
+ both themes instead of an inversion in one. */}
841
+ {showIcon && (
842
+ <Skeleton
843
+ className="size-4 rounded-md bg-sidebar-accent"
844
+ data-sidebar="menu-skeleton-icon"
845
+ />
846
+ )}
621
847
  <Skeleton
622
- className="h-4 max-w-(--skeleton-width) flex-1"
848
+ className="h-4 max-w-(--skeleton-width) flex-1 bg-sidebar-accent"
623
849
  data-sidebar="menu-skeleton-text"
624
850
  style={{ "--skeleton-width": width } as CSSProperties}
625
851
  />
@@ -675,8 +901,8 @@ export const SidebarMenuSubButton = forwardRef<
675
901
  data-size={size}
676
902
  data-active={isActive}
677
903
  className={cn(
678
- "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
679
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
904
+ "relative flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
905
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground data-[active=true]:before:pointer-events-none data-[active=true]:before:absolute data-[active=true]:before:inset-y-1.5 data-[active=true]:before:start-0 data-[active=true]:before:w-1 data-[active=true]:before:rounded-full data-[active=true]:before:bg-sidebar-primary",
680
906
  size === "sm" && "text-meta",
681
907
  size === "md" && "text-body",
682
908
  "group-data-[collapsible=icon]:hidden",
@@ -7,6 +7,7 @@ export type SkeletonProps = HTMLAttributes<HTMLDivElement>;
7
7
  export function Skeleton({ className, ...props }: SkeletonProps) {
8
8
  return (
9
9
  <div
10
+ data-slot="skeleton"
10
11
  className={cn("animate-pulse rounded-md bg-muted", className)}
11
12
  aria-hidden="true"
12
13
  {...props}
@@ -0,0 +1 @@
1
+ export * from "./skip-link";
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent } from "storybook/test";
3
+ import { CommandTrigger } from "../command-trigger";
4
+ import { SkipLink } from "./skip-link";
5
+
6
+ const meta = {
7
+ title: "Navigation/SkipLink",
8
+ component: SkipLink,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'The first focusable element of an application. Invisible until it receives focus, then a pill in the top-start corner that jumps past the whole nav rail to the page\'s `<main>`. Give the target `id="main-content"` and `tabIndex={-1}` so focus lands there rather than merely scrolling.',
14
+ },
15
+ },
16
+ },
17
+ tags: ["autodocs"],
18
+ } satisfies Meta<typeof SkipLink>;
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ /** Press Tab: the link appears. It is the only way a keyboard user skips the rail. */
23
+ export const Default: Story = {
24
+ render: () => (
25
+ <div>
26
+ <SkipLink />
27
+ <nav aria-label="Primary" className="p-4 text-body text-muted-foreground">
28
+ nav links live here
29
+ </nav>
30
+ <main id="main-content" tabIndex={-1} className="p-4 text-body">
31
+ Main content
32
+ </main>
33
+ </div>
34
+ ),
35
+ play: async ({ canvasElement }) => {
36
+ await userEvent.tab();
37
+ const link = canvasElement.querySelector('[data-slot="skip-link"]') as HTMLElement;
38
+ await expect(link).toHaveFocus();
39
+ // Not-sr-only when focused: it has real painted size.
40
+ await expect(link.getBoundingClientRect().width).toBeGreaterThan(40);
41
+ },
42
+ };
43
+
44
+ /**
45
+ * Finding 3 (task-11f-brief.md): the `Default` story above has no competing
46
+ * focusable furniture, so its own "the link comes first" assertion is
47
+ * vacuous — it would pass on a page with the link anywhere in the tab order,
48
+ * because there is nothing else to come before. This story composes the
49
+ * link inside a realistic shell — a `CommandTrigger` plus real nav
50
+ * `<a>`/`<button>` elements genuinely competing for first place — and a real
51
+ * `<main id="main-content" tabIndex={-1}>` target. It proves the tab-order
52
+ * claim for real: tabbing from a blurred page lands on the skip link ahead of
53
+ * every one of those competing controls. The second claim — that ACTIVATING
54
+ * the link moves real focus rather than merely scrolling — is only checked at
55
+ * the precondition level here (see the comment in the play function); this
56
+ * harness cannot exercise the activation itself.
57
+ */
58
+ export const RealisticShell: Story = {
59
+ render: () => (
60
+ <div>
61
+ <SkipLink />
62
+ <header className="flex items-center justify-between gap-4 border-b border-border p-4">
63
+ <nav aria-label="Primary" className="flex items-center gap-4">
64
+ <a href="#dashboard" className="text-body text-foreground">
65
+ Dashboard
66
+ </a>
67
+ <a href="#reports" className="text-body text-foreground">
68
+ Reports
69
+ </a>
70
+ <button type="button" className="text-body text-foreground">
71
+ Settings
72
+ </button>
73
+ </nav>
74
+ <CommandTrigger />
75
+ </header>
76
+ <main id="main-content" tabIndex={-1} className="p-4 text-body">
77
+ Main content
78
+ </main>
79
+ </div>
80
+ ),
81
+ play: async ({ canvasElement }) => {
82
+ // Start from a blurred page so Tab lands on whatever is genuinely FIRST
83
+ // in the DOM's tab order — not on whatever the previous story left
84
+ // focused.
85
+ (document.activeElement as HTMLElement | null)?.blur();
86
+ await userEvent.tab();
87
+ const link = canvasElement.querySelector('[data-slot="skip-link"]') as HTMLElement;
88
+ await expect(link).toHaveFocus();
89
+
90
+ // PRECONDITIONS only, not proof: activating the link really moving focus
91
+ // to the main landmark cannot be exercised in this harness — Playwright's
92
+ // pointer-driven `.click()` on a same-document `href="#…"` anchor makes
93
+ // this `@vitest/browser` composed-story runner tear down the page and
94
+ // report its own WebSocket connection as closed, even though the
95
+ // navigation itself is ordinary. So this locks the two things the
96
+ // focus-move mechanism depends on instead: the href fragment targets the
97
+ // right id, and that id is programmatically focusable.
98
+ const main = canvasElement.querySelector("#main-content") as HTMLElement;
99
+ await expect(link.getAttribute("href")).toBe(`#${main.id}`);
100
+ // Assert the ATTRIBUTE, not the `tabIndex` IDL property: for a
101
+ // non-interactive element the property getter returns -1 whether or not
102
+ // the attribute is present, so it cannot detect a missing attribute —
103
+ // the one thing this precondition exists to catch.
104
+ await expect(main.getAttribute("tabindex")).toBe("-1");
105
+ },
106
+ };
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { SkipLink } from "./skip-link";
4
+
5
+ describe("SkipLink", () => {
6
+ it("points at the main landmark and is reachable by keyboard", () => {
7
+ render(<SkipLink />);
8
+ const link = screen.getByRole("link", { name: "Skip to main content" });
9
+ expect(link).toHaveAttribute("href", "#main-content");
10
+ expect(link).not.toHaveAttribute("tabindex", "-1");
11
+ });
12
+
13
+ it("accepts a different target", () => {
14
+ render(<SkipLink targetId="reading-pane">Skip to the message</SkipLink>);
15
+ expect(screen.getByRole("link", { name: "Skip to the message" })).toHaveAttribute(
16
+ "href",
17
+ "#reading-pane",
18
+ );
19
+ });
20
+ });