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);
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).
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.
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)
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]>
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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)
aria-expandedandaria-controls.aria-current="page"on the open page.Blocks). The Overview has its own (LayoutDashboard).Phones (narrower than 48em)
role="dialog",aria-modal):inert, and Tab/Shift+Tab are trapped inside it;How
surfaces/shell/src/browser/:ShellLayoutpicks the layout and owns the drawer state;ShellNavigation(NavigationListand theShellBrandslot);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 andek-shell-class names, with a reduced-motion rule;sidebar-preference.ts, alocalStoragestore (ekklesia.dashboard.sidebar) that tolerates missing or throwing storage, behind a replaceable interface;host-viewport.ts, the media query adapter.main.tsxbuilds the viewport and preference fromwindow. It is still the only file that touches browser globals.[email protected]is added to@ekklesia/shell(private) only: ISC licence, no runtime dependencies, published 2026-09-24. No public@ekklesia/*package changed.@ekklesia/uionce two consumers need it, and the shell is the only one today.Docs
lucide-reactand keep icons decorative).DESIGN.md§27's "Final free icon library selection" is now resolved: §4.3 names Lucide, and locked decision 94 records it.Stage 0
/display/*, its script, and its service worker) is untouched.Tests
shell-navigation.test.tsx(31 tests). It covers:localStorage;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;shell-environment.test.ts: the preference store (reload, invalid values, throwing storage) and the viewport adapter.modularity.test.tsnow asserts that noalert/confirm/promptcall exists anywhere in shell source.host-app.test.tsxwas updated: sign-out goes through the account menu, and the sidebar track changed from a fixed 15rem column to an auto/16rem sidebar./<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 andsplit: 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.test:package-releaseandtest:package-publication. No public package changed.Deferred to later Stage 1 PRs
@ekklesia/ui.🤖 Generated with Claude Code
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]>