---
key: spa.sidebar-collapsible-accordions
title: '2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)'
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed.
signals: 'sidebar, collapsible nav groups, localStorage keys · paths: `web/src/app/AppShell.tsx`, `web/src/app/sidebarState.ts` · issues: #396'
mechanics: spa-conventions.md §5d; `ctv-sidebar-collapsed` / `ctv-sidebar-groups` keys
---

The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
**Primary** group is always open. Mirrors the Claude Design prototype's updated `Sidebar`.

- **State lives in a small hook, not App.** `web/src/app/sidebarState.ts` `useSidebarState()` owns both
  pieces of state + persistence; `AppShell` consumes it (nothing else needs it) and stamps
  `ctv-app-shell-collapsed` on the shell root so the collapse is CSS-driven from one class.
- **Persistence keys use the established `ctv-` hyphen convention, NOT the prototype's dotted names.**
  The issue quoted `ctv.sidebar.collapsed` / `ctv.sidebar.groups`, but every existing client-local pref
  is hyphenated (`ctv-theme`, `ctv-logs-page-size` — spa-conventions §5d), so we use
  **`ctv-sidebar-collapsed`** (`"1"`/`"0"`) and **`ctv-sidebar-groups`** (JSON `{groupKey: boolean}`,
  boolean = *collapsed*). Deliberate deviation from the issue's literal key text in favour of the repo
  convention the issue itself points to; helpers validate/parse defensively (bad JSON / non-boolean
  values → default).
- **Labeled groups default to COLLAPSED** (absent `ctv-sidebar-groups` entry ⇒ collapsed), so a fresh
  load shows only Primary — matching the prototype ("default-collapsed, leaving only Primary visible").
  A behavior change for existing users; `App.test.tsx`'s shell/nav suite seeds the two groups open
  because it clicks Media/System nav links directly (the accordion behavior is covered in its own
  describe).
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored — every
  item renders as an icon (label kept in the a11y tree via an sr-only span so the accessible name/tests
  survive; surfaced as a native `title` tooltip), groups separated by a hairline divider, numeric
  badges shown as a corner dot. The active-route indicator (left rail bar + active background) works in
  both states.
- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather
  than derived from the label, so renaming a label doesn't silently orphan persisted state.
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
