Files
Ekklesia/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

12 KiB
Raw Permalink Blame History

0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary

  • Status: Accepted
  • Date: 2026-10-14
  • Module-declared icons and pages decided by: 0063
  • Extends: 0026, 0027, 0039, 0057

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:

┌────────────┬──────────────────────────────┐
│ 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.