Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 906c015ca8 Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three
defects. This fixes them, and ADR 0065 records the decisions.

- Settings -> Displays now sets up a screen in one place. A "Set up a
  new screen" panel shows the platform display page to open on the
  screen, a Copy link button, and Add display directly beneath it; the
  step-by-step version is folded into a <details>. The addresses come
  from a new GET /api/v1/devices/setup, which requires
  core.devices.manage and lists each running display module's
  /display/<module-id> page. Nothing names Signage. With no display
  module installed, the panel says so and offers no Add display.
- iOS Safari zoomed into the pairing field because the shared
  .ek-control rendered at 14px. On phones and touch screens it now
  renders at max(1rem, 16px). The viewport is untouched, so zoom stays
  available, and a test guards that. The shared focus trap now returns
  focus with preventScroll, so closing a dialog no longer moves the page
  under the phone header.
- While the phone drawer is open, the page behind it no longer scrolls.
  The root's overflow is hidden, a non-passive touchmove listener
  cancels drags outside the drawer's list and overscroll past its ends,
  and root overscroll is turned off. The list itself still scrolls.
  Closing restores the exact scroll position unless another page opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:56:22 -05:00
Zayin_Admin 5f9b536bc6 Merge pull request 'Stage 1: UX cleanup from the assembled review — Discover, Settings → Displays, collapsed rail, phone header, radios' (#112) from fix/stage1-ux-review into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 48s
CI / image-build (push) Successful in 33s
CI / migration-smoke (push) Successful in 2m20s
CI / deploy-smoke (same-origin) (push) Successful in 57s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 3m35s
Reviewed-on: #112
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-16 01:51:52 +00:00
ekklesiaandClaude Opus 5.5 405e509bcb Fix the Stage 1 Dashboard UX defects found in assembled review
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with
the sidebar collapsed, and on an iPhone. This fixes what that review
found, and records the decisions in ADR 0064:

- Discover is its own page at /settings/discover, with Installed and
  Updates beneath it as children. There is no Browse item any more.
  /settings/discover/browse is still read, as an alias, and is respelled
  in place. Parents and children now read as a hierarchy: a guide line,
  text-only children, a disclosure chevron, and parent and current
  states that look different.
- The sidebar is navigation only. Display pairing moves from the sidebar
  and drawer to Settings -> Displays: the paired displays, a deliberate
  Add display dialog, and revocation. Nothing takes focus on arrival;
  the code field is focused only once Add display is chosen.
- Collapsed icons are centred. Nav-link layout moves from inline styles
  to the shell stylesheet, and every collapsed item is one square
  centred on the rail.
- /settings is an overview of the installation: displays paired,
  modules installed and running, and a line for each other area. It
  reads only existing endpoints.
- Phones get a sticky, safe-area-aware header holding the menu trigger
  and the page name. New pages start at the top, and focus no longer
  scrolls content under the header.
- @ekklesia/ui draws checkbox and radio indicators from tokens rather
  than accent-color, so the selected state is the strong accent in
  Light and Dark alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:32 -05:00
55 changed files with 3410 additions and 638 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
+17 -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,17 @@ 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** sets up, pairs, and revokes
display screens: how to start a new screen (the platform display page to open on it, with Copy link),
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),
[ADR 0065](docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+27
View File
@@ -13,6 +13,7 @@ import {
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
/**
* Host-owned device identity transport: pairing a display, and managing what has been paired
@@ -315,6 +316,32 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
},
);
/**
* How a new screen starts pairing (ADR 0065): the platform display page for every running module
* that shows content on screens, for Settings → Displays to tell an administrator what to open on
* the screen. Each page is the generic display host at `/display/<module-id>` (ADR 0033), which
* shows a pairing code until the screen is approved.
*
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
* this does not become a way to enumerate what is installed — an unpaired screen still learns
* nothing beyond the page it was told to open. It answers names and root-relative page paths only;
* the browser resolves them against the API origin, which is where the display host lives in both
* topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet.
*/
routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => {
noStore(c);
return c.json(
{
displays: options.moduleUi.listDisplays().map((display) => ({
moduleId: display.moduleId,
name: display.name,
url: moduleDisplayPageUrl(display.moduleId),
})),
},
200,
);
});
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();
@@ -419,6 +419,27 @@ describe.sequential("display device pairing, end to end", () => {
expect((await device.request("/display/announcements")).status).toBe(200);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
// How an administrator learns which page to open on that screen (ADR 0065): the platform
// display page of every module that uses displays, named, and only for an administrator. The
// screen itself — still anonymous — is refused, so setup never enumerates what is installed.
const setup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/devices/setup",
);
expect(setup.status).toBe(200);
expect(setup.headers.get("cache-control")).toBe("no-store");
expect(await setup.json()).toEqual({
displays: [
{
moduleId: "announcements",
name: "Announcements fixture",
url: "/display/announcements",
},
],
});
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
@@ -501,6 +522,7 @@ describe.sequential("display device pairing, end to end", () => {
});
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
expect((await device.request("/api/v1/devices")).status).toBe(403);
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
// 9. The administrator revokes it.
const devices = (await (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+2 -2
View File
@@ -524,10 +524,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.2.6 \
--build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.2.6 \
--tag ekklesia:0.2.5 \
.
```
@@ -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).
@@ -0,0 +1,148 @@
# 0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page
- Status: Accepted
- Date: 2026-10-16
- Extends: [0033](0033-display-kiosk-host-contract.md),
[0036](0036-managed-device-identity-and-pairing.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0045](0045-split-dashboard-and-api-origins.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0064](0064-stage-1-shell-review-corrections.md)
## Context
A second hand review on a real iPhone, after ADR 0064, found three defects:
- **Settings → Displays did not say how to set up a screen.** Add display took a pairing code, but
nothing on the page told an administrator how to get a physical screen to show one. The only setup
instructions were in Signage's own Displays panel. A platform-owned display has to be set up from
the platform, including on an installation without Signage.
- **iOS Safari zoomed into the Add display form.** Focusing the pairing code zoomed the page into the
dialog, and the page stayed zoomed after the dialog closed. The cause is the shared `.ek-control`
font size: `typography.size-sm`, 0.875rem, which is 14px. iOS Safari zooms the page into any
focused text field whose font is smaller than 16px.
- **The page behind the phone drawer still scrolled,** and pulling down behind it triggered the
browser's pull-to-refresh.
The display host is already generic. `/display/<module-id>` is the platform's own page (ADR 0033): it
shows a pairing code, waits for approval, then hosts that module's display artifact. A pairing request
names the module because approval grants exactly that module's display permission (ADR 0036).
However, nothing told an administrator which of these pages exist. ADR 0033's display routes noted:
"There is deliberately no display inventory in this pass. A management UI may need one later." That
management UI now exists.
## Decision
### `GET /api/v1/devices/setup`: where a new screen starts
The API answers, for an administrator holding `core.devices.manage`, the platform display page of
every running module that shows content on screens:
```json
{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] }
```
It reads the same eligible, running display registry as the display routes. It answers nothing to
anyone else: it is never anonymous, so an unpaired screen still cannot enumerate what is installed.
It is `no-store`. The URL is root-relative, and the browser resolves it against the API origin,
because the display host lives there in both topologies (ADR 0045).
**No module-independent display page is added.** A page that let a screen pair before choosing a
module would need a device to exist without a module permission. That is the ROADMAP §5.4 global
display model, and it needs its own decision about assigning devices to modules. Until then, every
screen pairs through a module's display page, and this endpoint is how the platform names those
pages.
### Setting up a screen is one flow, in one place
Settings → Displays opens with a **Set up a new screen** panel, above the paired displays. Its
contents are, in order:
1. one sentence: *Open this address on the new screen, then add it with the code it shows*;
2. the absolute display page, with **Copy link**;
3. **Add display**, right beneath it.
Each step is directly under the one before, so nobody has to go back up the page. **Add display**
lives here and only here, not in the page header.
The step-by-step version is folded away in a native `<details>` (*Step-by-step instructions*), for
whoever wants it, instead of a block of text everyone has to read past. With more than one module
that uses displays, each address is labelled with its module.
When no installed module uses displays, the panel says so and that one installed from Discover will
appear there. It offers no **Add display**, which could only fail. Nothing names Signage. If the
setup address cannot be read, the panel says so and still offers **Add display**, since a screen may
already be showing its code. The paired list keeps working either way.
**Copy link** reaches the clipboard through the clicked button's own window, never a browser global
(ADR 0058). If the browser cannot copy, the button says so and the address stays selectable.
### Text controls are at least 16px on phones and touch screens
`@ekklesia/ui` keeps `.ek-control` at `typography.size-sm` on desktop. Under the Dashboard's compact
breakpoint (`max-width: 47.999em`) or on a coarse pointer, it uses
`max(typography.size-md, 16px)`. Every shared text field and select is affected, in the Dashboard and
in every module root, so later Settings, People, and module forms cannot reintroduce the zoom.
**The viewport is not touched.** There is no `user-scalable=no`, `maximum-scale`, or `minimum-scale`,
and a test holds both shell pages to that, so pinch and accessibility zoom stay available.
Opening and closing a dialog must also not move the page. The shared focus trap now returns focus to
the opening control with `preventScroll`, because that control was on screen when it was used.
Scrolling it into view again, past the phone header's scroll padding, moved the page by the header's
height. The Add display button focuses itself the same way before opening the dialog.
### The drawer locks the page behind it
While the phone drawer is open, `lockDocumentScroll` (`surfaces/shell/src/browser/scroll-lock.ts`)
does three things:
- **The root stops scrolling.** It sets `overflow: hidden` on `<html>` and `<body>`, which ends wheel,
keyboard, and scrollbar scrolling, and touch scrolling on current iOS Safari. It keeps the scroll
position in place. The older iOS workaround, `position: fixed` on the body, moves the page and its
sticky header, which would show through the backdrop as a jump.
- **Touches outside the drawer go nowhere.** A non-passive `touchmove` listener cancels a one-finger
drag that starts outside the drawer's list. It also cancels a drag inside the list that would only
overscroll it past its top or bottom. This covers an iOS that ignores the root's `overflow`, and it
is what stops pull-to-refresh. Two-finger gestures are never cancelled, so pinch zoom stays.
- **Overscroll does not chain.** It sets `overscroll-behavior: none` on the root while locked. The
drawer and its list carry `overscroll-behavior: contain`, and the backdrop carries
`touch-action: pinch-zoom`.
The drawer's own list still scrolls by touch when it is taller than the screen.
Closing restores the inline styles that were there before, and the exact scroll position, unless a
different page opened meanwhile. A page chosen in the drawer, or reached by Back or Forward, starts at
its own top, as before.
When the drawer is closed nothing is set: the page scrolls and refreshes normally. The desktop
sidebar never locks anything. The focus trap, Escape, focus return, `inert`, and safe areas are
unchanged.
## Alternatives
- **Copy Signage's setup address into Settings.** Rejected: it names one module, and it is wrong on an
installation without it.
- **A public `/display` page listing the modules that use displays.** Rejected: it would tell anyone
on the network what is installed, which ADR 0036 deliberately prevents.
- **Pair first, choose the module later.** That is the right long-term model (ROADMAP §5.4). It is
deferred because it changes how a device's permission is granted, and needs its own decision.
- **Use the Dashboard navigation's `display.url`.** Rejected: the navigation lists only modules with
a Dashboard UI, and filters by module permissions. A display-only module, or one the administrator
cannot open, would be missing.
- **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and
hides the cause instead of fixing it.
- **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design.
- **`position: fixed` body scroll lock.** Rejected: it jumps the page, and the sticky phone header,
behind the backdrop.
## Consequences
- An administrator can set up a screen from Settings → Displays alone. Released Signage 0.1.5 keeps
its own instructions, which point to the same platform page, until its follow-up issue
(`Zayin/Signage#24`) is resolved.
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
included.
- A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's
focus-zoom or pull-to-refresh, so automated checks prove the computed font size and the cancelled
gestures, not Safari's own behaviour.
+4 -2
View File
@@ -66,9 +66,11 @@ 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
- [0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.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
+48
View File
@@ -462,6 +462,54 @@ 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.
The second review's corrections
([ADR 0065](../architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)) are
proven here:
- `apps/api/tests/device-pairing.integration.ts` covers `GET /api/v1/devices/setup`. It names each
display page, is `no-store`, and refuses an unpaired or paired screen.
- `surfaces/shell/tests/displays-settings.test.tsx` covers the setup steps on the API origin: Copy
link with and without a clipboard, several modules, none installed, a failed read, and a refused
non-root-relative page.
- `surfaces/shell/tests/scroll-lock.test.ts` covers:
- the root locked and its previous styles restored;
- drags outside the drawer cancelled, and drags inside it allowed except past its ends;
- pinch left alone;
- the scroll position restored unless a new page opened.
- `surfaces/shell/tests/settings-navigation.test.tsx` covers the drawer locking and releasing the
page, and a wide screen never locking.
- `surfaces/shell/tests/viewport.test.ts` keeps zoom available on both shell pages.
- `packages/ui/tests/tokens.test.ts` holds text controls at 16px or more on phones and touch screens.
- `packages/ui/tests/interaction.test.tsx` checks that the focus trap returns focus without scrolling.
- The deploy smoke checks, in real Chromium:
- the setup steps on the API origin;
- the page locked behind the open drawer and released after;
- the pairing field at 16px or more on a phone.
- iOS Safari's own zoom and pull-to-refresh are not reproducible in Chromium, so a change here also
needs a look on a real iPhone.
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ekklesia",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/contracts",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-database",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-pack",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-sdk",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-host",
"version": "0.2.6",
"version": "0.2.5",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-sdk",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/typescript-config",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/ui",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+5 -2
View File
@@ -38,7 +38,7 @@ export interface FocusTrapOptions {
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
* first wraps to the last, so focus cannot walk out into the page behind.
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
* the surface — if that control is still in the document.
* the surface — if that control is still in the document, without scrolling the page.
*
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
* as well as in a document: it only ever looks inside its own container and at the container's own
@@ -68,7 +68,10 @@ export function useFocusTrap(
const target = restoreTo.current;
restoreTo.current = null;
if (target?.isConnected) {
target.focus();
// Without scrolling: the control was on screen when it opened the surface, and scrolling it
// into view again — past a sticky header's scroll padding — would move the page under a
// person who just closed a dialog (ADR 0065).
target.focus({ preventScroll: true });
}
};
}, [active, containerRef, initialFocus]);
+90 -5
View File
@@ -177,6 +177,19 @@ export const EKKLESIA_UI_STYLESHEET = `
border-color: ${designToken("border.strong")};
}
/*
* Text a person types is never smaller than 16px on a phone or a touch screen. iOS Safari zooms the
* whole page into any focused text field whose font is smaller than that, and leaves it zoomed after
* the field closes, so the compact desktop size would turn every form on an iPhone into a zoomed-in
* page (ADR 0065). The fix is the font, never the viewport: zoom stays available to everyone who
* needs it. Desktop keeps its compact size.
*/
@media (max-width: 47.999em), (pointer: coarse) {
.ek-control {
font-size: max(${designToken("typography.size-md")}, 16px);
}
}
.ek-control::placeholder {
color: ${designToken("text.muted")};
}
@@ -198,17 +211,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")};
+13
View File
@@ -251,6 +251,19 @@ describe("ConfirmationDialog", () => {
expect(document.activeElement).toBe(byTestId("opener"));
});
it("returns focus without scrolling the page (ADR 0065)", () => {
openHarness({ onCancel: vi.fn() });
const opener = byTestId("opener");
const focus = vi.spyOn(opener, "focus");
press(dialog(), "Escape");
// The opener was on screen when it opened the dialog; scrolling it into view again would move a
// page with a sticky header under the person who just closed the dialog.
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(document.activeElement).toBe(opener);
});
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
const onCancel = vi.fn();
openHarness({ dialog: { busy: true }, onCancel });
+58
View File
@@ -119,3 +119,61 @@ 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);
});
});
describe("text controls never make iOS Safari zoom (ADR 0065)", () => {
it("renders typed text at 16px or more on phones and touch screens, and compact on desktop", () => {
// iOS Safari zooms the page into a focused field whose font is under 16px.
expect(ruleBody(".ek-control")).toContain("font-size: var(--ekklesia-typography-size-sm)");
const phone =
/@media \(max-width: 47\.999em\), \(pointer: coarse\) \{\s*\.ek-control \{([^}]*)\}/u.exec(
EKKLESIA_UI_STYLESHEET,
)?.[1];
expect(phone).toContain("font-size: max(var(--ekklesia-typography-size-md), 16px)");
});
it("puts the phone rule after the base rule, so it wins", () => {
expect(EKKLESIA_UI_STYLESHEET.indexOf("(pointer: coarse)")).toBeGreaterThan(
EKKLESIA_UI_STYLESHEET.indexOf("\n.ek-control {"),
);
});
});
+84 -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,55 @@ 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",
);
// ADR 0065: how to set up a new screen, in one place — the platform display page of every module
// that uses displays, on the API origin where the display host lives, with Add display right under
// it; or a plain note, and no Add display, when no installed module uses displays.
check(
await waitFor(
`${selector("display-setup")} && !${selector("display-setup-failed")} && (${selector("display-setup-none")} || (${selector("display-setup")}.contains(${selector("add-display")}) && [...document.querySelectorAll('[data-testid="display-setup-url"]')].every((element) => element.textContent.startsWith(${JSON.stringify(`${apiOrigin}/display/`)}))))`,
"the display setup steps",
),
"Settings → Displays explains how to set up a new screen, from the platform's own display page",
);
const displayModuleInstalled = !(await evaluate(`Boolean(${selector("display-setup-none")})`));
if (displayModuleInstalled) {
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");
} else {
check(
!(await evaluate(`Boolean(${selector("add-display")})`)),
"with no module that uses displays, Settings → Displays offers no Add display that could only fail",
);
}
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,11 +662,27 @@ 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",
);
const scrolledTo = await evaluate("scrollY");
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
"Settings in the phone drawer",
);
// ADR 0065: the page behind the open drawer is locked where it was, and only the drawer scrolls.
check(
await evaluate(
`getComputedStyle(document.documentElement).overflowY === "hidden" && getComputedStyle(document.documentElement).overscrollBehaviorY === "none" && scrollY === ${Number(scrolledTo)}`,
),
"opening the phone drawer locks the page behind it in place",
);
await evaluate(`${selector("select-settings-system")}.click()`);
check(
await waitFor(
@@ -632,6 +691,27 @@ check(
),
"the phone drawer offers the same Settings hierarchy and closes once a page is chosen",
);
check(
await evaluate(
`getComputedStyle(document.documentElement).overflowY !== "hidden" && document.documentElement.style.getPropertyValue("overscroll-behavior") === ""`,
),
"choosing a page from the drawer releases the page lock",
);
// ADR 0065: on a phone the pairing code is typed at 16px or more, so iOS Safari does not zoom.
if (displayModuleInstalled) {
await navigate(`${dashboardOrigin}/settings/displays`);
await waitFor(selector("add-display"), "Settings → Displays on a phone");
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`,
"the pairing code field's size on a phone",
),
"the pairing code field is at least 16px on a phone, so iOS does not zoom into it",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone");
}
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
+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
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/shell",
"version": "0.2.6",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+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,592 @@
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 MouseEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
approveDevicePairing,
type EkklesiaHostConfig,
type HostDeviceSummary,
type HostDisplaySetup,
HostProtocolError,
listHostDevices,
listHostDisplaySetup,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
import { calendarDate, lastSeenPhrase } from "./display-time.js";
import { ShellIcon } from "./shell-icons.js";
/**
* Settings → Displays (ADR 0064, ADR 0065): the platform's own administration page for the screens
* paired with this installation — how to set up a new screen, 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 SetupState =
| { readonly kind: "loading" }
| { readonly kind: "ready"; readonly displays: readonly HostDisplaySetup[] }
| { 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>
);
}
/** One display page to open on a screen, with a way to copy it there. */
function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled: boolean }) {
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
return (
<div className="ek-shell-setup-link" data-testid={`display-setup-${props.setup.moduleId}`}>
{props.labelled ? (
<span className="ek-shell-setup-link__module">For {props.setup.name}</span>
) : undefined}
<span className="ek-shell-setup-link__row">
<span className="ek-shell-setup-link__url" data-testid="display-setup-url">
{props.setup.url}
</span>
<Button
data-testid={`display-setup-copy-${props.setup.moduleId}`}
onClick={(event) => {
// The clipboard of this page's own window: the shell reads no browser global here.
const clipboard = event.currentTarget.ownerDocument.defaultView?.navigator.clipboard;
if (clipboard === undefined) {
setCopy("failed");
return;
}
clipboard.writeText(props.setup.url).then(
() => setCopy("copied"),
() => setCopy("failed"),
);
}}
size="sm"
>
Copy link
</Button>
<span aria-live="polite" className="ek-shell-setup-link__status">
{copy === "copied"
? "Link copied."
: copy === "failed"
? "This browser couldn't copy it. Select the address and copy it instead."
: ""}
</span>
</span>
</div>
);
}
/**
* Setting up a new screen, start to finish, in one place (ADR 0065): the address to open on the
* screen, then **Add display** to enter the code it shows — so the next step is always right under
* the one before, never back up the page. One sentence says what to do; the step-by-step version is
* there for whoever wants it, folded away.
*
* It is platform setup, not any module's: each address is the platform's own display page for a
* module that shows content on screens, as the server lists them. With no such module installed it
* says so, and offers no Add display that could only fail. If the addresses cannot be read, Add
* display is still offered, since a screen may already be showing its code.
*/
function DisplaySetupPanel(props: {
readonly setup: SetupState;
readonly onAdd: (event: MouseEvent<HTMLButtonElement>) => void;
}): ReactElement {
const { setup } = props;
const none = setup.kind === "ready" && setup.displays.length === 0;
const several = setup.kind === "ready" && setup.displays.length > 1;
return (
<Panel title="Set up a new screen">
<div data-testid="display-setup" style={{ display: "grid", gap: designToken("spacing.md") }}>
{setup.kind === "loading" ? <LoadingState label="Loading setup…" /> : undefined}
{setup.kind === "failed" ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-failed">
The setup address couldn't be loaded. Reload the page to try again.
</p>
) : undefined}
{none ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-none">
No installed module shows content on screens yet. Once one is installed from Discover,
its setup address appears here.
</p>
) : undefined}
{setup.kind === "ready" && !none ? (
<>
<p className="ek-shell-setup__lead">
Open {several ? "an address" : "this address"} on the new screen, then add it with the
code it shows.
</p>
{setup.displays.map((display) => (
<SetupLink key={display.moduleId} labelled={several} setup={display} />
))}
</>
) : undefined}
{none || setup.kind === "loading" ? undefined : (
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
)}
{setup.kind === "ready" && !none ? (
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
) : undefined}
</div>
</Panel>
);
}
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 [setup, setSetup] = useState<SetupState>({ 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]);
useEffect(() => {
const controller = new AbortController();
listHostDisplaySetup(config, controller.signal).then(
(displays) => {
if (!controller.signal.aborted) {
setSetup({ displays, kind: "ready" });
}
},
() => {
if (!controller.signal.aborted) {
setSetup({ kind: "failed" });
}
},
);
return () => controller.abort();
}, [config]);
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
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>
)}
{canAdd ? (
<DisplaySetupPanel
onAdd={(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 — without
// scrolling, which would move a page whose button sits under the phone header.
event.currentTarget.focus({ preventScroll: true });
setPaired(undefined);
setAdding(true);
}}
setup={setup}
/>
) : undefined}
{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="Set up a screen above, and it appears here once it's approved."
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}
+281 -77
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 {
/** 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 destination: DashboardDestination;
readonly label: string;
readonly description?: string;
readonly icon: ShellIconName;
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>
)}
</span>
</DashboardLink>
</li>
))}
</ul>
<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>
)}
</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
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}`,
}))}
/>
</Panel>
{visible("displays") || showModules ? (
<div className="ek-shell-overview">
{visible("displays") ? (
<DisplaysSummary
config={props.config}
history={props.history}
now={props.now?.() ?? new Date()}
/>
) : undefined}
{showModules ? (
<ModulesSummary
config={props.config}
history={props.history}
lifecycle={visible("discover")}
modules={modules}
/>
) : undefined}
</div>
) : undefined}
{modules.length > 0 ? (
<Panel title="Modules">
<SettingsLinkList
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}`,
}))}
/>
</Panel>
{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];
+49 -24
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";
@@ -29,6 +22,7 @@ import {
type NavigationPresentation,
ShellBrand,
} from "./ShellNavigation.js";
import { lockDocumentScroll } from "./scroll-lock.js";
import { ShellIcon } from "./shell-icons.js";
import { SHELL_STYLESHEET } from "./shell-styles.js";
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
@@ -42,7 +36,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 +78,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 +115,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,12 +138,12 @@ 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";
readonly account: HostAccount | "unavailable";
readonly route: DashboardRoute;
readonly scrollRegionRef: RefObject<HTMLDivElement | null>;
readonly onDismiss: () => void;
readonly onNavigate: () => void;
readonly onSignOut: () => void;
@@ -222,7 +213,11 @@ function NavigationDrawer(props: {
<ShellIcon name="closeNavigation" />
</button>
</div>
<div className="ek-shell-sidebar__scroll">
<div
className="ek-shell-sidebar__scroll"
data-testid="navigation-drawer-scroll"
ref={props.scrollRegionRef}
>
<NavigationList
history={props.history}
id={DRAWER_NAV_ID}
@@ -232,7 +227,6 @@ function NavigationDrawer(props: {
route={props.route}
settings={props.settings}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu
@@ -262,7 +256,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;
@@ -273,8 +269,11 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
const [drawerOpen, setDrawerOpen] = useState(false);
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const drawerScrollRef = useRef<HTMLDivElement>(null);
const previousCompact = useRef(compact);
const previousRoute = useRef(routeKey(route));
const currentRoute = useRef(routeKey(route));
currentRoute.current = routeKey(route);
const toggleMode = (): void => {
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
@@ -319,20 +318,36 @@ 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;
const pageInert = showDrawer ? true : undefined;
// While the drawer is open the page behind it does not scroll — not by touch, not by
// pull-to-refresh, not by keyboard — and only the drawer's own list does (ADR 0065). Closing it
// puts the page back exactly where it was, unless a different page opened meanwhile (chosen in the
// drawer, or by Back and Forward), which starts at its own top instead.
useLayoutEffect(() => {
const owner = mainRef.current?.ownerDocument;
if (!showDrawer || !owner) {
return;
}
const lockedRoute = currentRoute.current;
const release = lockDocumentScroll(owner, drawerScrollRef.current);
return () => release({ restoreScroll: currentRoute.current === lockedRoute });
}, [mainRef, showDrawer]);
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 +356,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 +367,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 +384,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 +409,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,13 +418,13 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
{showDrawer ? (
<NavigationDrawer
account={props.account}
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
scrollRegionRef={drawerScrollRef}
settings={props.settings}
/>
) : undefined}
+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);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Keeps the page behind a modal surface — the phone navigation drawer — from scrolling while the
* surface is open (ADR 0065), and puts everything back exactly when it closes.
*
* Three things together, because no one of them is enough on iOS Safari:
*
* - **The root stops scrolling.** `overflow: hidden` on `<html>` and `<body>` ends wheel, keyboard,
* and scrollbar scrolling everywhere, and on current iOS Safari touch scrolling too. The page's
* scroll position is kept in place, not reset, so nothing jumps: the drawer opens over the page
* exactly where it was. (`position: fixed` on the body, the older iOS workaround, moves the page
* and its sticky header, which shows through the backdrop as a jump.)
* - **Touches outside the drawer go nowhere.** A non-passive `touchmove` listener cancels every
* move that starts outside the region that may scroll, and every move inside it that would only
* overscroll it — past its top or bottom. That is what keeps an older iOS, which ignores the
* root's `overflow`, from scrolling the page behind, and what stops pull-to-refresh.
* - **Overscroll does not chain.** `overscroll-behavior: none` on the root, only while locked, so the
* page cannot rubber-band or refresh; the region itself carries `overscroll-behavior: contain` in
* the stylesheet. When the drawer is closed, nothing here is set and the page behaves normally,
* pull-to-refresh included.
*
* Everything is reached through the document it is given; the shell touches no browser global. The
* returned function undoes exactly what was done, restoring the inline styles that were there
* before, and — unless the drawer closed because a new page is opening, which starts at its own top
* — the scroll position too.
*/
export interface ScrollLockRelease {
(options?: { readonly restoreScroll?: boolean }): void;
}
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
interface SavedStyles {
readonly element: HTMLElement;
readonly values: readonly (readonly [string, string, string])[];
}
function save(element: HTMLElement): SavedStyles {
return {
element,
values: LOCKED_PROPERTIES.map(
(property) =>
[
property,
element.style.getPropertyValue(property),
element.style.getPropertyPriority(property),
] as const,
),
};
}
function restore(saved: SavedStyles): void {
for (const [property, value, priority] of saved.values) {
if (value === "") {
saved.element.style.removeProperty(property);
} else {
saved.element.style.setProperty(property, value, priority);
}
}
}
/** Whether `element` can scroll any further in the direction of a finger moving by `deltaY` (a
* positive delta is the finger moving down, which scrolls the content up, towards its top). */
function canScroll(element: HTMLElement, deltaY: number): boolean {
if (element.scrollHeight <= element.clientHeight) {
return false;
}
return deltaY > 0
? element.scrollTop > 0
: element.scrollTop + element.clientHeight < element.scrollHeight;
}
/**
* Locks `document`'s scrolling. `scrollRegion`, when given, is the one element inside the modal
* surface that may still scroll by touch.
*/
export function lockDocumentScroll(
document: Document,
scrollRegion?: HTMLElement | null,
): ScrollLockRelease {
const view = document.defaultView;
const root = document.documentElement;
const body = document.body;
const scrollX = view?.scrollX ?? 0;
const scrollY = view?.scrollY ?? 0;
const saved = [save(root), save(body)];
for (const element of [root, body]) {
element.style.setProperty("overflow", "hidden");
element.style.setProperty("overscroll-behavior", "none");
}
let lastY: number | undefined;
const onTouchStart = (event: TouchEvent): void => {
lastY = event.touches[0]?.clientY;
};
const onTouchMove = (event: TouchEvent): void => {
if (event.touches.length !== 1) {
// Pinch zoom stays the browser's: locking scroll never takes zoom away.
return;
}
const y = event.touches[0]?.clientY;
const deltaY = y === undefined || lastY === undefined ? 0 : y - lastY;
lastY = y;
const target = event.target;
const inside =
scrollRegion !== undefined &&
scrollRegion !== null &&
target instanceof Node &&
scrollRegion.contains(target);
if (!inside || !canScroll(scrollRegion, deltaY)) {
if (event.cancelable) {
event.preventDefault();
}
}
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchmove", onTouchMove, { passive: false });
let released = false;
return (options = {}) => {
if (released) {
return;
}
released = true;
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
for (const entry of saved) {
restore(entry);
}
const restoreScroll = options.restoreScroll ?? true;
if (restoreScroll && view !== null && (view.scrollX !== scrollX || view.scrollY !== scrollY)) {
view.scrollTo({ behavior: "instant", left: scrollX, top: scrollY });
}
};
}
+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}
/>
);
+357 -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,206 @@ 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;
}
/* Settings → Displays' setup steps (ADR 0065). The address wraps anywhere, so a long hostname never
pushes a phone sideways, and selects whole for copying by hand. */
.ek-shell-setup__lead {
color: ${designToken("text.primary")};
margin: 0;
}
/* The step-by-step version, folded away until someone asks for it. */
.ek-shell-setup__more > summary {
border-radius: ${designToken("radius.sm")};
color: ${designToken("accent.strong")};
cursor: pointer;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
width: fit-content;
}
.ek-shell-setup-steps {
color: ${designToken("text.secondary")};
display: grid;
font-size: ${designToken("typography.size-sm")};
gap: ${designToken("spacing.xs")};
margin: ${designToken("spacing.sm")} 0 0;
padding-inline-start: ${designToken("spacing.xl")};
}
.ek-shell-setup-steps > li {
padding-inline-start: ${designToken("spacing.2xs")};
}
.ek-shell-setup-link {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-setup-link__module {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
}
.ek-shell-setup-link__row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-shell-setup-link__url {
background: ${designToken("background.inset")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
font-size: ${designToken("typography.size-sm")};
min-width: 0;
overflow-wrap: anywhere;
padding: ${designToken("spacing.xs")} ${designToken("spacing.sm")};
user-select: all;
}
.ek-shell-setup-link__status {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
}
/* 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 +617,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,14 +657,47 @@ 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 {
background: ${designToken("background.overlay")};
inset: 0;
position: fixed;
/* A drag on the backdrop moves nothing behind it; a pinch still zooms (ADR 0065). */
touch-action: pinch-zoom;
z-index: 40;
}
@@ -372,10 +712,20 @@ 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));
overscroll-behavior: contain;
position: fixed;
width: 20rem;
z-index: 41;
}
/* The drawer's list scrolls by itself when it is taller than the screen, and reaching its end never
scrolls, bounces, or refreshes the page behind it. */
.ek-shell-drawer .ek-shell-sidebar__scroll {
overscroll-behavior: contain;
}
/* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */
`;
+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":
+103 -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,92 @@ export async function revokeHostDevice(
}
}
/**
* Where a new screen starts pairing, for one module that shows content on screens (ADR 0065): its
* name, and the absolute address of the platform display page a screen opens to show its pairing
* code. The address is the API origin's, because the display host lives there in both topologies
* (ADR 0045).
*/
export interface HostDisplaySetup {
readonly moduleId: string;
readonly name: string;
readonly url: string;
}
/**
* The display pages a new screen can open (`GET /api/v1/devices/setup`), for Settings → Displays.
* Requires `core.devices.manage`, like the device list. An empty list means no installed module
* uses displays yet.
*/
export async function listHostDisplaySetup(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDisplaySetup[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/setup", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (!hasExactKeys(payload, ["displays"]) || !Array.isArray(payload.displays)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
payload.displays.map((entry) => {
const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId);
const name = nonEmptyString((entry as { name?: unknown }).name);
const path = rootRelativePath(entry, "url");
if (!hasExactKeys(entry, ["moduleId", "name", "url"]) || !moduleId || !name || !path) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, name, url: resolveApiUrl(config, path) });
}),
);
}
/** 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,342 @@
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;
/** What `GET /api/v1/devices/setup` answers: the display pages, or a failure. */
readonly setup?: unknown[] | "fails";
}
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/setup") {
if (options.setup === "fails") {
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
}
return json({
displays: options.setup ?? [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
],
});
}
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");
// How to set up a new screen comes first, in one place: the platform display page on the API
// origin, then Add display right under it, with the step-by-step version folded away.
const setup = required("display-setup");
expect(setup.textContent).toContain("Open this address on the new screen");
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/signage`);
expect(setup.contains(required("add-display"))).toBe(true);
const steps = required("display-setup-steps");
expect(steps.tagName).toBe("DETAILS");
expect((steps as HTMLDetailsElement).open).toBe(false);
expect(steps.querySelector("summary")?.textContent).toBe("Step-by-step instructions");
expect(steps.textContent).toContain("six-digit pairing code");
// Not a second Add display up in the header.
expect(container.querySelectorAll('[data-testid="add-display"]')).toHaveLength(1);
// 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).sort()).toEqual([
"/api/v1/devices",
"/api/v1/devices/setup",
]);
expect(document.activeElement).toBe(required("add-display"));
});
});
describe("setting up a new screen (ADR 0065)", () => {
it("copies the setup address, and says so when the browser cannot", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { writeText },
});
await renderPage();
await click("display-setup-copy-signage");
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/signage`);
expect(required("display-setup").textContent).toContain("Link copied.");
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined });
await click("display-setup-copy-signage");
expect(required("display-setup").textContent).toContain("couldn't copy it");
});
it("names each module's page when more than one module uses displays", async () => {
await renderPage({
setup: [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
{ moduleId: "parent-call", name: "Parent Call", url: "/display/parent-call" },
],
});
expect(required("display-setup-signage").textContent).toContain("For Signage");
expect(required("display-setup-parent-call").textContent).toContain(
`For Parent Call${apiBaseUrl}/display/parent-call`,
);
});
it("makes sense with no module that uses displays installed, Signage included", async () => {
await renderPage({ setup: [] });
expect(required("display-setup-none").textContent).toContain(
"No installed module shows content on screens yet",
);
expect(query("display-setup-url")).toBeNull();
// No Add display that could only fail, and nothing names Signage.
expect(query("add-display")).toBeNull();
expect(required("display-setup").textContent).not.toContain("Signage");
});
it("keeps the rest of the page working when the setup address cannot be read", async () => {
await renderPage({ setup: "fails" });
expect(required("display-setup-failed").textContent).toContain("couldn't be loaded");
expect(required("device-device-1").textContent).toContain("Foyer TV");
await click("add-display");
expect(query("add-display-dialog")).not.toBeNull();
});
it("refuses a setup answer that is not a plain root-relative page", async () => {
await renderPage({
setup: [{ moduleId: "signage", name: "Signage", url: "https://elsewhere.example/display" }],
});
expect(query("display-setup-failed")).not.toBeNull();
expect(query("display-setup-url")).toBeNull();
});
});
/**
* 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).
]);
});
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lockDocumentScroll } from "../src/browser/scroll-lock.js";
/**
* The page behind the phone navigation drawer stays put while the drawer is open (ADR 0065): no
* scrolling, no pull-to-refresh, the drawer's own list still scrolls, and closing it puts the page
* back exactly as it was.
*/
let region: HTMLDivElement;
let inside: HTMLSpanElement;
let outside: HTMLDivElement;
beforeEach(() => {
region = document.createElement("div");
inside = document.createElement("span");
region.append(inside);
outside = document.createElement("div");
document.body.append(region, outside);
});
afterEach(() => {
region.remove();
outside.remove();
document.documentElement.removeAttribute("style");
document.body.removeAttribute("style");
vi.restoreAllMocks();
});
/** Gives the region a scrollable size and position, which a DOM without layout does not compute. */
function scrollable(element: HTMLElement, scrollTop: number): void {
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(element, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop });
}
function touch(target: EventTarget, type: "touchstart" | "touchmove", y: number, points = 1) {
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
touches: { clientY: number }[];
};
Object.defineProperty(event, "touches", {
value: Array.from({ length: points }, () => ({ clientY: y })),
});
target.dispatchEvent(event);
return event;
}
describe("the document scroll lock", () => {
it("stops the root from scrolling and overscrolling, and puts the styles back as they were", () => {
document.documentElement.style.setProperty("overflow", "auto");
const release = lockDocumentScroll(document, region);
for (const element of [document.documentElement, document.body]) {
expect(element.style.getPropertyValue("overflow")).toBe("hidden");
expect(element.style.getPropertyValue("overscroll-behavior")).toBe("none");
}
release();
expect(document.documentElement.style.getPropertyValue("overflow")).toBe("auto");
expect(document.documentElement.style.getPropertyValue("overscroll-behavior")).toBe("");
expect(document.body.style.getPropertyValue("overflow")).toBe("");
expect(document.body.style.getPropertyValue("overscroll-behavior")).toBe("");
});
it("cancels a touch drag that starts outside the drawer, which also stops pull-to-refresh", () => {
const release = lockDocumentScroll(document, region);
touch(outside, "touchstart", 100);
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(true);
release();
// Once released, the page scrolls normally again.
touch(outside, "touchstart", 100);
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(false);
});
it("lets the drawer's own list scroll, but never past its ends into the page", () => {
const release = lockDocumentScroll(document, region);
scrollable(region, 200);
touch(inside, "touchstart", 300);
expect(touch(inside, "touchmove", 200).defaultPrevented, "scrolling down").toBe(false);
touch(inside, "touchstart", 200);
expect(touch(inside, "touchmove", 300).defaultPrevented, "scrolling up").toBe(false);
// At its top, pulling down would only overscroll — the gesture behind pull-to-refresh.
scrollable(region, 0);
touch(inside, "touchstart", 200);
expect(touch(inside, "touchmove", 300).defaultPrevented, "pulled past the top").toBe(true);
// At its bottom, pushing up would only overscroll.
scrollable(region, 600);
touch(inside, "touchstart", 300);
expect(touch(inside, "touchmove", 200).defaultPrevented, "pushed past the bottom").toBe(true);
release();
});
it("never takes pinch zoom away", () => {
const release = lockDocumentScroll(document, region);
touch(outside, "touchstart", 100, 2);
expect(touch(outside, "touchmove", 300, 2).defaultPrevented).toBe(false);
release();
});
it("restores the exact scroll position on release, unless a new page opened", () => {
const view = document.defaultView as Window;
Object.defineProperty(view, "scrollY", { configurable: true, value: 1234 });
const scrollTo = vi.spyOn(view, "scrollTo").mockImplementation(() => undefined);
const release = lockDocumentScroll(document, region);
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
release();
expect(scrollTo).toHaveBeenCalledWith({ behavior: "instant", left: 0, top: 1234 });
scrollTo.mockClear();
const again = lockDocumentScroll(document, region);
Object.defineProperty(view, "scrollY", { configurable: true, value: 50 });
again({ restoreScroll: false });
expect(scrollTo).not.toHaveBeenCalled();
// Releasing twice does nothing the second time.
again();
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
});
});
+370 -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,26 @@ 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/setup")) {
return sections.includes("displays")
? Response.json({
displays: [{ moduleId: "signage", name: "Signage", url: "/display/signage" }],
})
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
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 +191,9 @@ async function render(
},
})}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
sidebarPreference={createMemorySidebarPreference(
options.collapsed ? "collapsed" : undefined,
)}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
@@ -157,33 +225,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 +300,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 +320,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 +331,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 +536,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 +589,195 @@ 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"));
});
});
describe("the phone drawer locks the page behind it (ADR 0065)", () => {
const rootOverflow = () => document.documentElement.style.getPropertyValue("overflow");
const rootOverscroll = () =>
document.documentElement.style.getPropertyValue("overscroll-behavior");
it("stops the page scrolling while open, and releases it on Escape", async () => {
await render("/settings", { compact: true });
expect(rootOverflow()).toBe("");
await click(required("mobile-navigation-trigger"));
expect(rootOverflow()).toBe("hidden");
expect(rootOverscroll()).toBe("none");
expect(document.body.style.getPropertyValue("overflow")).toBe("hidden");
// The drawer's own list is the one region that still scrolls.
expect(required("navigation-drawer").contains(required("navigation-drawer-scroll"))).toBe(true);
await act(async () => {
required("navigation-drawer").dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
);
});
expect(query("navigation-drawer")).toBeNull();
expect(rootOverflow()).toBe("");
expect(rootOverscroll()).toBe("");
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
});
it("cancels a drag on the page behind it, and releases it when a page is chosen", async () => {
await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drag = new Event("touchmove", { bubbles: true, cancelable: true });
Object.defineProperty(drag, "touches", { value: [{ clientY: 300 }] });
required("host-main-region").dispatchEvent(drag);
expect(drag.defaultPrevented).toBe(true);
await click(required("select-settings-displays"));
expect(query("navigation-drawer")).toBeNull();
expect(rootOverflow()).toBe("");
const after = new Event("touchmove", { bubbles: true, cancelable: true });
Object.defineProperty(after, "touches", { value: [{ clientY: 300 }] });
required("host-main-region").dispatchEvent(after);
expect(after.defaultPrevented).toBe(false);
});
it("never locks anything on a wide screen", async () => {
await render("/settings");
expect(query("mobile-navigation-trigger")).toBeNull();
expect(rootOverflow()).toBe("");
expect(document.body.style.getPropertyValue("overflow")).toBe("");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Zoom stays available to everyone who needs it (ADR 0065). iOS Safari's zoom into small form
* fields is fixed by the fields' own font size, never by locking the viewport.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function viewport(page: string): Promise<string> {
const html = await readFile(path.join(packageDirectory, page), "utf8");
const tag = /<meta\s+content="([^"]*)"\s+name="viewport"\s*\/?>/u.exec(html);
if (!tag?.[1]) {
throw new Error(`${page} has no viewport tag`);
}
return tag[1];
}
describe("the shell's pages never disable zoom", () => {
for (const page of ["index.html", "display.html"]) {
it(`${page} allows pinch and accessibility zoom`, async () => {
const content = (await viewport(page)).replace(/\s+/gu, "");
expect(content).toContain("width=device-width");
expect(content).not.toMatch(/user-scalable=(no|0)/iu);
expect(content).not.toMatch(/maximum-scale/iu);
expect(content).not.toMatch(/minimum-scale/iu);
});
}
it("lets the Dashboard lay itself out around a notch", async () => {
expect(await viewport("index.html")).toContain("viewport-fit=cover");
});
});
+8 -8
View File
@@ -13,18 +13,18 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
},
"dependencies": {
"@ekklesia/design-tokens": "^0.2.6",
"@ekklesia/module-database": "^0.2.6",
"@ekklesia/module-sdk": "^0.2.6",
"@ekklesia/module-ui-sdk": "^0.2.6",
"@ekklesia/ui": "^0.2.6",
"@ekklesia/design-tokens": "^0.2.5",
"@ekklesia/module-database": "^0.2.5",
"@ekklesia/module-sdk": "^0.2.5",
"@ekklesia/module-ui-sdk": "^0.2.5",
"@ekklesia/ui": "^0.2.5",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@ekklesia/module-pack": "0.2.6",
"@ekklesia/module-ui-build": "^0.2.6",
"@ekklesia/typescript-config": "^0.2.6",
"@ekklesia/module-pack": "0.2.5",
"@ekklesia/module-ui-build": "^0.2.5",
"@ekklesia/typescript-config": "^0.2.5",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",