Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce56de663d |
@@ -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 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.
|
||||
- **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.
|
||||
|
||||
## ADRs are authoritative
|
||||
|
||||
|
||||
@@ -925,12 +925,9 @@ 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 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
|
||||
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
|
||||
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
|
||||
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
|
||||
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
|
||||
sidebar's identity slot is still to come.
|
||||
|
||||
@@ -950,17 +947,11 @@ 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` 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.
|
||||
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.
|
||||
|
||||
**Modules are first-class parts of the Dashboard**
|
||||
([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
@@ -316,32 +315,6 @@ 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,27 +419,6 @@ 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",
|
||||
@@ -522,7 +501,6 @@ 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+2
-2
@@ -524,10 +524,10 @@ finds its files:
|
||||
```sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg VERSION=0.2.5 \
|
||||
--build-arg VERSION=0.2.6 \
|
||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||
--tag ekklesia:0.2.5 \
|
||||
--tag ekklesia:0.2.6 \
|
||||
.
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
- 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,7 +3,6 @@
|
||||
- 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),
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,148 +0,0 @@
|
||||
# 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.
|
||||
@@ -66,11 +66,9 @@ 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 (device panel and phone menu placement superseded by 0064)
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
- [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 (Discover's address and the landing page superseded by 0064)
|
||||
- [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
|
||||
- [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
|
||||
|
||||
@@ -602,11 +602,9 @@ 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 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
|
||||
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
|
||||
(`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
|
||||
|
||||
@@ -462,54 +462,6 @@ 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ekklesia",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/design-tokens",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-database",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-pack",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-sdk",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-build",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-host",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-sdk",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/typescript-config",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/ui",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -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, without scrolling the page.
|
||||
* the surface — if that control is still in the document.
|
||||
*
|
||||
* 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,10 +68,7 @@ export function useFocusTrap(
|
||||
const target = restoreTo.current;
|
||||
restoreTo.current = null;
|
||||
if (target?.isConnected) {
|
||||
// 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 });
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
}, [active, containerRef, initialFocus]);
|
||||
|
||||
@@ -177,19 +177,6 @@ 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")};
|
||||
}
|
||||
@@ -211,89 +198,17 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 {
|
||||
border-radius: ${designToken("radius.sm")};
|
||||
accent-color: ${designToken("accent.base")};
|
||||
height: ${designToken("spacing.lg")};
|
||||
width: ${designToken("spacing.lg")};
|
||||
}
|
||||
|
||||
.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 {
|
||||
.ek-checkbox__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")};
|
||||
|
||||
@@ -251,19 +251,6 @@ 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 });
|
||||
|
||||
@@ -119,61 +119,3 @@ 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 {"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,8 +574,7 @@ if (openedModuleId) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings (ADR 0061, ADR 0064): a deep link, a refresh, Displays, Discover, a module's settings,
|
||||
// the drawer ----------------------------------------------------------------------------------------
|
||||
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
|
||||
await navigate(`${dashboardOrigin}/settings`);
|
||||
check(
|
||||
await waitFor(
|
||||
@@ -594,55 +593,13 @@ 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" && ${selector("settings-discover-browse")} && ${selector("select-settings-discover")}?.getAttribute("aria-current") === "page"`,
|
||||
"Discover's own page",
|
||||
`location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`,
|
||||
"Discover opening at Browse",
|
||||
),
|
||||
"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",
|
||||
"entering Discover opens Browse at its one canonical address",
|
||||
);
|
||||
if (openedModuleId) {
|
||||
await navigate(`${dashboardOrigin}/settings/${openedModuleId}`);
|
||||
@@ -662,27 +619,11 @@ 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(
|
||||
@@ -691,27 +632,6 @@ 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,
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<html lang="en" style="min-width: 0; scrollbar-gutter: stable">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- 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" />
|
||||
<meta content="width=device-width, initial-scale=1" 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/shell",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -195,7 +195,7 @@ function ChoiceGroup<Value extends string>(props: {
|
||||
>
|
||||
<input
|
||||
checked={checked}
|
||||
className="ek-radio__input ek-shell-choice__input ek-focusable"
|
||||
className="ek-shell-choice__input ek-focusable"
|
||||
name={props.name}
|
||||
onChange={() => props.onChange(option.value)}
|
||||
type="radio"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
@@ -16,9 +17,7 @@ export interface DashboardLinkProps {
|
||||
readonly children: ReactNode;
|
||||
/** Marks the link as the page currently open, for assistive technology and visually. */
|
||||
readonly current?: boolean;
|
||||
/** `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";
|
||||
readonly variant?: "navigation" | "action";
|
||||
/** Called after an in-place navigation, including to the page already open — for a drawer that
|
||||
* closes once a destination is chosen. Not called for a click the browser handles itself. */
|
||||
readonly onNavigate?: () => void;
|
||||
@@ -30,8 +29,6 @@ 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";
|
||||
@@ -39,19 +36,24 @@ 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 ek-shell-link",
|
||||
text: "ek-focusable",
|
||||
navigation: "ek-button ek-focusable ek-button--quiet",
|
||||
} as const;
|
||||
|
||||
const actionStyle: CSSProperties = { textDecoration: "none" };
|
||||
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"),
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
export function isBrowserHandledClick(event: {
|
||||
@@ -83,7 +85,12 @@ export function isBrowserHandledClick(event: {
|
||||
export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
const href = dashboardPath(props.to);
|
||||
const variant = props.variant ?? "navigation";
|
||||
const baseStyle = variant === "navigation" ? undefined : actionStyle;
|
||||
const baseStyle =
|
||||
variant === "action"
|
||||
? { textDecoration: "none" }
|
||||
: props.current
|
||||
? currentStyle
|
||||
: navigationStyle;
|
||||
return (
|
||||
<a
|
||||
aria-current={props.current ? "page" : undefined}
|
||||
@@ -108,7 +115,6 @@ 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}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ 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";
|
||||
@@ -60,7 +59,6 @@ import {
|
||||
SettingsHomePage,
|
||||
SettingsLoadFailedPage,
|
||||
SettingsSectionPage,
|
||||
settingsBack,
|
||||
type SettingsReturn,
|
||||
SettingsUnavailablePage,
|
||||
} from "./SettingsPages.js";
|
||||
@@ -202,26 +200,17 @@ 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 { config, page, history, returnTo } = props;
|
||||
const { page, history, returnTo } = props;
|
||||
switch (page.kind) {
|
||||
case "home":
|
||||
return <SettingsHomePage config={config} history={history} settings={page.settings} />;
|
||||
return <SettingsHomePage history={history} settings={page.settings} />;
|
||||
case "section":
|
||||
return page.section === "displays" ? (
|
||||
<DisplaysSettingsPage
|
||||
back={settingsBack(history, returnTo)}
|
||||
config={config}
|
||||
moduleName={returnTo.moduleName}
|
||||
/>
|
||||
) : (
|
||||
<SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />
|
||||
);
|
||||
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
|
||||
case "discover":
|
||||
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
|
||||
case "module":
|
||||
@@ -672,12 +661,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
return;
|
||||
}
|
||||
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
|
||||
// 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 });
|
||||
mainRef.current?.focus();
|
||||
}
|
||||
shownRouteRef.current = currentRouteKey;
|
||||
}, [currentRouteKey, state.status]);
|
||||
@@ -773,6 +757,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
|
||||
return (
|
||||
<ShellLayout
|
||||
config={config}
|
||||
history={history}
|
||||
mainRef={mainRef}
|
||||
account={bootstrap.account}
|
||||
@@ -786,7 +771,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<SettingsPageView
|
||||
config={config}
|
||||
history={history}
|
||||
onRetry={retryBootstrap}
|
||||
page={settingsPage}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Button, EmptyState, ErrorState, PageHeader } from "@ekklesia/ui";
|
||||
import { type ReactElement, type ReactNode, useCallback, useEffect, useId, useState } from "react";
|
||||
import { Button, EmptyState, ErrorState, PageHeader, Panel } from "@ekklesia/ui";
|
||||
import type { ReactElement } from "react";
|
||||
import type {
|
||||
DashboardDestination,
|
||||
DiscoverView,
|
||||
SettingsPageSection,
|
||||
SettingsSection,
|
||||
} from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardSettings,
|
||||
type DashboardSettingsModule,
|
||||
type EkklesiaHostConfig,
|
||||
listHostDevices,
|
||||
listHostModuleLifecycle,
|
||||
} from "../host-protocol.js";
|
||||
import type { DashboardSettings, DashboardSettingsModule } 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,
|
||||
@@ -29,9 +21,9 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
/**
|
||||
* The Dashboard's Settings pages (ADR 0061).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
|
||||
@@ -49,7 +41,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()`. */
|
||||
export function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
return dashboardBack(history, {
|
||||
fallback: BACK_TO_SETTINGS,
|
||||
label: (destination) => {
|
||||
@@ -64,302 +56,106 @@ export function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
function SettingsLinkList(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly items: readonly {
|
||||
readonly key: string;
|
||||
readonly destination: DashboardDestination;
|
||||
readonly label: string;
|
||||
readonly to: DashboardDestination;
|
||||
readonly description?: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
}[];
|
||||
readonly history: HostHistory;
|
||||
readonly compact?: boolean;
|
||||
}): ReactElement {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className={props.compact ? "ek-shell-summary ek-shell-summary--compact" : "ek-shell-summary"}
|
||||
data-testid={props.testId}
|
||||
<ul
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: designToken("spacing.xs"),
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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, 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.
|
||||
* `/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.
|
||||
*/
|
||||
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 { 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;
|
||||
const modules = navigableModuleSettings(props.settings);
|
||||
const empty = props.settings.sections.length === 0 && modules.length === 0;
|
||||
return (
|
||||
<div data-testid="settings-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<PageHeader
|
||||
description="The state of this installation, and where to change it."
|
||||
title="Settings"
|
||||
/>
|
||||
<PageHeader description="How this installation and its modules work." 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}
|
||||
{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>
|
||||
{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>
|
||||
) : undefined}
|
||||
{others.length > 0 ? (
|
||||
<div className="ek-shell-overview ek-shell-overview--compact">
|
||||
{others.map((section) => (
|
||||
<SummaryCard
|
||||
compact
|
||||
history={props.history}
|
||||
icon={SETTINGS_SECTION_COPY[section].icon}
|
||||
key={section}
|
||||
links={[
|
||||
{
|
||||
key: section,
|
||||
label: `Open ${SETTINGS_SECTION_COPY[section].label}`,
|
||||
testId: `settings-home-${section}`,
|
||||
to: settingsSectionDestination(section),
|
||||
},
|
||||
]}
|
||||
status={SECTION_STATE[section]}
|
||||
testId={`settings-summary-${section}`}
|
||||
title={SETTINGS_SECTION_COPY[section].label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{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>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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({
|
||||
const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, 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.",
|
||||
});
|
||||
@@ -391,7 +187,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: PlaceholderSettingsSection;
|
||||
readonly section: SettingsPageSection;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = SETTINGS_SECTION_COPY[props.section];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
@@ -11,8 +12,14 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { type DashboardRoute, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { DashboardNavigation, DashboardSettings, HostAccount } from "../host-protocol.js";
|
||||
import type {
|
||||
DashboardNavigation,
|
||||
DashboardSettings,
|
||||
EkklesiaHostConfig,
|
||||
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";
|
||||
@@ -22,7 +29,6 @@ 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";
|
||||
@@ -36,6 +42,7 @@ 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";
|
||||
@@ -78,10 +85,9 @@ 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";
|
||||
@@ -115,6 +121,9 @@ 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
|
||||
@@ -138,12 +147,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;
|
||||
@@ -213,11 +222,7 @@ function NavigationDrawer(props: {
|
||||
<ShellIcon name="closeNavigation" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ek-shell-sidebar__scroll"
|
||||
data-testid="navigation-drawer-scroll"
|
||||
ref={props.scrollRegionRef}
|
||||
>
|
||||
<div className="ek-shell-sidebar__scroll">
|
||||
<NavigationList
|
||||
history={props.history}
|
||||
id={DRAWER_NAV_ID}
|
||||
@@ -227,6 +232,7 @@ function NavigationDrawer(props: {
|
||||
route={props.route}
|
||||
settings={props.settings}
|
||||
/>
|
||||
<DevicePanel config={props.config} />
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__bottom">
|
||||
<AccountMenu
|
||||
@@ -256,9 +262,7 @@ 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 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).
|
||||
* There is no application top bar: on a phone the one control above the page is the menu trigger.
|
||||
*/
|
||||
export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
const { viewport, sidebarPreference, mainRef, route } = props;
|
||||
@@ -269,11 +273,8 @@ 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";
|
||||
@@ -318,36 +319,20 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
}
|
||||
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
|
||||
pendingFocus.current = undefined;
|
||||
// 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 });
|
||||
target?.focus();
|
||||
}, [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%",
|
||||
}}
|
||||
@@ -356,6 +341,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
{compact ? undefined : (
|
||||
<Sidebar
|
||||
account={props.account}
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
mode={mode}
|
||||
navigation={props.navigation}
|
||||
@@ -367,7 +353,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{compact ? (
|
||||
<header className="ek-shell-mobile-bar" data-testid="mobile-header" inert={pageInert}>
|
||||
<div className="ek-shell-mobile-bar" inert={pageInert}>
|
||||
<button
|
||||
aria-controls={DRAWER_ID}
|
||||
aria-expanded={showDrawer}
|
||||
@@ -384,22 +370,10 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
>
|
||||
<ShellIcon name="openNavigation" size={24} />
|
||||
</button>
|
||||
{/* 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>
|
||||
</div>
|
||||
) : undefined}
|
||||
<main
|
||||
aria-label={props.pageTitle}
|
||||
className="ek-shell-main"
|
||||
data-testid="host-main-region"
|
||||
inert={pageInert}
|
||||
ref={mainRef}
|
||||
@@ -409,6 +383,7 @@ 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}
|
||||
>
|
||||
@@ -418,13 +393,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}
|
||||
|
||||
@@ -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_CHILD_VIEWS } from "../dashboard-routes.js";
|
||||
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
|
||||
import {
|
||||
DISCOVER_CHILD_LABEL,
|
||||
DISCOVER_VIEW_COPY,
|
||||
hasVisibleSettings,
|
||||
isSettingsRoute,
|
||||
navigableModuleSettings,
|
||||
@@ -55,54 +55,24 @@ 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 child item is text beneath its parent. */
|
||||
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
|
||||
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 { presentation } = props;
|
||||
const { tooltip, triggerProps } = useNavTooltip(props.label, presentation.collapsed);
|
||||
const style: CSSProperties = {
|
||||
minHeight:
|
||||
props.child && !presentation.compact
|
||||
? designToken("control.height-sm")
|
||||
: controlHeight(presentation),
|
||||
};
|
||||
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
|
||||
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
|
||||
return (
|
||||
<li>
|
||||
<DashboardLink
|
||||
className="ek-shell-nav-item"
|
||||
current={props.current}
|
||||
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 } : {})}
|
||||
@@ -117,21 +87,12 @@ function NavigationItem(props: {
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
|
||||
props.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>
|
||||
@@ -177,10 +138,8 @@ 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}
|
||||
@@ -192,15 +151,14 @@ function ModuleItems(props: {
|
||||
<li>
|
||||
<ul
|
||||
aria-label={entry.name}
|
||||
className="ek-shell-nav__group ek-shell-nav__children"
|
||||
className="ek-shell-nav__group ek-shell-nav__nested"
|
||||
data-testid={`module-subnavigation-${moduleId}`}
|
||||
// The children's indentation and guide line (as Discover's use) come from the
|
||||
// class; only the list marker is reset here.
|
||||
// The shared nested indentation (as Settings uses) comes 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}
|
||||
@@ -234,12 +192,9 @@ export interface NavigationListProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function DiscoverNavigation(props: {
|
||||
readonly id: string;
|
||||
@@ -249,42 +204,42 @@ function DiscoverNavigation(props: {
|
||||
readonly onNavigate?: () => void;
|
||||
}): ReactElement {
|
||||
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
||||
const { route, presentation } = props;
|
||||
const openView = route.kind === "discover" ? route.view : undefined;
|
||||
const showChildren = openView !== undefined && !presentation.collapsed;
|
||||
const open = props.route.kind === "discover";
|
||||
const copy = SETTINGS_SECTION_COPY.discover;
|
||||
return (
|
||||
<>
|
||||
<NavigationItem
|
||||
ancestor={showChildren && openView !== "browse"}
|
||||
current={openView !== undefined && (openView === "browse" || !showChildren)}
|
||||
current={false}
|
||||
destination={settingsSectionDestination("discover")}
|
||||
disclosure={showChildren ? "open" : "closed"}
|
||||
history={props.history}
|
||||
icon={copy.icon}
|
||||
label={copy.label}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
presentation={props.presentation}
|
||||
testId="select-settings-discover"
|
||||
/>
|
||||
{showChildren ? (
|
||||
{open ? (
|
||||
<li>
|
||||
<ul
|
||||
aria-label={copy.label}
|
||||
className="ek-shell-nav__group ek-shell-nav__children"
|
||||
className={
|
||||
props.presentation.collapsed
|
||||
? "ek-shell-nav__group"
|
||||
: "ek-shell-nav__group ek-shell-nav__nested"
|
||||
}
|
||||
data-testid="discover-subnavigation"
|
||||
style={{ listStyle: "none" }}
|
||||
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
||||
>
|
||||
{DISCOVER_CHILD_VIEWS.map((view) => (
|
||||
{DISCOVER_VIEWS.map((view) => (
|
||||
<NavigationItem
|
||||
child
|
||||
current={openView === view}
|
||||
current={props.route.kind === "discover" && props.route.view === view}
|
||||
destination={{ kind: "discover", view }}
|
||||
history={props.history}
|
||||
icon={DISCOVER_VIEW_COPY[view].icon}
|
||||
key={view}
|
||||
label={DISCOVER_CHILD_LABEL[view]}
|
||||
label={DISCOVER_VIEW_COPY[view].label}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
presentation={props.presentation}
|
||||
testId={`select-discover-${view}`}
|
||||
/>
|
||||
))}
|
||||
@@ -327,10 +282,8 @@ 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"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
DashboardDestination,
|
||||
DiscoverChildView,
|
||||
DiscoverView,
|
||||
SettingsPageSection,
|
||||
SettingsSection,
|
||||
@@ -47,49 +46,48 @@ export const SETTINGS_SECTION_COPY: Readonly<
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
/** 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`. */
|
||||
/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */
|
||||
export function settingsSectionDestination(section: SettingsSection): DashboardDestination {
|
||||
return section === "discover"
|
||||
? { kind: "discover", view: "browse" }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Blocks,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Compass,
|
||||
LayoutDashboard,
|
||||
@@ -9,9 +8,11 @@ import {
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Palette,
|
||||
PackageCheck,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
@@ -34,13 +35,12 @@ import type { ReactElement } from "react";
|
||||
*/
|
||||
export const SHELL_ICONS = Object.freeze({
|
||||
account: CircleUserRound,
|
||||
addDisplay: Plus,
|
||||
appearance: SunMoon,
|
||||
closeNavigation: X,
|
||||
collapseSidebar: PanelLeftClose,
|
||||
/** A navigation item that opens in place into the pages beneath it: points right while closed,
|
||||
* down while open. */
|
||||
disclosure: ChevronRight,
|
||||
discoverBrowse: Search,
|
||||
discoverInstalled: PackageCheck,
|
||||
discoverUpdates: RefreshCw,
|
||||
expandSidebar: PanelLeftOpen,
|
||||
module: Blocks,
|
||||
openNavigation: Menu,
|
||||
@@ -56,9 +56,6 @@ 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
|
||||
@@ -74,7 +71,7 @@ export function ShellIcon(props: {
|
||||
aria-hidden="true"
|
||||
data-icon={props.name}
|
||||
focusable="false"
|
||||
size={props.size ?? SHELL_ICON_SIZE}
|
||||
size={props.size ?? 20}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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
|
||||
@@ -27,28 +23,6 @@ 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")};
|
||||
@@ -59,10 +33,6 @@ 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;
|
||||
@@ -89,17 +59,6 @@ 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")};
|
||||
@@ -155,49 +114,11 @@ 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")};
|
||||
@@ -210,40 +131,16 @@ 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;
|
||||
margin-inline: auto;
|
||||
padding: 0;
|
||||
width: ${designToken("control.height-md")};
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.ek-shell-settings-link {
|
||||
@@ -286,206 +183,6 @@ 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")};
|
||||
@@ -617,16 +314,12 @@ export const SHELL_STYLESHEET = `
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.ek-shell-choice__input {
|
||||
accent-color: ${designToken("accent.base")};
|
||||
flex: 0 0 auto;
|
||||
height: 1.25rem;
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.ek-shell-choice__copy {
|
||||
@@ -657,47 +350,14 @@ 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 {
|
||||
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")});
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -712,20 +372,10 @@ html:has(.ek-shell-mobile-bar) {
|
||||
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). */
|
||||
`;
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
* / Overview
|
||||
* /settings Settings
|
||||
* /settings/<section> a platform Settings section: general, branding, displays, system
|
||||
* /settings/discover Discover itself: finding and installing modules (ADR 0064)
|
||||
* /settings/discover/<view> Discover's other views: installed, updates
|
||||
* /settings/discover/<view> finding, installing, and managing modules: browse, 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
|
||||
@@ -72,27 +71,14 @@ export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const);
|
||||
export type AccountSection = (typeof ACCOUNT_SECTIONS)[number];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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" }
|
||||
@@ -115,15 +101,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 discoverChildViews: ReadonlySet<string> = new Set(DISCOVER_CHILD_VIEWS);
|
||||
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
||||
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
|
||||
|
||||
function isSettingsPageSection(value: string): value is SettingsPageSection {
|
||||
return value !== "discover" && settingsSections.has(value);
|
||||
}
|
||||
|
||||
function isDiscoverChildView(value: string): value is DiscoverChildView {
|
||||
return discoverChildViews.has(value);
|
||||
function isDiscoverView(value: string): value is DiscoverView {
|
||||
return discoverViews.has(value);
|
||||
}
|
||||
|
||||
function isAccountSection(value: string): value is AccountSection {
|
||||
@@ -218,11 +204,10 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
|
||||
/**
|
||||
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
|
||||
if (rest.length === 0) {
|
||||
@@ -237,13 +222,9 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
|
||||
return { kind: "discover", view: "browse" };
|
||||
}
|
||||
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
|
||||
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" };
|
||||
return rest.length === 2 && view !== undefined && isDiscoverView(view)
|
||||
? { kind: "discover", view }
|
||||
: { kind: "not-found" };
|
||||
}
|
||||
if (rest.length !== 1) {
|
||||
return { kind: "not-found" };
|
||||
@@ -280,7 +261,7 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
case "settings-section":
|
||||
return `/settings/${route.section}`;
|
||||
case "discover":
|
||||
return route.view === "browse" ? "/settings/discover" : `/settings/discover/${route.view}`;
|
||||
return `/settings/discover/${route.view}`;
|
||||
case "module-settings":
|
||||
return `/settings/${encodeURIComponent(route.moduleId)}`;
|
||||
case "account":
|
||||
|
||||
@@ -502,26 +502,13 @@ export async function pollDevicePairing(
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
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(
|
||||
@@ -587,8 +574,6 @@ 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),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -622,92 +607,6 @@ 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);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
returnTargetFromSearch,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
DISCOVER_CHILD_VIEWS,
|
||||
DISCOVER_VIEWS,
|
||||
SETTINGS_SECTIONS,
|
||||
} from "../src/dashboard-routes.js";
|
||||
@@ -135,41 +134,26 @@ describe("Settings routes (ADR 0061)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
|
||||
for (const view of DISCOVER_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",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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)");
|
||||
});
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
// 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).
|
||||
// The host-owned device panel loads with the rest of the shell for an administrator.
|
||||
"/api/v1/devices",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -73,50 +73,6 @@ 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[];
|
||||
@@ -128,9 +84,7 @@ 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);
|
||||
@@ -153,26 +107,6 @@ 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 () => {
|
||||
@@ -191,9 +125,7 @@ async function render(
|
||||
},
|
||||
})}
|
||||
setDocumentTitle={(title) => titles.push(title)}
|
||||
sidebarPreference={createMemorySidebarPreference(
|
||||
options.collapsed ? "collapsed" : undefined,
|
||||
)}
|
||||
sidebarPreference={createMemorySidebarPreference()}
|
||||
viewport={createMemoryHostViewport(options.compact ?? false)}
|
||||
/>,
|
||||
);
|
||||
@@ -225,70 +157,33 @@ const current = () =>
|
||||
);
|
||||
|
||||
describe("the Settings landing page", () => {
|
||||
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();
|
||||
it("is a real page, not a redirect, listing what this person may open", async () => {
|
||||
const { history, titles } = await render("/settings");
|
||||
|
||||
expect(history.entries).toEqual(["/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(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
|
||||
expect(
|
||||
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
|
||||
[...container.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(["Displays", "Modules", "General", "Branding", "System"]);
|
||||
// Nothing on it takes focus by itself.
|
||||
expect(document.activeElement?.tagName).not.toBe("INPUT");
|
||||
).toEqual(["Platform", "Modules"]);
|
||||
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: [] } });
|
||||
|
||||
@@ -300,7 +195,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", "system"] as const) {
|
||||
for (const section of ["general", "branding", "displays", "system"] as const) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
@@ -320,10 +215,10 @@ describe("the platform sections", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
|
||||
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
|
||||
const { history, titles } = await render("/settings/discover");
|
||||
|
||||
expect(history.entries).toEqual(["/settings/discover"]);
|
||||
expect(history.entries).toEqual(["/settings/discover/browse"]);
|
||||
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
|
||||
"Discover modules",
|
||||
);
|
||||
@@ -331,86 +226,38 @@ 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");
|
||||
// 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();
|
||||
expect(current()).toEqual(["select-discover-browse"]);
|
||||
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
|
||||
"/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");
|
||||
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
|
||||
const { history } = await render("/settings/discover/browse");
|
||||
|
||||
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"]);
|
||||
const views = [...required("discover-subnavigation").querySelectorAll("a")];
|
||||
expect(views.map((view) => view.textContent)).toEqual(["Browse", "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",
|
||||
"/settings/discover/browse",
|
||||
"/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"] },
|
||||
@@ -536,13 +383,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-branding"));
|
||||
await click(required("select-settings-displays"));
|
||||
await click(required("select-module-settings-signage"));
|
||||
|
||||
await act(async () => {
|
||||
history.go(-1);
|
||||
});
|
||||
expect(query("settings-section-branding")).not.toBeNull();
|
||||
expect(query("settings-section-displays")).not.toBeNull();
|
||||
await act(async () => {
|
||||
history.go(1);
|
||||
});
|
||||
@@ -589,195 +436,3 @@ 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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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
@@ -13,18 +13,18 @@
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ekklesia/module-pack": "0.2.5",
|
||||
"@ekklesia/module-ui-build": "^0.2.5",
|
||||
"@ekklesia/typescript-config": "^0.2.5",
|
||||
"@ekklesia/module-pack": "0.2.6",
|
||||
"@ekklesia/module-ui-build": "^0.2.6",
|
||||
"@ekklesia/typescript-config": "^0.2.6",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
|
||||
Reference in New Issue
Block a user