diff --git a/README.md b/README.md index aeb7528..d5a74d0 100644 --- a/README.md +++ b/README.md @@ -916,8 +916,18 @@ each installed module's `/`; any other top-level path is its sanitize the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that uses one. An installed module that is disabled shows a host-owned *This page isn't available* rather than loading anything. A module's own -sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings -link came from are later Stage 1 work. +sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are +later Stage 1 work. + +The Dashboard's **shell** follows DESIGN.md §4 +([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a +collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the +choice remembered in this browser — with the Overview first, the server's groups and modules below it, +and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the +same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing +it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can +declare their own icon (manifest v9), every module shows one generic icon. Church branding in the +sidebar's identity slot is still to come. ## Quick start diff --git a/core/tests/identity-verification-boundaries.test.ts b/core/tests/identity-verification-boundaries.test.ts index 948907b..0ad01dc 100644 --- a/core/tests/identity-verification-boundaries.test.ts +++ b/core/tests/identity-verification-boundaries.test.ts @@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => { }); it("never puts a reset token in browser storage", async () => { - for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) { + // A reset token is a bearer credential for one account; the shell holds it in React state for + // the life of one form and nowhere else. The shell's one use of browser storage is the desktop + // sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is + // named only by the entry point, and only to hand it to the sidebar preference, whose one write + // is its own fixed key and a validated mode. + const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src")); + const writes: string[] = []; + for (const file of files) { const code = withoutComments(file.text); - // A reset token is a bearer credential for one account; the shell holds it in React state for - // the life of one form and nowhere else. - expect(code, file.path).not.toContain("localStorage"); expect(code, file.path).not.toContain("sessionStorage"); expect(code, file.path).not.toContain("document.cookie"); + writes.push( + ...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map( + (match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`, + ), + ); + if (file.path.endsWith(path.join("browser", "main.tsx"))) { + expect(code.match(/localStorage/gu), file.path).toHaveLength(1); + expect(code).toMatch( + /createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u, + ); + } else { + expect(code, file.path).not.toContain("localStorage"); + } + } + expect(writes).toEqual([ + `${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`, + ]); + // The files that ever hold the token touch no storage API at all. + for (const file of files.filter((candidate) => + ["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) => + candidate.path.endsWith(name), + ), + )) { + expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u); } }); }); diff --git a/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md new file mode 100644 index 0000000..2319cb1 --- /dev/null +++ b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md @@ -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 `
` 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`. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 3de77ca..7dc628c 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index b1af83a..a8e8174 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 diff --git a/docs/development/api.md b/docs/development/api.md index fcf1efa..bcebaac 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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 diff --git a/docs/development/testing.md b/docs/development/testing.md index ffcb303..2912186 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia schema gained a token column and no identity code hashes or compares a token, that nothing interpolates a token, link, address, or body into a log line, that a device has no email, password, -or verification concept, and that no browser source imports Better Auth or Core's mail. +or verification concept, that no browser source imports Better Auth or Core's mail, and that the +shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named +only by the entry point, one write of one fixed key, and no storage API in any file that holds the +reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an injected in-memory provider and never touch the network. They prove the parts the protocol cannot: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index f1ee630..c59031f 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee33fae..c8b7304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -407,6 +407,9 @@ importers: '@ekklesia/ui': specifier: workspace:* version: link:../../packages/ui + lucide-react: + specifier: 1.48.0 + version: 1.48.0(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -2401,6 +2404,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4369,6 +4377,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.48.0(react@19.2.8): + dependencies: + react: 19.2.8 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index c9ce2ab..4c7a904 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -191,8 +191,10 @@ check( const moduleButton = 'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')'; const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); +let openedModuleId; if (hasModule) { const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); + openedModuleId = moduleId; await evaluate(`${moduleButton}.click()`); const mounted = await waitFor( `${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`, @@ -282,9 +284,74 @@ if (displayPath === null) { ); } -// --- Sign out on the Dashboard ------------------------------------------------------------------- +// --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------ +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 2, + height: 844, + mobile: true, + width: 390, +}); await navigate(`${dashboardOrigin}/`); -await waitFor(selector("host-logout"), "the sign-out control"); +check( + await waitFor( + `${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`, + "the phone navigation trigger", + ), + "a phone-sized window shows the navigation trigger instead of the sidebar", +); +await evaluate(`${selector("mobile-navigation-trigger")}.click()`); +check( + await waitFor( + `document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`, + "focus inside the opened drawer", + ), + "the drawer opens as a modal, with focus inside it and the page inert", +); +await evaluate( + `document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`, +); +check( + await waitFor( + `!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`, + "the drawer to close on Escape", + ), + "Escape closes the drawer and returns focus to its trigger", +); +if (openedModuleId) { + await evaluate(`${selector("mobile-navigation-trigger")}.click()`); + await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer"); + await evaluate(`${selector(`select-${openedModuleId}`)}.click()`); + check( + await waitFor( + `location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`, + "the module chosen from the drawer to open", + ), + `choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`, + ); +} +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 1, + height: 1080, + mobile: false, + width: 1920, +}); +check( + await waitFor( + `${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${ + openedModuleId + ? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"` + : "" + }`, + "the sidebar after widening the window", + ), + "widening the window brings the sidebar back on the same page", +); + +// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar --------------- +await navigate(`${dashboardOrigin}/`); +await waitFor(selector("host-account-menu"), "the account control"); +await evaluate(`${selector("host-account-menu")}.click()`); +await waitFor(selector("host-logout"), "the sign-out item in the account menu"); await evaluate(`${selector("host-logout")}.click()`); check( await waitFor(selector("login-email"), "the sign-in form after sign-out"), diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json index dd54175..38d3bcd 100644 --- a/surfaces/shell/package.json +++ b/surfaces/shell/package.json @@ -37,6 +37,7 @@ "@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/ui": "workspace:*", + "lucide-react": "1.48.0", "react": "19.2.8", "react-dom": "19.2.8" }, diff --git a/surfaces/shell/src/browser/AccountMenu.tsx b/surfaces/shell/src/browser/AccountMenu.tsx new file mode 100644 index 0000000..e8c00c6 --- /dev/null +++ b/surfaces/shell/src/browser/AccountMenu.tsx @@ -0,0 +1,187 @@ +import { + type KeyboardEvent, + type ReactElement, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { useNavTooltip } from "./NavTooltip.js"; +import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js"; +import { ShellIcon, type ShellIconName } from "./shell-icons.js"; + +export interface AccountMenuProps { + readonly presentation: NavigationPresentation; + readonly onSignOut: () => void; +} + +interface AccountAction { + readonly id: string; + readonly label: string; + readonly icon: ShellIconName; + readonly testId: string; + readonly run: () => void; +} + +/** + * The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5). + * + * A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the + * trigger says it opens a menu and whether it is open; opening moves focus to the first item (or + * the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and + * returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it + * went. + * + * It offers **only actions that work today** — Sign out. Profile, personal preferences, and + * appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there + * is no dead link to `/account`. The shell knows the person only by an opaque user id + * (`/api/v1/principal`), which is never shown, so the control names the area rather than the person. + */ +export function AccountMenu(props: AccountMenuProps): ReactElement { + const [open, setOpen] = useState(false); + const [initialFocus, setInitialFocus] = useState<"first" | "last">("first"); + const wrapperRef = useRef(null); + const triggerRef = useRef(null); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const menuId = useId(); + const triggerId = useId(); + const { collapsed } = props.presentation; + const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open); + + const close = useCallback((returnFocus: boolean) => { + setOpen(false); + if (returnFocus) { + triggerRef.current?.focus(); + } + }, []); + + const actions: readonly AccountAction[] = [ + { + icon: "signOut", + id: "sign-out", + label: "Sign out", + run: () => { + setOpen(false); + props.onSignOut(); + }, + testId: "host-logout", + }, + ]; + + const openMenu = (focus: "first" | "last"): void => { + setInitialFocus(focus); + setOpen(true); + }; + + useEffect(() => { + if (!open) { + return; + } + const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null); + (initialFocus === "last" ? items.at(-1) : items[0])?.focus(); + }, [initialFocus, open]); + + // A press anywhere outside the menu and its trigger dismisses it, leaving focus alone. + useEffect(() => { + const owner = wrapperRef.current?.ownerDocument; + if (!open || !owner) { + return; + } + const dismiss = (event: Event): void => { + if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) { + setOpen(false); + } + }; + owner.addEventListener("pointerdown", dismiss); + return () => owner.removeEventListener("pointerdown", dismiss); + }, [open]); + + const onMenuKeyDown = (event: KeyboardEvent): void => { + const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null); + const index = items.indexOf(event.target as HTMLButtonElement); + const focusAt = (next: number): void => { + event.preventDefault(); + items[(next + items.length) % items.length]?.focus(); + }; + if (event.key === "Escape") { + // Only the menu closes: a drawer around it stays open. + event.preventDefault(); + event.stopPropagation(); + close(true); + } else if (event.key === "ArrowDown") { + focusAt(index + 1); + } else if (event.key === "ArrowUp") { + focusAt(index - 1); + } else if (event.key === "Home") { + focusAt(0); + } else if (event.key === "End") { + focusAt(items.length - 1); + } else if (event.key === "Tab") { + setOpen(false); + } + }; + + return ( +
+ {open ? ( + + ) : undefined} + + {tooltip} +
+ ); +} diff --git a/surfaces/shell/src/browser/DashboardLink.tsx b/surfaces/shell/src/browser/DashboardLink.tsx index 67489db..c251af6 100644 --- a/surfaces/shell/src/browser/DashboardLink.tsx +++ b/surfaces/shell/src/browser/DashboardLink.tsx @@ -1,5 +1,13 @@ import { designToken } from "@ekklesia/design-tokens"; -import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react"; +import type { + CSSProperties, + FocusEventHandler, + KeyboardEventHandler, + MouseEvent, + MouseEventHandler, + ReactElement, + ReactNode, +} from "react"; import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js"; import type { HostHistory } from "./host-history.js"; @@ -10,6 +18,16 @@ export interface DashboardLinkProps { /** Marks the link as the page currently open, for assistive technology and visually. */ readonly current?: boolean; readonly variant?: "navigation" | "action"; + /** Called after an in-place navigation, including to the page already open — for a drawer that + * closes once a destination is chosen. Not called for a click the browser handles itself. */ + readonly onNavigate?: () => void; + readonly className?: string; + readonly style?: CSSProperties; + readonly onBlur?: FocusEventHandler; + readonly onFocus?: FocusEventHandler; + readonly onKeyDown?: KeyboardEventHandler; + readonly onMouseEnter?: MouseEventHandler; + readonly onMouseLeave?: MouseEventHandler; readonly "data-testid"?: string; } @@ -55,26 +73,34 @@ function isBrowserHandledClick(event: MouseEvent): boolean { export function DashboardLink(props: DashboardLinkProps): ReactElement { const href = dashboardPath(props.to); const variant = props.variant ?? "navigation"; + const baseStyle = + variant === "action" + ? { textDecoration: "none" } + : props.current + ? currentStyle + : navigationStyle; return ( { if (isBrowserHandledClick(event)) { return; } event.preventDefault(); props.history.push(href); + props.onNavigate?.(); }} - style={ - variant === "action" - ? { textDecoration: "none" } - : props.current - ? currentStyle - : navigationStyle - } + onFocus={props.onFocus} + onKeyDown={props.onKeyDown} + onMouseEnter={props.onMouseEnter} + onMouseLeave={props.onMouseLeave} + style={props.style ? { ...baseStyle, ...props.style } : baseStyle} > {props.children} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 3981e47..92a8fe2 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -29,14 +29,15 @@ import { HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; -import { DashboardLink } from "./DashboardLink.js"; -import { DevicePanel } from "./DevicePanel.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import type { HostHistory } from "./host-history.js"; +import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js"; import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js"; +import { ShellLayout } from "./ShellLayout.js"; +import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js"; import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js"; type AuthenticatedPrincipal = Extract< @@ -122,77 +123,26 @@ export interface HostAppProps { /** Names the browser tab after the open page. Supplied by the entry point, which owns `document`; * left unset, the shell names nothing. */ readonly setDocumentTitle?: (title: string) => void; + /** + * Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media + * query; left unset, the wide layout. Must be a stable reference. + */ + readonly viewport?: HostViewport; + /** + * Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point + * passes this browser's storage; left unset, the mode lasts only as long as this component. + */ + readonly sidebarPreference?: SidebarPreference; /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ readonly importModule?: ModuleUiImporter; } /** - * The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal - * colours (ADR 0039). Nothing here is a brand decision any more: changing - * `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module - * artifact together, because custom properties inherit through the artifacts' isolated roots. + * The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens + * (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058). */ const shellStyles = { actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" }, - aside: { - background: designToken("background.canvas"), - borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`, - boxSizing: "border-box", - minWidth: "15rem", - overflowY: "auto", - padding: designToken("spacing.xl"), - width: "15rem", - }, - brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") }, - brandMark: { - background: designToken("accent.base"), - borderRadius: designToken("radius.md"), - boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`, - display: "block", - height: designToken("spacing.xl"), - width: designToken("spacing.xl"), - }, - groupLabel: { - color: designToken("text.muted"), - fontSize: designToken("typography.size-xs"), - letterSpacing: "0.05em", - margin: `0 0 ${designToken("spacing.xs")}`, - textTransform: "uppercase", - }, - header: { - alignItems: "center", - background: designToken("background.surface"), - borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`, - display: "flex", - fontWeight: designToken("typography.weight-bold"), - gridColumn: "1 / -1", - justifyContent: "space-between", - padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`, - }, - heading: { - fontSize: designToken("typography.size-lg"), - fontWeight: designToken("typography.weight-bold"), - margin: 0, - }, - layout: { - background: designToken("background.page"), - color: designToken("text.primary"), - display: "grid", - fontFamily: designToken("typography.family-sans"), - gridTemplateColumns: "15rem minmax(0, 1fr)", - gridTemplateRows: "auto minmax(0, 1fr)", - minHeight: "100vh", - minWidth: 0, - width: "100%", - }, - main: { - boxSizing: "border-box", - minWidth: 0, - // A programmatic focus target after a page change (never in the tab order), not a control. - outline: "none", - overflowX: "auto", - padding: designToken("spacing.xl"), - }, message: { color: designToken("text.secondary"), display: "grid", @@ -217,24 +167,6 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry ]; } -function ModuleLink(props: { - readonly entry: DashboardNavigationEntry; - readonly history: HostHistory; - readonly route: DashboardRoute; -}): ReactElement { - const moduleId = props.entry.descriptor.id; - return ( - - {props.entry.name} - - ); -} - /** A stable identity for "which page is showing", so a non-canonical spelling of the same page is * not a page change. */ function routeKey(route: DashboardRoute): string { @@ -310,6 +242,11 @@ export function HostApp(props: HostAppProps): ReactElement { const mainRef = useRef(null); const shownRouteRef = useRef(undefined); const { config, history, importModule, setDocumentTitle } = props; + // Fallbacks for a composition that supplies neither; created once, so they stay stable. + const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined)); + const [fallbackPreference] = useState(() => createMemorySidebarPreference()); + const viewport = props.viewport ?? fallbackViewport; + const sidebarPreference = props.sidebarPreference ?? fallbackPreference; const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); const readPathname = useCallback(() => history.pathname(), [history]); const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); @@ -619,88 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement { } const { bootstrap } = state; - const entries = allEntries(bootstrap); - const { groups, ungrouped } = bootstrap.navigation; return ( -
-
-
-
- -
- -
- {route.kind === "overview" ? ( - - ) : route.kind === "module" ? ( - openEntry ? ( - - ) : ( - - ) + void signOut()} + pageTitle={pageTitle} + route={route} + sidebarPreference={sidebarPreference} + viewport={viewport} + > + {route.kind === "overview" ? ( + + ) : route.kind === "module" ? ( + openEntry ? ( + ) : ( - - )} -
-
+ + ) + ) : ( + + )} + ); } diff --git a/surfaces/shell/src/browser/NavTooltip.tsx b/surfaces/shell/src/browser/NavTooltip.tsx new file mode 100644 index 0000000..e96f6aa --- /dev/null +++ b/surfaces/shell/src/browser/NavTooltip.tsx @@ -0,0 +1,122 @@ +import { + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + type ReactElement, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap + * onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */ +const HIDE_DELAY_MS = 150; + +interface Anchor { + readonly left: number; + readonly top: number; +} + +/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */ +export interface NavTooltipTriggerProps { + onBlur(event: FocusEvent): void; + onFocus(event: FocusEvent): void; + onKeyDown(event: KeyboardEvent): void; + onMouseEnter(event: MouseEvent): void; + onMouseLeave(event: MouseEvent): void; +} + +/** + * The visible name of an icon-only control in the collapsed sidebar (ADR 0058). + * + * The control's accessible name is its own text, kept in the DOM and visually hidden, so the + * tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the + * name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as + * well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can + * move onto it without it vanishing, and it stays until focus or the pointer leaves. + * + * Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar + * cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already + * shows every label. + * + * Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent + * consumers need it; when a module needs a tooltip, this is the shape to promote. + */ +export function useNavTooltip( + label: string, + enabled: boolean, +): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } { + const [anchor, setAnchor] = useState(undefined); + const hideTimer = useRef | undefined>(undefined); + + const cancelHide = useCallback(() => { + if (hideTimer.current !== undefined) { + clearTimeout(hideTimer.current); + hideTimer.current = undefined; + } + }, []); + + const hide = useCallback(() => { + cancelHide(); + setAnchor(undefined); + }, [cancelHide]); + + const show = useCallback( + (element: HTMLElement) => { + cancelHide(); + const rect = element.getBoundingClientRect(); + setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 }); + }, + [cancelHide], + ); + + useEffect(() => { + if (!enabled) { + hide(); + } + }, [enabled, hide]); + + useEffect(() => cancelHide, [cancelHide]); + + const triggerProps: NavTooltipTriggerProps = { + onBlur: hide, + onFocus: (event) => { + if (enabled) { + show(event.currentTarget); + } + }, + onKeyDown: (event) => { + if (event.key === "Escape" && anchor !== undefined) { + // Only this tooltip is dismissed; the event goes no further. + event.stopPropagation(); + hide(); + } + }, + onMouseEnter: (event) => { + if (enabled) { + show(event.currentTarget); + } + }, + onMouseLeave: () => { + cancelHide(); + hideTimer.current = setTimeout(hide, HIDE_DELAY_MS); + }, + }; + + const tooltip = + enabled && anchor !== undefined ? ( + + ) : null; + + return { tooltip, triggerProps }; +} diff --git a/surfaces/shell/src/browser/ShellLayout.tsx b/surfaces/shell/src/browser/ShellLayout.tsx new file mode 100644 index 0000000..12c0ea6 --- /dev/null +++ b/surfaces/shell/src/browser/ShellLayout.tsx @@ -0,0 +1,378 @@ +import { designToken } from "@ekklesia/design-tokens"; +import { + type KeyboardEvent, + type ReactElement, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { DashboardRoute } from "../dashboard-routes.js"; +import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js"; +import { AccountMenu } from "./AccountMenu.js"; +import { DevicePanel } from "./DevicePanel.js"; +import type { HostHistory } from "./host-history.js"; +import type { HostViewport } from "./host-viewport.js"; +import { useNavTooltip } from "./NavTooltip.js"; +import { + controlHeight, + NavigationList, + type NavigationPresentation, + ShellBrand, +} from "./ShellNavigation.js"; +import { ShellIcon } from "./shell-icons.js"; +import { SHELL_STYLESHEET } from "./shell-styles.js"; +import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js"; + +const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation"; +const DRAWER_ID = "ekklesia-navigation-drawer"; +const DRAWER_NAV_ID = "ekklesia-drawer-navigation"; +const SIDEBAR_WIDTH: Record = { collapsed: "4.5rem", expanded: "16rem" }; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export interface ShellLayoutProps { + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly navigation: DashboardNavigation; + readonly route: DashboardRoute; + readonly viewport: HostViewport; + readonly sidebarPreference: SidebarPreference; + readonly mainRef: RefObject; + readonly pageTitle: string | undefined; + readonly onSignOut: () => void; + readonly children: ReactNode; +} + +function SidebarToggle(props: { + readonly mode: SidebarMode; + readonly onToggle: () => void; +}): ReactElement { + const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation"; + const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed"); + return ( + <> + + {tooltip} + + ); +} + +/** + * The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the + * account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip. + */ +function Sidebar(props: { + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly navigation: DashboardNavigation; + readonly route: DashboardRoute; + readonly mode: SidebarMode; + readonly onToggle: () => void; + readonly onSignOut: () => void; +}): ReactElement { + const presentation: NavigationPresentation = { + collapsed: props.mode === "collapsed", + compact: false, + }; + return ( + + ); +} + +/** + * The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the + * sidebar, in a modal drawer. + * + * It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and + * Shift+Tab kept inside it here as well, 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 the + * layout returns focus to the trigger. + */ +function NavigationDrawer(props: { + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly navigation: DashboardNavigation; + readonly route: DashboardRoute; + readonly onDismiss: () => void; + readonly onNavigate: () => void; + readonly onSignOut: () => void; +}): ReactElement { + const panelRef = useRef(null); + const closeRef = useRef(null); + const presentation: NavigationPresentation = { collapsed: false, compact: true }; + + useEffect(() => { + closeRef.current?.focus(); + }, []); + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + props.onDismiss(); + return; + } + if (event.key !== "Tab" || !panelRef.current) { + return; + } + const focusable = [...panelRef.current.querySelectorAll(FOCUSABLE)]; + const first = focusable[0]; + const last = focusable.at(-1); + const active = panelRef.current.ownerDocument.activeElement; + if (event.shiftKey && (active === first || !panelRef.current.contains(active))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) { + event.preventDefault(); + first?.focus(); + } + }; + + return ( + <> +