feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058) #104

Merged
Zayin_Admin merged 2 commits from feat/dashboard-shell-navigation into main 2026-10-14 23:05:29 +00:00
Member

Stage 1 PR 2: the Dashboard shell and navigation foundation. The decisions are recorded in ADR 0058. Routing is unchanged; ADR 0057 / #103 stays authoritative.

What people will see

Desktop and tablet (48em / 768px and wider)

  • A collapsible left sidebar replaces the old header and fixed column. There is no top bar.
    • Expanded, it shows icons and labels. Collapsed, it shows icons only, each named by a tooltip that appears on hover and on keyboard focus.
    • The toggle ("Collapse navigation" / "Expand navigation") has aria-expanded and aria-controls.
    • The choice survives a reload, in this browser only.
  • The Overview comes first, then the server's groups and modules in the server's order, with aria-current="page" on the open page.
    • Disabled modules are not listed, because the server's navigation omits them.
    • Visiting a disabled module's address still shows This page isn't available.
  • Every module shows one generic Lucide icon (Blocks). The Overview has its own (LayoutDashboard).
  • The account control is at the bottom ("Your account"). It opens a product-owned menu whose only item is Sign out, which moved here from the header.
    • The shell only knows an opaque user id, so it shows no name or email. That id is never displayed.
  • The identity slot at the top is the neutral placeholder (a mark plus "Ekklesia"; only the mark when collapsed). It is no longer a page heading.
  • The transitional Display devices panel is still in the sidebar while it is expanded, and in the drawer. Stage 1 moves it to Settings → Displays later.

Phones (narrower than 48em)

  • The sidebar is not shown. A single "Open navigation" button sits above the page (no bar). It opens a drawer with the same navigation, current page, device panel, and account menu, always labelled, with 48px touch targets.
  • The drawer is a real modal (role="dialog", aria-modal):
    • the page behind it is inert, and Tab/Shift+Tab are trapped inside it;
    • focus starts on its close button;
    • Escape, the close button, or the backdrop closes it and returns focus to the menu button;
    • choosing a page closes it and moves focus to that page, including the page already open;
    • Back/Forward close it and keep working.
  • Changing layout (rotating, resizing) keeps the route, the desktop expanded/collapsed choice, and the mounted module, which is never remounted. An open drawer is dropped, and focus lands on the page.

