# 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 `
` 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 `` 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`.