@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.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 +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -19,6 +19,7 @@ import { useIsMobile } from "../../lib/use-mobile";
19
19
  import { Button } from "../button";
20
20
  import { useCollapsiblePanel } from "../collapsible-panel";
21
21
  import { Input } from "../input";
22
+ import { useLocale } from "../locale-provider";
22
23
  import { Separator } from "../separator";
23
24
  import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
24
25
  import { Skeleton } from "../skeleton";
@@ -55,12 +56,36 @@ export const SidebarProvider = forwardRef<
55
56
  defaultOpen?: boolean;
56
57
  open?: boolean;
57
58
  onOpenChange?: (open: boolean) => void;
59
+ /**
60
+ * Drives the frame's inset treatment from an ANCESTOR the whole frame can
61
+ * see — `SidebarInset` (#342 fix) reads it as `data-variant` on this
62
+ * wrapper via `group-data-[variant=inset]/sidebar-wrapper:`, which reaches
63
+ * regardless of DOM order (unlike the legacy `peer-*` combinator, which
64
+ * only matches a Sidebar that comes AFTER). Optional and unset by
65
+ * default, so an existing caller that only sets `variant` on `Sidebar`
66
+ * is unaffected.
67
+ */
68
+ variant?: "sidebar" | "floating" | "inset";
69
+ /**
70
+ * Default `"app"` — today's behaviour, byte-identical. `"nested"` is for
71
+ * a `SidebarProvider` a compound component (e.g. `ContextRail`, ADR 0035
72
+ * §4) mounts internally so `useSidebar()` reports ITS OWN state to its
73
+ * own parts: it still provides `SidebarContext` and the two
74
+ * `--sidebar-width*` custom properties (which survive `display:
75
+ * contents`), but renders no frame box, no `data-slot`/`data-variant`/
76
+ * `data-state`, registers no global `⌘B`/`Ctrl+B` listener, and writes no
77
+ * `sidebar_state` cookie — so a nested rail can never be mistaken for a
78
+ * second app frame. See the emission table in ADR 0035 §4.
79
+ */
80
+ frame?: "app" | "nested";
58
81
  }
59
82
  >(function SidebarProvider(
60
83
  {
61
84
  defaultOpen = true,
62
85
  open: openProp,
63
86
  onOpenChange: setOpenProp,
87
+ variant,
88
+ frame = "app",
64
89
  className,
65
90
  style,
66
91
  children,
@@ -68,6 +93,7 @@ export const SidebarProvider = forwardRef<
68
93
  },
69
94
  ref,
70
95
  ) {
96
+ const isNested = frame === "nested";
71
97
  const isMobile = useIsMobile();
72
98
  const [openMobile, setOpenMobile] = useState(false);
73
99
 
@@ -78,11 +104,14 @@ export const SidebarProvider = forwardRef<
78
104
  const openState = typeof value === "function" ? value(open) : value;
79
105
  if (setOpenProp) setOpenProp(openState);
80
106
  else _setOpen(openState);
81
- if (typeof document !== "undefined") {
107
+ // A nested provider (ADR 0035 §4) is not the app frame, so it must not
108
+ // persist ITS state as if it were — only `frame="app"` (the default)
109
+ // owns the `sidebar_state` cookie.
110
+ if (!isNested && typeof document !== "undefined") {
82
111
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
83
112
  }
84
113
  },
85
- [setOpenProp, open],
114
+ [setOpenProp, open, isNested],
86
115
  );
87
116
 
88
117
  const toggleSidebar = useCallback(() => {
@@ -90,6 +119,10 @@ export const SidebarProvider = forwardRef<
90
119
  }, [isMobile, setOpen]);
91
120
 
92
121
  useEffect(() => {
122
+ // Same reasoning as the cookie write above: the global keyboard shortcut
123
+ // belongs to the ONE app frame, not to every nested provider a compound
124
+ // component happens to mount.
125
+ if (isNested) return;
93
126
  const handleKeyDown = (event: KeyboardEvent) => {
94
127
  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
95
128
  event.preventDefault();
@@ -98,7 +131,7 @@ export const SidebarProvider = forwardRef<
98
131
  };
99
132
  window.addEventListener("keydown", handleKeyDown);
100
133
  return () => window.removeEventListener("keydown", handleKeyDown);
101
- }, [toggleSidebar]);
134
+ }, [isNested, toggleSidebar]);
102
135
 
