{
  "name": "Sidebar",
  "type": "component",
  "description": "An application sidebar: a named navigation landmark with a header, a scrolling middle, a footer and a collapsed icon rail, plus SidebarProvider and the useSidebar hook. Optionally resizable by dragging its trailing edge. Below --breakpoint-md it presents as SideModal.",
  "export": "@gtmi/strix-react/organisms/sidebar",
  "path": "src/organisms/sidebar/sidebar.tsx",
  "props": [
    {
      "name": "open / defaultOpen / onOpenChange",
      "type": "boolean / boolean / (open: boolean) => void",
      "description": "On SidebarProvider. Controlled or uncontrolled; never persisted."
    },
    {
      "name": "keyboardShortcut",
      "type": "boolean",
      "default": "false",
      "description": "Binds cmd/ctrl+B. Off by default."
    },
    {
      "name": "collapsible",
      "type": "'icon' | 'offcanvas'",
      "default": "icon",
      "description": "What closing means: a 48px rail, or nothing."
    },
    {
      "name": "resizable",
      "type": "boolean",
      "default": "false",
      "description": "On SidebarProvider. Moves the open width from w-64 onto --sidebar-width, enables SidebarResizeHandle, and withdraws SidebarRail. Off by default, so a sidebar that does not ask for it is unchanged."
    },
    {
      "name": "width / defaultWidth / onWidthChange",
      "type": "number / number / (width: number) => void",
      "default": "— / 256 / —",
      "description": "On SidebarProvider. Controlled or uncontrolled, never persisted, exactly as open is. The callback is handed the clamped width, never the raw one."
    },
    {
      "name": "minWidth / maxWidth",
      "type": "number / number",
      "default": "192 / 448",
      "description": "On SidebarProvider. Ordered and rounded rather than trusted, so inverted or non-finite bounds cannot produce an invalid ARIA range. 448 is derived: --breakpoint-md (768) minus WCAG 1.4.10's 320px."
    },
    {
      "name": "label / step",
      "type": "string / number",
      "default": "Resize sidebar / 16",
      "description": "On SidebarResizeHandle. The accessible name, and how far one arrow press moves the edge."
    },
    {
      "name": "label",
      "type": "string",
      "default": "Main",
      "description": "Accessible name for the navigation landmark."
    },
    {
      "name": "isActive",
      "type": "boolean",
      "description": "On SidebarMenuButton. Sets aria-current=\"page\"."
    }
  ],
  "parts": [
    {
      "name": "SidebarProvider",
      "description": "Owns open state and the media query. Renders the flex row the sidebar and inset sit in."
    },
    {
      "name": "useSidebar",
      "description": "Reads { open, setOpen, toggle, isMobile } plus the resizable state — { resizable, width, setWidth, minWidth, maxWidth, defaultWidth, resizing, setResizing, sidebarId }. Throws outside a provider. `resizing` is readable so a caller can suppress its own expensive work mid-drag."
    },
    {
      "name": "Sidebar",
      "description": "The nav landmark, or SideModal below the breakpoint. Carries group/sidebar for the rail."
    },
    {
      "name": "SidebarTrigger",
      "description": "An IconButton that toggles. Names itself for the action it will perform."
    },
    {
      "name": "SidebarInset",
      "description": "The page beside it. min-w-0 so a wide table scrolls."
    },
    {
      "name": "SidebarHeader / SidebarContent / SidebarFooter",
      "description": "Header, scrolling middle (min-h-0 + overflow-y-auto), footer (safe-area padded)."
    },
    {
      "name": "SidebarGroup / SidebarGroupLabel / SidebarGroupContent",
      "description": "A titled group. The label is sr-only on the rail rather than hidden."
    },
    {
      "name": "SidebarMenu / SidebarMenuItem / SidebarMenuButton",
      "description": "ul / li / row. The row takes render={<Link />} and aria-current for the active page."
    },
    {
      "name": "SidebarSeparator",
      "description": "A rule between groups. aria-hidden decoration."
    },
    {
      "name": "SidebarResizeHandle",
      "description": "The resizable variant's control: a window splitter on the trailing edge. role=separator, aria-orientation=vertical, a real tab stop with an ARIA range, pointer drag with capture, and arrow / Home / End / Enter keys. Exclusive with SidebarRail, and absent on a coarse pointer, while closed, and below the breakpoint."
    }
  ],
  "ai_tags": ["sidebar", "navigation", "layout", "shell", "responsive", "resizable", "splitter"],
  "since": "0.2.0",
  "tokens_used": [
    "--color-surface-secondary",
    "--color-surface-hover",
    "--color-surface-selection-layer-strong",
    "--color-border-neutral-soft",
    "--color-border-control",
    "--color-border-focus",
    "--spacing-safe-bottom",
    "--spacing-touch-target",
    "--breakpoint-md"
  ],
  "notes": "SEVEN DECISIONS. Six were taken up front in the 0.2.0 spec §3.2 rather than discovered, and each is a deliberate departure from shadcn's block that a reader will otherwise \"fix\" back. The seventh is the resizable variant, added later and on demand.\n\n1. CONTROLLED OR UNCONTROLLED, NEVER PERSISTED. shadcn writes a cookie. A published library writing cookies is a storage policy the consumer may not want and it fails where cookies are blocked, so the app owns persistence and passes `open`. A test asserts document.cookie does not change.\n\n2. THE cmd/ctrl+B SHORTCUT IS OPT-IN, off by default. A library binding a global accelerator collides with the host app — cmd+B is bold in an editor and bookmarks in some browsers.\n\n3. THE MOBILE PRESENTATION IS SideModal, not a new Drawer-based sheet: it is already a left/right panel, already passes every audit, and keeps one overlay idiom. This also settled the bottom-sheet question Plan C deferred for want of a design reference.\n\n   CHILDREN REMOUNT WHEN THE BREAKPOINT IS CROSSED, and that is a known trade rather than an oversight. The desktop presentation is a nav element and the mobile one is a portalled dialog, so the subtree moves and React unmounts it. Keeping it mounted would mean rendering both and hiding one, which duplicates every id and landmark in the accessibility tree — a worse defect. Acceptable because a sidebar holds navigation rather than form state; do not put an unsaved form in one.\n\n   The overlay's open state is tracked SEPARATELY from the sidebar's and starts closed. Sharing one flag would mean defaultOpen=true — right for a desktop sidebar — covering the whole page on a phone. They are two different questions.\n\n4. COLLAPSES AT md (768px) BY WIDTH, not by pointer. Every other responsive decision in Strix is keyed to pointer-coarse because a touch laptop needs touch treatment regardless of width; a sidebar collapses because there is not enough horizontal room, which is about space. Do not \"fix\" this to pointer-coarse for consistency.\n\n5. THE RAIL'S GEOMETRY IS shadcn's — 16rem open, 3rem closed — because neither Figma nor cr-web-demo draws a collapsed rail. This is the departure from the normal authority rule that spec §2.4 requires recording.\n\n6. FIFTEEN PARTS, NOT SEVENTEEN. SidebarMenuSub, SidebarMenuBadge, SidebarMenuSkeleton, SidebarInput and SidebarRail are not built: no call site exercises them, and an untested part is a liability rather than parity.\n\n7. THE RESIZABLE VARIANT IS OPT-IN, AND ITS HANDLE DOES NOT EXIST ON A COARSE POINTER.\n\n   WHY IT EXISTS AT ALL, given that the 0.2.0 spec dropped shadcn's `resizable` component as \"no Figma atom, no block, no consumer\". That decline was about a standalone Resizable primitive and was explicitly demand-driven; a decline naming a missing component is provisional and expires. This is a variant of a component that exists, with a request behind it, and it builds nothing generic: there is still no Resizable primitive here.\n\n   OFF BY DEFAULT, and the fixed column is byte-for-byte what it was. `sidebarStyles({})` still emits `w-64`; the variable width is a cva VARIANT rather than a base class plus an override, because `w-64` and `w-[var(--sidebar-width)]` are one tailwind-merge group and written the other way they would be resolved by argument order. The open width also had to move out of `collapsible`, which set `w-64` in both of its members.\n\n   THE WIDTH TRAVELS AS A CUSTOM PROPERTY, never as an inline `width`. An inline width is a declaration and beats every class, including `data-[state=closed]:w-12` — so collapsing to the rail would have broken the moment anything dragged. Through a variable the cva keeps the geometry: verified in the compiled stylesheet, `.data-[state=closed]:w-12[data-state=\"closed\"]` is 0-2-0 against `.w-[var(--sidebar-width)]`'s 0-1-0.\n\n   THE WIDTH IS NEVER PERSISTED, for the same reason `open` is not (decision 1). It is also clamped on READ rather than only inside `setWidth`, so a controlled `width={2000}` cannot reach `aria-valuenow`: an ARIA value outside its own declared range is worse than a wide sidebar. Bounds are ordered and rounded, so `minWidth > maxWidth` and `NaN` cannot produce an invalid range either.\n\n   THE HANDLE IS A WINDOW SPLITTER, not a second rail. `role=\"separator\"`, `aria-orientation=\"vertical\"` (separator defaults to horizontal), a real tab stop, and an ARIA range. SidebarRail can be `aria-hidden` with `tabIndex={-1}` because it duplicates SidebarTrigger; nothing else in Strix can move the width, so a pointer-only handle would put a function behind a gesture — WCAG 2.1.1. ONE DEPARTURE FROM THE APG's splitter pattern: Enter resets to `defaultWidth` instead of collapsing, because collapsing is SidebarTrigger's job and a second control that collapses is precisely what SidebarRail already is. What Enter buys is a keyboard route to the double-click reset.\n\n   IT IS EXCLUSIVE WITH SidebarRail, IN CODE. Both are optional parts a caller adds by hand and both are `absolute inset-y-0 end-0`, so rendering both puts two controls on the same 4px of edge that answer a press differently — one toggles, one drags. Documenting \"render one or the other\" was not enough, because the overlap is invisible until someone drags. The rail returns null when the provider is resizable, and warns in development so it is not diagnosed as a missing rail.\n\n   AND IT IS NOT RENDERED ON A COARSE POINTER, which is the decision most likely to be \"fixed\" back. 4px cannot be tapped, and the obvious fix is worse than the problem: widening the hit area to the 44px floor puts a dead strip down the sidebar's trailing edge exactly where every SidebarMenuAction already is — `absolute end-gap-100` at `pointer-coarse:size-touch-target` — so every row's action button would become a resize handle that does nothing when tapped. Withdrawing an enhancement is a smaller loss than breaking navigation. NOT IN THE DOM rather than `pointer-coarse:hidden`, because audit-touch's hover probe flags a focusable element that computes to `display: none` under touch emulation: CSS hiding would trade a 44px failure for a hover-only-affordance failure. No TOUCH_EXEMPT_SLOTS entry was added, which is the third route and the one the harness's own docs discourage. Note this keys to the POINTER while the collapse keys to WIDTH (decision 4), and the two are not inconsistent: the sidebar collapses for want of room, and the handle is withdrawn for want of precision. THIS EXPIRES if a consumer needs touch resizing.\n\n   THE HANDLE'S FILL IS --color-border-control, not the --color-border-neutral-soft the rail uses. The rail is decoration and this is a control with a state, so it takes WCAG 1.4.11's 3:1 against the surface it actually sits on — the sidebar's own surface-secondary fill, not the page. Measured 4.38:1 light and 4.20:1 dark. Both obvious alternatives pass in one theme only: border-neutral-soft is 1.07:1 / 1.77:1, and border-neutral-strong is 6.98:1 light but 1.77:1 dark, because dark aliases it to the same secondary-700 as soft. sidebar.test.tsx recomputes this from the cva's own output rather than from named tokens.\n\n   ONE PIECE OF ITS GEOMETRY WAS WRONG FIRST, and only a browser could say so. The handle straddled the trailing edge, as SidebarRail does with -translate-x-1/2. sidebarStyles is overflow-hidden, so the outer half is clipped — and a clipped region is not hit-tested either. Measured in Chromium at 1280px: a press at the sidebar's exact edge missed the handle, landed on SidebarInset, and the drag did nothing; what remained to aim at was 2px. All 70 unit tests passed throughout, because jsdom reports the unclipped box and hit-tests nothing. It is now flush INSIDE the edge, where all 4px are hittable and it still clears SidebarMenuAction's end-gap-100. SidebarRail has the same 2px hit strip for the same reason and is left alone: it is aria-hidden decoration, and moving it would be a visual diff (F3).\n\n   VERIFIED IN A REAL BROWSER, not only in jsdom: 320px resting width from --sidebar-width; a mouse drag 320 → 400 with data-resizing set and transition-property computing 'none' mid-drag and 'width' after; a drag past the bound clamped at 448; collapsing while resizable still measured 48, so the closed width wins; Home/ArrowRight/End/Enter measured 192/208/448/320 with the page not scrolling; the same physical drag under dir=rtl measured 320 → 261; and under touch emulation the handle was absent from the DOM while the sidebar stayed 320. The hover fill resolved to secondary-500. audit:a11y, audit:touch and audit:keyboard all pass on the Resizable story, and the claim that pointer-coarse:hidden would fail audit:touch instead was checked by hiding a focusable element under coarse emulation and watching HOVER_PROBE report it — note it skips anything under aria-expanded=\"false\", which invalidated the first attempt at that check.\n\n   NO BLOCK USES IT YET. apps/demo's sidebar-07 and dashboard-01 mirror shadcn's blocks, which have no resize handle, so adding one there would break check-block-mirror. Storybook's Resizable story is the exercise, at defaultWidth=320 so a capture shows the variable doing the work.\n\nACCESSIBILITY. The nav is named, because a page usually has more than one. The menu is a real ul/li, so its length is conveyed. The active row carries aria-current=\"page\" rather than only a fill, since colour alone is WCAG 1.4.1. A group label on the rail is sr-only rather than hidden, so the grouping is still announced. Every row takes the coarse-pointer 44px floor.\n\ngroup/sidebar on the nav is the seam a caller uses to hide their own labels on the rail — the sidebar cannot know which parts of a row are the label."
}
