[Feature] Sidebar: match shadcn (Base UI + vega)
What
Brings Sidebar in line with shadcn (Base UI + vega), following the audit in this branch. On desktop it now behaves like shadcn: same Tab order, Ctrl/⌘+B, trigger and rail toggles. The pixel diff against shadcn on the audit fixture went from about 860 px to 15 px per state.
Fixes
SidebarMenuItemrendered a<ul>, so screen readers saw every menu item as an empty nested list. It's an<li>now.SidebarRailpasseddata: {tabindex: "-1"}, which rendersdata-tabindex, so the rail was a Tab stop with no name. It now hastabindex="-1",aria-labelandtitle, as in shadcn.SidebarMenuAction(show_on_hover: true)did nothing because the kwarg was never assigned.Sidebar(variant: :bogus)raisedNoMethodError(missing comma inraise ArgumentError).- The
outlinevariant ofSidebarMenuButtonusedhsl(var(--sidebar-border)). With the oklch theme that's invalid CSS, so the button had no ring. It usesvar()like vega.
Matching shadcn
data-slot="sidebar-*"on every part, keepingdata-sidebar. The trigger replaces Button'sdata-slot="button".SidebarTriggeruses Buttonsize: :icon_sm(32px, was 28px) and hides its icon from assistive tech.Sidebar'sclass:/attrs go to the fixed container (data-slot="sidebar-container", which now carriesdata-side), as in shadcn. Note for upgraders: classes passed to a collapsibleSidebarused to land on the outer div.- Vega classes: floating
ring-1 ring-sidebar-border shadow-sm, insetshadow-sm, and a hidden scrollbar onSidebarContent(scrollbar-width: none, because RubyUI's CSS has nono-scrollbarutility). - Open-state styling reads
aria-expanded/data-open, which is what RubyUI's DropdownMenu trigger sets, instead ofdata-state=open. SidebarMenuSubButtondefaults to<a>. Note for upgraders: the default used to be<button>.
New
SidebarMenuButton(tooltip: "Inbox")shows a tooltip on the right while the desktop sidebar is collapsed.SidebarGroupLabel(as:).- Ctrl+B / ⌘+B toggles the sidebar.
- Each toggle dispatches
ruby-ui--sidebar:changewithdetail.open, the counterpart of shadcn'sonOpenChange. - Docs: tooltips in the example, plus "Tooltips" and "Keyboard shortcut and state" sections.
Audit
- Component map with selectors, fixtures, and
baseline sidebar 8a353667for the weekly drift check. - Divergences: separator width (shadcn's
data-horizontal:w-fullbeatsw-autoand overflows the sidebar border by 8px), andSidebar(open:)in place of the provider'sdefaultOpen/open/onOpenChange. dependencies.ymlgets asidebarentry (Button, Input, Separator, Sheet, Skeleton, Tooltip). It had none, so installing the sidebar with the generator installed none of the components it renders.- MCP registry rebuilt.
Mobile, after merging #642
The mobile gaps came from Sheet and depended on #642 (branch sheet-match-shadcn). With main merged in, checked in the browser at 375px against the shadcn sandbox: the panel is a modal <dialog> announced as dialog "Sidebar" with its description, focus moves to the first field (the search input, same as shadcn), Esc closes it and focus returns to the trigger. The panel is 288px (18rem) where shadcn renders 281px, because shadcn's Sheet w-3/4 beats the sidebar's width; #642 keeps the sidebar width on purpose.
Merging main also needed two fixes here: SidebarSeparator and the mobile SheetContent now emit only their own data-slot (sidebar-separator, sidebar) instead of joining it with the new separator/sheet-content defaults. Separator is semantic by default since #641, so the separator role=none divergence was dropped: the accessibility tree now matches shadcn on every part.
Follow-ups
- Tooltip visibility uses
body:has([data-slot=sidebar][data-state=expanded]), because the tooltip is cloned into<body>. With two independentSidebarWrappers on one page, an expanded sidebar hides the tooltips of a collapsed one. Fixing it means tying the cloned tooltip back to its sidebar in the Tooltip controller, so it belongs in a separate change.
Screenshots
Crops of the area that changed, at 1.5×: RubyUI on main, this PR, shadcn.




Audit checklist: main
shadcn audit ruby-ui/ruby_ui
base 25ccda6b head 25ccda6b shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
Sidebar (ref: bases/base/ui/sidebar.tsx + style-vega.css @ 8a353667)
❌ Look display: block
vega: display: list-item
SidebarMenuItem — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Follows from the <ul> tag (HTML line). Requested: SidebarMenuItem renders <li>. Requested: run `baseline sidebar 8a3536678a528cc012b9e62484290e79d0be410f` and commit audit/component_map/sidebar.yml + audit/fixtures/sidebar/ in the fix PR.
↳ computed styles (shadcn-audit collect)
⏸ Look properties: font-family, height, width
SidebarWrapper, Sidebar, SidebarGap, SidebarContainer, SidebarInner, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarSeparator, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton — explained by environment diff: font. Checked by hand per element: font-family differs only in the fallback list (both load Inter Variable); every width/height matches except SidebarTrigger (28 vs 32) and SidebarSeparator (239 vs 255), graded on their own lines. Kept as is: font fallback list only; sizes checked per element.
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list: | - list: | - button | - button "Toggle Sidebar": | - img | - text: Toggle Sidebar
shadcn: snapshot: only here: - separator | - listitem: | - listitem: | - listitem: | - listitem: | - listitem: | - button "Toggle Sidebar" | - button "Toggle Sidebar"
SidebarWrapper — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Root causes: SidebarMenuItem renders <ul> (list) not <li> (listitem); SidebarSeparator role=none vs separator; SidebarRail has no name; trigger SVG not aria-hidden (img). Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list: | - list: | - button
shadcn: snapshot: only here: - separator | - listitem: | - listitem: | - listitem: | - listitem: | - listitem: | - button "Toggle Sidebar"
Sidebar — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list: | - list: | - button
shadcn: snapshot: only here: - separator | - listitem: | - listitem: | - listitem: | - listitem: | - listitem: | - button "Toggle Sidebar"
SidebarContainer — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list: | - list: | - button
shadcn: snapshot: only here: - separator | - listitem: | - listitem: | - listitem: | - listitem: | - listitem: | - button "Toggle Sidebar"
SidebarInner — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - button "Toggle Sidebar": | - img | - text: Toggle Sidebar
shadcn: snapshot: only here: - button "Toggle Sidebar"
SidebarTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI's panel-left SVG has no aria-hidden, so it shows as an img inside the button; lucide-react sets aria-hidden="true". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - button
shadcn: snapshot: only here: - button "Toggle Sidebar"
SidebarRail — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI rail has no aria-label/title (shadcn: aria-label and title "Toggle Sidebar", sidebar.tsx:294). Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - button "Toggle Sidebar": | - img | - text: Toggle Sidebar
shadcn: snapshot: only here: - button "Toggle Sidebar"
SidebarInset — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list:
shadcn: snapshot: only here: - listitem:
SidebarFooter — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: (none)
shadcn: snapshot: only here: - separator
SidebarSeparator — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI Separator defaults to decorative role=none; Base UI Separator renders role=separator. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list:
shadcn: snapshot: only here: - listitem: | - listitem: | - listitem: | - listitem:
SidebarContent — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list:
shadcn: snapshot: only here: - listitem: | - listitem: | - listitem: | - listitem:
SidebarGroup — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list: | - list: | - list: | - list:
shadcn: snapshot: only here: - listitem: | - listitem: | - listitem: | - listitem:
SidebarGroupContent — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list:; snapshot: only here: - list: | - list: | - list: | - list:
shadcn: snapshot: only here: - listitem:; snapshot: only here: - listitem: | - listitem: | - listitem: | - listitem:
SidebarMenu — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Same root causes: <ul> menu items, separator role, unnamed rail, trigger SVG img. Requested: SidebarMenuItem renders <li>. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar". Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG. Suggested divergence (ARIA): keep decorative role=none on SidebarSeparator.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - list:
shadcn: snapshot: only here: - listitem:
SidebarMenuItem — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. SidebarMenuItem renders <ul> instead of <li> (see HTML line). Requested: SidebarMenuItem renders <li>.
↳ accessibility tree (shadcn-audit collect)
✅ ARIA SidebarGap, SidebarInput, SidebarHeader, SidebarGroupLabel, SidebarGroupAction, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
❌ Look SidebarTrigger: Button ghost/icon + h-7 w-7 → 28×28
vega: SidebarTrigger: Button size=icon-sm → size-8 rounded-[min(var(--radius-md),10px)] → 32×32
SidebarTrigger — width/height 28px vs 32px in every state (collect filed it under the font env ⏸); the Dashboard label shifts 4px left. Requested: SidebarTrigger uses Button size: :icon_sm (drop h-7 w-7) and aria-hidden on the SVG.
↳ bases/base/ui/sidebar.tsx:266 + style-vega.css:193
⚠️ Look SidebarSeparator: mx-2 w-auto → 239px (fits inside the 255px sidebar)
vega: SidebarSeparator: mx-2 w-auto, but Separator's data-horizontal:w-full wins → 255px, overflowing 8px past the sidebar border
SidebarSeparator — judgment call: shadcn's width is a cascade side effect (attribute variant beats w-auto), visible as an 8px stub over the border at x=255–263. RubyUI renders what the classes intend. Suggested divergence (Look): keep mx-2 w-auto (239px); shadcn's 255px is a specificity side effect that overflows the border.
↳ sandbox DOM; bases/base/ui/separator.tsx:16 + style-vega.css:1142
❌ Look SidebarMenuButton variant: :outline → shadow-[0_0_0_1px_hsl(var(--sidebar-border))] computes to box-shadow: none (hsl() of an oklch token is invalid)
vega: shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]
SidebarMenuButton outline — checked in the RubyUI preview: the rule ships but getComputedStyle gives none, so the outline variant has no ring at all (also on hover). Requested: outline variant uses shadow-[0_0_0_1px_var(--sidebar-border)] / hover var(--sidebar-accent).
↳ style-vega.css:1178
❌ Look Sidebar variant: :floating inner → rounded-lg border border-sidebar-border shadow
vega: sidebar-inner floating → rounded-lg ring-1 ring-sidebar-border shadow-sm
Sidebar floating — from source (not in the fixture): border vs ring-1 (border adds 1px to the box), shadow vs shadow-sm. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
↳ style-vega.css:1114
❌ Look SidebarInset with variant: :inset → md:peer-data-[variant=inset]:shadow
vega: md:peer-data-[variant=inset]:shadow-sm
SidebarInset inset variant — from source; margins and rounded-xl already match. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
↳ style-vega.css:1122
❌ Look SidebarContent: overflow-auto (scrollbar visible)
vega: cn-sidebar-content → no-scrollbar gap-2
SidebarContent — vega hides the scrollbar of a long sidebar; RubyUI shows it. Requested: align to vega (floating ring-1 ring-sidebar-border shadow-sm; inset shadow-sm; content no-scrollbar).
↳ style-vega.css:1134
❌ Look open-state hooks: SidebarMenuButton data-[state=open]:hover:*, SidebarMenuAction(show_on_hover) data-[state=open]:opacity-100
vega: SidebarMenuButton data-open:hover:* (+ cn-sidebar-menu-button-aria aria-expanded:hover:*), SidebarMenuAction aria-expanded:opacity-100
Open-state hooks — a RubyUI DropdownMenu trigger inside a menu item sets aria-expanded, not data-state=open, so the hover/visible-while-open styling never fires. From source. Requested: open-state hooks use aria-expanded / data-open like shadcn.
↳ style-vega.css:1166 + bases/base/ui/sidebar.tsx:578
❌ HTML SidebarMenuItem → <ul class="group/menu-item relative">
shadcn: SidebarMenuItem → <li data-slot="sidebar-menu-item">
SidebarMenuItem — <ul> nested directly in <ul>: lists of one empty list each for assistive tech, and display block instead of list-item. Requested: SidebarMenuItem renders <li>.
↳ bases/base/ui/sidebar.tsx:476
❌ HTML SidebarRail → <button data-sidebar="rail" data-tabindex="-1"> (no tabindex, no aria-label, no title)
shadcn: <button data-slot="sidebar-rail" aria-label="Toggle Sidebar" title="Toggle Sidebar" tabindex="-1">
SidebarRail — `data: {tabindex: "-1"}` renders data-tabindex, so the rail is a tab stop (Tab order: … Account → rail → trigger) with no name. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
↳ bases/base/ui/sidebar.tsx:291
❌ HTML no data-slot on any sidebar part (only data-sidebar); SidebarTrigger carries data-slot="button"
shadcn: every part has data-slot="sidebar-*" plus data-sidebar
data-slot — shadcn's selectors and user CSS key off data-slot (e.g. has-data-[variant=inset], in-data-[side=left]); RubyUI only emits data-sidebar. Requested: add data-slot="sidebar-*" to every part (keep data-sidebar).
↳ bases/base/ui/sidebar.tsx:131
❌ HTML SidebarMenuSubButton default as: :button
shadcn: SidebarMenuSubButton defaultTagName "a"
SidebarMenuSubButton — without as: :a a sub-item is a button; the fixture passes as: :a on the RubyUI side. Requested: SidebarMenuSubButton defaults to as: :a.
↳ bases/base/ui/sidebar.tsx:684
❌ HTML Sidebar(**attrs) → class/attrs on the outer peer div; MobileSidebar Sheet always rendered too
shadcn: className/props on sidebar-container (data-side set there); mobile Sheet only when isMobile
Sidebar — user classes land one level higher than in shadcn (e.g. a custom width or border on Sidebar won't hit the fixed container). The always-present Sheet <template> is the server-rendered equivalent of the isMobile branch: note only. Requested: Sidebar class/attrs go to the sidebar container, with data-side on it.
↳ bases/base/ui/sidebar.tsx:230
❌ Props SidebarMenuButton: no tooltip
shadcn: tooltip?: string | TooltipContent props (shown on the right when collapsed, hidden when expanded or mobile)
tooltip — collapsed to icons, RubyUI gives the icon buttons no visible label on hover/focus. Requested: add tooltip: to SidebarMenuButton (Tooltip, right side, only when collapsed).
↳ bases/base/ui/sidebar.tsx:519
❌ Props SidebarMenuAction(show_on_hover: true) → ignored (@show_on_hover never assigned)
shadcn: showOnHover → md:opacity-0 + group-hover/group-focus-within opacity-100
show_on_hover — verified: SidebarMenuAction.new(show_on_hover: true).call has no md:opacity-0; the initializer drops the kwarg. Requested: assign @show_on_hover and add a test.
↳ bases/base/ui/sidebar.tsx:578
❌ Props Sidebar(variant: :bogus) → NoMethodError (undefined method 'ArgumentError')
shadcn: variant: "sidebar" | "floating" | "inset" (typed)
variant validation — sidebar.rb:11 lacks the comma in `raise ArgumentError "…"`; side/collapsible validate fine. Requested: fix `raise ArgumentError, ...` in sidebar.rb:11 and add a test.
↳ bases/base/ui/sidebar.tsx:157
❌ Props SidebarWrapper: no defaultOpen/open/onOpenChange; initial state via Sidebar(open:)
shadcn: SidebarProvider defaultOpen, open, onOpenChange
Provider state — Sidebar(open:) covers defaultOpen (rubyui_only); controlled open/onOpenChange are React-only. Candidate for a divergence. Suggested divergence (Props): initial state via Sidebar(open:), no controlled open. Requested: dispatch ruby-ui--sidebar:change {open} on toggle (onOpenChange equivalent).
↳ bases/base/ui/sidebar.tsx:56
❌ Props SidebarGroupLabel: no as:
shadcn: SidebarGroupLabel render (e.g. a CollapsibleTrigger)
SidebarGroupLabel render — the other render props map to as: (MenuButton, MenuAction, GroupAction, MenuSubButton). Requested: add as: to SidebarGroupLabel.
↳ bases/base/ui/sidebar.tsx:404
✅ Props side, variant, collapsible; active/variant/size/as on MenuButton; active/size/as on MenuSubButton; show_icon on MenuSkeleton; as on GroupAction/MenuAction
rubyui_only: Sidebar open.
↳ bases/base/ui/sidebar.tsx
✅ Behavior trigger and rail toggle expanded↔collapsed (data-collapsible=icon), focus stays on the trigger, cookie sidebar_state=false; path=/
Desktop toggle at 1280×800; collapsed Tab order identical apart from the rail stop (HTML line).
↳ sandbox DOM (behavior.json)
⚠️ Behavior Ctrl+B / ⌘+B do nothing
shadcn: Ctrl+B and ⌘+B toggle the sidebar (window keydown)
Keyboard shortcut — verified both keys on both sides. Requested: add Ctrl+B / Cmd+B toggle (keydown@window on SidebarWrapper).
↳ bases/base/ui/sidebar.tsx:96
⚠️ Behavior 375px: trigger opens the Sheet, focus stays on the trigger behind it, Esc does not close it
shadcn: 375px: Sheet opens, focus moves to the first field inside, Esc closes and returns focus to the trigger
Mobile — root cause is RubyUI's Sheet (template + insertAdjacentHTML, no dialog semantics or focus management), not the sidebar. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
↳ sandbox DOM (behavior.json)
⚠️ ARIA mobile sheet panel: <div data-mobile> with no role, no aria-labelledby/describedby
shadcn: <div role="dialog" aria-labelledby=Sidebar title aria-describedby=description>
Mobile — inherited from Sheet; the sr-only SheetTitle "Sidebar" exists but nothing points at it. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
↳ sandbox DOM (behavior.json)
✅ Animation gap: width 0.2s linear; container: left, right, width 0.2s linear
Computed transitions on both sides.
↳ sandbox DOM (behavior.json)
⚠️ Native mobile drawer: Sheet <template> cloned into body, no focus/Esc handling
shadcn: Base UI Dialog (focus in, Esc, focus return)
Native — desktop toggle (data-state + cookie) has no native equivalent. The mobile drawer is a <dialog> + showModal() candidate, but that belongs to the Sheet fix. Kept as is here: fix belongs to Sheet (dialog semantics, focus, Esc); Sidebar inherits it.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
❌ Spec <ul> SidebarMenu > <ul> SidebarMenuItem
shadcn: <ul> > <li>
HTML: ul content model is zero or more li and script-supporting elements; RubyUI nests ul directly in ul. Requested: SidebarMenuItem renders <li>.
↳ https://html.spec.whatwg.org/multipage/grouping-content.html#the-ul-element
❌ Spec SidebarRail: focusable <button> with no accessible name
shadcn: tabindex=-1, aria-label="Toggle Sidebar"
WAI-ARIA 1.2 button: Accessible Name Required = True; the rail is a focusable <button> (in the Tab order) with no aria-label, title or text. Requested: SidebarRail gets tabindex=-1, aria-label and title "Toggle Sidebar".
↳ https://www.w3.org/TR/wai-aria-1.2/#button
⚠️ Spec SidebarTrigger: no aria-expanded / aria-controls
shadcn: SidebarTrigger: no aria-expanded / aria-controls either
APG disclosure recommendation (not normative): the toggle button should expose aria-expanded and aria-controls. RubyUI copies shadcn's gap. Kept as is: same as shadcn (APG recommendation only).
↳ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/#roles_states_properties
Audit checklist: this PR
shadcn audit ruby-ui/ruby_ui
base de613350 head e577287e shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
Sidebar (ref: bases/base/ui/sidebar.tsx + style-vega.css @ 8a353667)
⏸ Look properties: font-family, width
SidebarWrapper, Sidebar, SidebarGap, SidebarContainer, SidebarInner, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarSeparator, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton — explained by environment diff: font. Checked per element: every width/height matches except SidebarSeparator (239 vs 255px), the registered Look divergence.
⚠️ ARIA snapshot: same lines, different order
shadcn: snapshot: only here: - separator
SidebarWrapper — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: same lines, different order
shadcn: snapshot: only here: - separator
Sidebar — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: same lines, different order
shadcn: snapshot: only here: - separator
SidebarContainer — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: same lines, different order
shadcn: snapshot: only here: - separator
SidebarInner — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. Only difference left: the separator line inside this subtree (role=none vs separator), registered in audit/divergences/sidebar.yml; collect matches that entry only on the SidebarSeparator part.
↳ accessibility tree (shadcn-audit collect)
✅ ARIA snapshot: (none) — intentional
SidebarSeparator — The line only splits the header input from the menu, with no named sections around it; RubyUI separators are decorative by default
↳ audit/divergences/sidebar.yml
✅ ARIA SidebarGap, SidebarTrigger, SidebarRail, SidebarInset, SidebarInput, SidebarHeader, SidebarFooter, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarGroupAction, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ Look SidebarTrigger: Button size: :icon_sm → 32×32
Fixed: Button size: :icon_sm → 32×32 (getComputedStyle, after collect).
↳ bases/base/ui/sidebar.tsx:266 + style-vega.css:193
✅ Look SidebarSeparator: mx-2 w-auto → 239px (fits inside the 255px sidebar) — intentional
shadcn's width is a CSS specificity side effect, not a design choice; RubyUI keeps what mx-2 w-auto intend
↳ audit/divergences/sidebar.yml
✅ Look shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]
Fixed: box-shadow computes to oklch(0.922 0 0) 0 0 0 1px in the preview.
↳ style-vega.css:1178
✅ Look rounded-lg ring-1 ring-sidebar-border shadow-sm
Fixed: inner has ring-1 (1px oklch ring), shadow-sm, border 0 (getComputedStyle with data-variant=floating).
↳ style-vega.css:1114
✅ Look md:peer-data-[variant=inset]:shadow-sm
Fixed: /docs/sidebar/inset computes shadow-sm.
↳ style-vega.css:1122
✅ Look [scrollbar-width:none] [&::-webkit-scrollbar]:hidden
Fixed: scrollbar-width: none (+ ::-webkit-scrollbar hidden) instead of shadcn's no-scrollbar utility, which RubyUI's CSS doesn't define.
↳ style-vega.css:1134
✅ Look SidebarMenuButton data-open:hover:* + aria-expanded:hover:*, SidebarMenuAction aria-expanded:opacity-100
Fixed in source: data-open:hover and aria-expanded:hover on the button, aria-expanded:opacity-100 on the action.
↳ style-vega.css:1166 + bases/base/ui/sidebar.tsx:578
✅ HTML <li data-slot="sidebar-menu-item">
Fixed: <li data-slot="sidebar-menu-item">; accessibility tree shows listitems (after collect).
↳ bases/base/ui/sidebar.tsx:476
✅ HTML <button aria-label="Toggle Sidebar" title="Toggle Sidebar" tabindex="-1" data-slot="sidebar-rail">
Fixed: tabindex=-1, aria-label and title; out of the Tab order (behavior.json).
↳ bases/base/ui/sidebar.tsx:291
✅ HTML data-slot="sidebar-*" on every part
Fixed: every part carries data-slot="sidebar-*" plus data-sidebar; the trigger replaces Button's data-slot.
↳ bases/base/ui/sidebar.tsx:131
✅ HTML SidebarMenuSubButton default as: :a
Fixed: default as: :a.
↳ bases/base/ui/sidebar.tsx:684
✅ HTML class/attrs on sidebar-container (data-side set)
Fixed: caller class/attrs land on sidebar-container, which carries data-side. The Sheet <template> stays as the server-side isMobile branch.
↳ bases/base/ui/sidebar.tsx:230
✅ Props SidebarMenuButton(tooltip: "…")
Fixed: tooltip: renders a right-side Tooltip, display:none while expanded and shown when collapsed (checked on /docs/sidebar/example).
↳ bases/base/ui/sidebar.tsx:519
✅ Props show_on_hover: true → md:opacity-0 + hover/focus-within reveal
Fixed and tested: md:opacity-0 + group-hover/focus-within opacity-100.
↳ bases/base/ui/sidebar.tsx:578
✅ Props Sidebar(variant: :bogus) → ArgumentError
Fixed and tested: raises ArgumentError.
↳ bases/base/ui/sidebar.tsx:157
✅ Props SidebarWrapper: no defaultOpen/open/onOpenChange; initial state via Sidebar(open:) — intentional
Server-rendered: the app reads the sidebar_state cookie and passes open:; controlled state is a React pattern, and the change event covers onOpenChange (verified: ruby-ui--sidebar:change fires with detail.open false/true).
↳ audit/divergences/sidebar.yml
✅ Props SidebarGroupLabel(as:)
Fixed and tested: as: (default :div).
↳ bases/base/ui/sidebar.tsx:404
✅ Props side, variant, collapsible; active/variant/size/as on MenuButton; active/size/as on MenuSubButton; show_icon on MenuSkeleton; as on GroupAction/MenuAction; tooltip; show_on_hover; as on GroupLabel
rubyui_only: Sidebar open.
↳ bases/base/ui/sidebar.tsx
✅ Behavior trigger and rail toggle expanded↔collapsed (data-collapsible=icon), focus stays on the trigger, cookie sidebar_state=false; path=/
Desktop toggle at 1280×800; collapsed Tab order identical apart from the rail stop (HTML line).
↳ sandbox DOM (behavior.json)
✅ Behavior Ctrl+B and ⌘+B toggle the sidebar
Fixed: keydown.ctrl+b@window / keydown.meta+b@window on SidebarWrapper toggle it (behavior.json).
↳ sandbox DOM (after/behavior.json)
⚠️ Behavior 375px: trigger opens the Sheet, focus stays on the trigger behind it, Esc does not close it
shadcn: 375px: Sheet opens, focus moves to the first field inside, Esc closes and returns focus to the trigger
Mobile — root cause is RubyUI's Sheet (template + insertAdjacentHTML, no dialog semantics or focus management), not the sidebar. Resolved after merging #642; see "Mobile, after merging #642".
↳ sandbox DOM (behavior.json)
⚠️ ARIA mobile sheet panel: <div data-mobile> with no role, no aria-labelledby/describedby
shadcn: <div role="dialog" aria-labelledby=Sidebar title aria-describedby=description>
Mobile — inherited from Sheet; the sr-only SheetTitle "Sidebar" exists but nothing points at it. Resolved after merging #642; see "Mobile, after merging #642".
↳ sandbox DOM (behavior.json)
✅ Animation gap: width 0.2s linear; container: left, right, width 0.2s linear
Computed transitions on both sides.
↳ sandbox DOM (behavior.json)
⚠️ Native mobile drawer: Sheet <template> cloned into body, no focus/Esc handling
shadcn: Base UI Dialog (focus in, Esc, focus return)
Native — desktop toggle (data-state + cookie) has no native equivalent. The mobile drawer is a <dialog> + showModal() candidate, but that belongs to the Sheet fix. Resolved after merging #642; see "Mobile, after merging #642".
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
✅ Spec <ul> > <li>
Fixed: ul > li.
↳ https://html.spec.whatwg.org/multipage/grouping-content.html#the-ul-element
✅ Spec SidebarRail: tabindex=-1, aria-label
Fixed: tabindex=-1 and aria-label "Toggle Sidebar".
↳ https://www.w3.org/TR/wai-aria-1.2/#button
⚠️ Spec SidebarTrigger: no aria-expanded / aria-controls
shadcn: SidebarTrigger: no aria-expanded / aria-controls either
APG disclosure recommendation (not normative): the toggle button should expose aria-expanded and aria-controls. RubyUI copies shadcn's gap.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/#roles_states_properties
Test steps
cd gem && bundle exec rake(613 runs, standardrb clean).cd docs && bin/rails test(75 runs).- Open
/docs/sidebar/example: collapse with the trigger, Ctrl+B or ⌘+B; hover an icon to see its tooltip; Tab through the sidebar (the rail is skipped). - Optional:
/shadcn-audit sidebar.
🤖 Generated with Claude Code
Summary by cubic
Aligns the sidebar with shadcn (Base UI + vega). On desktop, tab order, trigger and rail toggles, the Ctrl/⌘+B shortcut, and styling now match shadcn, and the pixel diff on the audit fixture dropped from about 860 px to 15 px per state.
Fixes
SidebarMenuItemrenders<li>, and every part carriesdata-slot="sidebar-*".SidebarRailleaves the tab order (tabindex="-1") and gets an accessible name; the trigger hides its icon from assistive tech.show_on_hoveris honored, invalid variants raiseArgumentError, and theoutlinering uses CSS variables (the oldhsl()of an oklch token rendered nothing).SidebarTriggeruses Buttonsize: :icon_sm(32px).
New features and upgrade notes
SidebarMenuButton(tooltip:)shows a right-side tooltip while the desktop sidebar is collapsed;SidebarGroupLabel(as:)is new.- Ctrl/⌘+B toggles the sidebar; toggling dispatches
ruby-ui--sidebar:changewithdetail.openand writes thesidebar_statecookie, but all three are no-ops without a collapsible sidebar. A non-collapsible sidebar reportsdata-state="expanded"so tooltips stay hidden. Sidebarclasses and attrs now land on the fixed container (data-slot="sidebar-container"), not the outer div.SidebarMenuSubButtonnow defaults to<a>instead of<button>.- The generator now installs
Button,Input,Separator,Sheet,Skeleton, andTooltipwhen generating a sidebar (it installed none before). - Adds an audit component map, fixtures, and a weekly drift baseline. Below 768px the sidebar still opens a Sheet without dialog semantics, which follows from the
Sheetcomponent and will land with the Sheet fix.