103
136
  const state = open ? "expanded" : "collapsed";
104
137
 
@@ -112,9 +145,26 @@ export const SidebarProvider = forwardRef<
112
145
  <TooltipProvider delayDuration={0}>
113
146
  <div
114
147
  ref={ref}
115
- data-slot="sidebar-wrapper"
148
+ // `data-slot`/`data-variant`/`data-state`/`group/sidebar-wrapper`
149
+ // (in the class list below) are ADR 0035 §8 refinement 3's
150
+ // `frame="app"` surface. `frame="nested"` (ADR 0035 §4, Task 9A)
151
+ // omits all four: a nested rail's own state must never drive the
152
+ // outer frame's geometry through the same group name or be mistaken
153
+ // for a second "sidebar wrapper" by a selector/test targeting the
154
+ // slot. The literal string "sidebar-wrapper" stays below (as a
155
+ // conditional value) so `pnpm data-slot:check` still sees this
156
+ // module's declaration.
157
+ data-slot={isNested ? undefined : "sidebar-wrapper"}
158
+ data-variant={isNested ? undefined : variant}
159
+ data-state={isNested ? undefined : state}
116
160
  style={
117
161
  {
162
+ // These two custom properties are the ONE thing a nested
163
+ // provider still emits (ADR 0035 §4) — they survive `display:
164
+ // contents` because custom properties inherit down the DOM
165
+ // tree regardless of the box an element generates, which is
166
+ // exactly how a nested rail publishes its width to whatever it
167
+ // wraps. Never move them onto a child.
118
168
  "--sidebar-width": SIDEBAR_WIDTH,
119
169
  "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
120
170
  ...style,
@@ -131,7 +181,28 @@ export const SidebarProvider = forwardRef<
131
181
  // for every element that inherits its colour (outline Buttons, list
132
182
  // rows). See the chrome<canvas elevation invariant in
133
183
  // .claude/rules/styling-and-tokens.md.
134
- "group/sidebar-wrapper flex min-h-svh w-full text-foreground has-data-[variant=inset]:bg-sidebar",
184
+ // Omitted under `frame="nested"` a nested provider is a pure
185
+ // context + custom-property carrier, not a second frame box.
186
+ !isNested && "group/sidebar-wrapper flex min-h-svh w-full text-foreground",
187
+ // The frame's OWN `variant` is authoritative once set (ADR 0035 §8
188
+ // refinement 4): resolved in JS, not by a CSS descendant match, so
189
+ // a nested rail three levels down that happens to render
190
+ // `variant="inset"` can never repaint THIS frame's ground merely
191
+ // because `:has()` is depth-unlimited. `has-data-[variant=inset]`
192
+ // stays as the fallback ONLY while this provider's own `variant`
193
+ // is unset, which is exactly every existing caller (this prop
194
+ // didn't exist before #342) — so they render exactly as today.
195
+ // Also omitted under `frame="nested"`, same reasoning as above.
196
+ !isNested &&
197
+ (variant === undefined
198
+ ? "has-data-[variant=inset]:bg-sidebar"
199
+ : variant === "inset" && "bg-sidebar"),
200
+ // `frame="nested"` renders no box of its own: `display: contents`
201
+ // (the literal Tailwind class, never an inline style or a
202
+ // concatenated name) removes this element from layout while
203
+ // keeping its children — and the custom properties above —
204
+ // reachable.
205
+ isNested && "contents",
135
206
  className,
136
207
  )}
137
208
  {...props}
@@ -155,6 +226,7 @@ export const Sidebar = forwardRef<
155
226
  ref,
156
227
  ) {
157
228
  const { isMobile, openMobile, setOpenMobile, open, setOpen } = useSidebar();
229
+ const { t } = useLocale();
158
230
 
159
231
  // The collapse mechanism (gap spacer + fixed slide) is the canonical
160
232
  // useCollapsiblePanel hook (#190, research 09 §B.2) — Sidebar passes its
@@ -196,11 +268,11 @@ export const Sidebar = forwardRef<
196
268
  data-slot="sidebar"
197
269
  data-mobile="true"
198
270
  side={side}
199
- className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
271
+ className="w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
200
272
  style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE } as CSSProperties}
201
273
  >
202
274
  <SheetHeader className="sr-only">
203
- <SheetTitle>Sidebar</SheetTitle>
275
+ <SheetTitle>{t("ui.sidebar.title")}</SheetTitle>
204
276
  <SheetDescription>Displays the mobile sidebar.</SheetDescription>
205
277
  </SheetHeader>
206
278
  <div className="flex h-full w-full flex-col">{children}</div>
@@ -235,7 +307,16 @@ export const Sidebar = forwardRef<
235
307
  panel.containerClassName,
236
308
  variant === "floating" || variant === "inset"
237
309
  ? "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",
310
+ : // `border-sidebar-border`, not the inherited canvas `--border`:
311
+ // this edge is CHROME, so it takes the chrome edge token — the
312
+ // same one the `floating` variant's inner surface and
313
+ // `SidebarSeparator` already use. Left to the canvas token it was
314
+ // a near-white hairline (light: L 0.88), invisible against the
315
+ // page but a bright line as soon as the rail sits on a
316
+ // `bg-sidebar` ground — which is exactly what an `inset` frame
317
+ // puts behind it, measured at the dashboard shell's right-hand
318
+ // `ContextRail`.
319
+ "group-data-[collapsible=icon]:w-(--sidebar-width-icon) border-sidebar-border group-data-[side=left]:border-r group-data-[side=right]:border-l",
239
320
  className,
240
321
  )}
241
322
  {...props}
@@ -254,7 +335,12 @@ export const Sidebar = forwardRef<
254
335
 
