Merge pull request #448 (feat/396): collapsible sidebar + nav-group accordions
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

Fixes #396. Force-merged (operator-authorized): all real checks passed green on 8cef07a6 (Build & test, EF migration, Functional E2E, formatting, docs, API sync, decisions.md append-only); the red status was a cancelled redundant re-run.
This commit was merged in pull request #448.
This commit is contained in:
2026-07-18 15:24:53 +00:00
10 changed files with 621 additions and 13 deletions
+30
View File
@@ -1807,3 +1807,33 @@ page to completeness** — rather than option (b) (a generous cap + truncation s
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
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.
+31
View File
@@ -459,3 +459,34 @@ integration.
This module is intentionally reusable beyond SmartCollections — ChannelBuilder and Auto-Tune's
inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues
rather than wired in #176.
## 13. Collapsible sidebar + nav-group accordions (#396)
The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states.
Both are shell chrome — no screen participates.
- **State + persistence** live in `web/src/app/sidebarState.ts` (`useSidebarState()`), consumed by
`AppShell` alone. It follows the §5d client-local-prefs pattern (a try/catch `getStorage()`, a
validating getter, a write-through setter) over two namespaced keys:
- `ctv-sidebar-collapsed` — `"1"`/`"0"`; is the sidebar collapsed to the 60px **icon rail**?
- `ctv-sidebar-groups` — JSON `{groupKey: boolean}` where the boolean is **collapsed**. A labeled
group with **no stored entry defaults to collapsed**, so a fresh load shows only the always-open
**Primary** group. (Keys are the `ctv-` hyphen form, not the prototype's `ctv.sidebar.*` — see
`decisions.md` 2026-07-18.)
- **`AppShell` stamps `ctv-app-shell-collapsed` on the shell root** when collapsed; the rail look is
entirely CSS-driven from that one class (`shell.css` narrows the tracked `--sidebar-w` to 60px and
transitions `grid-template-columns`; `@media (prefers-reduced-motion: reduce)` drops the transition).
- **Nav is inventory-driven** from `sidebarNavGroups` (`app/routes.tsx`). Only **labeled** groups are
collapsible; each has an explicit stable `key` (`'media'`, `'system'`) used for the persisted map —
don't derive the key from the label (a rename would orphan persisted state). The unlabeled Primary
group is always rendered.
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored: every
item renders (icon-only), groups separated by a `.ctv-nav-divider`. The nav item's **label stays in
the a11y tree** (visually hidden via CSS, not `display:none`) so the accessible name — and every
`getByRole('link', { name })` test — still resolves; the label is also passed as the native `title`
tooltip (`NavItem` gained a `title` prop). Numeric badges collapse to a corner dot. The active-route
indicator works in both states.
- **Testing note**: `App.test.tsx`'s shell/nav suite clicks Media/System nav links directly, so its
`beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion /
rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence
helpers have a colocated `sidebarState.test.ts`.
+105
View File
@@ -16,6 +16,13 @@ describe('ChicoryTV SPA scaffold', () => {
beforeEach(() => {
window.localStorage.clear();
// #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests
// below click nav links inside those groups directly, so keep them expanded here; the
// collapse/accordion behavior itself is covered in its own describe block at the end.
window.localStorage.setItem(
'ctv-sidebar-groups',
JSON.stringify({ media: false, system: false })
);
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app');
vi.restoreAllMocks();
@@ -632,6 +639,104 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.location.pathname).toBe('/app/settings/scanner');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object));
});
// #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults
// (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed.
describe('collapsible sidebar (#396)', () => {
beforeEach(() => {
window.localStorage.removeItem('ctv-sidebar-groups');
});
it('collapses Media/System nav groups by default, leaving Primary visible', () => {
render(<App />);
// Primary group items always render.
expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument();
// Labeled groups start collapsed → their items are not rendered, but the accordion headers are.
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument();
});
it('expands a nav group when its header is clicked and persists the open state', () => {
render(<App />);
const systemHeader = screen.getByRole('button', { name: 'System', expanded: false });
fireEvent.click(systemHeader);
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}');
expect(stored.system).toBe(false);
});
it('restores expanded groups from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false }));
render(<App />);
// Media restored open; System still default-collapsed.
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
});
it('collapses the sidebar to the icon rail and persists it', () => {
const { container } = render(<App />);
const shell = container.querySelector('.ctv-app-shell');
expect(shell).not.toHaveClass('ctv-app-shell-collapsed');
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
expect(shell).toHaveClass('ctv-app-shell-collapsed');
expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1');
// The toggle now offers to expand again.
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => {
// Sidebar collapsed on load; groups at their default (collapsed) accordion state.
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
// Accordions do not apply in the rail: Media/System items are all present (label stays in the
// a11y tree so the link keeps its accessible name), and no accordion header is rendered.
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument();
});
it('restores the collapsed sidebar from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
window.history.replaceState(null, '', '/app/settings');
render(<App />);
// The active item is rendered and marked, and its group header shows expanded…
const settingsLink = screen.getByRole('link', { name: 'Settings' });
expect(settingsLink).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
// …but a sibling default-collapsed group (Media) stays collapsed…
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
});
});
});
function jsonResponse(body: unknown, status = 200): Response {
+99 -11
View File
@@ -17,6 +17,8 @@ import {
ClipboardCopy,
Info,
ListVideo,
PanelLeftClose,
PanelLeftOpen,
Plus,
Search
} from 'lucide-react';
@@ -29,8 +31,7 @@ import {
import {
Button,
IconButton,
NavItem,
NavSection
NavItem
} from '../components';
import {
designSystemThemes,
@@ -38,6 +39,7 @@ import {
} from '../designSystem';
import { usePrimaryActionHandler } from '../primaryAction';
import { navigateToPath } from '../routing';
import { useSidebarState } from './sidebarState';
import { DashboardHealthSummary } from '../screens/DashboardScreen';
import { UnauthorizedBanner } from '../UnauthorizedBanner';
import { UserMenu } from '../UserMenu';
@@ -53,10 +55,12 @@ type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
function SidebarNavGroup({
activeRoute,
collapsed,
ids,
onNavigate
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
ids: readonly ScreenId[];
onNavigate: NavigateHandler;
}) {
@@ -70,6 +74,9 @@ function SidebarNavGroup({
key={route.id}
icon={route.icon}
label={route.label}
// In the collapsed rail, the label is visually hidden (kept for a11y) so surface it
// as a native tooltip; expanded shows the label inline so a tooltip would be redundant.
title={collapsed ? route.label : undefined}
active={route.id === activeRoute?.id}
badge={route.badge}
badgeTone="warn"
@@ -111,12 +118,20 @@ function ThemeSwitcher({
function Sidebar({
activeRoute,
collapsed,
healthState,
onNavigate
isGroupCollapsed,
onNavigate,
onToggleCollapsed,
onToggleGroup
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
healthState: DashboardHealthQueryState;
isGroupCollapsed: (groupKey: string) => boolean;
onNavigate: NavigateHandler;
onToggleCollapsed: () => void;
onToggleGroup: (groupKey: string) => void;
}) {
return (
<aside className="ctv-sidebar">
@@ -125,15 +140,78 @@ function Sidebar({
<span className="ctv-brand-wordmark">
Chicory<span>TV</span>
</span>
<button
type="button"
className="ctv-sidebar-toggle"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={onToggleCollapsed}
>
{collapsed ? (
<PanelLeftOpen aria-hidden="true" size={17} />
) : (
<PanelLeftClose aria-hidden="true" size={17} />
)}
</button>
</div>
<nav aria-label="Primary" className="ctv-nav">
{sidebarNavGroups.map(({ ids, label }) => (
<Fragment key={label ?? 'Primary'}>
{label ? <NavSection>{label}</NavSection> : null}
<SidebarNavGroup activeRoute={activeRoute} ids={ids} onNavigate={onNavigate} />
</Fragment>
))}
{sidebarNavGroups.map((group) => {
const { ids, key, label } = group;
// Primary (unlabeled) is always open, in both the rail and the expanded sidebar.
if (!label) {
return (
<SidebarNavGroup
key="Primary"
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
);
}
const groupKey = key ?? label;
// Accordions only apply in the expanded sidebar; the rail always shows every item
// (separated by a hairline divider), so group-collapse is ignored there.
const groupCollapsed = isGroupCollapsed(groupKey);
// Always reveal the group that contains the active route so its active item is visible +
// highlighted in the expanded sidebar, even when the group's stored state is collapsed
// (e.g. a fresh deep-link to /app/settings). This does NOT persist — navigating away
// reverts to the stored preference (acceptance: "active route marked in both states").
const containsActive =
activeRoute != null && ids.includes(activeRoute.id);
const expanded = containsActive || !groupCollapsed;
const showItems = collapsed || expanded;
return (
<Fragment key={groupKey}>
{collapsed ? (
<div className="ctv-nav-divider" role="presentation" />
) : (
<button
type="button"
className="ctv-nav-group-header"
aria-expanded={expanded}
onClick={() => onToggleGroup(groupKey)}
>
<span>{label}</span>
<ChevronDown className="ctv-nav-group-chevron" aria-hidden="true" size={14} />
</button>
)}
{showItems ? (
<SidebarNavGroup
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
) : null}
</Fragment>
);
})}
</nav>
<div className="ctv-sidebar-health">
@@ -348,9 +426,19 @@ export function AppShell({
route: ScreenRoute | null;
theme: DesignSystemThemeId;
}) {
const { collapsed, isGroupCollapsed, toggleCollapsed, toggleGroup } = useSidebarState();
return (
<div className="ctv-app-shell">
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
<div className={`ctv-app-shell${collapsed ? ' ctv-app-shell-collapsed' : ''}`}>
<Sidebar
activeRoute={route}
collapsed={collapsed}
healthState={healthState}
isGroupCollapsed={isGroupCollapsed}
onNavigate={onNavigate}
onToggleCollapsed={toggleCollapsed}
onToggleGroup={toggleGroup}
/>
<div className="ctv-shell-body">
<TopBar route={route} />
<UnauthorizedBanner />
+5
View File
@@ -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',
+62
View File
@@ -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);
});
});
+104
View File
@@ -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<string, boolean> {
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<string, boolean> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value === 'boolean') {
out[key] = value;
}
}
return out;
}
} catch {
// malformed JSON — fall through to defaults
}
return {};
}
function setStoredSidebarGroups(groups: Record<string, boolean>): 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<boolean>(getStoredSidebarCollapsed);
const [groups, setGroups] = useState<Record<string, boolean>>(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 };
}
+82
View File
@@ -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;
+7 -1
View File
@@ -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}
</a>
@@ -57,6 +62,7 @@ export function NavItem({
onClick={onClick}
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
style={style}
title={title}
>
{content}
</button>
+96 -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,10 +3811,24 @@ body {
}
.ctv-nav,
.ctv-sidebar-health {
.ctv-sidebar-health,
.ctv-sidebar-toggle {
display: none;
}
/* The collapse feature is desktop-only (the nav is hidden here). If the sidebar was collapsed on
desktop, don't let the rail's brand-hiding leave an empty header on mobile — restore the brand
(#396). */
.ctv-app-shell-collapsed .ctv-brand {
padding: 0 14px;
justify-content: flex-start;
}
.ctv-app-shell-collapsed .ctv-brand img,
.ctv-app-shell-collapsed .ctv-brand-wordmark {
display: revert;
}
.ctv-topbar {
flex-wrap: wrap;
height: auto;