feat(396): collapsible sidebar + nav-group accordions

Add two independent, persisted collapse states to the shell sidebar:
- a header toggle that collapses it to a 60px icon rail
- collapsible accordions per labeled nav group (Media, System);
  the unlabeled Primary group is always open, default-collapsed groups

State + persistence live in web/src/app/sidebarState.ts (useSidebarState);
AppShell stamps ctv-app-shell-collapsed on the shell root and the rail look
is CSS-driven. Two namespaced localStorage keys (ctv-sidebar-collapsed,
ctv-sidebar-groups) per the persisted-UI-state convention. In the rail,
accordions are ignored (all items shown icon-only, label kept in the a11y
tree + surfaced as a title tooltip, badges as a corner dot); active-route
indicator works in both states; grid-column transition respects
prefers-reduced-motion.

Tests: colocated sidebarState.test.ts + a new describe in App.test.tsx
(default-collapsed, accordion toggle+persist, rail, reload persistence).
Docs: spa-conventions §13 + decisions.md 2026-07-18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:43:33 +02:00
co-authored by Claude Opus 4.8
parent 1cd06395aa
commit eb47aed767
10 changed files with 585 additions and 13 deletions
+83 -1
View File
@@ -26,6 +26,21 @@ body {
color: var(--text-primary);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: var(--text-sm, 13px);
/* Animate the sidebar collapse/expand (#396). Only the grid track width transitions so the
content reflows smoothly without a layout jump. */
transition: grid-template-columns var(--dur-base, 140ms) var(--ease-standard);
}
/* Collapsed sidebar = the 60px icon rail (#396). Narrowing the tracked custom property drives the
grid-template-columns transition above. */
.ctv-app-shell-collapsed {
--sidebar-w: 60px;
}
@media (prefers-reduced-motion: reduce) {
.ctv-app-shell {
transition: none;
}
}
.ctv-sidebar {
@@ -97,6 +112,72 @@ body {
font-size: var(--text-2xs, 11px);
}
/* Sidebar collapse toggle in the brand header (#396). */
.ctv-sidebar-toggle {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: var(--radius-sm, 5px);
background: transparent;
color: var(--text-disabled);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard);
}
.ctv-sidebar-toggle:hover {
background: var(--ctv-accent-soft);
color: var(--text-primary);
}
.ctv-sidebar-toggle:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
}
/* Hairline divider shown between nav groups in the collapsed rail (accordions don't apply
there, so the group label/chevron is replaced by a plain separator — #396). */
.ctv-nav-divider {
height: 1px;
margin: var(--space-4, 8px) var(--space-4, 8px);
background: var(--border-hairline);
}
/* ---- Collapsed (icon-rail) sidebar overrides (#396) ---- */
.ctv-app-shell-collapsed .ctv-brand {
padding: 0;
justify-content: center;
}
.ctv-app-shell-collapsed .ctv-brand img,
.ctv-app-shell-collapsed .ctv-brand-wordmark {
display: none;
}
.ctv-app-shell-collapsed .ctv-sidebar-toggle {
margin-left: 0;
}
/* Footer: hide the version text block and the health label, keep the status dot centered. */
.ctv-app-shell-collapsed .ctv-sidebar-health {
justify-content: center;
padding: var(--space-6, 12px) 0;
}
.ctv-app-shell-collapsed .ctv-sidebar-health > div {
display: none;
}
.ctv-app-shell-collapsed .ctv-sidebar-health .ctv-status-dot > span:last-child {
display: none;
}
.ctv-shell-body {
min-width: 0;
height: 100vh;
@@ -3730,7 +3811,8 @@ body {
}
.ctv-nav,
.ctv-sidebar-health {
.ctv-sidebar-health,
.ctv-sidebar-toggle {
display: none;
}