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]>
193 lines
12 KiB
Markdown
193 lines
12 KiB
Markdown
# 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`.
|