255
336
  export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeof Button>>(
256
337
  function SidebarTrigger({ className, onClick, ...props }, ref) {
257
- const { toggleSidebar } = useSidebar();
338
+ const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
339
+ // `toggleSidebar` flips `openMobile` below the mobile breakpoint and `open`
340
+ // above it, so the state this button EXPOSES has to be read the same way —
341
+ // reporting the desktop `open` on a mobile viewport would announce the
342
+ // opposite of what the button does.
343
+ const expanded = isMobile ? openMobile : open;
258
344
  return (
259
345
  <Button
260
346
  ref={ref}
@@ -262,6 +348,18 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
262
348
  data-slot="sidebar-trigger"
263
349
  variant="ghost"
264
350
  size="icon"
351
+ // A disclosure control must EXPOSE the state it toggles (WCAG 4.1.2).
352
+ // This button's whole accessible name is the static "Toggle Sidebar"
353
+ // below, so without this attribute nothing tells a screen-reader user
354
+ // whether the rail is currently open — and no axe rule catches it,
355
+ // because a <button> has no REQUIRED expanded state.
356
+ //
357
+ // `aria-controls` is deliberately omitted, not forgotten: on mobile the
358
+ // sidebar renders into a `Sheet` that is not in the document while
359
+ // closed, so the attribute would point at an absent id — worse than
360
+ // leaving it off. Spread last, so a caller that really does own a
361
+ // stable target can still supply both.
362
+ aria-expanded={expanded}
265
363
  className={cn("size-7", className)}
266
364
  onClick={(event) => {
267
365
  onClick?.(event);
@@ -300,22 +398,124 @@ export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button"
300
398
  },
301
399
  );
302
400
 
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
- );
401
+ /**
402
+ * Which sides of the floating "inset" surface get a gutter, once an ancestor
403
+ * drives the treatment (`SidebarProvider variant="inset"`, or the legacy
404
+ * immediately-preceding `Sidebar variant="inset"`).
405
+ *
406
+ * - `"auto"` (default) — today's rule, unchanged: a gutter on every side
407
+ * except the leading edge (`m-2 ms-0`, because the classic layout puts the
408
+ * sidebar there), plus the leading edge's gutter returns (`ms-2`) once that
409
+ * sidebar collapses and its own gap closes.
410
+ * - `"none"` — no gutter margin (the unconditional radius/shadow below still
411
+ * apply).
412
+ * - An object — pick sides explicitly. `{ start: true, bottom: true }` is the
413
+ * "flush rail" geometry: a leading + bottom gutter only, no top, no
414
+ * trailing, because the trailing edge sits flush against a rail — a tab
415
+ * must touch the page it belongs to.
416
+ *
417
+ * PRECEDENCE (fix round 1, #342): a caller composing BOTH mechanisms at once
418
+ * — `SidebarProvider variant="inset"` AND a LEFT `Sidebar variant="inset"`,
419
+ * the shape a left-hand shell (e.g. the sidebar-02 rebuild) uses — never hits
420
+ * a class-order race, because the ancestor-scoped and legacy peer-scoped
421
+ * margin classes are BOTH derived from this same `gutter` value, so whenever
422
+ * both selectors match they emit identical declarations instead of competing
423
+ * ones; the resolved geometry is always exactly what `gutter` says, decided
424
+ * by this prop, never by the generated stylesheet's rule order.
425
+ */
426
+ export type SidebarInsetGutter =
427
+ | "auto"
428
+ | "none"
429
+ | { top?: boolean; bottom?: boolean; start?: boolean; end?: boolean };
430
+
431
+ export interface SidebarInsetProps extends ComponentProps<"main"> {
432
+ gutter?: SidebarInsetGutter;
433
+ }
434
+
435
+ // Each side is a COMPLETE literal utility string, one per SELECTOR SCOPE:
436
+ // `ancestor` (`group-data-…/sidebar-wrapper:`, reaches a `SidebarProvider
437
+ // variant="inset"` regardless of DOM order — the #342 fix) and `legacy`
438
+ // (`peer-data-…:`, reaches a `Sidebar variant="inset"` that immediately
439
+ // precedes this element — every caller before #342). Both scopes read the
440
+ // SAME `gutter` value below, which is what keeps them from ever disagreeing.
441
+ // Tailwind's content scanner only recognises literal class text in source —
442
+ // never a name assembled by concatenation/interpolation
443
+ // (.claude/rules/styling-and-tokens.md) — so the object form below picks
444
+ // among these literals, it never builds one.
445
+ const SIDEBAR_INSET_GUTTER_SIDE_CLASS = {
446
+ top: {
447
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mt-2",
448
+ legacy: "md:peer-data-[variant=inset]:mt-2",
317
449
  },
318
- );
450
+ bottom: {
451
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mb-2",
452
+ legacy: "md:peer-data-[variant=inset]:mb-2",
453
+ },
454
+ start: {
455
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:ms-2",
456
+ legacy: "md:peer-data-[variant=inset]:ms-2",
457
+ },
458
+ end: {
459
+ ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:me-2",
460
+ legacy: "md:peer-data-[variant=inset]:me-2",
461
+ },
462
+ } as const;
463
+
464
+ // "auto" is its own complete literal pair, not composed from the map above,
465
+ // because it also carries the collapsed-state clause: the sidebar's own gap
466
+ // closes on collapse, so the inset's leading margin has to come back.
467
+ const SIDEBAR_INSET_GUTTER_AUTO_CLASS = {
468
+ ancestor:
469
+ "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",
470
+ legacy:
471
+ "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",
472
+ } as const;
473
+
474
+ function sidebarInsetGutterClassName(
475
+ gutter: SidebarInsetGutter,
476
+ scope: "ancestor" | "legacy",
477
+ ): string {
478
+ if (gutter === "auto") return SIDEBAR_INSET_GUTTER_AUTO_CLASS[scope];
479
+ if (gutter === "none") return "";
480
+ return cn(
481
+ gutter.top && SIDEBAR_INSET_GUTTER_SIDE_CLASS.top[scope],
482
+ gutter.bottom && SIDEBAR_INSET_GUTTER_SIDE_CLASS.bottom[scope],
483
+ gutter.start && SIDEBAR_INSET_GUTTER_SIDE_CLASS.start[scope],
484
+ gutter.end && SIDEBAR_INSET_GUTTER_SIDE_CLASS.end[scope],
485
+ );
486
+ }
487
+
488
+ export const SidebarInset = forwardRef<HTMLDivElement, SidebarInsetProps>(function SidebarInset(
489
+ { className, gutter = "auto", ...props },
490
+ ref,
491
+ ) {
492
+ return (
493
+ <main
494
+ ref={ref}
495
+ data-slot="sidebar-inset"
496
+ className={cn(
497
+ "relative flex w-full flex-1 flex-col bg-background",
498
+ // Ancestor-scoped (#342 fix): `group/sidebar-wrapper` spans the whole
499
+ // frame, so this reaches a right-hand or reordered `Sidebar` the old
500
+ // peer-* combinator could not (it only matches a sibling that comes
501
+ // AFTER). Reads `SidebarProvider`'s own `data-variant`/`data-state`.
502
+ // Radius/shadow are unconditional here — not gated by `gutter`.
503
+ "md:group-data-[variant=inset]/sidebar-wrapper:rounded-xl md:group-data-[variant=inset]/sidebar-wrapper:shadow-sm",
504
+ sidebarInsetGutterClassName(gutter, "ancestor"),
505
+ // Legacy peer rule — reaches a shell that sets `variant` only on
506
+ // `Sidebar` (every caller before #342). Radius/shadow unconditional
507
+ // here too; the margin is driven by the SAME `gutter` value as the
508
+ // ancestor rule above (fix round 1, #342), so a caller composing both
509
+ // mechanisms at once never hits a stylesheet-order race — whichever
510
+ // selector matches emits the identical declaration.
511
+ "md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
512
+ sidebarInsetGutterClassName(gutter, "legacy"),
513
+ className,
514
+ )}
515
+ {...props}
516
+ />
517
+ );
518
+ });
319
519
 
320
520
  export const SidebarInput = forwardRef<HTMLInputElement, ComponentProps<typeof Input>>(
321
521
  function SidebarInput({ className, ...props }, ref) {
@@ -416,8 +616,8 @@ export const SidebarGroupLabel = forwardRef<
416
616
  data-slot="sidebar-group-label"
417
617
  data-sidebar="group-label"
418
618
  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",
619
+ "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",
620
+ "group-data-[collapsible=icon]:hidden",
421
621
  className,
422
622
  )}
423
623
  {...props}
@@ -436,7 +636,7 @@ export const SidebarGroupAction = forwardRef<
436
636
  data-slot="sidebar-group-action"
437
637
  data-sidebar="group-action"
438
638
  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",
639
+ "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
640
  "group-data-[collapsible=icon]:hidden",
441
641
  className,
442
642
  )}
