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
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:
co-authored by
Claude Opus 5.5
parent
f3b7e19651
commit
af31724cb4
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user