feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058) #104

Merged
Zayin_Admin merged 2 commits from feat/dashboard-shell-navigation into main 2026-10-14 23:05:29 +00:00
27 changed files with 2607 additions and 195 deletions
+12 -2
View File
@@ -916,8 +916,18 @@ each installed module's `/<module-id>`; any other top-level path is its sanitize
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and 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 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 *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 sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
link came from are later Stage 1 work. 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 ## Quick start
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
}); });
it("never puts a reset token in browser storage", async () => { it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for // 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 life of one form and nowhere else. The shell's one use of browser storage is the desktop
expect(code, file.path).not.toContain("localStorage"); // 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);
expect(code, file.path).not.toContain("sessionStorage"); expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie"); 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);
} }
}); });
}); });
@@ -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`.
+1
View File
@@ -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 - [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 - [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 - [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
+4 -2
View File
@@ -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 **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 Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed; see landing page have since landed, see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment [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 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. 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 Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
+3 -1
View File
@@ -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?**, because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038). 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. 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 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 loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
+4 -1
View File
@@ -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 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 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, 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 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: injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
+11
View File
@@ -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* own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface. 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 ### Your module's display name
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees `name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
+12
View File
@@ -407,6 +407,9 @@ importers:
'@ekklesia/ui': '@ekklesia/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
lucide-react:
specifier: 1.48.0
version: 1.48.0([email protected])
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@@ -2401,6 +2404,11 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]: [email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4369,6 +4377,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
[email protected]([email protected]):
dependencies:
react: 19.2.8
[email protected]: [email protected]:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
+69 -2
View File
@@ -191,8 +191,10 @@ check(
const moduleButton = const moduleButton =
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')'; 'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
let openedModuleId;
if (hasModule) { if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
openedModuleId = moduleId;
await evaluate(`${moduleButton}.click()`); await evaluate(`${moduleButton}.click()`);
const mounted = await waitFor( const mounted = await waitFor(
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`, `${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 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()`); await evaluate(`${selector("host-logout")}.click()`);
check( check(
await waitFor(selector("login-email"), "the sign-in form after sign-out"), await waitFor(selector("login-email"), "the sign-in form after sign-out"),
+1
View File
@@ -37,6 +37,7 @@
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*", "@ekklesia/ui": "workspace:*",
"lucide-react": "1.48.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
+187
View File
@@ -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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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<HTMLDivElement>): 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 (
<div ref={wrapperRef}>
{open ? (
<div
aria-labelledby={triggerId}
className="ek-shell-menu"
data-testid="host-account-menu-list"
id={menuId}
onKeyDown={onMenuKeyDown}
role="menu"
>
{actions.map((action, index) => (
<button
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
data-testid={action.testId}
key={action.id}
onClick={action.run}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
type="button"
>
<ShellIcon name={action.icon} />
{action.label}
</button>
))}
</div>
) : undefined}
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="menu"
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
data-testid="host-account-menu"
id={triggerId}
onClick={() => (open ? close(false) : openMenu("first"))}
ref={triggerRef}
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
type="button"
{...triggerProps}
onKeyDown={(event) => {
triggerProps.onKeyDown(event);
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}}
>
<ShellIcon name="account" />
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
Your account
</span>
</button>
{tooltip}
</div>
);
}
+35 -9
View File
@@ -1,5 +1,13 @@
import { designToken } from "@ekklesia/design-tokens"; 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 DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.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. */ /** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean; readonly current?: boolean;
readonly variant?: "navigation" | "action"; 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<HTMLAnchorElement>;
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string; readonly "data-testid"?: string;
} }
@@ -55,26 +73,34 @@ function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
export function DashboardLink(props: DashboardLinkProps): ReactElement { export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to); const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation"; const variant = props.variant ?? "navigation";
const baseStyle =
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle;
return ( return (
<a <a
aria-current={props.current ? "page" : undefined} aria-current={props.current ? "page" : undefined}
className={variantClass[variant]} className={
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
href={href} href={href}
onBlur={props.onBlur}
onClick={(event) => { onClick={(event) => {
if (isBrowserHandledClick(event)) { if (isBrowserHandledClick(event)) {
return; return;
} }
event.preventDefault(); event.preventDefault();
props.history.push(href); props.history.push(href);
props.onNavigate?.();
}} }}
style={ onFocus={props.onFocus}
variant === "action" onKeyDown={props.onKeyDown}
? { textDecoration: "none" } onMouseEnter={props.onMouseEnter}
: props.current onMouseLeave={props.onMouseLeave}
? currentStyle style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
: navigationStyle
}
> >
{props.children} {props.children}
</a> </a>
+30 -144
View File
@@ -29,14 +29,15 @@ import {
HostProtocolError, HostProtocolError,
loadHostPrincipal, loadHostPrincipal,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js"; import type { HostHistory } from "./host-history.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.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"; import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract< 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`; /** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */ * left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void; 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. */ /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter; readonly importModule?: ModuleUiImporter;
} }
/** /**
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal * The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
* colours (ADR 0039). Nothing here is a brand decision any more: changing * (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
* `--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.
*/ */
const shellStyles = { const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" }, 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: { message: {
color: designToken("text.secondary"), color: designToken("text.secondary"),
display: "grid", 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 (
<DashboardLink
current={props.route.kind === "module" && props.route.moduleId === moduleId}
data-testid={`select-${moduleId}`}
history={props.history}
to={{ kind: "module", moduleId }}
>
{props.entry.name}
</DashboardLink>
);
}
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is /** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */ * not a page change. */
function routeKey(route: DashboardRoute): string { function routeKey(route: DashboardRoute): string {
@@ -310,6 +242,11 @@ export function HostApp(props: HostAppProps): ReactElement {
const mainRef = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined); const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props; 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 subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]); const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
@@ -619,69 +556,19 @@ export function HostApp(props: HostAppProps): ReactElement {
} }
const { bootstrap } = state; const { bootstrap } = state;
const entries = allEntries(bootstrap);
const { groups, ungrouped } = bootstrap.navigation;
return ( return (
<div data-testid="host-shell" style={shellStyles.layout}> <ShellLayout
<header style={shellStyles.header}> config={config}
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
</div>
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
Sign out
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history} history={history}
to={{ kind: "overview" }} mainRef={mainRef}
navigation={bootstrap.navigation}
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
sidebarPreference={sidebarPreference}
viewport={viewport}
> >
Overview
</DashboardLink>
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
) : undefined}
</nav>
</aside>
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{route.kind === "overview" ? ( {route.kind === "overview" ? (
<OverviewPage /> <OverviewPage />
) : route.kind === "module" ? ( ) : route.kind === "module" ? (
@@ -700,7 +587,6 @@ export function HostApp(props: HostAppProps): ReactElement {
) : ( ) : (
<NotFoundPage history={history} /> <NotFoundPage history={history} />
)} )}
</main> </ShellLayout>
</div>
); );
} }
+122
View File
@@ -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<HTMLElement>): void;
onFocus(event: FocusEvent<HTMLElement>): void;
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
onMouseEnter(event: MouseEvent<HTMLElement>): void;
onMouseLeave(event: MouseEvent<HTMLElement>): 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<Anchor | undefined>(undefined);
const hideTimer = useRef<ReturnType<typeof setTimeout> | 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 ? (
<span
aria-hidden="true"
className="ek-shell-tooltip"
data-testid="nav-tooltip"
onMouseEnter={cancelHide}
onMouseLeave={hide}
style={{ left: anchor.left, top: anchor.top }}
>
{label}
</span>
) : null;
return { tooltip, triggerProps };
}
+378
View File
@@ -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<SidebarMode, string> = { 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<HTMLElement | null>;
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 (
<>
<button
aria-controls={SIDEBAR_NAV_ID}
aria-expanded={props.mode === "expanded"}
aria-label={label}
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="sidebar-toggle"
onClick={props.onToggle}
style={{ minHeight: controlHeight({ collapsed: false, compact: false }) }}
type="button"
{...triggerProps}
>
<ShellIcon name={props.mode === "expanded" ? "collapseSidebar" : "expandSidebar"} />
</button>
{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 (
<aside
className="ek-shell-sidebar"
data-sidebar={props.mode}
data-testid="host-sidebar"
style={{ width: SIDEBAR_WIDTH[props.mode] }}
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={presentation.collapsed} />
<SidebarToggle mode={props.mode} onToggle={props.onToggle} />
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={SIDEBAR_NAV_ID}
navigation={props.navigation}
presentation={presentation}
route={props.route}
/>
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</aside>
);
}
/**
* 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<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const presentation: NavigationPresentation = { collapsed: false, compact: true };
useEffect(() => {
closeRef.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === "Escape") {
event.preventDefault();
props.onDismiss();
return;
}
if (event.key !== "Tab" || !panelRef.current) {
return;
}
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(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 (
<>
<div
aria-hidden="true"
className="ek-shell-drawer-backdrop"
data-testid="navigation-drawer-backdrop"
onClick={props.onDismiss}
/>
<div
aria-label="Navigation"
aria-modal="true"
className="ek-shell-drawer"
data-testid="navigation-drawer"
id={DRAWER_ID}
onKeyDown={onKeyDown}
ref={panelRef}
role="dialog"
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={false} />
<button
aria-label="Close navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="navigation-drawer-close"
onClick={props.onDismiss}
ref={closeRef}
style={{
minHeight: controlHeight(presentation),
minWidth: controlHeight(presentation),
}}
type="button"
>
<ShellIcon name="closeNavigation" />
</button>
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={DRAWER_NAV_ID}
navigation={props.navigation}
onNavigate={props.onNavigate}
presentation={presentation}
route={props.route}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</div>
</>
);
}
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
/**
* The authenticated Dashboard's frame (ADR 0058): a collapsible sidebar on wide screens, a drawer
* on phones, and the page.
*
* The page's `<main>` keeps one position in the tree whichever layout is showing, so changing
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
* There is no application top bar: on a phone the one control above the page is the menu trigger.
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
const subscribe = useCallback((listener: () => void) => viewport.subscribe(listener), [viewport]);
const readCompact = useCallback(() => viewport.isCompact(), [viewport]);
const compact = useSyncExternalStore(subscribe, readCompact, readCompact);
const [mode, setMode] = useState<SidebarMode>(() => sidebarPreference.read() ?? "expanded");
const [drawerOpen, setDrawerOpen] = useState(false);
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const previousCompact = useRef(compact);
const previousRoute = useRef(routeKey(route));
const toggleMode = (): void => {
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
setMode(next);
sidebarPreference.write(next);
};
const closeDrawer = useCallback((focus: "trigger" | "main") => {
pendingFocus.current = focus;
setDrawerOpen(false);
}, []);
// A layout change drops an open drawer. Focus inside a drawer or sidebar that just went away
// would otherwise fall to the document; it lands on the page instead.
useLayoutEffect(() => {
if (previousCompact.current === compact) {
return;
}
previousCompact.current = compact;
if (drawerOpen) {
closeDrawer("main");
return;
}
const owner = mainRef.current?.ownerDocument;
if (owner && (owner.activeElement === null || owner.activeElement === owner.body)) {
mainRef.current?.focus();
}
}, [closeDrawer, compact, drawerOpen, mainRef]);
// Back and Forward close the drawer too; the shell moves focus to the new page itself.
useEffect(() => {
const key = routeKey(route);
if (previousRoute.current !== key) {
previousRoute.current = key;
setDrawerOpen(false);
}
}, [route]);
useEffect(() => {
if (drawerOpen || pendingFocus.current === undefined) {
return;
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
const pageInert = showDrawer ? true : undefined;
return (
<div
data-layout={compact ? "compact" : "wide"}
data-testid="host-shell"
style={{
display: compact ? "block" : "grid",
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
}}
>
<style>{SHELL_STYLESHEET}</style>
{compact ? undefined : (
<Sidebar
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
onSignOut={props.onSignOut}
onToggle={toggleMode}
route={route}
/>
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
aria-label="Open navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="mobile-navigation-trigger"
onClick={() => setDrawerOpen(true)}
ref={triggerRef}
style={{
minHeight: controlHeight({ collapsed: false, compact: true }),
minWidth: controlHeight({ collapsed: false, compact: true }),
}}
type="button"
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
) : undefined}
<main
aria-label={props.pageTitle}
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
style={{
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
{props.children}
</main>
</div>
{showDrawer ? (
<NavigationDrawer
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,208 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
*/
export interface NavigationPresentation {
readonly collapsed: boolean;
readonly compact: boolean;
}
/** The control height for this presentation: the medium control on desktop, the large one in the
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
export function controlHeight(presentation: NavigationPresentation): string {
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
}
/**
* The identity slot at the top of the navigation (ADR 0058).
*
* A neutral placeholder — a plain mark and the product's name — until the installation's own
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
* branding work owns the decision of what goes here and where it comes from, and replaces this
* component's body rather than undoing a configuration model invented early. It is not a heading;
* each page's own title is. Collapsed, only the mark shows.
*/
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
return (
<div className="ek-shell-brand" data-testid="shell-brand">
<span aria-hidden="true" className="ek-shell-brand__mark" />
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
</div>
);
}
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
readonly icon: ShellIconName;
readonly current: boolean;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly testId: string;
}): ReactElement {
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
style={style}
to={props.destination}
{...triggerProps}
>
<ShellIcon name={props.icon} />
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
</DashboardLink>
{tooltip}
</li>
);
}
function ModuleItems(props: {
readonly entries: readonly DashboardNavigationEntry[];
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
return (
<ul
aria-labelledby={props.labelledBy}
className="ek-shell-nav__group"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
return (
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", moduleId }}
history={props.history}
// Every module gets the one generic icon until modules can declare their own
// (ADR 0058); there is no table of known modules.
icon="module"
key={`${moduleId}@${entry.descriptor.version}`}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
testId={`select-${moduleId}`}
/>
);
})}
</ul>
);
}
export interface NavigationListProps {
readonly id: string;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
* server's navigation does not list it (ADR 0027, ADR 0028).
*
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
* visually.
*/
export function NavigationList(props: NavigationListProps): ReactElement {
const { groups, ungrouped } = props.navigation;
const { collapsed } = props.presentation;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const empty = groups.length === 0 && ungrouped.length === 0;
return (
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<NavigationItem
current={props.route.kind === "overview"}
destination={{ kind: "overview" }}
history={props.history}
icon="overview"
label="Overview"
{...onNavigate}
presentation={props.presentation}
testId="select-overview"
/>
</ul>
{empty && !collapsed ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => {
const labelId = `${props.id}-group-${group.id}`;
return (
<section data-testid={`group-${group.id}`} key={group.id}>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={labelId}
>
{group.label}
</h2>
<ModuleItems
entries={group.modules}
history={props.history}
labelledBy={labelId}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
);
})}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? (
<>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={`${props.id}-group-other`}
>
Other
</h2>
</>
) : undefined}
<ModuleItems
entries={ungrouped}
history={props.history}
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
) : undefined}
</nav>
);
}
@@ -0,0 +1,63 @@
/**
* Whether the Dashboard is laid out for a phone (ADR 0058).
*
* Below the breakpoint the sidebar becomes a drawer with the same navigation; at and above it the
* collapsible sidebar is shown, tablets included (DESIGN.md §4.6–4.7). The shell reads the answer
* through this interface rather than from `window.matchMedia`, so the entry point stays the only
* file that touches a browser global and every responsive behaviour is testable by resizing an
* in-memory viewport.
*/
export interface HostViewport {
isCompact(): boolean;
subscribe(listener: () => void): () => void;
}
/** `em` in a media query is the browser's initial font size, so the breakpoint follows a person's
* own text-size setting: 48em is 768px at the default 16px. */
export const HOST_COMPACT_MEDIA_QUERY = "(max-width: 47.999em)";
type MediaQueryListLike = Pick<MediaQueryList, "matches"> & {
addEventListener(type: "change", listener: () => void): void;
removeEventListener(type: "change", listener: () => void): void;
};
/** The browser's media query, handed in by the entry point. Without one, the wide layout. */
export function createBrowserHostViewport(
matchMedia: ((query: string) => MediaQueryListLike) | undefined,
): HostViewport {
const mediaQuery = matchMedia?.(HOST_COMPACT_MEDIA_QUERY);
return {
isCompact: () => mediaQuery?.matches ?? false,
subscribe(listener) {
mediaQuery?.addEventListener("change", listener);
return () => mediaQuery?.removeEventListener("change", listener);
},
};
}
export interface MemoryHostViewport extends HostViewport {
setCompact(compact: boolean): void;
}
export function createMemoryHostViewport(compact = false): MemoryHostViewport {
let current = compact;
const listeners = new Set<() => void>();
return {
isCompact: () => current,
setCompact(next) {
if (next === current) {
return;
}
current = next;
for (const listener of [...listeners]) {
listener();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+27 -1
View File
@@ -5,6 +5,8 @@ import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js"; import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js"; import { createBrowserHostHistory } from "./host-history.js";
import { createBrowserHostViewport } from "./host-viewport.js";
import { createLocalSidebarPreference } from "./sidebar-preference.js";
/** /**
* The one place the shell touches a browser global. * The one place the shell touches a browser global.
@@ -31,6 +33,22 @@ const setDocumentTitle = (title: string): void => {
document.title = title; document.title = title;
}; };
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
* mode for this page only. */
const viewport = createBrowserHostViewport(
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
);
const sidebarPreference = createLocalSidebarPreference(
(() => {
try {
return window.localStorage;
} catch {
return undefined;
}
})(),
);
const container = document.getElementById("ekklesia-host"); const container = document.getElementById("ekklesia-host");
if (!container) { if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element."); throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -66,9 +84,17 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
config={config} config={config}
history={history} history={history}
setDocumentTitle={setDocumentTitle} setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/> />
) : ( ) : (
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} /> <HostApp
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
); );
} }
@@ -0,0 +1,58 @@
import {
Blocks,
CircleUserRound,
LayoutDashboard,
type LucideIcon,
LogOut,
Menu,
PanelLeftClose,
PanelLeftOpen,
X,
} from "lucide-react";
import type { ReactElement } from "react";
/**
* Every icon the shell uses, imported from Lucide in this one file (ADR 0058).
*
* Lucide is Ekklesia's icon vocabulary. Importing each icon by name keeps the bundle to exactly the
* icons named here, and keeping them in one place keeps the vocabulary reviewable: an icon is chosen
* for a meaning, once, rather than per call site.
*
* There is deliberately **no per-module entry**. Every module is drawn with the one generic
* {@link SHELL_ICONS.module} until modules can declare their own icon through the module navigation
* contract; a table of known module ids here would be a second, host-owned answer to a question the
* module should own.
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
overview: LayoutDashboard,
signOut: LogOut,
} satisfies Record<string, LucideIcon>);
export type ShellIconName = keyof typeof SHELL_ICONS;
/**
* A decorative icon. Always hidden from assistive technology: every control that shows one also
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
* the surrounding text colour, so icons take their colour from the design tokens like text does.
*/
export function ShellIcon(props: {
readonly name: ShellIconName;
readonly size?: number;
}): ReactElement {
const Icon = SHELL_ICONS[props.name];
return (
<Icon
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
strokeWidth={1.75}
/>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { designToken } from "@ekklesia/design-tokens";
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
* and drawer.
*
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
* into the shell's own document only: each module artifact still mounts in its own isolated root
* (ADR 0040), which this stylesheet cannot reach.
*/
export const SHELL_STYLESHEET = `
.ek-shell-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
position: sticky;
top: 0;
transition: width 160ms ease;
}
.ek-shell-sidebar__top {
align-items: center;
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: space-between;
min-height: ${designToken("control.height-md")};
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top {
flex-direction: column;
}
.ek-shell-sidebar__scroll {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: ${designToken("spacing.xl")};
min-height: 0;
overflow-y: auto;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
position: relative;
}
.ek-shell-brand {
align-items: center;
color: ${designToken("text.primary")};
display: flex;
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-bold")};
gap: ${designToken("spacing.sm")};
min-width: 0;
}
.ek-shell-brand__mark {
background: ${designToken("accent.base")};
border-radius: ${designToken("radius.md")};
box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")};
display: block;
flex: 0 0 auto;
height: ${designToken("spacing.xl")};
width: ${designToken("spacing.xl")};
}
.ek-shell-nav,
.ek-shell-nav__group {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-nav {
gap: ${designToken("spacing.lg")};
}
.ek-shell-nav__group-label {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
letter-spacing: 0.05em;
margin: 0 0 ${designToken("spacing.2xs")};
padding-inline: ${designToken("control.padding-inline")};
text-transform: uppercase;
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
height: auto;
justify-content: flex-start;
text-align: start;
}
.ek-shell-nav-item svg {
flex: 0 0 auto;
}
.ek-shell-nav-item__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
justify-content: center;
padding-inline: 0;
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.sm")};
box-shadow: ${designToken("shadow.md")};
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
position: fixed;
transform: translateY(-50%);
white-space: nowrap;
z-index: 30;
}
.ek-shell-menu {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
bottom: calc(100% + ${designToken("spacing.xs")});
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.2xs")};
left: 0;
margin: 0;
min-width: 12rem;
padding: ${designToken("spacing.xs")};
position: absolute;
z-index: 20;
}
.ek-shell-menu__caption {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
margin: 0;
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
}
.ek-shell-menu__item {
justify-content: flex-start;
width: 100%;
}
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
}
.ek-shell-drawer-backdrop {
background: ${designToken("background.overlay")};
inset: 0;
position: fixed;
z-index: 40;
}
.ek-shell-drawer {
background: ${designToken("background.canvas")};
box-shadow: ${designToken("shadow.lg")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
inset: 0 auto 0 0;
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
position: fixed;
width: 20rem;
z-index: 41;
}
@media (prefers-reduced-motion: reduce) {
.ek-shell-sidebar {
transition: none;
}
}
`;
@@ -0,0 +1,70 @@
/**
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
*
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
* writes it only through this interface, so a later store — synced, or per person — replaces the
* implementation without touching a component.
*/
export type SidebarMode = "expanded" | "collapsed";
export interface SidebarPreference {
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
read(): SidebarMode | undefined;
write(mode: SidebarMode): void;
}
/** One namespaced key. Names no person and holds nothing but the mode. */
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
function isSidebarMode(value: unknown): value is SidebarMode {
return value === "expanded" || value === "collapsed";
}
/**
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
*
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
* ignored rather than trusted.
*/
export function createLocalSidebarPreference(
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
): SidebarPreference {
let remembered: SidebarMode | undefined;
return {
read() {
if (remembered !== undefined) {
return remembered;
}
try {
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
return isSidebarMode(stored) ? stored : undefined;
} catch {
return undefined;
}
},
write(mode) {
remembered = mode;
try {
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
} catch {
// Persistence is a convenience; the in-memory mode above still holds for this page.
}
},
};
}
/** Nothing persisted: for tests, and for a host composed without browser storage. */
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
let mode = initial;
return {
read: () => mode,
write(next) {
mode = next;
},
};
}
+12 -1
View File
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostHistory } from "../src/browser/host-history.js";
import {
createLocalSidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/** /**
@@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding)
<HostApp <HostApp
config={config} config={config}
history={createMemoryHostHistory("/")} history={createMemoryHostHistory("/")}
// The production store over this browser's real storage, so the assertions below cover
// the shell's one use of it (ADR 0058) as well.
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
{...(authLanding ? { authLanding } : {})} {...(authLanding ? { authLanding } : {})}
/>, />,
); );
@@ -463,7 +470,11 @@ describe("reset password", () => {
// A reset token is a bearer credential for one account. It lives in React state for the life of // A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else. // the form and nowhere else.
expect(localStorage.length).toBe(0); // The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
for (let index = 0; index < localStorage.length; index += 1) {
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
}
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
expect(sessionStorage.length).toBe(0); expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc"); expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc"); expect(container.innerHTML).not.toContain("reset-token-abc");
+20 -9
View File
@@ -147,6 +147,13 @@ async function render(
}); });
} }
/** Sign-out lives in the account menu at the bottom of the sidebar (ADR 0058). */
async function openAccountMenu(): Promise<void> {
await act(async () => {
(query("host-account-menu") as HTMLButtonElement).click();
});
}
function text(): string { function text(): string {
return container.textContent ?? ""; return container.textContent ?? "";
} }
@@ -391,7 +398,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending; await signInPending;
await Promise.resolve(); await Promise.resolve();
}); });
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
}); });
it("clears the password while post-sign-in principal resolution is still pending", async () => { it("clears the password while post-sign-in principal resolution is still pending", async () => {
@@ -424,7 +431,7 @@ describe("Ekklesia shell — browser authentication", () => {
await principalPending; await principalPending;
await Promise.resolve(); await Promise.resolve();
}); });
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
}); });
it("submits from the password field with the Enter key", async () => { it("submits from the password field with the Enter key", async () => {
@@ -454,7 +461,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending; await signInPending;
await Promise.resolve(); await Promise.resolve();
}); });
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
}); });
it("re-resolves principal and navigation after login without a page reload", async () => { it("re-resolves principal and navigation after login without a page reload", async () => {
@@ -467,7 +474,7 @@ describe("Ekklesia shell — browser authentication", () => {
await submitLogin(); await submitLogin();
expect(query("host-login")).toBeNull(); expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce(); expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([ expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
@@ -524,7 +531,7 @@ describe("Ekklesia shell — browser authentication", () => {
await act(async () => { await act(async () => {
(retry as HTMLButtonElement).click(); (retry as HTMLButtonElement).click();
}); });
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
}); });
it("fails safely on a malformed successful principal response and recovers on retry", async () => { it("fails safely on a malformed successful principal response and recovers on retry", async () => {
@@ -555,7 +562,7 @@ describe("Ekklesia shell — browser authentication", () => {
(retry as HTMLButtonElement).click(); (retry as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve();
}); });
expect(query("host-logout")).not.toBeNull(); expect(query("host-account-menu")).not.toBeNull();
}); });
}); });
@@ -610,6 +617,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}), }),
); );
await openAccountMenu();
await act(async () => { await act(async () => {
(query("host-logout") as HTMLButtonElement).click(); (query("host-logout") as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve();
@@ -668,6 +676,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
if (!lateRequest) { if (!lateRequest) {
throw new Error("module context was not captured"); throw new Error("module context was not captured");
} }
await openAccountMenu();
await act(async () => { await act(async () => {
(query("host-logout") as HTMLButtonElement).click(); (query("host-logout") as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve();
@@ -707,6 +716,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}); });
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0")); await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await openAccountMenu();
await act(async () => { await act(async () => {
(query("host-logout") as HTMLButtonElement).click(); (query("host-logout") as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve();
@@ -1024,9 +1034,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
const aside = shell.querySelector("aside") as HTMLElement; const aside = shell.querySelector("aside") as HTMLElement;
const main = shell.querySelector("main") as HTMLElement; const main = shell.querySelector("main") as HTMLElement;
const frame = query("module-container") as HTMLElement; const frame = query("module-container") as HTMLElement;
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)"); // The sidebar sizes its own track (ADR 0058); the page track can shrink to nothing but never
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)"); // pushes the sidebar.
expect(aside.style.width).toBe("15rem"); expect(shell.style.gridTemplateColumns).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("16rem");
expect(main.style.minWidth).toBe("0"); expect(main.style.minWidth).toBe("0");
expect(main.style.overflowX).toBe("auto"); expect(main.style.overflowX).toBe("auto");
expect(frame.style.minWidth).toBe("0"); expect(frame.style.minWidth).toBe("0");
+12
View File
@@ -95,6 +95,18 @@ describe("the shell is coupled to no module", () => {
} }
}); });
it("never calls a browser-native dialog: alert, confirm, and prompt are prohibited in product UI", () => {
for (const [file, source] of sources) {
const code = source
.split("\n")
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
.join("\n");
expect(code, path.relative(packageDirectory, file)).not.toMatch(
/\b(?:window\.|globalThis\.|self\.)?(?:alert|confirm|prompt)\s*\(/u,
);
}
});
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => { it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
for (const [file, source] of sources) { for (const [file, source] of sources) {
// Comment lines are dropped so a doc comment may still *describe* the loader's own // Comment lines are dropped so a doc comment may still *describe* the loader's own
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import {
createBrowserHostViewport,
createMemoryHostViewport,
HOST_COMPACT_MEDIA_QUERY,
} from "../src/browser/host-viewport.js";
import {
createLocalSidebarPreference,
createMemorySidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
function fakeStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
}),
values,
};
}
describe("the sidebar preference (this browser only, ADR 0058)", () => {
it("survives a reload: what one store writes, a new store over the same storage reads", () => {
const storage = fakeStorage();
createLocalSidebarPreference(storage).write("collapsed");
expect(storage.values.get(SIDEBAR_PREFERENCE_KEY)).toBe("collapsed");
expect(createLocalSidebarPreference(storage).read()).toBe("collapsed");
createLocalSidebarPreference(storage).write("expanded");
expect(createLocalSidebarPreference(storage).read()).toBe("expanded");
});
it("uses one namespaced key that names no person", () => {
expect(SIDEBAR_PREFERENCE_KEY).toBe("ekklesia.dashboard.sidebar");
});
it("ignores a stored value it does not recognise rather than trusting it", () => {
for (const value of ["", "COLLAPSED", "true", "{}", "collapsed "]) {
expect(
createLocalSidebarPreference(fakeStorage({ [SIDEBAR_PREFERENCE_KEY]: value })).read(),
value,
).toBeUndefined();
}
});
it("keeps working, for this page only, when storage is missing or throws", () => {
const throwing = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
for (const storage of [undefined, throwing]) {
const preference = createLocalSidebarPreference(storage);
expect(preference.read()).toBeUndefined();
expect(() => preference.write("collapsed")).not.toThrow();
expect(preference.read()).toBe("collapsed");
}
});
it("has an in-memory store with the same contract, for tests and as the fallback", () => {
const preference = createMemorySidebarPreference("collapsed");
expect(preference.read()).toBe("collapsed");
preference.write("expanded");
expect(preference.read()).toBe("expanded");
expect(createMemorySidebarPreference().read()).toBeUndefined();
});
});
describe("the host viewport", () => {
it("is compact below 48em, the phone layout's breakpoint", () => {
expect(HOST_COMPACT_MEDIA_QUERY).toBe("(max-width: 47.999em)");
});
it("reads and follows a media query list", () => {
let matches = false;
const listeners = new Set<() => void>();
const mediaQuery = {
addEventListener: vi.fn((_type: "change", listener: () => void) => listeners.add(listener)),
get matches() {
return matches;
},
removeEventListener: vi.fn((_type: "change", listener: () => void) =>
listeners.delete(listener),
),
};
const matchMedia = vi.fn(() => mediaQuery);
const viewport = createBrowserHostViewport(matchMedia);
expect(matchMedia).toHaveBeenCalledWith(HOST_COMPACT_MEDIA_QUERY);
const listener = vi.fn();
const unsubscribe = viewport.subscribe(listener);
expect(viewport.isCompact()).toBe(false);
matches = true;
for (const notify of listeners) {
notify();
}
expect(listener).toHaveBeenCalledTimes(1);
expect(viewport.isCompact()).toBe(true);
unsubscribe();
expect(mediaQuery.removeEventListener).toHaveBeenCalledTimes(1);
});
it("falls back to the wide layout where media queries are unavailable", () => {
const viewport = createBrowserHostViewport(undefined);
expect(viewport.isCompact()).toBe(false);
expect(() => viewport.subscribe(() => undefined)()).not.toThrow();
});
it("has an in-memory viewport a test can resize", () => {
const viewport = createMemoryHostViewport(true);
const listener = vi.fn();
viewport.subscribe(listener);
viewport.setCompact(false);
expect(viewport.isCompact()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
viewport.setCompact(false);
expect(listener).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,687 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import { createMemoryHostViewport, type MemoryHostViewport } from "../src/browser/host-viewport.js";
import {
createLocalSidebarPreference,
createMemorySidebarPreference,
type SidebarPreference,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard shell's navigation (ADR 0058): the collapsible desktop sidebar, its account area,
* and the phone drawer. Every test drives the real `HostApp` with in-memory history, viewport, and
* sidebar preference, so routing, resizing, and persistence are all observable.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
window.localStorage.clear();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
// What the server's navigation returns: the eligible modules only. A disabled module (`retired`) is
// simply absent from it (ADR 0028), which is the whole of "hidden from navigation".
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
interface Platform {
readonly fetch: typeof fetch;
readonly calls: string[];
}
function platform(): Platform {
let authenticated = true;
const calls: string[] = [];
const implementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/auth/sign-out")) {
authenticated = false;
return Response.json({ success: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.endsWith("/api/v1/devices")) {
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return { calls, fetch: implementation };
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly viewport: MemoryHostViewport;
readonly preference: SidebarPreference;
readonly platform: Platform;
}
async function render(
options: {
readonly path?: string;
readonly compact?: boolean;
readonly preference?: SidebarPreference;
readonly history?: MemoryHostHistory;
readonly viewport?: MemoryHostViewport;
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(options.path ?? "/");
const viewport = options.viewport ?? createMemoryHostViewport(options.compact ?? false);
const preference = options.preference ?? createMemorySidebarPreference();
const stub = platform();
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: stub.fetch };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => artifactFor(url)}
sidebarPreference={preference}
viewport={viewport}
/>,
);
});
return { history, platform: stub, preference, viewport };
}
async function remount(rendered: Rendered, preference = rendered.preference): Promise<void> {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render({ history: rendered.history, preference, viewport: rendered.viewport });
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function click(element: Element, init: MouseEventInit = {}): Promise<MouseEvent> {
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function key(element: Element, keyName: string, init: KeyboardEventInit = {}) {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: keyName,
...init,
});
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function focus(element: HTMLElement): Promise<void> {
await act(async () => {
element.focus();
});
}
const sidebar = (): HTMLElement => required("host-sidebar");
const navLink = (id: string): HTMLElement => required(`select-${id}`);
describe("desktop sidebar", () => {
it("is expanded by default: icons and labels, Overview first, then the modules in the server's order", async () => {
await render();
expect(sidebar().dataset.sidebar).toBe("expanded");
const links = [...sidebar().querySelectorAll("nav a")];
expect(links.map((link) => link.textContent)).toEqual(["Overview", "Signage", "Calendar"]);
for (const link of links) {
const icon = link.querySelector("svg");
expect(icon?.getAttribute("aria-hidden"), link.textContent ?? "").toBe("true");
}
expect(query("nav-tooltip")).toBeNull();
});
it("draws the Overview with its own icon and every module with the one generic module icon", async () => {
await render();
expect(navLink("overview").querySelector("svg")?.dataset.icon).toBe("overview");
for (const id of ["signage", "calendar"]) {
expect(navLink(id).querySelector("svg")?.dataset.icon, id).toBe("module");
expect(navLink(id).querySelector("svg")?.classList.contains("lucide-blocks"), id).toBe(true);
}
});
it("marks the open page, the Overview or a module, with aria-current", async () => {
await render({ path: "/calendar" });
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
expect(navLink("overview").getAttribute("aria-current")).toBeNull();
await click(navLink("overview"));
expect(navLink("overview").getAttribute("aria-current")).toBe("page");
expect(navLink("calendar").getAttribute("aria-current")).toBeNull();
});
it("does not list a disabled module, whose address still says it is unavailable", async () => {
await render({ path: "/retired" });
expect(query("select-retired")).toBeNull();
expect(query("host-module-unavailable")).not.toBeNull();
expect(container.querySelector('nav [aria-current="page"]')).toBeNull();
});
it("collapses to icons only, through a labelled toggle that says what it controls", async () => {
const { preference } = await render();
const toggle = required("sidebar-toggle");
const nav = sidebar().querySelector("nav");
expect(toggle.tagName).toBe("BUTTON");
expect(toggle.getAttribute("aria-label")).toBe("Collapse navigation");
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(toggle.getAttribute("aria-controls")).toBe(nav?.id);
expect(nav?.id).toBeTruthy();
await click(toggle);
expect(sidebar().dataset.sidebar).toBe("collapsed");
expect(toggle.getAttribute("aria-label")).toBe("Expand navigation");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(preference.read()).toBe("collapsed");
// Icon-only to the eye, still named to assistive technology.
for (const id of ["overview", "signage", "calendar"]) {
const label = navLink(id).querySelector("[data-nav-label]");
expect(label?.classList.contains("ek-shell-visually-hidden"), id).toBe(true);
expect(navLink(id).textContent, id).not.toBe("");
}
await click(toggle);
expect(sidebar().dataset.sidebar).toBe("expanded");
expect(preference.read()).toBe("expanded");
});
it("keeps the chosen mode across a remount, as across a reload", async () => {
const rendered = await render();
await click(required("sidebar-toggle"));
await remount(rendered);
expect(sidebar().dataset.sidebar).toBe("collapsed");
});
it("keeps it in this browser's own storage, so a reload with a fresh page still finds it", async () => {
const rendered = await render({
preference: createLocalSidebarPreference(window.localStorage),
});
await click(required("sidebar-toggle"));
await remount(rendered, createLocalSidebarPreference(window.localStorage));
expect(sidebar().dataset.sidebar).toBe("collapsed");
});
it("names a collapsed item in a tooltip on keyboard focus and on hover, and Escape dismisses it", async () => {
await render({ preference: createMemorySidebarPreference("collapsed") });
await focus(navLink("signage"));
const tooltip = required("nav-tooltip");
expect(tooltip.textContent).toBe("Signage");
// A visual duplicate of the link's own accessible name, so it is not announced twice.
expect(tooltip.getAttribute("aria-hidden")).toBe("true");
await key(navLink("signage"), "Escape");
expect(query("nav-tooltip")).toBeNull();
// Escape hides the tooltip without moving focus.
expect(document.activeElement).toBe(navLink("signage"));
await act(async () => {
navLink("signage").blur();
});
await act(async () => {
navLink("calendar").dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
expect(required("nav-tooltip").textContent).toBe("Calendar");
});
it("shows no tooltip while expanded, where the label is already visible", async () => {
await render();
await focus(navLink("signage"));
expect(query("nav-tooltip")).toBeNull();
});
it("names the collapse toggle and the account control in tooltips when collapsed", async () => {
await render({ preference: createMemorySidebarPreference("collapsed") });
await focus(required("sidebar-toggle"));
expect(required("nav-tooltip").textContent).toBe("Expand navigation");
await focus(required("host-account-menu"));
expect(required("nav-tooltip").textContent).toBe("Your account");
});
it("is the only navigation: no application top bar", async () => {
await render();
expect(container.querySelector('[data-testid="host-shell"] > header')).toBeNull();
expect(query("mobile-navigation-trigger")).toBeNull();
// The neutral identity placeholder sits at the top of the sidebar, and is not a heading: each
// page's own title is.
const brand = required("shell-brand");
expect(sidebar().contains(brand)).toBe(true);
expect(brand.querySelector("h1, h2")).toBeNull();
});
it("orders keyboard focus top to bottom: toggle, Overview, modules, then the account control", async () => {
await render();
const focusable = [
...sidebar().querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
].map((element) => element.dataset.testid);
expect(focusable).toEqual([
"sidebar-toggle",
"select-overview",
"select-signage",
"select-calendar",
"host-account-menu",
]);
});
it("gives every navigation item a pointer and touch target of at least the medium control height", async () => {
await render();
for (const id of ["overview", "signage", "calendar"]) {
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-md)");
}
});
});
describe("the account area", () => {
it("is anchored at the bottom of the sidebar as a product-owned menu button", async () => {
await render();
const trigger = required("host-account-menu");
expect(sidebar().lastElementChild?.contains(trigger)).toBe(true);
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("Your account");
// Only the opaque user id is known to the shell, and it is never shown.
expect(container.textContent).not.toContain("user-1");
expect(query("host-account-menu-list")).toBeNull();
});
it("opens on activation with focus on its first item, and offers only actions that work today", async () => {
await render();
const trigger = required("host-account-menu");
await click(trigger);
const menu = required("host-account-menu-list");
expect(menu.getAttribute("role")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
const items = [...menu.querySelectorAll('[role="menuitem"]')];
expect(items.map((item) => item.textContent)).toEqual(["Sign out"]);
expect(document.activeElement).toBe(items[0]);
expect(menu.querySelector("a")).toBeNull();
});
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
await render();
const trigger = required("host-account-menu");
await focus(trigger);
await key(trigger, "ArrowDown");
expect(document.activeElement).toBe(required("host-logout"));
await key(required("host-logout"), "Escape");
expect(query("host-account-menu-list")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("closes when focus or a pointer leaves it, without signing anyone out", async () => {
const { platform: stub } = await render();
await click(required("host-account-menu"));
await act(async () => {
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
});
expect(query("host-account-menu-list")).toBeNull();
await click(required("host-account-menu"));
await key(required("host-logout"), "Tab");
expect(query("host-account-menu-list")).toBeNull();
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(false);
});
it("signs out from the menu, back to the sign-in form", async () => {
const { platform: stub } = await render({ path: "/calendar" });
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(query("host-login")).not.toBeNull();
expect(query("host-sidebar")).toBeNull();
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(true);
});
it("never uses a browser-native dialog", async () => {
const alert = vi.fn();
const confirm = vi.fn(() => true);
const prompt = vi.fn(() => null);
vi.stubGlobal("alert", alert);
vi.stubGlobal("confirm", confirm);
vi.stubGlobal("prompt", prompt);
try {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await key(navLink("overview"), "Escape");
await click(required("mobile-navigation-trigger"));
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(alert).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});
describe("phone navigation drawer", () => {
it("replaces the sidebar with one labelled menu trigger, and renders no navigation until opened", async () => {
await render({ compact: true });
expect(query("host-sidebar")).toBeNull();
const trigger = required("mobile-navigation-trigger");
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("aria-label")).toBe("Open navigation");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBe("ekklesia-navigation-drawer");
expect(query("navigation-drawer")).toBeNull();
expect(query("host-overview")).not.toBeNull();
});
it("opens as a modal dialog with the same navigation, focus inside, and the page inert", async () => {
await render({ compact: true, path: "/calendar" });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
const drawer = required("navigation-drawer");
expect(drawer.id).toBe("ekklesia-navigation-drawer");
expect(drawer.getAttribute("role")).toBe("dialog");
expect(drawer.getAttribute("aria-modal")).toBe("true");
expect(drawer.getAttribute("aria-label")).toBe("Navigation");
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(required("navigation-drawer-close"));
expect([...drawer.querySelectorAll("nav a")].map((link) => link.textContent)).toEqual([
"Overview",
"Signage",
"Calendar",
]);
// The same hierarchy and the same current page as on desktop, always with labels.
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
expect(drawer.querySelector(".ek-shell-visually-hidden[data-nav-label]")).toBeNull();
expect(required("host-main-region").hasAttribute("inert")).toBe(true);
expect(drawer.contains(required("host-account-menu"))).toBe(true);
});
it("closes on Escape and returns focus to the trigger", async () => {
await render({ compact: true });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
await key(navLink("signage"), "Escape");
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
});
it("closes from its close button and from the backdrop, returning focus each time", async () => {
await render({ compact: true });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
await click(required("navigation-drawer-close"));
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
await click(trigger);
await click(required("navigation-drawer-backdrop"));
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("traps Tab and Shift+Tab inside itself while open", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
const focusable = [...drawer.querySelectorAll<HTMLElement>("a[href], button:not([disabled])")];
const first = focusable[0] as HTMLElement;
const last = focusable.at(-1) as HTMLElement;
expect(first).toBe(required("navigation-drawer-close"));
expect(last).toBe(required("host-account-menu"));
await focus(last);
const forward = await key(last, "Tab");
expect(forward.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(first);
const backward = await key(first, "Tab", { shiftKey: true });
expect(backward.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(last);
// Tab between two inner items is left to the browser.
await focus(navLink("overview"));
expect((await key(navLink("overview"), "Tab")).defaultPrevented).toBe(false);
});
it("closes when a destination is chosen, opens that page, and puts focus on the page", async () => {
const { history } = await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(navLink("signage"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/", "/signage"]);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(document.activeElement).toBe(required("host-main-region"));
});
it("closes when the page already open is chosen, and still moves focus to the page", async () => {
const { history } = await render({ compact: true, path: "/calendar" });
await click(required("mobile-navigation-trigger"));
await click(navLink("calendar"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/calendar"]);
expect(document.activeElement).toBe(required("host-main-region"));
});
it("closes on Back and Forward, which keep working with the drawer", async () => {
const { history } = await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(navLink("calendar"));
await click(required("mobile-navigation-trigger"));
await act(async () => {
history.go(-1);
});
expect(query("navigation-drawer")).toBeNull();
expect(query("host-overview")).not.toBeNull();
await act(async () => {
history.go(1);
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("signs out from the account menu inside the drawer", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(query("host-login")).not.toBeNull();
});
it("gives drawer items a touch target of at least the large control height", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
for (const id of ["overview", "signage", "calendar"]) {
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-lg)");
}
expect(required("mobile-navigation-trigger").style.minHeight).toBe(
"var(--ekklesia-control-height-lg)",
);
});
});
describe("moving between phone and desktop layouts", () => {
it("keeps the open page and the desktop mode, and drops an open drawer cleanly", async () => {
const { viewport, history } = await render({
compact: true,
path: "/signage",
preference: createMemorySidebarPreference("collapsed"),
});
await click(required("mobile-navigation-trigger"));
await focus(navLink("calendar"));
await act(async () => {
viewport.setCompact(false);
});
expect(query("navigation-drawer")).toBeNull();
expect(sidebar().dataset.sidebar).toBe("collapsed");
expect(navLink("signage").getAttribute("aria-current")).toBe("page");
expect(history.entries).toEqual(["/signage"]);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
// Focus was inside the drawer that went away; it lands on the page rather than nowhere.
expect(document.activeElement).toBe(required("host-main-region"));
await act(async () => {
viewport.setCompact(true);
});
expect(query("host-sidebar")).toBeNull();
expect(query("navigation-drawer")).toBeNull();
expect(required("mobile-navigation-trigger").getAttribute("aria-expanded")).toBe("false");
});
it("does not remount the open module when the layout changes", async () => {
const mounts: string[] = [];
const viewport = createMemoryHostViewport(false);
const history = createMemoryHostHistory("/signage");
const stub = platform();
await act(async () => {
root.render(
<HostApp
config={{ apiBaseUrl, fetch: stub.fetch }}
history={history}
importModule={async (url) => {
mounts.push(url);
return artifactFor(url);
}}
sidebarPreference={createMemorySidebarPreference()}
viewport={viewport}
/>,
);
});
await act(async () => {
viewport.setCompact(true);
});
await act(async () => {
viewport.setCompact(false);
});
expect(mounts).toHaveLength(1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
});
});