Stage 1: UX cleanup from the assembled review — Discover, Settings → Displays, collapsed rail, phone header, radios #112

Merged
Zayin_Admin merged 1 commits from fix/stage1-ux-review into main 2026-10-16 01:51:52 +00:00
31 changed files with 2346 additions and 611 deletions
+5 -5
View File
@@ -54,11 +54,11 @@ Rules from those documents that every change here must respect:
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
- **Displays are platform-owned.** Display pairing lives in **Settings → Displays** (ADR 0064); the
sidebar is navigation only. Signage's own display roster is still a transitional implementation
that Stage 1 replaces with the platform's displays. Keep existing paired displays working across
any change, and do not build a module installer, theme editor, or display features beyond what a
task asks for early.
## ADRs are authoritative
+15 -8
View File
@@ -925,9 +925,12 @@ 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
and an account menu anchored at the bottom (Sign out, for now). There is no top bar on a wide screen.
On a phone a restrained sticky header keeps the menu button and the page's name in reach below the
status bar, and 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
([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)). The sidebar is
navigation only. 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.
@@ -947,11 +950,15 @@ schemes, and corners scale from one `radius.scale` token. Displays stay light.
**Settings** has its own place in the sidebar
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each
module's settings at `/settings/<module-id>`. Discover is where modules are found, installed, managed,
and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own
Settings page. A person sees only what the server says they may administer, and no Settings link at all
when that is nothing. The pages are placeholders until each section is built.
landing page at `/settings` that summarises the installation — paired displays, installed modules — and
links to where each is changed; the platform's General, Branding, Displays, Discover, and System; and
each module's settings at `/settings/<module-id>`. Discover is where modules are found and installed
(`/settings/discover`), with Installed and Updates beneath it (`/settings/discover/installed`,
`/updates`); configuring a module is its own Settings page. **Settings → Displays** pairs and revokes
display screens: the paired displays with when each was last seen, and an *Add display* flow for the
code a screen shows ([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)).
A person sees only what the server says they may administer, and no Settings link at all when that is
nothing. General, Branding, System, Installed, and Updates are placeholders until each is built.
**Modules are first-class parts of the Dashboard**
([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module
@@ -3,6 +3,7 @@
- Status: Accepted
- Date: 2026-10-14
- Module-declared icons and pages decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Device panel placement and the phone menu placement superseded by: [0064](0064-stage-1-shell-review-corrections.md)
- 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),
@@ -3,6 +3,7 @@
- Status: Accepted
- Date: 2026-10-15
- Module → Settings return link decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Discover's address, the landing page, and the device panel's move decided by: [0064](0064-stage-1-shell-review-corrections.md)
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
[0028](0028-module-operational-lifecycle.md),
[0029](0029-module-configuration-and-secrets.md),
@@ -0,0 +1,241 @@
# 0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header
- Status: Accepted
- Date: 2026-10-15
- Supersedes, in part: [0058](0058-dashboard-shell-navigation-and-lucide-icons.md) (the device panel
in the sidebar and drawer; "no application top bar" on phones),
[0061](0061-settings-hierarchy-and-module-settings-routes.md) (Discover entered at
`/settings/discover/browse`; the landing page as an index)
- Extends: [0036](0036-managed-device-identity-and-pairing.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md),
[0060](0060-light-dark-system-theme-and-motion.md),
[0063](0063-module-navigation-contract-manifest-v9.md)
## Context
The assembled Stage 1 Dashboard was reviewed by hand on a real desktop, with the sidebar expanded and
collapsed, and on a real iPhone in Safari. The review found defects that no unit test had caught,
because each only shows in the assembled product:
- **Discover and Browse were the same thing twice.** ADR 0061 made Discover a navigation item that
opened Browse, at `/settings/discover/browse`, with Browse, Installed, and Updates listed beneath it.
In use, "Discover" and "Browse" named one page, and the children looked as if they had simply
appeared, with no relationship to Discover.
- **The sidebar still held the Stage 0 device panel.** It showed a *Display devices* heading, a pairing
code field, and the list of paired screens beneath the navigation. A form in the navigation is not
navigation. The field also held focus as the page loaded on the review device, although nobody had
asked to pair anything.
- **Collapsed icons sat off centre.** `DashboardLink` set `justify-content: flex-start` inline. An
inline style outranks the stylesheet, so the collapsed rule's `justify-content: center` never
applied, and every glyph sat at the left of its 47px box, with 1px on its left and 26px on its
right. The toggle above the items had its own, different geometry.
- **The Settings landing page repeated the sidebar.** ADR 0061 called it "an index, not a dashboard":
each section was named and described in a line. It listed exactly what the sidebar beside it already
listed.
- **On the phone the menu scrolled away.** ADR 0058 put the menu button above the page as "the one
control there, not a bar". It scrolled out of reach with the page. It also did not respect the
status bar or notch. A page opened from the drawer could start part-way down, because focusing
`<main>` scrolled it to the very top of the viewport.
- **The light theme's selected radio looked wrong.** Radios were native, tinted with
`accent-color: accent.base`. Given an accent, each browser picks its own contrasting mark, and on
the light theme's pale teal (`#4fb3a8`) that mark was a near-black dot and ring. The shared
`Checkbox` in `@ekklesia/ui` used the same technique.
ROADMAP §5.3 and DESIGN.md §9.4 still name `/settings/discover/browse` as canonical. This decision
reverses that at the maintainer's direction after the review, and the product documents need the
same correction.
## Decision
### Discover is its own page, at `/settings/discover`
`/settings/discover` is where modules are found and installed. It is Discover's own page, titled
*Discover modules*, not the parent of a separate Browse page. Its children, beneath it, are:
```text
/settings/discover Discover: find and install modules
/settings/discover/installed Installed: see and manage installed modules
/settings/discover/updates Updates: module and platform updates
```
- **No Browse item.** The navigation names Installed and Updates beneath Discover, and nothing else.
- **The old address is an alias, never a link.** `/settings/discover/browse` still opens Discover's
page, so a bookmark from before this decision keeps working. The shell then respells it in place to
`/settings/discover` (`replace`, keeping any `?return=`), exactly as it corrects a trailing slash
(ADR 0057). Nothing links to it.
- **Internal name.** The route keeps `view: "browse"` as the internal name of Discover's own page.
Only its address changes. The API already serves the Dashboard throughout `/settings/*`, so no server
or proxy change is needed.
### Parents and children read as a hierarchy
The same treatment applies to every parent in the navigation: Discover with Installed and Updates,
Settings with its sections, and a module with its declared pages (ADR 0063).
- **Children are text only**, marked `data-nav-level="child"`. On desktop they use the small control
height and regular weight. In the drawer they keep the large touch target.
- **They hang from a guide line** that runs down from beneath the parent's icon. Their text starts past
the parent's label.
- **Exactly one item is current** (`aria-current="page"`, selected fill, and weight). The parent of the
open child is marked `data-nav-ancestor`: primary text, bold, and the strong accent on its icon, but
no fill. A parent and its child are therefore never both "selected", and the relationship reads at a
glance.
- **An item that opens in place carries a disclosure chevron.** It points right while closed and down
while open, and it rotates over `motion.duration-short`, which is zero when motion is reduced
(ADR 0060). It is decorative: the nested list, named by its parent, carries the structure for
assistive technology.
- **Collapsed, the rail shows parents only.** Discover is current for every Discover page, and its
children, which need the labels the rail has no room for, are not drawn. A module's pages were
already hidden in the rail.
### The sidebar is navigation only; displays are paired in Settings → Displays
The device panel leaves the sidebar and the drawer entirely. Its working functionality moves, rather
than being rebuilt, to **Settings → Displays** (`/settings/displays`). This is the move ADR 0061
deferred, and the first platform Settings page that does real work. The page has:
- a page header with **Add display**;
- **Paired displays:** one card per screen, with its name, a *Paired* or *Revoked* badge, when it was
paired, when it was last seen, and **Revoke**;
- an **Add display** dialog: the code shown on the screen, then **Find display**; then the display's
one name, then **Pair display**;
- the existing in-product confirmation before revoking;
- empty, loading, failed (with Retry), and "not available to you" states.
It uses the same platform-owned display identity, the same endpoints (`GET /api/v1/devices`,
`POST /api/v1/device-pairing/lookups` and `/approvals`, `POST /api/v1/devices/<id>/revoke`), and the
same permission (`core.devices.manage`) as before (ADR 0036). No display concept is added.
The device list already returned `createdAt` and `lastSeenAt`, and the client now reads them.
`lastSeenAt` is written at most every few minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so the page says
when a screen was last in touch, coarsely ("within the last hour", "3 days ago"). It never says a
screen is online.
**Nothing takes focus by itself.** No page load or navigation focuses a form field. Focus moves into
the code field only once someone has chosen **Add display**, because typing the code is then plainly
the next thing to do. It moves to the name field once a display is found. The dialog's focus trap
returns focus to **Add display** when the dialog closes. The button focuses itself before opening the
dialog, because Safari does not focus a button that is clicked.
### `/settings` opens on an overview of the installation
`/settings` stays a real landing page, never a redirect (ADR 0061). It now answers "what state is this
installation in?" instead of repeating the sidebar:
- **Displays:** how many are paired, and which was most recently seen. The link is *Manage displays*,
or *Add a display* when none are paired.
- **Modules:** how many are installed, and whether all are running or how many are turned off or
cannot start. The links are *Manage modules* and each running module's *<name> settings*.
- **General, Branding, and System:** one restrained line each about what is true today, such as
*Using the default name and colours*, with the one link that changes it. There are no large
*Not available yet* boxes.
The overview reads only existing data, and only for what this person may see:
- the Displays summary uses `GET /api/v1/devices`, read only when Displays is visible;
- the Modules summary uses `GET /api/v1/module-lifecycle`, read only when Discover is visible. That
endpoint requires `core.modules.lifecycle.manage`, the same permission Discover does.
A refusal or failure shows as a quiet *Couldn't check …* line. No backend API was added.
### Phones get a restrained sticky header
Below the compact breakpoint, a `<header>` holds the menu trigger and the open page's name. It is
`position: sticky` at the top while the page scrolls, and the drawer opens from that stable trigger.
It is not a desktop top bar and holds nothing else. The page name is a visual echo of the page's own
`<h1>`, so it is `aria-hidden`. Wide screens still have no top bar.
- **Safe areas.** The Dashboard page declares `viewport-fit=cover`, so the shell lays itself out with
`env(safe-area-inset-*)`:
- the header clears the status bar or notch;
- the page, the drawer, and a sidebar shown on a wide landscape phone clear the notch and the home
indicator.
- **Viewport height.** The shell's minimum height is `100dvh`, falling back to `100vh`, so Safari's
collapsing toolbar never leaves the page a toolbar's height too tall.
- **Scrolling and focus.**
- A new page starts at its top, as a full page load would. Focus moves to `<main>` with
`preventScroll`, so the page's top is never scrolled under the header.
- Focus that returns to the trigger after the drawer closes does not scroll either.
- `scroll-padding-top` keeps anything else scrolled into view, such as a focused field, below the
header.
- **Page margin.** A phone's margin is `spacing.lg` instead of `spacing.xl`.
### Collapsed geometry belongs to the stylesheet
A navigation link's layout and its open-page look are classes in the shell stylesheet: `ek-shell-link`,
plus `[aria-current="page"]`. They are never inline styles. Collapsed, every navigation item has the
same geometry, whatever its level:
- a `control.height-md` square;
- `display: flex`, centred on the rail with auto margins;
- no padding and no gap;
- its glyph centred.
The rail scrolls without a visible scrollbar, which would otherwise take its width from one side. The
tooltip, the keyboard order, and each item's visually hidden name are unchanged. No icon is nudged by
a one-off offset.
### Choice indicators are drawn from tokens, in `@ekklesia/ui`
`@ekklesia/ui`'s stylesheet draws the checkbox (`ek-checkbox__input`) and a radio (`ek-radio__input`)
itself, as native inputs with `appearance: none`. They are never tinted with `accent-color`.
- **Unselected:** a `border.control` ring on `background.surface`.
- **Selected:** `accent.strong` in both themes. The radio is a ring, a gap of the surface, and a dot.
The checkbox is a filled box with a surface-coloured check.
- **Other states:** hover strengthens the ring to `border.strong`, disabled uses
`state.disabled-opacity`, and forced colours use the system's own selection colours.
- **Size:** a larger control sets `--ek-choice-size`.
The Appearance page's radios use this shared indicator. Its rows keep their own focus ring, so the
radio inside draws none of its own.
No `Radio` component is added: ADR 0039 adds an element to `@ekklesia/ui` once two independent
consumers need it, and the shell is the only one. The change is to the existing stylesheet and the
existing `Checkbox`, whose selected state now matches. That is additive for modules: a module using
`Checkbox` gets the same, now consistent, selected state.
## Alternatives
- **Keep Browse and make it look more like a child.** Rejected: the review's finding was that
Discover and Browse are one concept. Two names for one page is the defect, not its styling.
- **Remove `/settings/discover/browse` outright.** Rejected: it shipped, and a bookmark should not
become *Page not found*. An alias respelled in place costs nothing and is never linked.
- **Pair displays from a route of its own (`/settings/displays/add`).** Deferred, not rejected. A dialog
keeps the routing contract unchanged and gets focus handling from the shared `Dialog`. A deep link
to *Add display*, for example from Signage, can add a route later without changing this page.
- **Keep the device panel, collapsed by default.** Rejected: the sidebar is navigation, and a form in
it is the defect.
- **Redirect `/settings` to General, or keep the index.** Rejected: the redirect, as in ADR 0061. The
index repeated the sidebar.
- **New backend endpoints for richer overview cards** (version, health, update counts). Rejected for
now: the overview uses what exists, and System, Updates, and Branding gain real summaries with the
work that builds them.
- **A full top bar on every screen size.** Rejected: DESIGN.md §4.2 keeps navigation in the sidebar.
Phones need only the menu kept within reach.
- **Fix the radio by changing `accent.base`, or with page-specific colours.** Rejected: a browser's
mark on an accent-tinted control is its own choice and differs between engines. Drawing the
indicator from tokens is the only way to get one result in every browser and both themes.
- **Offset individual icons to centre them.** Rejected: the cause was an inline style outranking the
collapsed rule, and one geometry for every item removes it.
## Consequences
- Discover's canonical address is `/settings/discover`. Nothing links to `/browse`, and it respells
itself away.
- The shell no longer asks for the device list on every page. Displays are read on Settings →
Displays and the Settings overview, for people allowed to see them.
- Released Signage 0.1.5 tells administrators to enter a screen's code "under Display devices in the
Dashboard sidebar". That text is now stale and must change in Signage. The pairing itself, and every
paired display, is unaffected.
- `@ekklesia/ui`'s checkbox looks different when checked: the strong accent instead of the browser's
accent-tinted control. Its behaviour and markup are unchanged.
- ROADMAP §5.3 and DESIGN.md §9.4, §12.1, and decision 98 still describe Browse at
`/settings/discover/browse` and need the same correction.
## Deferred
The real General, Branding, System, Installed, and Updates pages, and richer overview summaries with
them. Display rooms or locations, rename, and history (DESIGN.md §11). A routable *Add display* for
modules to link to. Signage consuming platform displays (ROADMAP §5.8).
+3 -2
View File
@@ -66,9 +66,10 @@ ADRs capture durable choices that affect more than one package or phase.
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted (device panel and phone menu placement superseded by 0064)
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted (Discover's address and the landing page superseded by 0064)
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted
- [0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header](0064-stage-1-shell-review-corrections.md) — Accepted
+5 -3
View File
@@ -602,9 +602,11 @@ durations when motion is reduced — in CSS alone, so the Dashboard and every mo
operating system live; the host chooses an explicit appearance through a small controller, applied
before React renders. Contrast is tested for every pairing the elements use, in both schemes
([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). Settings is one host-owned
hierarchy in the same sidebar: a landing page, the platform's General, Branding, Displays, Discover
(module lifecycle: Browse, Installed, Updates), and System, and each module's settings at
`/settings/<module-id>`. Which of them a person sees is evaluated by the server
hierarchy in the same sidebar: a landing page that summarises the installation, the platform's
General, Branding, Displays (where display screens are paired and revoked), Discover (module
lifecycle: Discover's own page at `/settings/discover`, with Installed and Updates beneath it), and
System, and each module's settings at `/settings/<module-id>`; the sidebar itself is navigation only
([ADR 0064](decisions/0064-stage-1-shell-review-corrections.md)). Which of them a person sees is evaluated by the server
(`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own
enforcement; the Settings section names are reserved module ids
([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration
+22
View File
@@ -462,6 +462,28 @@ proven at each layer:
- The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS
change, reduced motion, a module's Shadow DOM following the theme, and phone width.
The Stage 1 review corrections
([ADR 0064](../architecture/decisions/0064-stage-1-shell-review-corrections.md)) are proven here:
- `surfaces/shell/tests/dashboard-routes.test.ts` reads Discover's own page at `/settings/discover`,
Installed and Updates beneath it, and `/settings/discover/browse` only as an alias that respells to
`/settings/discover`.
- `surfaces/shell/tests/settings-navigation.test.tsx` covers:
- the Settings overview's summaries, read only for what the person may see;
- Discover with no Browse child, its parent and child marking, and the collapsed rail showing
Discover alone;
- the alias respelled in place, keeping `?return=`;
- no display-pairing block in the sidebar or drawer, and no field focused on arrival;
- the collapsed geometry left to the stylesheet;
- the sticky phone header and the drawer opening from it.
- `surfaces/shell/tests/displays-settings.test.tsx` covers Settings → Displays: the list, empty and
refused states, the Add display flow focusing each next step, Cancel, and revocation behind a product
dialog.
- `packages/ui/tests/tokens.test.ts` holds the checkbox and radio indicators to tokens: never
`accent-color`, and the strong accent for the selected state.
- None of these defects showed until the product was assembled, so a change to the shell's layout
also gets a real-browser look at wide, collapsed, and phone widths in Light and Dark.
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:
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
+77 -5
View File
@@ -198,17 +198,89 @@ export const EKKLESIA_UI_STYLESHEET = `
gap: ${designToken("spacing.sm")};
}
.ek-checkbox__input {
accent-color: ${designToken("accent.base")};
height: ${designToken("spacing.lg")};
width: ${designToken("spacing.lg")};
/*
* Choice indicators — a checkbox, and a radio (ek-radio__input, on a native radio input) — drawn
* from tokens rather than the browser's own control tinted with accent-color. Given an accent, each
* browser picks its own contrasting mark: on the light theme's pale teal that was a near-black dot
* and ring, which matched neither theme (ADR 0064). Drawn here, a selected indicator is the strong
* accent in both themes and every browser, and it is still a native input: its keyboard behaviour,
* form association, and announcement stay the browser's. A larger control sets --ek-choice-size.
*/
.ek-checkbox__input,
.ek-radio__input {
--ek-choice-size: ${designToken("spacing.lg")};
appearance: none;
background: ${designToken("background.surface")};
border: calc(2 * ${designToken("border.width")}) solid ${designToken("border.control")};
box-sizing: border-box;
cursor: pointer;
display: inline-grid;
flex: 0 0 auto;
height: var(--ek-choice-size);
margin: 0;
place-content: center;
width: var(--ek-choice-size);
}
.ek-checkbox__input:disabled {
.ek-checkbox__input {
border-radius: ${designToken("radius.sm")};
}
.ek-radio__input {
border-radius: ${designToken("radius.pill")};
}
.ek-checkbox__input:hover:not(:disabled):not(:checked),
.ek-radio__input:hover:not(:disabled):not(:checked) {
border-color: ${designToken("border.strong")};
}
/* A ring, a gap of the surface, and a dot, all in proportion to the control. */
.ek-radio__input:checked {
background: ${designToken("accent.strong")};
border-color: ${designToken("accent.strong")};
box-shadow: inset 0 0 0 calc(var(--ek-choice-size) * 0.18) ${designToken("background.surface")};
}
.ek-checkbox__input:checked {
background: ${designToken("accent.strong")};
border-color: ${designToken("accent.strong")};
}
.ek-checkbox__input:checked::before {
aspect-ratio: 1;
background: ${designToken("background.surface")};
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
content: "";
display: block;
width: calc(var(--ek-choice-size) * 0.6);
}
.ek-checkbox__input:disabled,
.ek-radio__input:disabled {
cursor: not-allowed;
opacity: ${designToken("state.disabled-opacity")};
}
/* Forced colours drop backgrounds and shadows, so a checked indicator uses the system's own
selection colours instead. */
@media (forced-colors: active) {
.ek-checkbox__input:checked,
.ek-radio__input:checked {
background: Highlight;
border-color: Highlight;
forced-color-adjust: none;
}
.ek-radio__input:checked {
box-shadow: inset 0 0 0 calc(var(--ek-choice-size) * 0.18) Canvas;
}
.ek-checkbox__input:checked::before {
background: Canvas;
}
}
.ek-panel {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
+40
View File
@@ -119,3 +119,43 @@ describe("the element stylesheet takes every colour, radius, and duration from a
}
});
});
/** The body of the rule whose whole selector list is exactly `selector`. */
function ruleBody(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const match = new RegExp(`(?<!,)\n${escaped} \\{([^}]*)\\}`, "u").exec(EKKLESIA_UI_STYLESHEET);
return match?.[1] ?? "";
}
describe("choice indicators are drawn from tokens (ADR 0064)", () => {
it("never leaves a checkbox or radio to the browser's accent-tinted rendering", () => {
// `accent-color` lets each browser pick its own mark for the selected state — a near-black dot
// on the light theme's pale teal — so the selected state matched neither theme.
expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/accent-color\s*:/u);
const base = ruleBody(".ek-checkbox__input,\n.ek-radio__input");
expect(base).toContain("appearance: none");
expect(base).toContain("var(--ekklesia-border-control)");
expect(base).toContain("var(--ekklesia-background-surface)");
});
it("marks the selected state with the strong accent, the same in light and dark", () => {
const radio = ruleBody(".ek-radio__input:checked");
expect(radio).toContain("background: var(--ekklesia-accent-strong)");
expect(radio).toContain("border-color: var(--ekklesia-accent-strong)");
expect(radio).toContain("var(--ekklesia-background-surface)");
const checkbox = ruleBody(".ek-checkbox__input:checked");
expect(checkbox).toContain("background: var(--ekklesia-accent-strong)");
expect(ruleBody(".ek-checkbox__input:checked::before")).toContain(
"background: var(--ekklesia-background-surface)",
);
// A radio is round by the pill radius token, never a literal.
expect(ruleBody(".ek-radio__input")).toContain("var(--ekklesia-radius-pill)");
});
it("keeps disabled and forced-colour states", () => {
expect(ruleBody(".ek-checkbox__input:disabled,\n.ek-radio__input:disabled")).toContain(
"var(--ekklesia-state-disabled-opacity)",
);
expect(EKKLESIA_UI_STYLESHEET).toMatch(/@media \(forced-colors: active\)/u);
});
});
+37 -4
View File
@@ -574,7 +574,8 @@ if (openedModuleId) {
}
}
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
// --- Settings (ADR 0061, ADR 0064): a deep link, a refresh, Displays, Discover, a module's settings,
// the drawer ----------------------------------------------------------------------------------------
await navigate(`${dashboardOrigin}/settings`);
check(
await waitFor(
@@ -593,13 +594,37 @@ check(
),
"refreshing a Settings page stays on it, marked in the navigation",
);
check(
await evaluate(
`!${selector("host-sidebar")}.querySelector("input, form") && document.activeElement?.tagName !== "INPUT"`,
),
"the sidebar is navigation only, and arriving at Displays focuses no field",
);
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
"the Add display dialog with its code field focused",
),
"Add display opens a dialog that focuses the pairing code only once asked",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
await navigate(`${dashboardOrigin}/settings/discover`);
check(
await waitFor(
`location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`,
"Discover opening at Browse",
`location.pathname === "/settings/discover" && ${selector("settings-discover-browse")} && ${selector("select-settings-discover")}?.getAttribute("aria-current") === "page"`,
"Discover's own page",
),
"entering Discover opens Browse at its one canonical address",
"Discover is its own page at /settings/discover",
);
await navigate(`${dashboardOrigin}/settings/discover/browse`);
check(
await waitFor(
`location.pathname === "/settings/discover" && ${selector("settings-discover-browse")}`,
"the earlier Browse address respelled",
),
"the earlier /settings/discover/browse respells to /settings/discover",
);
if (openedModuleId) {
await navigate(`${dashboardOrigin}/settings/${openedModuleId}`);
@@ -619,6 +644,14 @@ await send("Emulation.setDeviceMetricsOverride", {
});
await navigate(`${dashboardOrigin}/settings`);
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger in Settings");
await evaluate("window.scrollTo(0, document.documentElement.scrollHeight)");
check(
await waitFor(
`${selector("mobile-header")} && ${selector("mobile-navigation-trigger")}.getBoundingClientRect().top >= 0 && document.documentElement.scrollWidth <= innerWidth`,
"the phone header after scrolling",
),
"the phone header keeps the menu in reach after scrolling, with no horizontal overflow",
);
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en" style="min-width: 0; scrollbar-gutter: stable">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<!-- viewport-fit=cover lets the shell lay itself out around a notch, a status bar, and a home
indicator with env(safe-area-inset-*) (ADR 0064), rather than the browser letterboxing it. -->
<meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport" />
<!-- The API's origin (ADR 0045). The API serving this page fills it in at startup; empty means
this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. -->
+1 -1
View File
@@ -195,7 +195,7 @@ function ChoiceGroup<Value extends string>(props: {
>
<input
checked={checked}
className="ek-shell-choice__input ek-focusable"
className="ek-radio__input ek-shell-choice__input ek-focusable"
name={props.name}
onChange={() => props.onChange(option.value)}
type="radio"
+16 -22
View File
@@ -1,4 +1,3 @@
import { designToken } from "@ekklesia/design-tokens";
import type {
CSSProperties,
FocusEventHandler,
@@ -17,7 +16,9 @@ export interface DashboardLinkProps {
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
/** `navigation` for the sidebar and lists, `action` for a button-like link, `text` for a link in
* running text or a card, styled by its own `className`. */
readonly variant?: "navigation" | "action" | "text";
/** 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;
@@ -29,6 +30,8 @@ export interface DashboardLinkProps {
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string;
/** Presentation hooks for the shell stylesheet, such as `data-nav-ancestor`. */
readonly dataAttributes?: Readonly<Record<`data-${string}`, string | undefined>>;
/** For a link that is an item of a menu (the account menu): its role, its roving `tabIndex`, and
* a ref so the menu can move focus to it. */
readonly role?: "menuitem";
@@ -36,24 +39,19 @@ export interface DashboardLinkProps {
readonly ref?: Ref<HTMLAnchorElement>;
}
/**
* A navigation link's layout and its open-page look are the shell stylesheet's `ek-shell-link`
* (ADR 0058), never inline styles: an inline `justify-content` once outranked the collapsed sidebar's
* centring and pushed every icon to one side (ADR 0064). The open page is shown by weight as well as
* colour, so it never depends on colour alone.
*/
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
navigation: "ek-button ek-focusable ek-button--quiet ek-shell-link",
text: "ek-focusable",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
const actionStyle: CSSProperties = { textDecoration: "none" };
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
export function isBrowserHandledClick(event: {
@@ -85,12 +83,7 @@ export function isBrowserHandledClick(event: {
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
const baseStyle =
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle;
const baseStyle = variant === "navigation" ? undefined : actionStyle;
return (
<a
aria-current={props.current ? "page" : undefined}
@@ -115,6 +108,7 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement {
ref={props.ref}
role={props.role}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
{...(props.dataAttributes ?? {})}
tabIndex={props.tabIndex}
>
{props.children}
-246
View File
@@ -1,246 +0,0 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import {
approveDevicePairing,
type EkklesiaHostConfig,
type HostDeviceSummary,
listHostDevices,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
/**
* The administrator's whole device surface: type the code from a screen, name it, approve it, and
* later take it away again.
*
* Deliberately small. Device identity belongs to the platform rather than to any module, so it has
* to live in the host — but this is infrastructure plumbing, not a fleet-management product, and
* there is no grouping, filtering, bulk action, targeting, health, or remote control here on
* purpose (ADR 0036). It renders only for an administrator who actually holds `core.devices.manage`:
* a viewer without it is told nothing about which devices exist.
*/
const panelStyles = {
meta: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
margin: 0,
},
row: {
alignItems: "center",
borderTop: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
gap: designToken("spacing.sm"),
justifyContent: "space-between",
padding: `${designToken("spacing.sm")} 0`,
},
section: { display: "grid", gap: designToken("spacing.sm") },
title: {
color: designToken("text.secondary"),
fontSize: designToken("typography.size-xs"),
fontWeight: designToken("typography.weight-bold"),
letterSpacing: "0.05em",
margin: 0,
},
} as const;
type PairingStep =
| { readonly step: "idle" }
| { readonly step: "busy" }
| { readonly step: "found"; readonly moduleId: string; readonly permissionId: string }
| { readonly step: "error"; readonly message: string };
export interface DevicePanelProps {
readonly config: EkklesiaHostConfig;
}
export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
const { config } = props;
const [devices, setDevices] = useState<readonly HostDeviceSummary[] | undefined>(undefined);
const [permitted, setPermitted] = useState(true);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
/** The device an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const refresh = useCallback(async (): Promise<void> => {
try {
setDevices(await listHostDevices(config));
} catch {
// The panel simply does not render for someone who may not manage devices; it never explains
// what it would have shown.
setPermitted(false);
}
}, [config]);
useEffect(() => {
void refresh();
}, [refresh]);
if (!permitted) {
return undefined;
}
const lookUp = async (event: FormEvent): Promise<void> => {
event.preventDefault();
setPairing({ step: "busy" });
try {
const found = await lookupDevicePairing(config, code.trim());
setPairing({ moduleId: found.moduleId, permissionId: found.permissionId, step: "found" });
} catch {
// One message for expired, consumed, mistyped, and never-existed alike.
setPairing({ message: "That code is not waiting for approval.", step: "error" });
}
};
const approve = async (): Promise<void> => {
setPairing({ step: "busy" });
try {
await approveDevicePairing(config, code.trim(), name.trim());
setCode("");
setName("");
setPairing({ step: "idle" });
await refresh();
} catch {
setPairing({ message: "That code could not be approved.", step: "error" });
}
};
const revoke = async (deviceId: string): Promise<void> => {
setRevoking(true);
try {
await revokeHostDevice(config, deviceId);
} catch {
// Left to the next refresh rather than asserting a failure the operator cannot act on.
}
await refresh();
setRevoking(false);
setConfirming(undefined);
};
return (
<section data-testid="host-devices" style={panelStyles.section}>
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
<form
onSubmit={(event) => void lookUp(event)}
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<Field label="Pairing code shown on the screen">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
value={code}
/>
)}
</Field>
<Button data-testid="device-pairing-lookup" size="sm" type="submit">
Find display
</Button>
</form>
{pairing.step === "found" ? (
<div
data-testid="device-pairing-found"
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<p style={panelStyles.meta}>
A display is asking to show <strong>{pairing.moduleId}</strong>.
</p>
<Field label="Name this display">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
value={name}
/>
)}
</Field>
<Button
data-testid="device-pairing-approve"
disabled={name.trim().length === 0}
onClick={() => void approve()}
size="sm"
variant="primary"
>
Approve
</Button>
</div>
) : undefined}
{pairing.step === "error" ? (
<p data-testid="device-pairing-error" style={panelStyles.meta}>
{pairing.message}
</p>
) : undefined}
{devices !== undefined && devices.length === 0 ? (
<p data-testid="device-list-empty" style={panelStyles.meta}>
No displays are paired.
</p>
) : undefined}
{devices?.map((device) => (
<div data-testid={`device-${device.id}`} key={device.id} style={panelStyles.row}>
<span>
{device.name}
{device.revoked ? " (revoked)" : ""}
</span>
{device.revoked ? undefined : (
<Button
data-testid={`device-revoke-${device.id}`}
onClick={() => setConfirming(device)}
size="sm"
>
Revoke
</Button>
)}
</div>
))}
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</section>
);
}
@@ -0,0 +1,454 @@
import { designToken } from "@ekklesia/design-tokens";
import {
Badge,
Button,
Dialog,
EmptyState,
ErrorState,
Field,
InlineNotice,
LoadingState,
PageHeader,
type PageHeaderBack,
Panel,
TextInput,
} from "@ekklesia/ui";
import {
type FormEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
approveDevicePairing,
type EkklesiaHostConfig,
type HostDeviceSummary,
HostProtocolError,
listHostDevices,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
import { calendarDate, lastSeenPhrase } from "./display-time.js";
import { ShellIcon } from "./shell-icons.js";
/**
* Settings → Displays (ADR 0064): the platform's own administration page for the screens paired
* with this installation — the paired displays, adding one with the code its screen shows, and
* revoking one.
*
* It is the device surface that used to sit in the sidebar, moved rather than rebuilt: the same
* platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per
* physical screen, named once here, never a second name kept by a module. Deliberately still small —
* no grouping, filtering, bulk action, health, or remote control — and only reachable by someone who
* holds `core.devices.manage`; the server refuses everyone else whatever this page shows.
*
* Nothing on this page takes focus by itself. The code field is focused only once the administrator
* has chosen **Add display**, because typing the code is then plainly the next thing to do.
*/
type ListState =
| { readonly kind: "loading" }
| { readonly kind: "ready"; readonly devices: readonly HostDeviceSummary[] }
| { readonly kind: "denied" }
| { readonly kind: "failed" };
type PairingStep =
| { readonly step: "code"; readonly error?: string }
| { readonly step: "finding" }
| { readonly step: "found"; readonly moduleId: string; readonly error?: string }
| { readonly step: "approving"; readonly moduleId: string };
export interface DisplaysSettingsPageProps {
readonly config: EkklesiaHostConfig;
/** Back to Settings, or to where a validated return target says (ADR 0063). */
readonly back: PageHeaderBack;
/** A module's name, from the navigation, for "a display is asking to show Signage". */
readonly moduleName: (moduleId: string) => string | undefined;
/** For tests: the clock "last seen" is measured against. */
readonly now?: () => Date;
}
/** The "Add display" flow: the code from the screen, then the display's one name, then approval. */
function AddDisplayDialog(props: {
readonly config: EkklesiaHostConfig;
readonly open: boolean;
readonly moduleName: (moduleId: string) => string | undefined;
readonly onDismiss: () => void;
readonly onPaired: (name: string) => void;
}): ReactElement {
const { config, onPaired } = props;
const formId = useId();
const codeRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "code" });
// A fresh flow every time it opens.
useEffect(() => {
if (props.open) {
setCode("");
setName("");
setPairing({ step: "code" });
}
}, [props.open]);
// Once the display is found, naming it is the next thing to do.
const found = pairing.step === "found";
useEffect(() => {
if (found) {
nameRef.current?.focus();
}
}, [found]);
const busy = pairing.step === "finding" || pairing.step === "approving";
const submit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
if (pairing.step === "code") {
setPairing({ step: "finding" });
try {
const lookup = await lookupDevicePairing(config, code.trim());
setPairing({ moduleId: lookup.moduleId, step: "found" });
} catch {
// One message for expired, consumed, mistyped, and never-existed alike.
setPairing({
error: "That code is not waiting for approval. Check the code on the screen.",
step: "code",
});
codeRef.current?.focus();
}
return;
}
if (pairing.step === "found") {
const displayName = name.trim();
setPairing({ moduleId: pairing.moduleId, step: "approving" });
try {
await approveDevicePairing(config, code.trim(), displayName);
onPaired(displayName);
} catch {
setPairing({
error: "That display could not be approved. Its code may have expired.",
moduleId: pairing.moduleId,
step: "found",
});
}
}
};
const moduleId =
pairing.step === "found" || pairing.step === "approving" ? pairing.moduleId : undefined;
const naming = moduleId !== undefined;
const error = pairing.step === "code" || pairing.step === "found" ? pairing.error : undefined;
return (
<Dialog
actions={
<>
<Button data-testid="add-display-cancel" disabled={busy} onClick={props.onDismiss}>
Cancel
</Button>
{naming ? (
<Button
data-testid="device-pairing-approve"
disabled={busy || name.trim().length === 0}
form={formId}
type="submit"
variant="primary"
>
Pair display
</Button>
) : (
<Button
data-testid="device-pairing-lookup"
disabled={busy || code.trim().length === 0}
form={formId}
type="submit"
variant="primary"
>
Find display
</Button>
)}
</>
}
data-testid="add-display-dialog"
description={
naming
? "Give it the name everyone will know it by. Every module that uses this screen shows the same name."
: "Open the display page on the screen you want to add. It shows a six-digit pairing code."
}
dismissible={!busy}
initialFocusRef={codeRef}
onDismiss={props.onDismiss}
open={props.open}
title="Add display"
>
<form
id={formId}
onSubmit={(event) => void submit(event)}
style={{ display: "grid", gap: designToken("spacing.md") }}
>
<Field label="Pairing code">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
disabled={naming || busy}
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
ref={codeRef}
value={code}
/>
)}
</Field>
{naming ? (
<>
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(moduleId) ?? moduleId}</strong>.
</p>
<Field label="Display name">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
disabled={busy}
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
ref={nameRef}
value={name}
/>
)}
</Field>
</>
) : undefined}
{error === undefined ? undefined : (
<InlineNotice tone="danger">
<span data-testid="device-pairing-error">{error}</span>
</InlineNotice>
)}
</form>
</Dialog>
);
}
function DisplayCard(props: {
readonly device: HostDeviceSummary;
readonly now: Date;
readonly onRevoke: () => void;
}): ReactElement {
const { device, now } = props;
const facts: string[] = [];
if (device.revoked) {
// The badge says so; nothing about when it was last seen is worth acting on any more.
} else if (device.lastSeenAt !== undefined) {
facts.push(`Last seen ${lastSeenPhrase(device.lastSeenAt, now)}`);
} else {
facts.push("Not seen since it was paired");
}
if (device.pairedAt !== undefined) {
facts.push(`Paired ${calendarDate(device.pairedAt)}`);
}
return (
<li className="ek-shell-display-card" data-testid={`device-${device.id}`}>
<span aria-hidden="true" className="ek-shell-display-card__icon">
<ShellIcon name="settingsDisplays" />
</span>
<div className="ek-shell-display-card__copy">
<div className="ek-shell-display-card__title">
<span className="ek-shell-display-card__name">{device.name}</span>
<Badge tone={device.revoked ? "neutral" : "success"}>
{device.revoked ? "Revoked" : "Paired"}
</Badge>
</div>
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
</div>
{device.revoked ? undefined : (
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
Revoke
</Button>
)}
</li>
);
}
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
const { config } = props;
const [list, setList] = useState<ListState>({ kind: "loading" });
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<string | undefined>(undefined);
/** The display an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const dismissAdding = useCallback(() => setAdding(false), []);
const refresh = useCallback(async (): Promise<void> => {
try {
setList({ devices: await listHostDevices(config), kind: "ready" });
} catch (error) {
setList({
kind:
error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED"
? "denied"
: "failed",
});
}
}, [config]);
useEffect(() => {
void refresh();
}, [refresh]);
const revoke = async (deviceId: string): Promise<void> => {
setRevoking(true);
try {
await revokeHostDevice(config, deviceId);
} catch {
// Left to the refresh below rather than asserting a failure the operator cannot act on.
}
await refresh();
setRevoking(false);
setConfirming(undefined);
};
const onPaired = (name: string): void => {
setAdding(false);
setPaired(name);
// The new display appears without a page refresh (DESIGN.md §11.4).
void refresh();
};
const now = props.now?.() ?? new Date();
const canAdd = list.kind === "ready";
return (
<div
data-testid="settings-section-displays"
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
actions={
canAdd ? (
<Button
data-testid="add-display"
onClick={(event) => {
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a
// button that is clicked, so it is focused deliberately before the dialog opens.
event.currentTarget.focus();
setPaired(undefined);
setAdding(true);
}}
variant="primary"
>
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
) : undefined
}
back={props.back}
description="The screens paired with this installation. Each has one name, used by every module that shows content on it."
title="Displays"
/>
{paired === undefined ? undefined : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{paired} is paired and can be used by your modules.
</span>
</InlineNotice>
)}
{list.kind === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
{list.kind === "denied" ? (
<EmptyState
description="If you need to pair or revoke displays, ask an administrator."
title="Displays aren't available to you"
/>
) : undefined}
{list.kind === "failed" ? (
<ErrorState
actions={
<Button
onClick={() => {
setList({ kind: "loading" });
void refresh();
}}
>
Retry
</Button>
}
description="Try again in a moment."
title="The displays could not be loaded"
/>
) : undefined}
{list.kind === "ready" ? (
<Panel title="Paired displays">
{list.devices.length === 0 ? (
<div data-testid="device-list-empty">
<EmptyState
description="Choose Add display, then enter the code shown on the screen."
title="No displays are paired yet"
/>
</div>
) : (
<ul className="ek-shell-display-list" data-testid="device-list">
{list.devices.map((device) => (
<DisplayCard
device={device}
key={device.id}
now={now}
onRevoke={() => setConfirming(device)}
/>
))}
</ul>
)}
</Panel>
) : undefined}
<AddDisplayDialog
config={config}
moduleName={props.moduleName}
onDismiss={dismissAdding}
onPaired={onPaired}
open={adding}
/>
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</div>
);
}
+21 -5
View File
@@ -45,6 +45,7 @@ import {
} from "./appearance-preference.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { DisplaysSettingsPage } from "./DisplaysSettingsPage.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
@@ -59,6 +60,7 @@ import {
SettingsHomePage,
SettingsLoadFailedPage,
SettingsSectionPage,
settingsBack,
type SettingsReturn,
SettingsUnavailablePage,
} from "./SettingsPages.js";
@@ -200,17 +202,26 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
}
function SettingsPageView(props: {
readonly config: EkklesiaHostConfig;
readonly page: SettingsPage;
readonly history: HostHistory;
readonly onRetry: () => void;
readonly returnTo: SettingsReturn;
}): ReactElement {
const { page, history, returnTo } = props;
const { config, page, history, returnTo } = props;
switch (page.kind) {
case "home":
return <SettingsHomePage history={history} settings={page.settings} />;
return <SettingsHomePage config={config} history={history} settings={page.settings} />;
case "section":
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
return page.section === "displays" ? (
<DisplaysSettingsPage
back={settingsBack(history, returnTo)}
config={config}
moduleName={returnTo.moduleName}
/>
) : (
<SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />
);
case "discover":
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
case "module":
@@ -661,7 +672,12 @@ export function HostApp(props: HostAppProps): ReactElement {
return;
}
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
mainRef.current?.focus();
// A new page starts at its top, as a full page load would, and focusing it must not scroll
// it: focus() alone would scroll the page's top to the very top of the viewport, beneath
// the phone's sticky header, so every page opened from the drawer started under it.
const main = mainRef.current;
main?.focus({ preventScroll: true });
main?.ownerDocument.scrollingElement?.scrollTo?.({ left: 0, top: 0 });
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
@@ -757,7 +773,6 @@ export function HostApp(props: HostAppProps): ReactElement {
return (
<ShellLayout
config={config}
history={history}
mainRef={mainRef}
account={bootstrap.account}
@@ -771,6 +786,7 @@ export function HostApp(props: HostAppProps): ReactElement {
>
{settingsPage !== undefined ? (
<SettingsPageView
config={config}
history={history}
onRetry={retryBootstrap}
page={settingsPage}
+273 -69
View File
@@ -1,14 +1,22 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, EmptyState, ErrorState, PageHeader, Panel } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { Button, EmptyState, ErrorState, PageHeader } from "@ekklesia/ui";
import { type ReactElement, type ReactNode, useCallback, useEffect, useId, useState } from "react";
import type {
DashboardDestination,
DiscoverView,
SettingsPageSection,
SettingsSection,
} from "../dashboard-routes.js";
import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js";
import {
type DashboardSettings,
type DashboardSettingsModule,
type EkklesiaHostConfig,
listHostDevices,
listHostModuleLifecycle,
} from "../host-protocol.js";
import { dashboardBack } from "./dashboard-back.js";
import { DashboardLink } from "./DashboardLink.js";
import { lastSeenPhrase } from "./display-time.js";
import type { HostHistory } from "./host-history.js";
import {
DISCOVER_VIEW_COPY,
@@ -21,9 +29,9 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* The Dashboard's Settings pages (ADR 0061).
*
* This is the foundation only: the landing page, and a page at every Settings address, each honest
* about what exists. No section's real controls exist yet, so each says so plainly rather than
* showing fake fields; the work that builds a section replaces its placeholder.
* The landing page is an overview of the installation, and Displays is a real page of its own
* (`DisplaysSettingsPage`). Every other section's controls do not exist yet, so each says so plainly
* rather than showing fake fields; the work that builds a section replaces its placeholder.
*/
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
@@ -41,7 +49,7 @@ export interface SettingsReturn {
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
* Never the browser's own `history.back()`. */
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
export function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
return dashboardBack(history, {
fallback: BACK_TO_SETTINGS,
label: (destination) => {
@@ -56,106 +64,302 @@ function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
});
}
function SettingsLinkList(props: {
readonly history: HostHistory;
readonly items: readonly {
readonly key: string;
readonly destination: DashboardDestination;
readonly label: string;
readonly description?: string;
/** One card of the Settings overview: an area, its state in a line or two, and where to change it. */
function SummaryCard(props: {
readonly testId: string;
readonly icon: ShellIconName;
readonly title: string;
readonly status: ReactNode;
readonly detail?: ReactNode;
readonly links: readonly {
readonly key: string;
readonly label: string;
readonly to: DashboardDestination;
readonly testId: string;
}[];
readonly history: HostHistory;
readonly compact?: boolean;
}): ReactElement {
const headingId = useId();
return (
<ul
style={{
display: "grid",
gap: designToken("spacing.xs"),
listStyle: "none",
margin: 0,
padding: 0,
}}
<section
aria-labelledby={headingId}
className={props.compact ? "ek-shell-summary ek-shell-summary--compact" : "ek-shell-summary"}
data-testid={props.testId}
>
{props.items.map((item) => (
<li key={item.key}>
<DashboardLink
className="ek-shell-settings-link"
data-testid={item.testId}
history={props.history}
style={{ minHeight: designToken("control.height-lg") }}
to={item.destination}
>
<ShellIcon name={item.icon} />
<span className="ek-shell-settings-link__copy">
<span className="ek-shell-settings-link__label">{item.label}</span>
{item.description === undefined ? undefined : (
<span className="ek-shell-settings-link__description">{item.description}</span>
<h2 className="ek-shell-summary__heading" id={headingId}>
<ShellIcon name={props.icon} />
{props.title}
</h2>
<div style={{ display: "grid", gap: designToken("spacing.2xs") }}>
<p className="ek-shell-summary__status" data-testid={`${props.testId}-status`}>
{props.status}
</p>
{props.detail === undefined ? undefined : (
<p className="ek-shell-summary__detail">{props.detail}</p>
)}
</span>
</div>
{props.links.length === 0 ? undefined : (
<ul className="ek-shell-summary__links">
{props.links.map((link) => (
<li key={link.key}>
<DashboardLink
className="ek-shell-summary__link"
data-testid={link.testId}
history={props.history}
to={link.to}
variant="text"
>
{link.label}
</DashboardLink>
</li>
))}
</ul>
)}
</section>
);
}
type Loaded<T> =
| { readonly kind: "loading" }
| { readonly kind: "ready"; readonly value: T }
| { readonly kind: "failed" };
/** Reads one summary's data once, only when `enabled`; a refusal or failure is just "failed". */
function useSummary<T>(enabled: boolean, load: (signal: AbortSignal) => Promise<T>): Loaded<T> {
const [state, setState] = useState<Loaded<T>>({ kind: "loading" });
useEffect(() => {
if (!enabled) {
return;
}
const controller = new AbortController();
load(controller.signal).then(
(value) => {
if (!controller.signal.aborted) {
setState({ kind: "ready", value });
}
},
() => {
if (!controller.signal.aborted) {
setState({ kind: "failed" });
}
},
);
return () => controller.abort();
}, [enabled, load]);
return state;
}
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
function DisplaysSummary(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly now: Date;
}): ReactElement {
const { config } = props;
const load = useCallback((signal: AbortSignal) => listHostDevices(config, signal), [config]);
const devices = useSummary(true, load);
const active =
devices.kind === "ready" ? devices.value.filter((device) => !device.revoked) : undefined;
const latest = active
?.filter((device) => device.lastSeenAt !== undefined)
.sort((a, b) => (b.lastSeenAt?.getTime() ?? 0) - (a.lastSeenAt?.getTime() ?? 0))[0];
return (
<SummaryCard
detail={
latest?.lastSeenAt === undefined
? active !== undefined && active.length === 0
? "Pair a screen with the code it shows."
: undefined
: `${latest.name} was last seen ${lastSeenPhrase(latest.lastSeenAt, props.now)}.`
}
history={props.history}
icon="settingsDisplays"
links={[
{
key: "displays",
label: active !== undefined && active.length === 0 ? "Add a display" : "Manage displays",
testId: "settings-home-displays",
to: settingsSectionDestination("displays"),
},
]}
status={
devices.kind === "loading"
? "Checking…"
: devices.kind === "failed"
? "Couldn't check displays"
: active?.length === 0
? "No displays paired yet"
: `${plural(active?.length ?? 0, "display", "displays")} paired`
}
testId="settings-summary-displays"
title={SETTINGS_SECTION_COPY.displays.label}
/>
);
}
function ModulesSummary(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly lifecycle: boolean;
readonly modules: readonly DashboardSettingsModule[];
}): ReactElement {
const { config } = props;
const load = useCallback(
(signal: AbortSignal) => listHostModuleLifecycle(config, signal),
[config],
);
const lifecycle = useSummary(props.lifecycle, load);
const installed = lifecycle.kind === "ready" ? lifecycle.value : undefined;
const off = installed?.filter((module) => module.state === "disabled").length ?? 0;
const blocked = installed?.filter((module) => module.state === "blocked").length ?? 0;
const links = [
...(props.lifecycle
? [
{
key: "installed",
label: "Manage modules",
testId: "settings-home-discover",
to: { kind: "discover", view: "installed" } as const,
},
]
: []),
...props.modules.map((module) => ({
key: `module-${module.id}`,
label: `${module.name} settings`,
testId: `settings-home-module-${module.id}`,
to: { kind: "module-settings", moduleId: module.id } as const,
})),
];
return (
<SummaryCard
detail={
installed === undefined || installed.length === 0
? undefined
: off === 0 && blocked === 0
? "All of them are running."
: [
off === 0 ? undefined : `${off} turned off`,
blocked === 0 ? undefined : `${blocked} can't start`,
]
.filter((part) => part !== undefined)
.join(" · ")
}
history={props.history}
icon="module"
links={links}
status={
!props.lifecycle
? "Module settings"
: lifecycle.kind === "loading"
? "Checking…"
: lifecycle.kind === "failed"
? "Couldn't check modules"
: installed?.length === 0
? "No modules installed"
: `${plural(installed?.length ?? 0, "module", "modules")} installed`
}
testId="settings-summary-modules"
title="Modules"
/>
);
}
/** What General, Branding, and System can honestly say today: each is not built yet, so its card is
* one restrained line rather than a "Not available yet" box (ADR 0064). */
const SECTION_STATE: Readonly<Record<"general" | "branding" | "system", string>> = Object.freeze({
branding: "Using the default name and colours",
general: "Using the defaults",
system: "Details aren't shown here yet",
});
/**
* `/settings`: a real landing page, not a redirect to the first section — the two groups the
* person may open, each item named and described in a line. Not a dashboard of its own.
* `/settings`: a real landing page, never a redirect to the first section (ADR 0061) — and, since
* the sidebar already lists every section, not a second copy of that list either (ADR 0064). It
* answers "what state is this installation in?": how many displays are paired, which modules are
* installed and running, and a line for each other area this person may open, each with the one
* link that changes it. It reads only what already exists — the device list and module lifecycle —
* and only when this person may see that area.
*/
export function SettingsHomePage(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly settings: DashboardSettings;
/** For tests: the clock "last seen" is measured against. */
readonly now?: () => Date;
}): ReactElement {
const modules = navigableModuleSettings(props.settings);
const empty = props.settings.sections.length === 0 && modules.length === 0;
const { settings } = props;
const modules = navigableModuleSettings(settings);
const empty = settings.sections.length === 0 && modules.length === 0;
const visible = (section: SettingsSection) => settings.sections.includes(section);
const others = (["general", "branding", "system"] as const).filter(visible);
const showModules = visible("discover") || modules.length > 0;
return (
<div data-testid="settings-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader description="How this installation and its modules work." title="Settings" />
<PageHeader
description="The state of this installation, and where to change it."
title="Settings"
/>
{empty ? (
<EmptyState
description="Ask an administrator if you need to change how something works."
title="There are no settings for you to change"
/>
) : undefined}
{props.settings.sections.length > 0 ? (
<Panel title="Platform">
<SettingsLinkList
{visible("displays") || showModules ? (
<div className="ek-shell-overview">
{visible("displays") ? (
<DisplaysSummary
config={props.config}
history={props.history}
items={props.settings.sections.map((section) => ({
description: SETTINGS_SECTION_COPY[section].description,
destination: settingsSectionDestination(section),
icon: SETTINGS_SECTION_COPY[section].icon,
key: section,
label: SETTINGS_SECTION_COPY[section].label,
testId: `settings-home-${section}`,
}))}
now={props.now?.() ?? new Date()}
/>
</Panel>
) : undefined}
{modules.length > 0 ? (
<Panel title="Modules">
<SettingsLinkList
{showModules ? (
<ModulesSummary
config={props.config}
history={props.history}
items={modules.map((module) => ({
destination: { kind: "module-settings", moduleId: module.id },
icon: "module",
key: module.id,
label: module.name,
testId: `settings-home-module-${module.id}`,
}))}
lifecycle={visible("discover")}
modules={modules}
/>
</Panel>
) : undefined}
</div>
) : undefined}
{others.length > 0 ? (
<div className="ek-shell-overview ek-shell-overview--compact">
{others.map((section) => (
<SummaryCard
compact
history={props.history}
icon={SETTINGS_SECTION_COPY[section].icon}
key={section}
links={[
{
key: section,
label: `Open ${SETTINGS_SECTION_COPY[section].label}`,
testId: `settings-home-${section}`,
to: settingsSectionDestination(section),
},
]}
status={SECTION_STATE[section]}
testId={`settings-summary-${section}`}
title={SETTINGS_SECTION_COPY[section].label}
/>
))}
</div>
) : undefined}
</div>
);
}
const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.freeze({
/** The sections that are still placeholders. Displays is a real page (`DisplaysSettingsPage`). */
export type PlaceholderSettingsSection = Exclude<SettingsPageSection, "displays">;
const SECTION_NOT_YET: Readonly<Record<PlaceholderSettingsSection, string>> = Object.freeze({
branding: "Your church's name, logo, and colours will be managed here.",
displays:
"Paired screens will be managed here. Until then, pair and revoke displays from Display devices in the navigation.",
general: "Settings that apply across this installation will be managed here.",
system: "Version, health, and diagnostics for this installation will appear here.",
});
@@ -187,7 +391,7 @@ export function DiscoverPage(props: {
/** `/settings/<section>`: a platform section the person may see, not built yet. */
export function SettingsSectionPage(props: {
readonly history: HostHistory;
readonly section: SettingsPageSection;
readonly section: PlaceholderSettingsSection;
readonly returnTo?: SettingsReturn;
}): ReactElement {
const copy = SETTINGS_SECTION_COPY[props.section];
+24 -23
View File
@@ -1,4 +1,3 @@
import { designToken } from "@ekklesia/design-tokens";
import {
type KeyboardEvent,
type ReactElement,
@@ -12,14 +11,8 @@ import {
useSyncExternalStore,
} from "react";
import { type DashboardRoute, dashboardPath } from "../dashboard-routes.js";
import type {
DashboardNavigation,
DashboardSettings,
EkklesiaHostConfig,
HostAccount,
} from "../host-protocol.js";
import type { DashboardNavigation, DashboardSettings, HostAccount } 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";
@@ -42,7 +35,6 @@ 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 settings: DashboardSettings | "unavailable";
@@ -85,9 +77,10 @@ function SidebarToggle(props: {
/**
* 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.
*
* It is navigation and nothing else (ADR 0064): pairing a display is Settings → Displays.
*/
function Sidebar(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
@@ -121,9 +114,6 @@ function Sidebar(props: {
route={props.route}
settings={props.settings}
/>
{/* 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
@@ -147,7 +137,6 @@ function Sidebar(props: {
* layout returns focus to the trigger.
*/
function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
@@ -232,7 +221,6 @@ function NavigationDrawer(props: {
route={props.route}
settings={props.settings}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu
@@ -262,7 +250,9 @@ function routeKey(route: DashboardRoute): string {
* 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.
* There is no application top bar on a wide screen. On a phone a restrained header stays at the top
* while the page scrolls — the menu trigger and the open page's name, below the status bar or notch
* — so the navigation is always one tap away (ADR 0064).
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
@@ -319,7 +309,9 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
// Never scrolls: the trigger sits in the phone's sticky header, which is always in view, and
// the browser would otherwise scroll the page to clear the header's scroll padding.
target?.focus({ preventScroll: true });
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
@@ -327,12 +319,12 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
return (
<div
className="ek-shell-root"
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%",
}}
@@ -341,7 +333,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
{compact ? undefined : (
<Sidebar
account={props.account}
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
@@ -353,7 +344,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<header className="ek-shell-mobile-bar" data-testid="mobile-header" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
@@ -370,10 +361,22 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
{/* The page's own <h1> names it for assistive technology; this is its visual echo
while that heading is scrolled away, so it is not announced twice. */}
{props.pageTitle === undefined ? undefined : (
<span
aria-hidden="true"
className="ek-shell-mobile-bar__title"
data-testid="mobile-header-title"
>
{props.pageTitle}
</span>
)}
</header>
) : undefined}
<main
aria-label={props.pageTitle}
className="ek-shell-main"
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
@@ -383,7 +386,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
@@ -393,7 +395,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
{showDrawer ? (
<NavigationDrawer
account={props.account}
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
+74 -27
View File
@@ -11,9 +11,9 @@ import { DashboardLink } from "./DashboardLink.js";
import { ModuleIcon } from "./module-icon.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
import { DISCOVER_CHILD_VIEWS } from "../dashboard-routes.js";
import {
DISCOVER_VIEW_COPY,
DISCOVER_CHILD_LABEL,
hasVisibleSettings,
isSettingsRoute,
navigableModuleSettings,
@@ -55,24 +55,54 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement
);
}
/**
* One navigation item: a real link (ADR 0057) laid out by the shell stylesheet.
*
* - `current` is the open page: `aria-current="page"`, a selected background, and weight.
* - `ancestor` is the parent of the open page — Discover while Installed is open, a module while one
* of its pages is — drawn in the primary text colour with an accent icon but no fill, so parent and
* child are never both "selected" and the relationship still reads at a glance.
* - `child` is an item beneath a parent: text only, indented past the parent's label, and — on
* desktop — a smaller control, so it is clearly subordinate. In the drawer it keeps the large touch
* target.
* - `disclosure` marks an item that opens in place into the items beneath it, with a chevron that
* points down while they are showing. It is decorative: the nested list itself, named by its
* parent, carries the structure for assistive technology.
*
* Collapsed, every item has one square geometry, centred on the rail by the stylesheet, whatever its
* level.
*/
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
/** A shell icon by name, a module's own icon, or none — a child item is text beneath its parent. */
readonly icon?: ShellIconName | ReactElement;
readonly current: boolean;
readonly ancestor?: boolean;
readonly child?: boolean;
readonly disclosure?: "open" | "closed";
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) };
const { presentation } = props;
const { tooltip, triggerProps } = useNavTooltip(props.label, presentation.collapsed);
const style: CSSProperties = {
minHeight:
props.child && !presentation.compact
? designToken("control.height-sm")
: controlHeight(presentation),
};
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
dataAttributes={{
"data-nav-ancestor": props.ancestor && !props.current ? "" : undefined,
"data-nav-level": props.child ? "child" : undefined,
}}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
@@ -87,12 +117,21 @@ function NavigationItem(props: {
)}
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
{props.disclosure === undefined || presentation.collapsed ? undefined : (
<span
className="ek-shell-nav-item__disclosure"
data-open={props.disclosure === "open" ? "true" : "false"}
data-testid={`${props.testId}-disclosure`}
>
<ShellIcon name="disclosure" size={16} />
</span>
)}
</DashboardLink>
{tooltip}
</li>
@@ -138,8 +177,10 @@ function ModuleItems(props: {
return (
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
<NavigationItem
ancestor={showPages && openPage !== undefined}
current={resolution !== undefined && (openPage === undefined || !showPages)}
destination={moduleDestination(moduleId)}
{...(entry.pages.length > 0 ? { disclosure: showPages ? "open" : "closed" } : {})}
history={props.history}
icon={<ModuleIcon icon={entry.icon} />}
label={entry.name}
@@ -151,14 +192,15 @@ function ModuleItems(props: {
<li>
<ul
aria-label={entry.name}
className="ek-shell-nav__group ek-shell-nav__nested"
className="ek-shell-nav__group ek-shell-nav__children"
data-testid={`module-subnavigation-${moduleId}`}
// The shared nested indentation (as Settings uses) comes from the class; only the
// list marker is reset here.
// The children's indentation and guide line (as Discover's use) come from the
// class; only the list marker is reset here.
style={{ listStyle: "none" }}
>
{entry.pages.map((page) => (
<NavigationItem
child
current={openPage?.id === page.id}
destination={moduleDestination(moduleId, page.path)}
history={props.history}
@@ -192,9 +234,12 @@ export interface NavigationListProps {
}
/**
* Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is
* open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does
* not, so exactly one item is the current page.
* Discover in the Settings navigation (ADR 0064): one item that opens Discover's own page, where
* modules are found and installed, and — while any Discover page is open — shows Installed and
* Updates beneath it as its children. Exactly one item is the current page: Discover on its own
* page, or the open child, with Discover marked as that child's parent. Collapsed, the rail shows
* Discover alone, current for every Discover page; its children need the labels the rail has no
* room for.
*/
function DiscoverNavigation(props: {
readonly id: string;
@@ -204,42 +249,42 @@ function DiscoverNavigation(props: {
readonly onNavigate?: () => void;
}): ReactElement {
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const open = props.route.kind === "discover";
const { route, presentation } = props;
const openView = route.kind === "discover" ? route.view : undefined;
const showChildren = openView !== undefined && !presentation.collapsed;
const copy = SETTINGS_SECTION_COPY.discover;
return (
<>
<NavigationItem
current={false}
ancestor={showChildren && openView !== "browse"}
current={openView !== undefined && (openView === "browse" || !showChildren)}
destination={settingsSectionDestination("discover")}
disclosure={showChildren ? "open" : "closed"}
history={props.history}
icon={copy.icon}
label={copy.label}
{...onNavigate}
presentation={props.presentation}
presentation={presentation}
testId="select-settings-discover"
/>
{open ? (
{showChildren ? (
<li>
<ul
aria-label={copy.label}
className={
props.presentation.collapsed
? "ek-shell-nav__group"
: "ek-shell-nav__group ek-shell-nav__nested"
}
className="ek-shell-nav__group ek-shell-nav__children"
data-testid="discover-subnavigation"
style={{ listStyle: "none", margin: 0, padding: 0 }}
style={{ listStyle: "none" }}
>
{DISCOVER_VIEWS.map((view) => (
{DISCOVER_CHILD_VIEWS.map((view) => (
<NavigationItem
current={props.route.kind === "discover" && props.route.view === view}
child
current={openView === view}
destination={{ kind: "discover", view }}
history={props.history}
icon={DISCOVER_VIEW_COPY[view].icon}
key={view}
label={DISCOVER_VIEW_COPY[view].label}
label={DISCOVER_CHILD_LABEL[view]}
{...onNavigate}
presentation={props.presentation}
presentation={presentation}
testId={`select-discover-${view}`}
/>
))}
@@ -282,8 +327,10 @@ function SettingsNavigation(props: {
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<ul className="ek-shell-nav__group" style={listStyle}>
<NavigationItem
ancestor={open && route.kind !== "settings"}
current={route.kind === "settings"}
destination={{ kind: "settings" }}
disclosure={open ? "open" : "closed"}
history={props.history}
icon="settings"
label="Settings"
@@ -0,0 +1,30 @@
/**
* How Settings words a display's dates. Coarse on purpose: the server records when a display was last
* in touch at most every few minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so "within the last hour" is
* the finest honest answer, and nothing here ever claims a screen is online right now.
*/
const HOUR_MS = 60 * 60_000;
const DAY_MS = 24 * HOUR_MS;
/** "within the last hour", "3 hours ago", "yesterday", "5 days ago", or the date for anything older
* than a week. */
export function lastSeenPhrase(lastSeenAt: Date, now: Date, locale?: string): string {
const elapsed = Math.max(0, now.getTime() - lastSeenAt.getTime());
if (elapsed < HOUR_MS) {
return "within the last hour";
}
const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (elapsed < DAY_MS) {
return relative.format(-Math.floor(elapsed / HOUR_MS), "hour");
}
if (elapsed < 7 * DAY_MS) {
return relative.format(-Math.floor(elapsed / DAY_MS), "day");
}
return `on ${calendarDate(lastSeenAt, locale)}`;
}
/** A plain calendar date, such as "1 Oct 2026" or "Oct 1, 2026", in the reader's own locale. */
export function calendarDate(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
}
+14 -12
View File
@@ -1,5 +1,6 @@
import type {
DashboardDestination,
DiscoverChildView,
DiscoverView,
SettingsPageSection,
SettingsSection,
@@ -46,48 +47,49 @@ export const SETTINGS_SECTION_COPY: Readonly<
});
/**
* Discover's views (ADR 0061). "Discover" is where module lifecycle lives in the navigation; inside
* it the pages speak of modules — "Discover modules", "Installed modules", "Module updates" —
* because Modules is the product's word for them.
* Discover's views (ADR 0061, ADR 0064). "Discover" is where module lifecycle lives in the
* navigation, and its own page — `browse` — is where modules are found and installed; inside it the
* pages speak of modules — "Discover modules", "Installed modules", "Module updates" — because
* Modules is the product's word for them. Only Installed and Updates are named in the navigation,
* beneath Discover; Discover's own page is Discover's item itself.
*/
export const DISCOVER_VIEW_COPY: Readonly<
Record<
DiscoverView,
{
readonly label: string;
readonly title: string;
readonly description: string;
readonly notYet: string;
readonly icon: ShellIconName;
}
>
> = Object.freeze({
browse: {
description:
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
icon: "discoverBrowse",
label: "Browse",
notYet: "Finding and installing modules will happen here.",
title: "Discover modules",
},
installed: {
description: "The modules installed for your church.",
icon: "discoverInstalled",
label: "Installed",
notYet:
"Seeing your installed modules, turning them on or off, inspecting them, and removing them will happen here.",
title: "Installed modules",
},
updates: {
description: "Updates for your modules and for this installation.",
icon: "discoverUpdates",
label: "Updates",
notYet: "Checking for and applying module and platform updates will happen here.",
title: "Module updates",
},
});
/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */
/** How Discover's children are named beneath it in the navigation. */
export const DISCOVER_CHILD_LABEL: Readonly<Record<DiscoverChildView, string>> = Object.freeze({
installed: "Installed",
updates: "Updates",
});
/** Where a platform section's navigation item leads: its page — for Discover, Discover's own page,
* `/settings/discover`. */
export function settingsSectionDestination(section: SettingsSection): DashboardDestination {
return section === "discover"
? { kind: "discover", view: "browse" }
+10 -7
View File
@@ -1,5 +1,6 @@
import {
Blocks,
ChevronRight,
CircleUserRound,
Compass,
LayoutDashboard,
@@ -8,11 +9,9 @@ import {
Menu,
MonitorSmartphone,
Palette,
PackageCheck,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Search,
Plus,
Server,
Settings,
SlidersHorizontal,
@@ -35,12 +34,13 @@ import type { ReactElement } from "react";
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
addDisplay: Plus,
appearance: SunMoon,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
discoverBrowse: Search,
discoverInstalled: PackageCheck,
discoverUpdates: RefreshCw,
/** A navigation item that opens in place into the pages beneath it: points right while closed,
* down while open. */
disclosure: ChevronRight,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
@@ -56,6 +56,9 @@ export const SHELL_ICONS = Object.freeze({
export type ShellIconName = keyof typeof SHELL_ICONS;
/** The size every navigation icon is drawn at, which the stylesheet lines nested items up against. */
export const SHELL_ICON_SIZE = 20;
/**
* 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
@@ -71,7 +74,7 @@ export function ShellIcon(props: {
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
size={props.size ?? SHELL_ICON_SIZE}
strokeWidth={1.75}
/>
);
+284 -7
View File
@@ -1,4 +1,8 @@
import { designToken } from "@ekklesia/design-tokens";
import { SHELL_ICON_SIZE } from "./shell-icons.js";
/** The phone header's own height: one large control plus a little room above and below it. */
const MOBILE_BAR_HEIGHT = `calc(${designToken("control.height-lg")} + 2 * ${designToken("spacing.xs")})`;
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
@@ -23,6 +27,28 @@ export const SHELL_STYLESHEET = `
width: 1px;
}
/* The authenticated shell's frame. dvh where the browser has it, so a phone's collapsing browser
chrome never leaves the page a toolbar's height too tall. */
.ek-shell-root {
min-height: 100vh;
min-height: 100dvh;
}
/* The page's own region. A phone gets a slimmer margin than a desktop, and on a device with a notch
or a home indicator the page's edges keep clear of them. */
.ek-shell-main {
padding: ${designToken("spacing.xl")};
padding-bottom: calc(${designToken("spacing.xl")} + env(safe-area-inset-bottom, 0px));
padding-right: calc(${designToken("spacing.xl")} + env(safe-area-inset-right, 0px));
}
.ek-shell-root[data-layout="compact"] .ek-shell-main {
padding: ${designToken("spacing.lg")};
padding-bottom: calc(${designToken("spacing.lg")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.lg")} + env(safe-area-inset-left, 0px));
padding-right: calc(${designToken("spacing.lg")} + env(safe-area-inset-right, 0px));
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
@@ -33,6 +59,10 @@ export const SHELL_STYLESHEET = `
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
/* A landscape phone wide enough for the sidebar puts its left edge beside the notch, and its
account area above the home indicator. */
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
transition: width ${designToken("motion.duration-short")} ease;
@@ -59,6 +89,17 @@ export const SHELL_STYLESHEET = `
overflow-y: auto;
}
/* The rail is exactly as wide as its items. A scrollbar would take its width from one side and push
every icon off centre, so the rail scrolls without one (wheel, touch, and keyboard focus still
scroll it). */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll {
scrollbar-width: none;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll::-webkit-scrollbar {
display: none;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
@@ -114,11 +155,49 @@ export const SHELL_STYLESHEET = `
padding-inline-start: ${designToken("spacing.xs")};
}
/* A parent's children — a module's pages, Discover's Installed and Updates (ADR 0064). The guide
line runs down from beneath the parent's icon, and the children's text starts past the parent's
label, so the relationship reads at a glance. */
.ek-shell-nav__children {
border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")};
display: grid;
gap: ${designToken("spacing.2xs")};
margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.xs")}
calc(${designToken("control.padding-inline")} + ${SHELL_ICON_SIZE / 2}px);
padding: 0 0 0 ${designToken("spacing.lg")};
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
/* A navigation link (DashboardLink). Its layout and open-page look live here, never inline, so
the collapsed rail below can override them (ADR 0064). */
.ek-shell-link {
justify-content: flex-start;
text-decoration: none;
width: 100%;
}
.ek-shell-link[aria-current="page"],
.ek-shell-link[aria-current="page"]:hover:not(:disabled) {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
font-weight: ${designToken("typography.weight-bold")};
}
/* The parent of the open page: named in the primary colour, its icon in the accent, and no fill —
the fill belongs to the open page alone. */
.ek-shell-link[data-nav-ancestor] {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-shell-link[data-nav-ancestor] > svg {
color: ${designToken("accent.strong")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
@@ -131,16 +210,40 @@ export const SHELL_STYLESHEET = `
flex: 0 0 auto;
}
.ek-shell-nav-item[data-nav-level="child"]:not([aria-current="page"]) {
font-weight: ${designToken("typography.weight-regular")};
}
.ek-shell-nav-item__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-nav-item__disclosure {
color: ${designToken("text.muted")};
display: inline-flex;
flex: 0 0 auto;
transition: transform ${designToken("motion.duration-short")} ease;
}
.ek-shell-nav-item__disclosure[data-open="true"] {
transform: rotate(90deg);
}
/* Collapsed, every item is the same square, centred on the rail, with its glyph centred in it —
whatever its level, and whatever the expanded layout's padding, gap, or alignment would say. */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
/* A block-level flex box, so the auto margins centre it on the rail. */
display: flex;
gap: 0;
height: ${designToken("control.height-md")};
justify-content: center;
padding-inline: 0;
margin-inline: auto;
padding: 0;
width: ${designToken("control.height-md")};
}
.ek-shell-settings-link {
@@ -183,6 +286,142 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-sm")};
}
/* Settings → Displays: one card per paired screen, wrapping onto two lines on a phone. */
.ek-shell-display-list {
display: grid;
gap: ${designToken("spacing.sm")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-display-card {
align-items: center;
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")} ${designToken("spacing.md")};
padding: ${designToken("spacing.md")};
}
.ek-shell-display-card__icon {
align-items: center;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.secondary")};
display: inline-flex;
flex: 0 0 auto;
height: ${designToken("control.height-md")};
justify-content: center;
width: ${designToken("control.height-md")};
}
.ek-shell-display-card__copy {
display: grid;
flex: 1 1 12rem;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-shell-display-card__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-shell-display-card__name {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
overflow-wrap: anywhere;
}
.ek-shell-display-card__facts {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
/* The Settings overview (ADR 0064): what state the installation is in, a card per area. */
.ek-shell-overview {
display: grid;
gap: ${designToken("spacing.md")};
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.ek-shell-overview--compact {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.ek-shell-summary {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.sm")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
grid-template-rows: auto 1fr auto;
min-width: 0;
padding: ${designToken("spacing.lg")};
}
.ek-shell-summary__heading {
align-items: center;
color: ${designToken("text.secondary")};
display: flex;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
gap: ${designToken("spacing.sm")};
margin: 0;
}
.ek-shell-summary__status {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-lg")};
font-weight: ${designToken("typography.weight-bold")};
margin: 0;
}
.ek-shell-summary--compact .ek-shell-summary__status {
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
}
.ek-shell-summary__detail {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-shell-summary__links {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.xs")} ${designToken("spacing.md")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-summary__link {
border-radius: ${designToken("radius.sm")};
color: ${designToken("accent.strong")};
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
text-decoration: none;
}
.ek-shell-summary__link:hover {
text-decoration: underline;
}
.ek-shell-summary__link:focus-visible {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
@@ -314,12 +553,16 @@ export const SHELL_STYLESHEET = `
color: ${designToken("state.selected-foreground")};
}
.ek-shell-choice__input {
accent-color: ${designToken("accent.base")};
flex: 0 0 auto;
height: 1.25rem;
/* The radio itself is @ekklesia/ui's shared indicator (ek-radio__input), larger here because the
whole row is the target and the radio is its one visual anchor. */
.ek-shell-choice .ek-shell-choice__input {
--ek-choice-size: 1.25rem;
margin: 0.125rem 0 0;
width: 1.25rem;
}
/* The whole row carries the focus ring (above), so the radio inside it draws none of its own. */
.ek-shell-choice .ek-shell-choice__input:focus-visible {
outline: none;
}
.ek-shell-choice__copy {
@@ -350,8 +593,39 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-xs")};
}
/* The phone header (ADR 0064): the menu trigger and the open page's name, kept at the top while the
page scrolls, below the status bar or notch (viewport-fit=cover). Not a desktop top bar: it
holds nothing else. */
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
align-items: center;
background: ${designToken("background.canvas")};
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
gap: ${designToken("spacing.sm")};
min-height: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px));
padding: calc(${designToken("spacing.xs")} + env(safe-area-inset-top, 0px))
calc(${designToken("spacing.sm")} + env(safe-area-inset-right, 0px))
${designToken("spacing.xs")}
calc(${designToken("spacing.sm")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
z-index: 10;
}
.ek-shell-mobile-bar__title {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Something scrolled into view — a focused field, a fragment — lands below the header, not under it. */
html:has(.ek-shell-mobile-bar) {
scroll-padding-top: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px) + ${designToken("spacing.sm")});
}
.ek-shell-drawer-backdrop {
@@ -372,6 +646,9 @@ export const SHELL_STYLESHEET = `
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px));
position: fixed;
width: 20rem;
z-index: 41;
+34 -15
View File
@@ -11,7 +11,8 @@
* / Overview
* /settings Settings
* /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/discover Discover itself: finding and installing modules (ADR 0064)
* /settings/discover/<view> Discover's other views: installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account your account
* /account/<section> a personal settings page: appearance
@@ -71,14 +72,27 @@ export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const);
export type AccountSection = (typeof ACCOUNT_SECTIONS)[number];
/**
* Discover's views, in navigation order: Browse (find and install modules), Installed (see and
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
* opens. They live beneath `/settings/discover/`, so none can collide with a module id.
* Discover's views (ADR 0064). `browse` — finding and installing modules — is Discover's own page,
* at `/settings/discover` itself; it is never a child of Discover, so it is never listed beneath it.
* Installed (see and manage installed modules) and Updates (module and platform updates) are
* Discover's children, in navigation order, beneath `/settings/discover/`, so none can collide with
* a module id.
*/
export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const);
export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
/** Discover's children: the views shown beneath it in the navigation, in order. */
export const DISCOVER_CHILD_VIEWS = Object.freeze(["installed", "updates"] as const);
export type DiscoverChildView = (typeof DISCOVER_CHILD_VIEWS)[number];
/**
* The earlier spelling of Discover's own page (ADR 0061). Still read, so a bookmark keeps working,
* and respelled in place to `/settings/discover`; never linked to.
*/
export const DISCOVER_BROWSE_ALIAS = "browse";
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
@@ -101,15 +115,15 @@ const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const hostNamespaces: ReadonlySet<string> = new Set(HOST_NAMESPACES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
const discoverChildViews: ReadonlySet<string> = new Set(DISCOVER_CHILD_VIEWS);
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
function isSettingsPageSection(value: string): value is SettingsPageSection {
return value !== "discover" && settingsSections.has(value);
}
function isDiscoverView(value: string): value is DiscoverView {
return discoverViews.has(value);
function isDiscoverChildView(value: string): value is DiscoverChildView {
return discoverChildViews.has(value);
}
function isAccountSection(value: string): value is AccountSection {
@@ -204,10 +218,11 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
/**
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
* module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose
* canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is
* one Browse address rather than two. Anything deeper is reserved for later Settings pages and is
* `not-found` until they exist, rather than silently showing their parent.
* module's settings. `/settings/discover` is Discover's own page, where modules are found and
* installed (ADR 0064); `/settings/discover/browse`, its earlier spelling, reads as the same page and
* the shell respells it in place, so there is one address for it rather than two. Anything deeper is
* reserved for later Settings pages and is `not-found` until they exist, rather than silently showing
* their parent.
*/
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
if (rest.length === 0) {
@@ -222,9 +237,13 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
return { kind: "discover", view: "browse" };
}
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
return rest.length === 2 && view !== undefined && isDiscoverView(view)
? { kind: "discover", view }
: { kind: "not-found" };
if (rest.length !== 2 || view === undefined) {
return { kind: "not-found" };
}
if (view === DISCOVER_BROWSE_ALIAS) {
return { kind: "discover", view: "browse" };
}
return isDiscoverChildView(view) ? { kind: "discover", view } : { kind: "not-found" };
}
if (rest.length !== 1) {
return { kind: "not-found" };
@@ -261,7 +280,7 @@ export function dashboardPath(route: DashboardDestination): string {
case "settings-section":
return `/settings/${route.section}`;
case "discover":
return `/settings/discover/${route.view}`;
return route.view === "browse" ? "/settings/discover" : `/settings/discover/${route.view}`;
case "module-settings":
return `/settings/${encodeURIComponent(route.moduleId)}`;
case "account":
+63 -2
View File
@@ -502,13 +502,26 @@ export async function pollDevicePairing(
return status;
}
/** One paired display, as the administrator's panel shows it. Deliberately no credential, no hash,
* and no pairing history — none of that is anything an operator can act on. */
/** One paired display, as Settings → Displays shows it. Deliberately no credential, no hash, and no
* pairing history — none of that is anything an operator can act on. `lastSeenAt` is coarse by
* design (the server records it at most every few minutes), so it says when a screen was last in
* touch, never that it is online right now. */
export interface HostDeviceSummary {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly revoked: boolean;
readonly pairedAt: Date | undefined;
readonly lastSeenAt: Date | undefined;
}
/** An optional ISO timestamp from the server: a `Date`, or `undefined` when absent or unreadable. */
function optionalTimestamp(value: unknown): Date | undefined {
if (typeof value !== "string") {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
export async function lookupDevicePairing(
@@ -574,6 +587,8 @@ export async function listHostDevices(
name,
permissions: Object.freeze(permissions.map((permission) => String(permission))),
revoked: (entry as { revoked?: unknown }).revoked === true,
pairedAt: optionalTimestamp((entry as { createdAt?: unknown }).createdAt),
lastSeenAt: optionalTimestamp((entry as { lastSeenAt?: unknown }).lastSeenAt),
});
}),
);
@@ -607,6 +622,52 @@ export async function revokeHostDevice(
}
}
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
export interface HostModuleLifecycleSummary {
readonly id: string;
readonly version: string;
readonly state: "running" | "disabled" | "blocked";
}
const MODULE_RUNTIME_STATES = new Set(["running", "disabled", "blocked"]);
/**
* The installed modules and whether each is running, from the existing lifecycle resource
* (`GET /api/v1/module-lifecycle`). It requires `core.modules.lifecycle.manage` — the same permission
* that shows Discover — so a person who may not see Discover is refused, never shown a partial list.
*/
export async function listHostModuleLifecycle(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostModuleLifecycleSummary[]> {
const payload = await requestPlatformJson(config, "/api/v1/module-lifecycle", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
const modules =
typeof payload === "object" && payload !== null && "modules" in payload
? (payload as { modules: unknown }).modules
: undefined;
if (!Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
modules.map((module) => {
const id = nonEmptyString((module as { id?: unknown }).id);
const version = nonEmptyString((module as { version?: unknown }).version);
const state = (module as { runtime?: { state?: unknown } }).runtime?.state;
if (
id === undefined ||
version === undefined ||
typeof state !== "string" ||
!MODULE_RUNTIME_STATES.has(state)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ id, state: state as HostModuleLifecycleSummary["state"], version });
}),
);
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
+22 -6
View File
@@ -12,6 +12,7 @@ import {
returnTargetFromSearch,
dashboardPath,
parseDashboardPath,
DISCOVER_CHILD_VIEWS,
DISCOVER_VIEWS,
SETTINGS_SECTIONS,
} from "../src/dashboard-routes.js";
@@ -134,26 +135,41 @@ describe("Settings routes (ADR 0061)", () => {
}
});
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
for (const view of DISCOVER_VIEWS) {
it("reads Discover's own page at /settings/discover, and Installed and Updates beneath it", () => {
expect(DISCOVER_CHILD_VIEWS).toEqual(["installed", "updates"]);
// Discover itself is where modules are found and installed (ADR 0064): one canonical address.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(parseDashboardPath("/settings/discover/")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover");
for (const view of DISCOVER_CHILD_VIEWS) {
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
kind: "discover",
view,
});
expect(dashboardPath({ kind: "discover", view }), view).toBe(`/settings/discover/${view}`);
}
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
// place, so there is one meaningful Browse address rather than two.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
for (const pathname of [
"/settings/discover/catalog",
"/settings/discover/browse/signage",
"/settings/discover/installed/signage",
"/settings/discover//",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("still reads the earlier /settings/discover/browse, only as an alias the shell respells", () => {
// A bookmark from before ADR 0064 keeps working: it reads as Discover's own page, whose one
// canonical spelling is `/settings/discover`, so the shell replaces it in place.
expect(parseDashboardPath("/settings/discover/browse")).toEqual({
kind: "discover",
view: "browse",
});
expect(dashboardPath(parseDashboardPath("/settings/discover/browse") as never)).toBe(
"/settings/discover",
);
});
it("reads every other name as a module's settings — never under /settings/modules/", () => {
expect(parseDashboardPath("/settings/signage")).toEqual({
kind: "module-settings",
-106
View File
@@ -1,106 +0,0 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevicePanel } from "../src/browser/DevicePanel.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
function server() {
let revoked = false;
const revokes: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/devices")) {
return json({
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
});
}
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
revoked = true;
revokes.push(url);
return new Response(null, { status: 204 });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, revokes };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function click(testId: string): Promise<void> {
await act(async () => {
query(testId)?.click();
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
*/
describe("display device revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
const platform = server();
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
expect(dialog?.textContent).toContain("pairing code");
expect(platform.revokes).toEqual([]);
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.revokes).toEqual([]);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = server();
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.revokes).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(query("device-device-1")?.textContent).toContain("(revoked)");
});
});
@@ -0,0 +1,256 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DisplaysSettingsPage } from "../src/browser/DisplaysSettingsPage.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
*/
const apiBaseUrl = "https://ekklesia.example.test";
const NOW = new Date("2026-10-15T12:00:00.000Z");
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface ServerOptions {
readonly devices?: unknown[];
readonly denied?: boolean;
}
function server(options: ServerOptions = {}) {
let devices: unknown[] = options.devices ?? [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: "2026-10-15T11:50:00.000Z",
name: "Foyer TV",
permissions: [],
revoked: false,
},
];
const requests: { path: string; body: unknown }[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, path });
if (path === "/api/v1/devices") {
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
}
if (path === "/api/v1/devices/device-1/revoke") {
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
return new Response(null, { status: 204 });
}
if (path === "/api/v1/device-pairing/lookups") {
return (body as { code: string }).code === "123456"
? json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: "signage",
permissionId: "signage.display",
requestedAt: "2026-10-15T11:59:00.000Z",
})
: json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
}
if (path === "/api/v1/device-pairing/approvals") {
devices = [
...devices,
{
createdAt: "2026-10-15T12:00:00.000Z",
id: "device-2",
name: (body as { deviceName: string }).deviceName,
permissions: [],
revoked: false,
},
];
return json({ moduleId: "signage", permissionId: "signage.display" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, requests };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function click(testId: string): Promise<void> {
await act(async () => {
required(testId).click();
await settle();
});
}
async function type(testId: string, value: string): Promise<void> {
const input = required(testId) as HTMLInputElement;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function submit(testId: string): Promise<void> {
const form = (required(testId) as HTMLButtonElement).form;
await act(async () => {
form?.requestSubmit();
await settle();
});
}
async function renderPage(options: ServerOptions = {}) {
const platform = server(options);
await act(async () => {
root.render(
<DisplaysSettingsPage
back={{ href: "/settings", label: "Back to Settings", onBack: () => undefined }}
config={{ apiBaseUrl, fetch: platform.fetch }}
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
now={() => NOW}
/>,
);
await settle();
});
return platform;
}
describe("Settings → Displays", () => {
it("lists the paired displays with what is known about each, and takes no focus", async () => {
const before = document.activeElement;
await renderPage();
const card = required("device-device-1");
expect(card.textContent).toContain("Foyer TV");
expect(card.textContent).toContain("Paired");
expect(card.textContent).toContain("Last seen within the last hour");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// No pairing form until someone asks for one, and nothing grabbed focus.
expect(container.querySelector("input")).toBeNull();
expect(document.activeElement).toBe(before);
});
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
await renderPage({ devices: [] });
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
await act(async () => root.unmount());
root = createRoot(container);
await renderPage({ denied: true });
expect(container.textContent).toContain("Displays aren't available to you");
expect(query("add-display")).toBeNull();
});
it("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
const platform = await renderPage();
await click("add-display");
const dialog = required("add-display-dialog");
expect(dialog.getAttribute("role")).toBe("dialog");
// Typing the code is now plainly the next thing to do, so the field has focus.
expect(document.activeElement).toBe(required("device-pairing-code-input"));
await type("device-pairing-code-input", "999999");
await submit("device-pairing-lookup");
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
await type("device-pairing-code-input", "123456");
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain("Signage");
expect(document.activeElement).toBe(required("device-pairing-name"));
await type("device-pairing-name", "Sanctuary left");
await submit("device-pairing-approve");
expect(query("add-display-dialog")).toBeNull();
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: "123456", deviceName: "Sanctuary left" });
// The new display appears without a page refresh, and the page says so.
expect(required("device-device-2").textContent).toContain("Sanctuary left");
expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired");
// Focus is back on the control that opened the flow.
expect(document.activeElement).toBe(required("add-display"));
});
it("closes the Add display flow on Cancel without pairing anything", async () => {
const platform = await renderPage();
await click("add-display");
await click("add-display-cancel");
expect(query("add-display-dialog")).toBeNull();
expect(platform.requests.map((r) => r.path)).toEqual(["/api/v1/devices"]);
expect(document.activeElement).toBe(required("add-display"));
});
});
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the page explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
*/
describe("display revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
const platform = await renderPage();
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
expect(dialog?.textContent).toContain("pairing code");
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.requests.some((r) => r.path.endsWith("/revoke"))).toBe(false);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = await renderPage();
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(required("device-device-1").textContent).toContain("Revoked");
expect(query("device-revoke-device-1")).toBeNull();
});
});
+2 -2
View File
@@ -487,8 +487,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/v1/dashboard/settings",
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
"/api/v1/account",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
// Nothing else: the sidebar is navigation only, so the shell no longer asks for the device
// list on every page. Displays are Settings → Displays' own business (ADR 0064).
]);
});
+310 -25
View File
@@ -73,6 +73,50 @@ const ADMINISTRATOR: DashboardSettings = {
sections: ["general", "branding", "displays", "discover", "system"],
};
const DEVICES = [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(),
name: "Foyer TV",
permissions: ["signage.display"],
revoked: false,
},
{
createdAt: "2026-08-01T09:00:00.000Z",
id: "device-2",
name: "Old hall screen",
permissions: ["signage.display"],
revoked: true,
},
];
const LIFECYCLE = [
{
configured: { enabled: true, explicit: false },
id: "signage",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.5",
},
{
configured: { enabled: false, explicit: true },
id: "archive",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
];
/** Lets pending requests settle, so the overview's summaries have answered. */
async function settle(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly calls: string[];
@@ -84,7 +128,9 @@ async function render(
options: {
readonly settings?: DashboardSettings | "fails";
readonly compact?: boolean;
readonly collapsed?: boolean;
readonly history?: MemoryHostHistory;
readonly devices?: unknown[];
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
@@ -107,6 +153,19 @@ async function render(
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
}
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
const visible = options.settings ?? ADMINISTRATOR;
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
if (url.endsWith("/api/v1/devices")) {
return sections.includes("displays")
? Response.json({ devices: options.devices ?? DEVICES })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.endsWith("/api/v1/module-lifecycle")) {
return sections.includes("discover")
? Response.json({ modules: LIFECYCLE })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}) as unknown as typeof fetch;
await act(async () => {
@@ -125,7 +184,9 @@ async function render(
},
})}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
sidebarPreference={createMemorySidebarPreference(
options.collapsed ? "collapsed" : undefined,
)}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
@@ -157,33 +218,70 @@ const current = () =>
);
describe("the Settings landing page", () => {
it("is a real page, not a redirect, listing what this person may open", async () => {
const { history, titles } = await render("/settings");
it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => {
const { history, titles, calls } = await render("/settings");
await settle();
expect(history.entries).toEqual(["/settings"]);
expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
const home = required("settings-home");
expect(home.querySelector("h1")?.textContent).toBe("Settings");
// Each area is a summary of its state, read from what already exists (ADR 0064).
expect(required("settings-summary-displays-status").textContent).toBe("1 display paired");
expect(required("settings-summary-displays").textContent).toContain(
"Foyer TV was last seen within the last hour.",
);
expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed");
expect(required("settings-summary-modules").textContent).toContain("1 turned off");
expect(required("settings-summary-branding-status").textContent).toBe(
"Using the default name and colours",
);
expect(calls).toContain("/api/v1/devices");
expect(calls).toContain("/api/v1/module-lifecycle");
// The one link that changes each area, rather than the sidebar's list again.
expect(
[...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/settings/displays",
"/settings/discover/installed",
"/settings/signage",
"/settings/general",
"/settings/branding",
"/settings/displays",
"/settings/discover/browse",
"/settings/system",
"/settings/signage",
]);
// Not one navigation item per section, the way the sidebar lists them.
expect(home.querySelector(".ek-shell-settings-link")).toBeNull();
// A disabled module's settings are not offered.
expect(query("settings-home-module-archive")).toBeNull();
// Each group is a section of the page, below its one h1.
expect(
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
).toEqual(["Platform", "Modules"]);
).toEqual(["Displays", "Modules", "General", "Branding", "System"]);
// Nothing on it takes focus by itself.
expect(document.activeElement?.tagName).not.toBe("INPUT");
expect(titles.at(-1)).toBe("Settings");
expect(current()).toEqual(["select-settings"]);
});
it("summarises only what this person may see, and asks for nothing else", async () => {
const { calls } = await render("/settings", {
settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] },
});
await settle();
expect(query("settings-summary-displays")).toBeNull();
expect(required("settings-summary-modules-status").textContent).toBe("Module settings");
expect(calls).not.toContain("/api/v1/devices");
expect(calls).not.toContain("/api/v1/module-lifecycle");
});
it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => {
await render("/settings", { devices: [] });
await settle();
expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet");
expect(required("settings-home-displays").textContent).toBe("Add a display");
});
it("says plainly when there is nothing for this person to change", async () => {
await render("/settings", { settings: { modules: [], sections: [] } });
@@ -195,7 +293,7 @@ describe("the Settings landing page", () => {
describe("the platform sections", () => {
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
for (const section of ["general", "branding", "system"] as const) {
await act(async () => {
root.unmount();
});
@@ -215,10 +313,10 @@ describe("the platform sections", () => {
}
});
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
const { history, titles } = await render("/settings/discover");
expect(history.entries).toEqual(["/settings/discover/browse"]);
expect(history.entries).toEqual(["/settings/discover"]);
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
"Discover modules",
);
@@ -226,38 +324,86 @@ describe("the platform sections", () => {
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
);
expect(titles.at(-1)).toBe("Discover modules");
expect(current()).toEqual(["select-discover-browse"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
"/settings/discover/browse",
);
// Discover is the current page; it is not a parent of some separate Browse page.
expect(current()).toEqual(["select-settings-discover"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover");
expect(query("select-discover-browse")).toBeNull();
});
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover/browse");
it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => {
const { history } = await render("/settings/discover/browse?return=%2Fsignage");
const views = [...required("discover-subnavigation").querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]);
expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]);
expect(query("settings-discover-browse")).not.toBeNull();
expect(current()).toEqual(["select-settings-discover"]);
// Nothing links to the alias.
expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull();
});
it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover");
const children = required("discover-subnavigation");
const views = [...children.querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]);
expect(views.map((view) => view.getAttribute("href"))).toEqual([
"/settings/discover/browse",
"/settings/discover/installed",
"/settings/discover/updates",
]);
// Children: text only, marked as children, and listed beneath Discover by name.
expect(children.getAttribute("aria-label")).toBe("Discover");
expect(children.className).toContain("ek-shell-nav__children");
for (const view of views) {
expect(view.dataset.navLevel).toBe("child");
expect(view.querySelector("svg")).toBeNull();
}
expect(required("select-settings-discover-disclosure").dataset.open).toBe("true");
await click(required("select-discover-installed"));
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
"Installed modules",
);
// The child is the current page and Discover is marked as its parent, never also current.
expect(current()).toEqual(["select-discover-installed"]);
expect(required("select-settings-discover").dataset.navAncestor).toBe("");
await click(required("select-discover-updates"));
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
"Module updates",
);
await click(required("select-settings-discover"));
expect(current()).toEqual(["select-settings-discover"]);
expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined();
expect(history.entries).toEqual([
"/settings/discover/browse",
"/settings/discover",
"/settings/discover/installed",
"/settings/discover/updates",
"/settings/discover",
]);
});
it("shows Discover closed, with no children, while another Settings page is open", async () => {
await render("/settings/general");
expect(query("discover-subnavigation")).toBeNull();
expect(required("select-settings-discover-disclosure").dataset.open).toBe("false");
});
it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => {
for (const path of ["/settings/discover", "/settings/discover/installed"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path, { collapsed: true });
expect(query("discover-subnavigation"), path).toBeNull();
expect(query("select-discover-installed"), path).toBeNull();
expect(current(), path).toEqual(["select-settings-discover"]);
// No disclosure chevron in the rail: it has no room for the children it would announce.
expect(query("select-settings-discover-disclosure"), path).toBeNull();
}
});
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
await render("/settings/discover/installed", {
settings: { modules: [], sections: ["displays"] },
@@ -383,13 +529,13 @@ describe("Settings in the navigation", () => {
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
const history = createMemoryHostHistory("/settings");
await render("/settings", { history });
await click(required("select-settings-displays"));
await click(required("select-settings-branding"));
await click(required("select-module-settings-signage"));
await act(async () => {
history.go(-1);
});
expect(query("settings-section-displays")).not.toBeNull();
expect(query("settings-section-branding")).not.toBeNull();
await act(async () => {
history.go(1);
});
@@ -436,3 +582,142 @@ describe("Settings in the navigation", () => {
expect(query("settings-section-system")).not.toBeNull();
});
});
describe("the sidebar is navigation only (ADR 0064)", () => {
it("has no display-pairing block, expanded or in the phone drawer", async () => {
await render("/settings");
const sidebar = required("host-sidebar");
expect(query("host-devices")).toBeNull();
expect(sidebar.querySelector("input, form")).toBeNull();
expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu);
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(drawer.querySelector("input, form")).toBeNull();
expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu);
});
it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => {
for (const path of ["/settings", "/settings/displays", "/signage", "/"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path);
await settle();
expect(document.activeElement?.tagName, path).not.toBe("INPUT");
expect(container.querySelector("input"), path).toBeNull();
if (path === "/settings/displays") {
expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe(
"Displays",
);
}
}
// Moving to Displays from the navigation moves focus to the page, never into a field.
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings");
await click(required("select-settings-displays"));
await settle();
expect(document.activeElement).toBe(required("host-main-region"));
expect(required("add-display").textContent).toContain("Add display");
});
});
describe("collapsed navigation geometry (ADR 0064)", () => {
it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => {
await render("/settings/general", { collapsed: true });
const sidebar = required("host-sidebar");
expect(sidebar.dataset.sidebar).toBe("collapsed");
const items = [...sidebar.querySelectorAll<HTMLElement>("nav a")];
expect(items.map((item) => item.dataset.testid)).toEqual([
"select-overview",
"select-signage",
"select-calendar",
"select-settings",
"select-settings-general",
"select-settings-branding",
"select-settings-displays",
"select-settings-discover",
"select-settings-system",
"select-module-settings-signage",
]);
for (const item of items) {
// One class carries the geometry; no inline layout can outrank the collapsed rule.
expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true);
expect(item.style.justifyContent, item.dataset.testid).toBe("");
expect(item.style.width, item.dataset.testid).toBe("");
expect(item.style.padding, item.dataset.testid).toBe("");
// Its name stays for assistive technology; only one glyph is drawn.
expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1);
expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy();
}
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const collapsedRule =
/\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec(
stylesheet,
)?.[1];
expect(collapsedRule).toContain("justify-content: center");
expect(collapsedRule).toContain("margin-inline: auto");
expect(collapsedRule).toContain("padding: 0");
expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)");
expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)");
});
});
describe("the phone header (ADR 0064)", () => {
it("is a sticky header holding the menu trigger and the open page's name", async () => {
await render("/settings/displays", { compact: true });
const header = required("mobile-header");
expect(header.tagName).toBe("HEADER");
expect(header.className).toBe("ek-shell-mobile-bar");
expect(header.contains(required("mobile-navigation-trigger"))).toBe(true);
// A visual echo of the page title; the page's own h1 is what assistive technology reads.
const title = required("mobile-header-title");
expect(title.textContent).toBe("Displays settings");
expect(title.getAttribute("aria-hidden")).toBe("true");
const trigger = required("mobile-navigation-trigger");
expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)");
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1];
expect(rule).toContain("position: sticky");
expect(rule).toContain("top: 0");
expect(rule).toContain("env(safe-area-inset-top, 0px)");
});
it("follows the open page, and the drawer still opens from it and returns focus to it", async () => {
await render("/settings", { compact: true });
expect(required("mobile-header-title").textContent).toBe("Settings");
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(document.activeElement).toBe(required("navigation-drawer-close"));
await act(async () => {
drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
await click(required("mobile-navigation-trigger"));
await click(required("select-settings-discover"));
expect(query("navigation-drawer")).toBeNull();
expect(required("mobile-header-title").textContent).toBe("Discover modules");
expect(document.activeElement).toBe(required("host-main-region"));
});
});