feat(client): nav shell — icons, collapse, switchers, native drawer (#210) #211

Merged
james merged 5 commits from 210-nav-shell-features into main 2026-06-21 16:28:14 +00:00
Owner

Summary

Brings the universal client's nav shell up to parity with the design package's spec. Closes / addresses #210.

  • Icons on every nav row via lucide-react-native@1.21.0 (pure-JS, no lavamoat allowlist work) + its peer react-native-svg@15.15.4 (the version paired with Expo SDK 56 in bundledNativeModules.json).
  • Collapse / expand sidebar with a chevron toggle in the brand row. Persists via a small lib/sidebarCollapsed.ts helper — SecureStore on native, localStorage on web. Collapsed width 64px, expanded 240px.
  • Theme + locale switchers in the sidebar footer. Theme is a tri-state segmented control (auto/light/dark) wired to useSetThemePreferenceuseUpdateSettings. Locale cycles through SUPPORTED_LOCALES and persists locally via lib/i18n/persistence.ts (locale isn't on the SettingsDto today). Both controls collapse to icon-only buttons.
  • Native drawer + mobile hamburger via expo-router's Drawer layout (which bundles react-navigation drawer + react-native-drawer-layout inside SDK 56 — no extra workspace dep). Web ≥720px uses drawerType: "permanent" + defaultStatus: "open". Native + narrow web uses drawerType: "front" with a thin MobileHeader containing a Lucide hamburger that calls navigation.openDrawer(). GestureHandlerRootView mounted at the root.
  • Active-route + brand polish — 2px left border in tokens.accent plus the soft accentSubtle fill on active rows; brand wordmark uses tokens.accentText so the brand row reads as brand, not nav.

Icon mapping (for review)

Route Lucide icon
notes StickyNote
profile User
skills Sparkles
experience Briefcase
network Users
projects FolderKanban
applications Inbox
chat MessageSquare
account Settings

Sidebar chrome:

  • Brand toggle: ChevronLeft / ChevronRight
  • Theme switcher: Monitor / Sun / Moon
  • Locale switcher: Globe
  • Footer logout: LogOut
  • Mobile header: Menu

Commits

  1. chore(client): add lucide-react-native + nav icons (#210)
  2. feat(client): collapse + expand sidebar with persistence (#210)
  3. feat(client): theme + locale switchers in the sidebar footer (#210)
  4. feat(client): native drawer + mobile hamburger (#210)
  5. feat(client): tighter active-route + brand styling (#210)

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck / lint / test (555 passed, 107 skipped)
  • pnpm -F @carol/api openapi:check
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test
  • pnpm -F @carol/client export:web produces a clean dist/

Tradeoffs / notes

  • Drawer mode-switching at 720pxuseWindowDimensions() re-renders the layout on viewport changes and we pass drawerType and defaultStatus reactively. react-native-drawer-layout handles the drawerType swap idempotently (it treats permanent as open: true regardless of the open prop), so resizing a desktop browser past the breakpoint swaps modes live without remount loops.
  • Animation — shipped the static width swap for collapse/expand. The two halves of the width transition (SIDEBAR_WIDTHS.collapsed vs expanded) are the load-bearing change; a Reanimated transition would be polish, not parity. Filed as a follow-up.
  • Locale switcher — chose tap-to-cycle over a popup. With two locales today (en + es) cycle is the right shape; when a third locale lands the control graduates to a popup.
  • Settings DTO not extended — sidebar collapsed and locale persist via a tiny expo-secure-store / localStorage helper rather than expanding the API contract. Theme keeps using useSettings/useUpdateSettings because it already exists in SettingsDto.
  • Expo-router 56 drawer — uses expo-router/drawer (which inlines react-navigation/drawer + react-native-drawer-layout in the SDK 56 build), so no extra workspace dep. Mounted GestureHandlerRootView at the root layout for swipe-to-open on native.
  • Sidebar style hardening — the linter pass tightened Sidebar.tsx and FooterControls.tsx to use pre-flattened plain-object styles (vs [a, b] arrays). Carried over from PR #209's RNW interop fix; the drawer's inline-style merging exposed the same edge.

Follow-ups worth filing

  • Animated collapse/expand transition via Reanimated.
  • Locale switcher popup when SUPPORTED_LOCALES grows past two.
  • sidebarCollapsed and locale could graduate to the API SettingsDto if cross-device sync is desired later; the current local-only persistence is intentional for the v1 scope.
  • E2E smoke for drawer-mode swap on the 720px breakpoint.

🤖 Generated with Claude Code

## Summary Brings the universal client's nav shell up to parity with the design package's spec. Closes / addresses #210. - **Icons on every nav row** via `lucide-react-native@1.21.0` (pure-JS, no lavamoat allowlist work) + its peer `react-native-svg@15.15.4` (the version paired with Expo SDK 56 in `bundledNativeModules.json`). - **Collapse / expand sidebar** with a chevron toggle in the brand row. Persists via a small `lib/sidebarCollapsed.ts` helper — SecureStore on native, localStorage on web. Collapsed width 64px, expanded 240px. - **Theme + locale switchers** in the sidebar footer. Theme is a tri-state segmented control (auto/light/dark) wired to `useSetThemePreference` → `useUpdateSettings`. Locale cycles through `SUPPORTED_LOCALES` and persists locally via `lib/i18n/persistence.ts` (locale isn't on the `SettingsDto` today). Both controls collapse to icon-only buttons. - **Native drawer + mobile hamburger** via expo-router's `Drawer` layout (which bundles react-navigation drawer + `react-native-drawer-layout` inside SDK 56 — no extra workspace dep). Web ≥720px uses `drawerType: "permanent"` + `defaultStatus: "open"`. Native + narrow web uses `drawerType: "front"` with a thin `MobileHeader` containing a Lucide hamburger that calls `navigation.openDrawer()`. `GestureHandlerRootView` mounted at the root. - **Active-route + brand polish** — 2px left border in `tokens.accent` plus the soft `accentSubtle` fill on active rows; brand wordmark uses `tokens.accentText` so the brand row reads as brand, not nav. ## Icon mapping (for review) | Route | Lucide icon | | -------------- | --------------- | | notes | `StickyNote` | | profile | `User` | | skills | `Sparkles` | | experience | `Briefcase` | | network | `Users` | | projects | `FolderKanban` | | applications | `Inbox` | | chat | `MessageSquare` | | account | `Settings` | Sidebar chrome: - Brand toggle: `ChevronLeft` / `ChevronRight` - Theme switcher: `Monitor` / `Sun` / `Moon` - Locale switcher: `Globe` - Footer logout: `LogOut` - Mobile header: `Menu` ## Commits 1. `chore(client): add lucide-react-native + nav icons (#210)` 2. `feat(client): collapse + expand sidebar with persistence (#210)` 3. `feat(client): theme + locale switchers in the sidebar footer (#210)` 4. `feat(client): native drawer + mobile hamburger (#210)` 5. `feat(client): tighter active-route + brand styling (#210)` ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` (555 passed, 107 skipped) - [x] `pnpm -F @carol/api openapi:check` - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` - [x] `pnpm -F @carol/client export:web` produces a clean `dist/` ## Tradeoffs / notes - **Drawer mode-switching at 720px** — `useWindowDimensions()` re-renders the layout on viewport changes and we pass `drawerType` and `defaultStatus` reactively. `react-native-drawer-layout` handles the `drawerType` swap idempotently (it treats `permanent` as `open: true` regardless of the open prop), so resizing a desktop browser past the breakpoint swaps modes live without remount loops. - **Animation** — shipped the static width swap for collapse/expand. The two halves of the width transition (`SIDEBAR_WIDTHS.collapsed` vs `expanded`) are the load-bearing change; a Reanimated transition would be polish, not parity. Filed as a follow-up. - **Locale switcher** — chose tap-to-cycle over a popup. With two locales today (en + es) cycle is the right shape; when a third locale lands the control graduates to a popup. - **Settings DTO not extended** — sidebar collapsed and locale persist via a tiny `expo-secure-store` / `localStorage` helper rather than expanding the API contract. Theme keeps using `useSettings`/`useUpdateSettings` because it already exists in `SettingsDto`. - **Expo-router 56 drawer** — uses `expo-router/drawer` (which inlines `react-navigation/drawer` + `react-native-drawer-layout` in the SDK 56 build), so no extra workspace dep. Mounted `GestureHandlerRootView` at the root layout for swipe-to-open on native. - **Sidebar style hardening** — the linter pass tightened `Sidebar.tsx` and `FooterControls.tsx` to use pre-flattened plain-object styles (vs `[a, b]` arrays). Carried over from PR #209's RNW interop fix; the drawer's inline-style merging exposed the same edge. ## Follow-ups worth filing - Animated collapse/expand transition via Reanimated. - Locale switcher popup when SUPPORTED_LOCALES grows past two. - `sidebarCollapsed` and `locale` could graduate to the API `SettingsDto` if cross-device sync is desired later; the current local-only persistence is intentional for the v1 scope. - E2E smoke for drawer-mode swap on the 720px breakpoint. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The minimal sidebar from #185 was label-only. Adding icons brings it
toward the design package's spec — icon + label expanded, icon-only
when the next commit lands collapse support.

Pinned `lucide-react-native@1.21.0` (pure-JS package; no lifecycle
scripts to thread through the lavamoat allowlist) plus its peer
`react-native-svg@15.15.4` — the version `expo/bundledNativeModules.json`
pairs with Expo SDK 56. Picked sensible Lucide line icons per route
(StickyNote / User / Sparkles / Briefcase / Users / FolderKanban /
Inbox / MessageSquare / Settings).
Extracts the sidebar into `lib/nav/Sidebar.tsx` so the layout file can
focus on shell composition, and adds a collapse toggle:

- Toggle button in the brand row uses `nav.collapseSidebar` /
  `nav.expandSidebar` for the accessibility label.
- Collapsed width 64px (icon-only with the row label exposed as the
  pressable's accessibilityLabel for screen readers); expanded 240px.
- State persists via a tiny helper: SecureStore on native, localStorage
  on web. Not added to the SettingsDto — too much API surface for a
  pure UI preference.

Static width swap is the load-bearing change. An animated transition
via Reanimated or LayoutAnimation can land as a follow-up; the layout
change itself is the gating UX.
Adds a `FooterControls` component the sidebar renders via its
`renderFooterControls` slot:

- Theme: tri-state segmented control (auto/light/dark) using the
  existing Lucide line icons (Monitor / Sun / Moon). Reuses
  `useSetThemePreference` — which round-trips through
  `useUpdateSettings` — so a sidebar change drives the same React
  Query cache the Account screen will read in a later slice.
- Locale: a Globe + locale-code pressable that cycles through
  `SUPPORTED_LOCALES` on tap. With two locales today (en + es) the
  cycle is the right shape; a third locale graduates this to a popup.

Both controls collapse to a single icon button when the sidebar is
collapsed; tapping the collapsed button performs the same cycle.

Locale isn't on the API `SettingsDto` today (it's theme-only), so the
choice persists via a small `lib/i18n/persistence.ts` helper —
SecureStore on native, localStorage on web — and feeds into the boot
resolver so the override survives reloads.
Replaces the manually rendered web-only sidebar shell with
expo-router's `Drawer` layout (which bundles
`@react-navigation/drawer` + `react-native-drawer-layout` inside the
SDK 56 build — no extra workspace dep needed). The same `Sidebar`
component now hosts both modes via the navigator's `drawerContent`
slot:

- Web >= 720px → `drawerType: "permanent"`, `defaultStatus: "open"`.
  The drawer sits inline as a sidebar; collapse toggle is visible.
- Native and web < 720px → `drawerType: "front"`. A thin custom
  `MobileHeader` renders a Lucide hamburger that calls
  `navigation.openDrawer()`. Tapping a nav row closes the drawer
  automatically via the new `Sidebar.onNavigate` callback.

Mode switching is `useWindowDimensions()`-driven so resizing a
desktop browser past 720px swaps modes live without a remount loop.
`GestureHandlerRootView` is mounted at the root layout — required by
react-native-drawer-layout for swipe-to-open on native.

Sidebar and FooterControls were also tightened to use the
pre-flattened style pattern documented in PR #209 (style props on
DOM-leaf elements must be plain objects, never arrays) — the drawer's
inline-style merging exposed the same RNW interop edge as the link
asChild path did, and lifting all style merges to plain objects keeps
the surface defensive.
feat(client): tighter active-route + brand styling (#210)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 2m18s
PR / Static analysis (pull_request) Successful in 2m29s
PR / Typecheck (pull_request) Successful in 2m39s
PR / OpenAPI (pull_request) Successful in 2m45s
PR / Lint (pull_request) Successful in 4m3s
PR / Build (pull_request) Successful in 4m20s
PR / Client (web export smoke) (pull_request) Successful in 4m26s
PR / Test (postgres) (pull_request) Failing after 4m27s
PR / Package age policy (soft) (pull_request) Successful in 1m48s
Secrets / gitleaks (pull_request) Successful in 1m50s
PR / Test (sqlite) (pull_request) Successful in 4m37s
PR / pnpm audit (pull_request) Successful in 4m44s
PR / Coverage (soft) (pull_request) Successful in 2m27s
PR / Trivy (image) (pull_request) Failing after 3m3s
8666916970
Lifts the brand wordmark to `tokens.accentText` (both the sidebar
brand row and the mobile-header brand label) so the top of each
surface reads as brand, not as a nav row. The active-route 2px left
border in `tokens.accent` and the brand-row separator landed with
the earlier sidebar/drawer commits.

📊 Test coverage

Patch coverage: no testable lines changed.

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

Metric Value Soft target
Lines 82.9% ≥ 50%
Branches 76.0% ≥ 75%
Functions 91.3% informational

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 82.9% ✅ | ≥ 50% | | Branches | 76.0% ✅ | ≥ 75% | | Functions | 91.3% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

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` |
james merged commit af8da7ad3e into main 2026-06-21 16:28:14 +00:00
james deleted branch 210-nav-shell-features 2026-06-21 16:28:14 +00:00
Sign in to join this conversation.
No description provided.