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

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]>
This commit is contained in:
ekklesia
2026-10-14 17:49:56 -05:00
co-authored by Claude Opus 5.5
parent f3b7e19651
commit af31724cb4
24 changed files with 2559 additions and 189 deletions
@@ -0,0 +1,192 @@
# 0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md)
## Context
ADR 0057 gave every Dashboard page a real address, but the shell around those pages was still the
first proof's layout: a top header holding a hard-coded product name and a Sign out button, a fixed
`15rem` column of text buttons, and the transitional device panel above the navigation. It had no
icons, could not collapse, had no account area, and on a phone it was the same two columns squeezed
until the page was unusable.
DESIGN.md §4 fixes what the shell is: a collapsible left sidebar — icons and labels expanded, icons
only collapsed, with accessible names — church identity at the top, the account area anchored at the
bottom, no persistent top bar, and on phones a drawer with the **same** hierarchy (locked decisions
4–8). It left one thing open that this work cannot proceed without: **which icon library**
(§27, "Final free icon library selection"). The maintainer has decided it: Lucide.
## Decision
### Lucide is Ekklesia's icon vocabulary
Lucide (`lucide-react`, ISC-licensed, no runtime dependencies) is the one icon set for first-party
Ekklesia UI: the shell, `@ekklesia/ui` if it ever draws an icon, and first-party modules. Where
Lucide has an appropriate icon, nobody draws their own; a meaning that Lucide genuinely cannot
express is the only reason to design one, and that is a decision to record, not a shortcut.
Icons are **decorative**. Every control that shows one also has a text name — visible, or visually
hidden when the sidebar is collapsed — and every icon is `aria-hidden`. An icon never carries
meaning alone, and its stroke is `currentColor`, so it takes its colour from the design tokens
exactly as text does (ADR 0039). Icons are imported by name, so a bundle contains exactly the icons
it uses.
The shell imports every icon it uses in one file (`surfaces/shell/src/browser/shell-icons.tsx`),
keyed by meaning — `overview`, `module`, `account`, `signOut`, … — so the vocabulary is reviewable
and an icon is chosen once per meaning rather than per call site. `lucide-react` is a dependency of
`@ekklesia/shell` only, pinned exactly (`1.48.0`); no public `@ekklesia/*` package gains it here.
A module that needs icons in its own UI uses `lucide-react` itself, which `pnpm boundaries` already
permits (module `.svg` presentation assets remain rejected).
### Modules have no icon of their own yet
Every module in the navigation is drawn with **one generic module icon** (Lucide `Blocks`). There is
no table mapping known module ids to icons: that would be a second, host-owned answer to a question
the module owns, and every new module would wait on a shell release to look right. A module will
declare its own icon — by Lucide name — through the module navigation contract that also brings its
sub-pages (manifest v9). Nothing in the manifest changes here.
### The shell's layout
On a wide screen (at or above `48em`, which is 768px at the default text size and follows a
person's own text-size setting), the Dashboard is a sidebar and the page:
```text
┌────────────┬──────────────────────────────┐
│ identity ◂│ │
│ Overview │ │
│ GROUP │ the page │
│ Module │ │
│ Module │ │
│ (devices) │ │
│────────────│ │
│ Your account │
└────────────┴──────────────────────────────┘
```
- **Top:** the identity slot and the collapse toggle. The slot is a neutral placeholder (a plain mark
and the product name) and deliberately takes **no inputs**: the branding work (issue #101) owns
what goes there and where it comes from, and replaces this one component rather than undoing a
configuration model invented early. It is not a heading; each page's own title is.
- **Middle:** the navigation, scrolling on its own — the Overview first, then the server's groups and
modules in the server's order (ADR 0027). The shell sorts and filters nothing; a disabled module is
absent because the navigation endpoint does not list it (ADR 0028), and its address still shows
*This page isn't available* (ADR 0057). Below it, while expanded, the transitional device panel
(ADR 0036) until Settings → Displays replaces it.
- **Bottom:** the account control, a product-owned menu button (below).
**Collapsed**, the sidebar is icons only (`4.5rem` rather than `16rem`). Each item keeps its text in
the DOM, visually hidden, as its accessible name, and shows a tooltip on hover **and on keyboard
focus**. The tooltip is a visual duplicate of that name, so it is `aria-hidden` and nothing is
announced twice; it meets WCAG 2.2 §1.4.13 — dismissible with Escape without moving focus, hoverable,
and persistent until focus or the pointer leaves — and is positioned beside the item so a scrolling
sidebar cannot clip it. Group labels stay in the DOM for assistive technology, with a divider
standing in for them visually. The toggle states what it does ("Collapse navigation" / "Expand
navigation") and carries `aria-expanded` and `aria-controls`.
There is **no application top bar**: the page begins at the top of its column.
### Expanded or collapsed is a preference of this browser
The mode is remembered in this browser's `localStorage` under `ekklesia.dashboard.sidebar` (exactly
`expanded` or `collapsed`; anything else is ignored), so it survives a reload. It is a preference of
the device rather than the person: a narrow laptop and a wide desktop reasonably want different
answers, and DESIGN.md's synced personal preferences belong to the account area, which does not exist
yet. Storage can be missing, blocked, or throw; then the mode is remembered for the page only and
nothing breaks.
The shell reads and writes it through a two-method `SidebarPreference` interface built by the entry
point, so a synced or per-person store replaces the implementation without touching a component. The
entry point likewise builds the browser's media query (`HostViewport`) and history (ADR 0057), and
remains the only file in the package that touches `window`.
### The account area
The signed-in person's control at the bottom of the sidebar opens a product-owned menu following the
WAI-ARIA menu button pattern: `aria-haspopup="menu"` and `aria-expanded` on the trigger; opening moves
focus to the first item (ArrowUp opens on the last); arrow keys, Home, and End move between items;
Escape closes it and returns focus to the trigger; Tab or a press outside closes it and leaves focus
where it went. It contains **only actions that work today** — Sign out. Profile, personal preferences,
appearance, and notifications belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages;
until then there is no dead link to the reserved `/account`.
The shell knows the person only as an opaque user id (`/api/v1/principal`, ADR 0034), which is
never displayed, so the control reads "Your account" with a generic avatar rather than a name or
email. Showing the person's name needs a deliberate principal or profile projection, which this
decision does not add.
### Phones: the same navigation in a drawer
Below the breakpoint the sidebar is not rendered. A single menu button ("Open navigation", with
`aria-expanded` and `aria-controls`) sits above the page — the one control there, not a bar — and opens
a drawer containing the **same** identity slot, navigation, current page, device panel, and account
area, always with labels and with large-control touch targets (48px). The drawer is a real modal:
- `role="dialog"`, `aria-modal="true"`, named "Navigation";
- the page behind it is `inert`, and Tab and Shift+Tab are also kept inside it, for a browser that
does not yet honour `inert`;
- opening moves focus to its close button; Escape, the close button, and the backdrop dismiss it and
return focus to the menu button;
- choosing a destination closes it and moves focus to the new page (ADR 0057), including when the
destination is the page already open;
- Back and Forward close it and still work.
### Changing layout never disturbs the page
The page's `<main>` keeps one position in the tree in both layouts, so rotating a tablet or resizing
a window never remounts the open module. The route comes from the URL and the desktop mode from the
preference, so neither is touched by a layout change. Only an open drawer is dropped, and focus that
was inside navigation that has just gone away lands on the page rather than on the document.
### Shell-local, not yet `@ekklesia/ui`
The tooltip, the account menu, and the drawer live in the shell. ADR 0039 adds an element to
`@ekklesia/ui` once two independent consumers need it, and today the shell is the only one; adding
them to the public package would put unproven API in front of every module. Each is written as a
self-contained, generic piece so it can be promoted unchanged when a module needs one. The shell's
own stylesheet (hover, `:focus-visible`, reduced motion) is a token-only, `ek-shell-`-prefixed string
installed into the shell's document, beside `@ekklesia/ui`'s, and cannot reach a module's isolated
root (ADR 0040).
## Alternatives
- **Another icon library** (Heroicons, Phosphor, Tabler, Material Symbols). Rejected by the
maintainer's decision; Lucide's breadth, consistent stroke style, ISC licence, and per-icon imports
fit a calm interface that many modules will extend.
- **Ekklesia's own SVG set.** Rejected: an icon set is a long-term design and maintenance commitment
that buys nothing a mature open set does not already have.
- **A host-owned map of module ids to icons.** Rejected: the module owns its identity, and a map makes
every new module wait on a shell release. The generic icon is honest until modules declare one.
- **A module-manifest `icon` field now.** Deferred to the manifest-v9 navigation contract, so modules
change manifest version once for icons, sub-pages, and the rest of that contract together.
- **Persisting the mode on the server, per person.** Deferred to the account preferences work; the
interface is shaped so that store can replace this one.
- **CSS-only responsive switching.** Rejected: the drawer must be a modal with focus management, which
needs the layout in script, and a media query adapter keeps it testable.
- **A top bar holding the brand and the menu button on phones.** Rejected: DESIGN.md has no persistent
top bar; one button is all the phone layout needs above the page.
- **Native `<dialog>` for the drawer.** Not chosen for now: its focus return and inertness differ
across the browsers a church's volunteers use, and the shell's own handling is explicit and tested.
## Consequences
- The Dashboard has the shell DESIGN.md describes on desktop, tablet, and phone, proven in unit tests
against in-memory history, viewport, and preference, and in the deploy smoke test in a real browser:
at desktop size (the account menu's Sign out) and at phone size (the drawer's focus, Escape, and
choosing a module), and back again.
- Sign out moved from the header into the account menu.
- The hard-coded product name now lives in one placeholder component, waiting for branding; there is
no other person-facing "Ekklesia" in the authenticated shell.
- `@ekklesia/shell` depends on `lucide-react`. No public package changed.
## Deferred
Church branding in the identity slot (issue #101); module-declared icons and module sub-pages in the
sidebar (manifest v9); group icons and the navigation editor; the account pages and synced personal
preferences, including a per-person sidebar mode; showing the person's name or picture; Settings in the
navigation; promoting the tooltip, menu, or drawer to `@ekklesia/ui`.
+1
View File
@@ -66,3 +66,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted