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
+4 -2
View File
@@ -344,8 +344,10 @@ shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed; see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
landing page have since landed, see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md), and the shell itself is now the
collapsible sidebar, account menu, and phone drawer of
[ADR 0058](decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
+3 -1
View File
@@ -233,7 +233,9 @@ password, URL, or stack reaches browser output or logging. The form offers **no
because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038).
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
Sign-out is the account menu's one item, at the bottom of the sidebar or the phone drawer
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). It
posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
During that transition the Dashboard subtree is unmounted, which invokes the generic module
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
+11
View File
@@ -576,6 +576,17 @@ module's own HTTP routes, and creating relationship-derived grants automatically
own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface.
### Icons in your module's UI
Lucide is Ekklesia's icon vocabulary
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). If your
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
decorative: give every control a text name, visible or visually hidden, and keep the icon
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
module will declare its own navigation icon through the module navigation contract (manifest v9), not
yet.
### Your module's display name
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees