feat(pwa): collapsable sidebar + narrow-viewport drawer (#162) #166

Merged
james merged 1 commit from 162-sidebar-collapse into main 2026-06-20 02:37:17 +00:00
Owner

Closes #162. Follow-up to #140 / #161, which deferred collapse behavior.

Summary

  • Manual collapse/expand toggle in the brand row at the right edge when expanded; when collapsed the toggle takes the logo's place, centered.
  • Wide collapsed mode — 64px rail, icons only, hover/focus tooltips on each nav item; footer collapses to a vertical stack (single theme-toggle icon, avatar tile, logout icon).
  • Narrow viewport (<900px) — sidebar hidden entirely; floating toggle button in the top-left opens it as an overlay drawer. Scrim click or Escape closes; drawer resets on viewport flip and route change.
  • Persistence — collapse state stored in localStorage under carol:sidebar:collapsed. A new SidebarInitScript (mirroring ThemeInitScript) synchronously sets <html data-sidebar-collapsed> in <head> before paint, so reloads no longer flash an expanded sidebar before snapping to collapsed. The CSS module keys collapsed-mode rules off the html attribute via :global(...).
  • i18n — new nav.collapseSidebar, nav.expandSidebar, nav.toggleTheme, nav.theme keys. The existing hardcoded aria-label="Theme" on the theme switcher's segmented control is translated in passing.

Implementation

File What changed
app/(app)/components/sidebar-shell.tsx (new) Client wrapper around the <aside> + <main> grid. Owns collapsed (via useSyncExternalStore reading the html attribute) and drawerOpen state. Exposes context to SidebarToggle. Renders both the in-aside toggle and the floating toggle, plus a scrim button. Escape closes the drawer; route changes reset it.
app/(app)/components/sidebar-toggle.tsx (new) PanelLeft icon button rendered in two placements: brand (inside the aside) and floating (fixed, narrow-viewport only). At narrow widths it controls drawerOpen; at wide widths it flips collapsed.
app/(app)/components/sidebar-init-script.tsx (new) Inline <script> mounted from the root layout's <head> next to ThemeInitScript. Reads localStorage, sets <html data-sidebar-collapsed> before paint.
app/(app)/components/sidebar.tsx No longer renders <aside> (SidebarShell does). Brand row now: [logo + wordmark] [toggle].
app/(app)/components/sidebar-nav.tsx aria-label on each Link; visible label + hidden tooltip <span> that becomes a positioned tooltip when collapsed.
app/(app)/components/sidebar-theme-switcher.tsx Adds a single-icon themeToggleIcon button for collapsed mode (toggles to the opposite theme).
app/(app)/components/sidebar.module.css Major additions: collapsed-rail rules under :global(html[data-sidebar-collapsed="1"]) .shell, narrow-viewport rules under @media (max-width: 899px) (fixed-position aside, floating toggle, scrim).
app/(app)/layout.tsx Renders <SidebarShell sidebar={<Sidebar />}>{children}</SidebarShell>.
app/layout.tsx Adds <SidebarInitScript /> alongside <ThemeInitScript /> in <head>.
messages/en.json Four new nav.* keys (collapseSidebar, expandSidebar, toggleTheme, theme).

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 440 passed, 91 skipped (no regressions).
  • Browser verification (Playwright headless chromium, 22/22 assertions):
    • Wide expanded: brand row shows logo + wordmark + toggle on right.
    • Click toggle → 64px rail; brand link hidden; toggle centered; nav labels hidden; segmented theme switch hidden; single theme-toggle icon visible; user card collapsed to avatar + logout stack.
    • Hover collapsed nav item → tooltip with label appears to the right.
    • Reload while collapsed → html[data-sidebar-collapsed="1"] and aside is 64px on the first sample (no flash).
    • Resize to 800×900 → aside off-screen (x=-273); data-narrow="true"; floating toggle visible.
    • Click floating toggle → drawer slides in at x=0 (full expanded labels); floating toggle hidden; scrim visible.
    • Escape closes drawer.
    • Click scrim closes drawer.
    • Dark theme renders correctly in expanded, collapsed, and drawer-open states.
  • Screen reader walkthrough (no AT in CI environment — code sets aria-label on each nav Link and aria-expanded + aria-controls on the toggle, but live AT behavior untested).

🤖 Generated with Claude Code

Closes #162. Follow-up to #140 / #161, which deferred collapse behavior. ## Summary - **Manual collapse/expand toggle** in the brand row at the right edge when expanded; when collapsed the toggle takes the logo's place, centered. - **Wide collapsed mode** — 64px rail, icons only, hover/focus tooltips on each nav item; footer collapses to a vertical stack (single theme-toggle icon, avatar tile, logout icon). - **Narrow viewport (<900px)** — sidebar hidden entirely; floating toggle button in the top-left opens it as an overlay drawer. Scrim click or `Escape` closes; drawer resets on viewport flip and route change. - **Persistence** — collapse state stored in `localStorage` under `carol:sidebar:collapsed`. A new `SidebarInitScript` (mirroring `ThemeInitScript`) synchronously sets `<html data-sidebar-collapsed>` in `<head>` before paint, so reloads no longer flash an expanded sidebar before snapping to collapsed. The CSS module keys collapsed-mode rules off the html attribute via `:global(...)`. - **i18n** — new `nav.collapseSidebar`, `nav.expandSidebar`, `nav.toggleTheme`, `nav.theme` keys. The existing hardcoded `aria-label="Theme"` on the theme switcher's segmented control is translated in passing. ## Implementation | File | What changed | |------|---| | `app/(app)/components/sidebar-shell.tsx` (new) | Client wrapper around the `<aside>` + `<main>` grid. Owns `collapsed` (via `useSyncExternalStore` reading the html attribute) and `drawerOpen` state. Exposes context to `SidebarToggle`. Renders both the in-aside toggle and the floating toggle, plus a scrim button. Escape closes the drawer; route changes reset it. | | `app/(app)/components/sidebar-toggle.tsx` (new) | `PanelLeft` icon button rendered in two placements: `brand` (inside the aside) and `floating` (fixed, narrow-viewport only). At narrow widths it controls `drawerOpen`; at wide widths it flips collapsed. | | `app/(app)/components/sidebar-init-script.tsx` (new) | Inline `<script>` mounted from the root layout's `<head>` next to `ThemeInitScript`. Reads localStorage, sets `<html data-sidebar-collapsed>` before paint. | | `app/(app)/components/sidebar.tsx` | No longer renders `<aside>` (SidebarShell does). Brand row now: `[logo + wordmark] [toggle]`. | | `app/(app)/components/sidebar-nav.tsx` | `aria-label` on each `Link`; visible label + hidden tooltip `<span>` that becomes a positioned tooltip when collapsed. | | `app/(app)/components/sidebar-theme-switcher.tsx` | Adds a single-icon `themeToggleIcon` button for collapsed mode (toggles to the opposite theme). | | `app/(app)/components/sidebar.module.css` | Major additions: collapsed-rail rules under `:global(html[data-sidebar-collapsed="1"]) .shell`, narrow-viewport rules under `@media (max-width: 899px)` (fixed-position aside, floating toggle, scrim). | | `app/(app)/layout.tsx` | Renders `<SidebarShell sidebar={<Sidebar />}>{children}</SidebarShell>`. | | `app/layout.tsx` | Adds `<SidebarInitScript />` alongside `<ThemeInitScript />` in `<head>`. | | `messages/en.json` | Four new `nav.*` keys (`collapseSidebar`, `expandSidebar`, `toggleTheme`, `theme`). | ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 440 passed, 91 skipped (no regressions). - [x] **Browser verification** (Playwright headless chromium, 22/22 assertions): - [x] Wide expanded: brand row shows logo + wordmark + toggle on right. - [x] Click toggle → 64px rail; brand link hidden; toggle centered; nav labels hidden; segmented theme switch hidden; single theme-toggle icon visible; user card collapsed to avatar + logout stack. - [x] Hover collapsed nav item → tooltip with label appears to the right. - [x] Reload while collapsed → `html[data-sidebar-collapsed="1"]` and aside is 64px on the **first sample** (no flash). - [x] Resize to 800×900 → aside off-screen (`x=-273`); `data-narrow="true"`; floating toggle visible. - [x] Click floating toggle → drawer slides in at `x=0` (full expanded labels); floating toggle hidden; scrim visible. - [x] Escape closes drawer. - [x] Click scrim closes drawer. - [x] Dark theme renders correctly in expanded, collapsed, and drawer-open states. - [ ] Screen reader walkthrough (no AT in CI environment — code sets `aria-label` on each nav `Link` and `aria-expanded` + `aria-controls` on the toggle, but live AT behavior untested). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(pwa): collapsable sidebar with narrow-viewport drawer (#162)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 23s
PR / OSV-Scanner (pull_request) Successful in 46s
PR / Static analysis (pull_request) Successful in 1m1s
PR / Package age policy (soft) (pull_request) Successful in 18s
Secrets / gitleaks (pull_request) Successful in 14s
PR / Trivy (image) (pull_request) Failing after 1m32s
PR / npm audit (pull_request) Successful in 4m28s
PR / Test (postgres) (pull_request) Successful in 4m28s
PR / Typecheck (pull_request) Successful in 4m33s
PR / Lint (pull_request) Successful in 4m36s
PR / Test (sqlite) (pull_request) Successful in 4m41s
PR / Coverage (soft) (pull_request) Successful in 4m28s
PR / Build (pull_request) Successful in 5m1s
7c56d3bbc6
Adds a manual collapse/expand toggle to the left sidebar shipped in
#140 and a narrow-viewport mode where the sidebar hides entirely
behind a floating toggle that reveals it as an overlay drawer.

- SidebarShell client wrapper owns collapsed + drawerOpen state,
  exposes context to the toggle button. Collapse state persists to
  localStorage (`carol:sidebar:collapsed`).
- SidebarInitScript mirrors ThemeInitScript: synchronously sets
  `<html data-sidebar-collapsed>` in <head> before paint, so reloads
  no longer flash an expanded sidebar before snapping to collapsed.
  CSS module keys collapsed-mode rules off the html attribute.
- Wide collapsed: 64px rail, icons only, hover/focus tooltips, footer
  collapsed to single theme-toggle icon + avatar tile + logout.
- Narrow viewport (<900px): sidebar hidden; floating toggle in
  top-left opens it as a drawer overlay. Scrim click or Escape closes.
  Drawer resets on viewport flip and route change.
- New `nav.collapseSidebar` / `nav.expandSidebar` / `nav.toggleTheme`
  / `nav.theme` i18n keys; existing hardcoded "Theme" aria-label
  translated in passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |

📊 Test coverage

Patch coverage: 0.0% (0/204 added lines) ⚠️ (soft target ≥ 80%)

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 80.8% ≥ 50%
Branches 80.8% ≥ 75%
Functions 88.5% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
app/(app)/components/sidebar-init-script.tsx 0.0% (0/6) 0.0% 0.0%
app/(app)/components/sidebar-nav.tsx 0.0% (0/6) 0.0% 0.0%
app/(app)/components/sidebar-shell.tsx 0.0% (0/117) 0.0% 0.0%
app/(app)/components/sidebar-theme-switcher.tsx 0.0% (0/35) 0.0% 0.0%
app/(app)/components/sidebar-toggle.tsx 0.0% (0/25) 0.0% 0.0%
app/(app)/components/sidebar.tsx 0.0% (0/15) 0.0% 0.0%

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 0.0% (0/204 added lines) ⚠️ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 80.8% ✅ | ≥ 50% | | Branches | 80.8% ✅ | ≥ 75% | | Functions | 88.5% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/(app)/components/sidebar-init-script.tsx` | 0.0% (0/6) | 0.0% | 0.0% | | `app/(app)/components/sidebar-nav.tsx` | 0.0% (0/6) | 0.0% | 0.0% | | `app/(app)/components/sidebar-shell.tsx` | 0.0% (0/117) | 0.0% | 0.0% | | `app/(app)/components/sidebar-theme-switcher.tsx` | 0.0% (0/35) | 0.0% | 0.0% | | `app/(app)/components/sidebar-toggle.tsx` | 0.0% (0/25) | 0.0% | 0.0% | | `app/(app)/components/sidebar.tsx` | 0.0% (0/15) | 0.0% | 0.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit fca6d753d0 into main 2026-06-20 02:37:17 +00:00
james deleted branch 162-sidebar-collapse 2026-06-20 02:37:17 +00:00
Sign in to join this conversation.
No description provided.