@@ -489,7 +689,7 @@ export const SidebarMenuItem = forwardRef<HTMLLIElement, ComponentProps<"li">>(
489
689
  );
490
690
 
491
691
  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",
692
+ "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
693
  {
494
694
  variants: {
495
695
  variant: {
@@ -573,7 +773,7 @@ export const SidebarMenuAction = forwardRef<
573
773
  data-slot="sidebar-menu-action"
574
774
  data-sidebar="menu-action"
575
775
  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",
776
+ "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
777
  "group-data-[collapsible=icon]:hidden",
578
778
  showOnHover &&
579
779
  "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 +817,21 @@ export function SidebarMenuSkeleton({
617
817
  className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
618
818
  {...props}
619
819
  >
620
- {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
820
+ {/* `bg-sidebar-accent`, overriding `Skeleton`'s own `bg-muted`. `--muted`
821
+ is a CANVAS token: in the light theme it is a near-white
822
+ `oklch(0.968 …)` sitting on this sidebar's dark `oklch(0.3 …)` ground,
823
+ which measures 12.42:1 — the placeholder becomes the loudest thing on
824
+ a screen that has nothing loaded yet. The sidebar's own quiet rung
825
+ measures 1.26:1 on light and 1.29:1 on dark, i.e. a placeholder in
826
+ both themes instead of an inversion in one. */}
827
+ {showIcon && (
828
+ <Skeleton
829
+ className="size-4 rounded-md bg-sidebar-accent"
830
+ data-sidebar="menu-skeleton-icon"
831
+ />
832
+ )}
621
833
  <Skeleton
622
- className="h-4 max-w-(--skeleton-width) flex-1"
834
+ className="h-4 max-w-(--skeleton-width) flex-1 bg-sidebar-accent"
623
835
  data-sidebar="menu-skeleton-text"
624
836
  style={{ "--skeleton-width": width } as CSSProperties}
625
837
  />
@@ -675,8 +887,8 @@ export const SidebarMenuSubButton = forwardRef<
675
887
  data-size={size}
676
888
  data-active={isActive}
677
889
  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",
890
+ "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",
891
+ "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
892
  size === "sm" && "text-meta",
681
893
  size === "md" && "text-body",
682
894
  "group-data-[collapsible=icon]:hidden",
@@ -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
+ });
@@ -0,0 +1,34 @@
1
+ import { forwardRef, type ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface SkipLinkProps extends ComponentProps<"a"> {
5
+ /** The id of the landmark to jump to. Defaults to "main-content". */
6
+ targetId?: string;
7
+ }
8
+
9
+ /**
10
+ * The first focusable element of an application: invisible until focused, then a
11
+ * token-styled pill pinned to the top-start corner. Give the target element
12
+ * `id={targetId}` and `tabIndex={-1}` so focus actually lands there.
13
+ */
14
+ export const SkipLink = forwardRef<HTMLAnchorElement, SkipLinkProps>(function SkipLink(
15
+ { targetId = "main-content", className, children, ...props },
16
+ ref,
17
+ ) {
18
+ return (
19
+ <a
20
+ ref={ref}
21
+ data-slot="skip-link"
22
+ href={`#${targetId}`}
23
+ className={cn(
24
+ "sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:start-4 focus-visible:top-4 focus-visible:z-50",
25
+ "focus-visible:rounded-md focus-visible:bg-card focus-visible:px-3 focus-visible:py-2 focus-visible:text-body focus-visible:text-foreground focus-visible:shadow-ring-md",
26
+ "focus-ring",
27
+ className,
28
+ )}
29
+ {...props}
30
+ >
31
+ {children ?? "Skip to main content"}
32
+ </a>
33
+ );
34
+ });
@@ -124,7 +124,7 @@ export const Slider = forwardRef<ElementRef<typeof SliderPrimitive.Root>, Slider
124
124
  aria-valuetext={ariaValueText}
125
125
  {...safeThumbProps}
126
126
  className={cn(
127
- "block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
127
+ "block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50",
128
128
  safeThumbProps?.className,
129
129
  )}
130
130
  />
@@ -1 +1,6 @@
1
- export { SplitPanel, type SplitPanelProps, type SplitPanelTone } from "./split-panel";
1
+ export {
2
+ SplitPanel,
3
+ splitPaneVariants,
4
+ type SplitPanelProps,
5
+ type SplitPanelTone,
6
+ } from "./split-panel";
@@ -4,7 +4,23 @@ import { SplitPanel } from "./split-panel";
4
4
  const meta = {
5
5
  title: "Layout/SplitPanel",
6
6
  component: SplitPanel,
7
- parameters: { layout: "fullscreen" },
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A fixed two-pane layout with per-pane surface tones — panes the user can drag to " +
14
+ "resize are `Layout/Resizable`, see " +
15
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
16
+ '`SplitPanel` sizes the start pane with a CSS grid track (`startSize`, e.g. `"320px"`, ' +
17
+ '`"40%"`, `"1fr"`) rather than a percentage, so layout is predictable in tests and SSR ' +
18
+ "with no client-side measurement. The three-tone `plain`/`muted`/`card` ground-offset " +
19
+ "system on `startTone`/`endTone` is exported as `splitPaneVariants` — apply it to a " +
20
+ "`ResizablePanel` for the identical tiering on a draggable layout.",
21
+ },
22
+ },
23
+ },
8
24
  } satisfies Meta<typeof SplitPanel>;
9
25
  export default meta;
10
26
  type Story = StoryObj<typeof meta>;
@@ -28,7 +44,7 @@ export const MasterDetail: Story = {
28
44
  * the page itself is recessed below white; see research/structural-design 08 §H). A
29
45
  * `muted` list well is a light-theme-only enhancement (in dark, `--surface-muted` reads
30
46
  * lighter than `--card`), so this demo keeps the list `plain` to read correctly across
31
- * all three themes.
47
+ * every theme.
32
48
  */
33
49
  export const Tiered: Story = {
34
50
  render: () => (