+
diff --git a/web/src/app/routes.tsx b/web/src/app/routes.tsx
index c0d56a49c..b81fb9ff1 100644
--- a/web/src/app/routes.tsx
+++ b/web/src/app/routes.tsx
@@ -464,6 +464,9 @@ export const routeById = new Map(routes.map((route) => [route.id, route]));
export interface SidebarNavGroupDefinition {
label?: string;
+ // Stable key for persisted collapse state (ctv-sidebar-groups). Only labeled (collapsible)
+ // groups need one; the unlabeled Primary group is always open (#396).
+ key?: string;
ids: readonly ScreenId[];
}
@@ -485,6 +488,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
},
{
label: 'Media',
+ key: 'media',
ids: [
'media',
'search',
@@ -500,6 +504,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
},
{
label: 'System',
+ key: 'system',
ids: [
'settings',
'apiKey',
diff --git a/web/src/app/sidebarState.test.ts b/web/src/app/sidebarState.test.ts
new file mode 100644
index 000000000..ec29b579a
--- /dev/null
+++ b/web/src/app/sidebarState.test.ts
@@ -0,0 +1,62 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ getStoredSidebarCollapsed,
+ getStoredSidebarGroups,
+ useSidebarState
+} from './sidebarState';
+
+describe('sidebarState persistence (#396)', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('defaults to expanded when nothing is stored', () => {
+ expect(getStoredSidebarCollapsed()).toBe(false);
+ expect(getStoredSidebarGroups()).toEqual({});
+ });
+
+ it('reads the collapsed flag only from the exact "1" sentinel', () => {
+ window.localStorage.setItem('ctv-sidebar-collapsed', '1');
+ expect(getStoredSidebarCollapsed()).toBe(true);
+
+ window.localStorage.setItem('ctv-sidebar-collapsed', '0');
+ expect(getStoredSidebarCollapsed()).toBe(false);
+ });
+
+ it('ignores malformed or non-boolean group JSON', () => {
+ window.localStorage.setItem('ctv-sidebar-groups', 'not json');
+ expect(getStoredSidebarGroups()).toEqual({});
+
+ window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify(['media']));
+ expect(getStoredSidebarGroups()).toEqual({});
+
+ window.localStorage.setItem(
+ 'ctv-sidebar-groups',
+ JSON.stringify({ media: true, system: 'nope' })
+ );
+ expect(getStoredSidebarGroups()).toEqual({ media: true });
+ });
+
+ it('treats an unknown labeled group as collapsed by default, and toggles + persists it', () => {
+ const { result } = renderHook(() => useSidebarState());
+
+ expect(result.current.isGroupCollapsed('media')).toBe(true);
+
+ act(() => result.current.toggleGroup('media'));
+
+ expect(result.current.isGroupCollapsed('media')).toBe(false);
+ expect(getStoredSidebarGroups()).toEqual({ media: false });
+ });
+
+ it('toggles + persists the sidebar collapsed flag', () => {
+ const { result } = renderHook(() => useSidebarState());
+
+ expect(result.current.collapsed).toBe(false);
+
+ act(() => result.current.toggleCollapsed());
+
+ expect(result.current.collapsed).toBe(true);
+ expect(getStoredSidebarCollapsed()).toBe(true);
+ });
+});
diff --git a/web/src/app/sidebarState.ts b/web/src/app/sidebarState.ts
new file mode 100644
index 000000000..f530bb6d8
--- /dev/null
+++ b/web/src/app/sidebarState.ts
@@ -0,0 +1,104 @@
+import { useCallback, useState } from 'react';
+
+// Persisted UI state for the shell sidebar (issue #396). Two client-local preferences,
+// namespaced `ctv-*` per the persisted-UI-state convention (spa-conventions.md §5d):
+// - ctv-sidebar-collapsed: "1" | "0" — is the sidebar collapsed to the icon rail?
+// - ctv-sidebar-groups: JSON {groupKey: boolean} — is a labeled nav group collapsed?
+// A labeled group with no stored entry defaults to COLLAPSED, so a fresh load shows only the
+// always-open Primary group. (The prototype named these keys `ctv.sidebar.*`; we use the
+// established `ctv-` hyphen form — see docs/decisions.md 2026-07-18.)
+
+const COLLAPSED_KEY = 'ctv-sidebar-collapsed';
+const GROUPS_KEY = 'ctv-sidebar-groups';
+
+function getStorage(): Storage | null {
+ try {
+ return window.localStorage;
+ } catch {
+ // Storage can be disabled/unavailable (privacy mode, sandboxed iframe) — degrade to defaults.
+ return null;
+ }
+}
+
+export function getStoredSidebarCollapsed(): boolean {
+ return getStorage()?.getItem(COLLAPSED_KEY) === '1';
+}
+
+function setStoredSidebarCollapsed(collapsed: boolean): void {
+ try {
+ getStorage()?.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
+ } catch {
+ // ignore write failures (quota / unavailable) — in-memory state still updates
+ }
+}
+
+export function getStoredSidebarGroups(): Record {
+ const raw = getStorage()?.getItem(GROUPS_KEY);
+ if (!raw) {
+ return {};
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(parsed as Record)) {
+ if (typeof value === 'boolean') {
+ out[key] = value;
+ }
+ }
+ return out;
+ }
+ } catch {
+ // malformed JSON — fall through to defaults
+ }
+
+ return {};
+}
+
+function setStoredSidebarGroups(groups: Record): void {
+ try {
+ getStorage()?.setItem(GROUPS_KEY, JSON.stringify(groups));
+ } catch {
+ // ignore write failures — in-memory state still updates
+ }
+}
+
+export interface SidebarState {
+ /** Is the sidebar collapsed to the icon rail? */
+ collapsed: boolean;
+ toggleCollapsed: () => void;
+ /** Is a labeled nav group collapsed? Unknown groups default to collapsed. */
+ isGroupCollapsed: (groupKey: string) => boolean;
+ toggleGroup: (groupKey: string) => void;
+}
+
+export function useSidebarState(): SidebarState {
+ const [collapsed, setCollapsed] = useState(getStoredSidebarCollapsed);
+ const [groups, setGroups] = useState>(getStoredSidebarGroups);
+
+ const toggleCollapsed = useCallback(() => {
+ setCollapsed((previous) => {
+ const next = !previous;
+ setStoredSidebarCollapsed(next);
+ return next;
+ });
+ }, []);
+
+ const isGroupCollapsed = useCallback(
+ // Absent entry ⇒ default collapsed for labeled groups.
+ (groupKey: string) => groups[groupKey] ?? true,
+ [groups]
+ );
+
+ const toggleGroup = useCallback((groupKey: string) => {
+ setGroups((previous) => {
+ const currentlyCollapsed = previous[groupKey] ?? true;
+ const next = { ...previous, [groupKey]: !currentlyCollapsed };
+ setStoredSidebarGroups(next);
+ return next;
+ });
+ }, []);
+
+ return { collapsed, toggleCollapsed, isGroupCollapsed, toggleGroup };
+}
diff --git a/web/src/components/components.css b/web/src/components/components.css
index bd9ab3a60..958b76644 100644
--- a/web/src/components/components.css
+++ b/web/src/components/components.css
@@ -861,6 +861,88 @@
text-transform: uppercase;
}
+/* Collapsible nav-group header (accordion toggle) — the interactive replacement for the static
+ NavSection label in the expanded sidebar (#396). */
+.ctv-nav-group-header {
+ box-sizing: border-box;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 8px 0 2px;
+ padding: 6px 10px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-disabled);
+ cursor: pointer;
+ font-size: var(--text-2xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: var(--tracking-caps);
+ line-height: 1;
+ text-align: left;
+ text-transform: uppercase;
+ transition: color var(--dur-fast) var(--ease-standard);
+}
+
+.ctv-nav-group-header:hover {
+ color: var(--text-secondary);
+}
+
+.ctv-nav-group-header:focus-visible {
+ outline: none;
+ box-shadow: var(--ring-focus);
+}
+
+.ctv-nav-group-header > span {
+ flex: 1;
+ min-width: 0;
+}
+
+.ctv-nav-group-chevron {
+ flex: 0 0 auto;
+ transition: transform var(--dur-fast) var(--ease-standard);
+}
+
+/* Collapsed group ⇒ chevron points right (closed). */
+.ctv-nav-group-header[aria-expanded='false'] .ctv-nav-group-chevron {
+ transform: rotate(-90deg);
+}
+
+/* ---- Collapsed (icon-rail) nav items (#396) ---- */
+.ctv-app-shell-collapsed .ctv-nav-item {
+ justify-content: center;
+ gap: 0;
+ padding: 0;
+}
+
+/* Keep the label in the a11y tree (accessible name) but visually hidden in the rail; the native
+ title tooltip surfaces it for sighted mouse users. */
+.ctv-app-shell-collapsed .ctv-nav-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* A numeric badge collapses to a small corner dot in the rail. */
+.ctv-app-shell-collapsed .ctv-nav-badge {
+ position: absolute;
+ top: 5px;
+ right: 9px;
+ min-width: 0;
+ width: 7px;
+ height: 7px;
+ padding: 0;
+ font-size: 0;
+ line-height: 0;
+}
+
.ctv-tabs {
display: flex;
gap: 2px;
diff --git a/web/src/components/navigation.tsx b/web/src/components/navigation.tsx
index bfaec8744..46b99f40a 100644
--- a/web/src/components/navigation.tsx
+++ b/web/src/components/navigation.tsx
@@ -13,6 +13,9 @@ export interface NavItemProps {
href?: string;
onClick?: (e: MouseEvent) => void;
style?: CSSProperties;
+ // Native tooltip; used by the collapsed sidebar rail to surface the label that is visually
+ // hidden there (the label stays in the a11y tree for the accessible name — #396).
+ title?: string;
}
export function NavItem({
@@ -23,7 +26,8 @@ export function NavItem({
badgeTone = 'warn',
href,
onClick,
- style
+ style,
+ title
}: NavItemProps) {
const content = (
<>
@@ -44,6 +48,7 @@ export function NavItem({
onClick={onClick}
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
style={style}
+ title={title}
>
{content}
@@ -57,6 +62,7 @@ export function NavItem({
onClick={onClick}
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
style={style}
+ title={title}
>
{content}
diff --git a/web/src/shell.css b/web/src/shell.css
index 3d52bfdbf..b6552e8f9 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -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;
}