How

  • surfaces/shell/src/browser/:
    • ShellLayout picks the layout and owns the drawer state;
    • ShellNavigation (NavigationList and the ShellBrand slot);
    • AccountMenu (the WAI-ARIA menu button pattern);
    • NavTooltip (meets WCAG 2.2 §1.4.13: dismissible, hoverable, persistent);
    • shell-icons.tsx, the single place Lucide icons are imported, keyed by meaning, with no per-module map;
    • shell-styles.ts, a stylesheet that uses only design tokens and ek-shell- class names, with a reduced-motion rule;
    • sidebar-preference.ts, a localStorage store (ekklesia.dashboard.sidebar) that tolerates missing or throwing storage, behind a replaceable interface;
    • host-viewport.ts, the media query adapter.
  • main.tsx builds the viewport and preference from window. It is still the only file that touches browser globals.
  • Dependency: [email protected] is added to @ekklesia/shell (private) only: ISC licence, no runtime dependencies, published 2026-09-24. No public @ekklesia/* package changed.
  • Kept in the shell: the tooltip, menu, and drawer are written generically, but ADR 0039 adds an element to @ekklesia/ui once two consumers need it, and the shell is the only one today.

Docs

  • New ADR 0058 (Lucide as the icon vocabulary; the generic module icon until manifest v9; shell layout; per-browser sidebar preference; account area; phone drawer) and its index entry.
  • README, platform architecture, API docs (sign-out location), and module-development docs ("Icons in your module's UI": use lucide-react and keep icons decorative).
  • Outside the repository, DESIGN.md §27's "Final free icon library selection" is now resolved: §4.3 names Lucide, and locked decision 94 records it.

Stage 0

  • The display host (/display/*, its script, and its service worker) is untouched.
  • Module mounting, the stale-load guard, session expiry, and sign-out cleanup behave as before. Sign-out is reached through the account menu.

Tests

  • New shell-navigation.test.tsx (31 tests). It covers:
    • the expanded default, collapse, and persistence across a remount and through real localStorage;
    • tooltips on focus and hover, Escape dismissal, and none while expanded;
    • Overview and module active states, disabled modules hidden, and the generic module icon;
    • no top bar, tab order, and touch-target sizes;
    • the account menu: opening, ArrowDown, Escape with focus return, dismissal by outside press and by Tab, and sign-out;
    • no native dialogs;
    • the drawer: open state, modal semantics, inert, Escape and focus return, close button and backdrop, the focus trap both ways, closing on navigation (including to the current page), Back/Forward, sign-out inside the drawer, and touch targets;
    • layout changes keeping the route and mode, landing focus on the page, and never remounting the module.
  • New shell-environment.test.ts: the preference store (reload, invalid values, throwing storage) and the viewport adapter.
  • modularity.test.ts now asserts that no alert/confirm/prompt call exists anywhere in shell source.
  • host-app.test.tsx was updated: sign-out goes through the account menu, and the sidebar track changed from a fixed 15rem column to an auto/16rem sidebar.
  • Deploy smoke (real Chromium): sign-out now goes through the account menu. A new phone-width step (390×844) checks: trigger instead of sidebar; drawer modal with focus inside and the page inert; Escape closes and returns focus; choosing the module closes the drawer and opens /<id>; widening restores the sidebar on the same page.

Results (all run on the pushed commit)

  • pnpm verify: passed (shell 304, api 522, core 1161, root 156, all packages).
  • pnpm infrastructure:config, pnpm deploy:config: passed.
  • pnpm test:migrations:integration: passed, 401 tests.
  • scripts/smoke-deploy.sh, both same-origin and split: passed, including the new drawer and account-menu checks in every browser pass.
  • scripts/check-image.sh: passed. The image installs the new dependency from the frozen lockfile.
  • Visual check in headless Chromium against a stubbed platform, at 1280×800 and 390×780: expanded, collapsed with a focus tooltip, the open account menu, the phone layout, and the open drawer.
  • Not run: test:package-release and test:package-publication. No public package changed.

Deferred to later Stage 1 PRs

  • Church branding in the identity slot (#101).
  • Module-declared icons and sub-pages (manifest v9).
  • Group icons and the navigation editor.
  • Account pages and synced preferences, including a per-person sidebar mode.
  • The person's name or avatar, which needs a principal/profile projection.
  • Settings in the navigation.
  • Promoting the tooltip, menu, or drawer to @ekklesia/ui.
  • Theme modes.

🤖 Generated with Claude Code

Stage 1 PR 2: **the Dashboard shell and navigation foundation.** The decisions are recorded in [ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md). Routing is unchanged; ADR 0057 / #103 stays authoritative. ## What people will see **Desktop and tablet (48em / 768px and wider)** - A collapsible left sidebar replaces the old header and fixed column. There is no top bar. - Expanded, it shows icons and labels. Collapsed, it shows icons only, each named by a tooltip that appears on hover **and on keyboard focus**. - The toggle ("Collapse navigation" / "Expand navigation") has `aria-expanded` and `aria-controls`. - The choice survives a reload, in this browser only. - The Overview comes first, then the server's groups and modules in the server's order, with `aria-current="page"` on the open page. - Disabled modules are not listed, because the server's navigation omits them. - Visiting a disabled module's address still shows *This page isn't available*. - Every module shows one generic Lucide icon (`Blocks`). The Overview has its own (`LayoutDashboard`). - **The account control is at the bottom** ("Your account"). It opens a product-owned menu whose only item is **Sign out**, which moved here from the header. - The shell only knows an opaque user id, so it shows no name or email. That id is never displayed. - The identity slot at the top is the neutral placeholder (a mark plus "Ekklesia"; only the mark when collapsed). It is no longer a page heading. - The transitional *Display devices* panel is still in the sidebar while it is expanded, and in the drawer. Stage 1 moves it to Settings → Displays later. **Phones (narrower than 48em)** - The sidebar is not shown. A single "Open navigation" button sits above the page (no bar). It opens a drawer with the same navigation, current page, device panel, and account menu, always labelled, with 48px touch targets. - The drawer is a real modal (`role="dialog"`, `aria-modal`): - the page behind it is `inert`, and Tab/Shift+Tab are trapped inside it; - focus starts on its close button; - Escape, the close button, or the backdrop closes it and returns focus to the menu button; - choosing a page closes it and moves focus to that page, including the page already open; - Back/Forward close it and keep working. - Changing layout (rotating, resizing) keeps the route, the desktop expanded/collapsed choice, and the mounted module, which is never remounted. An open drawer is dropped, and focus lands on the page. ## How - **`surfaces/shell/src/browser/`**: - `ShellLayout` picks the layout and owns the drawer state; - `ShellNavigation` (`NavigationList` and the `ShellBrand` slot); - `AccountMenu` (the WAI-ARIA menu button pattern); - `NavTooltip` (meets WCAG 2.2 §1.4.13: dismissible, hoverable, persistent); - `shell-icons.tsx`, the single place Lucide icons are imported, keyed by meaning, with no per-module map; - `shell-styles.ts`, a stylesheet that uses only design tokens and `ek-shell-` class names, with a reduced-motion rule; - `sidebar-preference.ts`, a `localStorage` store (`ekklesia.dashboard.sidebar`) that tolerates missing or throwing storage, behind a replaceable interface; - `host-viewport.ts`, the media query adapter. - `main.tsx` builds the viewport and preference from `window`. It is still the only file that touches browser globals. - **Dependency:** `[email protected]` is added to `@ekklesia/shell` (private) only: ISC licence, no runtime dependencies, published 2026-09-24. **No public `@ekklesia/*` package changed.** - **Kept in the shell:** the tooltip, menu, and drawer are written generically, but ADR 0039 adds an element to `@ekklesia/ui` once two consumers need it, and the shell is the only one today. ## Docs - New **ADR 0058** (Lucide as the icon vocabulary; the generic module icon until manifest v9; shell layout; per-browser sidebar preference; account area; phone drawer) and its index entry. - README, platform architecture, API docs (sign-out location), and module-development docs ("Icons in your module's UI": use `lucide-react` and keep icons decorative). - Outside the repository, `DESIGN.md` §27's "Final free icon library selection" is now resolved: §4.3 names Lucide, and locked decision 94 records it. ## Stage 0 - The display host (`/display/*`, its script, and its service worker) is untouched. - Module mounting, the stale-load guard, session expiry, and sign-out cleanup behave as before. Sign-out is reached through the account menu. ## Tests - **New `shell-navigation.test.tsx`** (31 tests). It covers: - the expanded default, collapse, and persistence across a remount and through real `localStorage`; - tooltips on focus and hover, Escape dismissal, and none while expanded; - Overview and module active states, disabled modules hidden, and the generic module icon; - no top bar, tab order, and touch-target sizes; - the account menu: opening, ArrowDown, Escape with focus return, dismissal by outside press and by Tab, and sign-out; - no native dialogs; - the drawer: open state, modal semantics, `inert`, Escape and focus return, close button and backdrop, the focus trap both ways, closing on navigation (including to the current page), Back/Forward, sign-out inside the drawer, and touch targets; - layout changes keeping the route and mode, landing focus on the page, and never remounting the module. - **New `shell-environment.test.ts`**: the preference store (reload, invalid values, throwing storage) and the viewport adapter. - `modularity.test.ts` now asserts that **no `alert`/`confirm`/`prompt` call** exists anywhere in shell source. - `host-app.test.tsx` was updated: sign-out goes through the account menu, and the sidebar track changed from a fixed 15rem column to an auto/16rem sidebar. - **Deploy smoke (real Chromium)**: sign-out now goes through the account menu. A new phone-width step (390×844) checks: trigger instead of sidebar; drawer modal with focus inside and the page inert; Escape closes and returns focus; choosing the module closes the drawer and opens `/<id>`; widening restores the sidebar on the same page. ### Results (all run on the pushed commit) - `pnpm verify`: passed (shell 304, api 522, core 1161, root 156, all packages). - `pnpm infrastructure:config`, `pnpm deploy:config`: passed. - `pnpm test:migrations:integration`: passed, 401 tests. - `scripts/smoke-deploy.sh`, both same-origin and `split`: passed, including the new drawer and account-menu checks in every browser pass. - `scripts/check-image.sh`: passed. The image installs the new dependency from the frozen lockfile. - Visual check in headless Chromium against a stubbed platform, at 1280×800 and 390×780: expanded, collapsed with a focus tooltip, the open account menu, the phone layout, and the open drawer. - Not run: `test:package-release` and `test:package-publication`. No public package changed. ## Deferred to later Stage 1 PRs - Church branding in the identity slot (#101). - Module-declared icons and sub-pages (manifest v9). - Group icons and the navigation editor. - Account pages and synced preferences, including a per-person sidebar mode. - The person's name or avatar, which needs a principal/profile projection. - Settings in the navigation. - Promoting the tooltip, menu, or drawer to `@ekklesia/ui`. - Theme modes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 1 commit 2026-10-14 22:50:27 +00:00
feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
af31724cb4
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Zayin_Admin added 1 commit 2026-10-14 22:55:09 +00:00
test(core): pin the shell's one use of browser storage, not ban it
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
cf2f847c8c
CI failed in `identity-verification-boundaries.test.ts`: its guard that a
reset token never reaches browser storage was a blanket ban on the word
`localStorage` anywhere in the shell. The sidebar preference (ADR 0058)
legitimately reads it in the entry point.

The guarantee is unchanged: the reset token stays in React state for
the life of one form. The guard is now precise:

- `localStorage` appears only in `main.tsx`, only to hand it to the
  sidebar preference;
- the shell's only storage write is the sidebar's own key and a
  validated mode;
- the files that ever hold the token (`auth-protocol.ts`,
  `ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all;
- `sessionStorage` and `document.cookie` stay banned everywhere.

A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails
it. The runtime reset-token test in `auth-recovery.test.tsx` now runs
with the production localStorage-backed preference and asserts storage
holds nothing but the sidebar key.

It passed locally only because Turbo restored `@ekklesia/core:test` from
cache: that test reads shell source that Core's task hash does not
include.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Zayin_Admin merged commit 651efa8829 into main 2026-10-14 23:05:29 +00:00
Zayin_Admin deleted branch feat/dashboard-shell-navigation 2026-10-14 23:05:29 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#104