Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f9b536bc6
commit
906c015ca8
@@ -954,9 +954,11 @@ landing page at `/settings` that summarises the installation — paired displays
|
|||||||
links to where each is changed; the platform's General, Branding, Displays, Discover, and System; 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
|
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`,
|
(`/settings/discover`), with Installed and Updates beneath it (`/settings/discover/installed`,
|
||||||
`/updates`); configuring a module is its own Settings page. **Settings → Displays** pairs and revokes
|
`/updates`); configuring a module is its own Settings page. **Settings → Displays** sets up, pairs, and revokes
|
||||||
display screens: the paired displays with when each was last seen, and an *Add display* flow for the
|
display screens: how to start a new screen (the platform display page to open on it, with Copy link),
|
||||||
code a screen shows ([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)).
|
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
|
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.
|
nothing. General, Branding, System, Installed, and Updates are placeholders until each is built.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
requireTrustedOrigin,
|
requireTrustedOrigin,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.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
|
* Host-owned device identity transport: pairing a display, and managing what has been paired
|
||||||
@@ -315,6 +316,32 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a new screen starts pairing (ADR 0065): the platform display page for every running module
|
||||||
|
* that shows content on screens, for Settings → Displays to tell an administrator what to open on
|
||||||
|
* the screen. Each page is the generic display host at `/display/<module-id>` (ADR 0033), which
|
||||||
|
* shows a pairing code until the screen is approved.
|
||||||
|
*
|
||||||
|
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
|
||||||
|
* this does not become a way to enumerate what is installed — an unpaired screen still learns
|
||||||
|
* nothing beyond the page it was told to open. It answers names and root-relative page paths only;
|
||||||
|
* the browser resolves them against the API origin, which is where the display host lives in both
|
||||||
|
* topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet.
|
||||||
|
*/
|
||||||
|
routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => {
|
||||||
|
noStore(c);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
displays: options.moduleUi.listDisplays().map((display) => ({
|
||||||
|
moduleId: display.moduleId,
|
||||||
|
name: display.name,
|
||||||
|
url: moduleDisplayPageUrl(display.moduleId),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||||
try {
|
try {
|
||||||
const devices = await options.devices.listDevices();
|
const devices = await options.devices.listDevices();
|
||||||
|
|||||||
@@ -419,6 +419,27 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
expect((await device.request("/display/announcements")).status).toBe(200);
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||||
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
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.
|
// 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", {
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
@@ -501,6 +522,7 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
});
|
});
|
||||||
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
||||||
expect((await device.request("/api/v1/devices")).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.
|
// 9. The administrator revokes it.
|
||||||
const devices = (await (
|
const devices = (await (
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-16
|
||||||
|
- Extends: [0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[0039](0039-shared-design-tokens-and-elements.md),
|
||||||
|
[0045](0045-split-dashboard-and-api-origins.md),
|
||||||
|
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||||
|
[0064](0064-stage-1-shell-review-corrections.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A second hand review on a real iPhone, after ADR 0064, found three defects:
|
||||||
|
|
||||||
|
- **Settings → Displays did not say how to set up a screen.** Add display took a pairing code, but
|
||||||
|
nothing on the page told an administrator how to get a physical screen to show one. The only setup
|
||||||
|
instructions were in Signage's own Displays panel. A platform-owned display has to be set up from
|
||||||
|
the platform, including on an installation without Signage.
|
||||||
|
- **iOS Safari zoomed into the Add display form.** Focusing the pairing code zoomed the page into the
|
||||||
|
dialog, and the page stayed zoomed after the dialog closed. The cause is the shared `.ek-control`
|
||||||
|
font size: `typography.size-sm`, 0.875rem, which is 14px. iOS Safari zooms the page into any
|
||||||
|
focused text field whose font is smaller than 16px.
|
||||||
|
- **The page behind the phone drawer still scrolled,** and pulling down behind it triggered the
|
||||||
|
browser's pull-to-refresh.
|
||||||
|
|
||||||
|
The display host is already generic. `/display/<module-id>` is the platform's own page (ADR 0033): it
|
||||||
|
shows a pairing code, waits for approval, then hosts that module's display artifact. A pairing request
|
||||||
|
names the module because approval grants exactly that module's display permission (ADR 0036).
|
||||||
|
However, nothing told an administrator which of these pages exist. ADR 0033's display routes noted:
|
||||||
|
"There is deliberately no display inventory in this pass. A management UI may need one later." That
|
||||||
|
management UI now exists.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### `GET /api/v1/devices/setup`: where a new screen starts
|
||||||
|
|
||||||
|
The API answers, for an administrator holding `core.devices.manage`, the platform display page of
|
||||||
|
every running module that shows content on screens:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads the same eligible, running display registry as the display routes. It answers nothing to
|
||||||
|
anyone else: it is never anonymous, so an unpaired screen still cannot enumerate what is installed.
|
||||||
|
It is `no-store`. The URL is root-relative, and the browser resolves it against the API origin,
|
||||||
|
because the display host lives there in both topologies (ADR 0045).
|
||||||
|
|
||||||
|
**No module-independent display page is added.** A page that let a screen pair before choosing a
|
||||||
|
module would need a device to exist without a module permission. That is the ROADMAP §5.4 global
|
||||||
|
display model, and it needs its own decision about assigning devices to modules. Until then, every
|
||||||
|
screen pairs through a module's display page, and this endpoint is how the platform names those
|
||||||
|
pages.
|
||||||
|
|
||||||
|
### Setting up a screen is one flow, in one place
|
||||||
|
|
||||||
|
Settings → Displays opens with a **Set up a new screen** panel, above the paired displays. Its
|
||||||
|
contents are, in order:
|
||||||
|
|
||||||
|
1. one sentence: *Open this address on the new screen, then add it with the code it shows*;
|
||||||
|
2. the absolute display page, with **Copy link**;
|
||||||
|
3. **Add display**, right beneath it.
|
||||||
|
|
||||||
|
Each step is directly under the one before, so nobody has to go back up the page. **Add display**
|
||||||
|
lives here and only here, not in the page header.
|
||||||
|
|
||||||
|
The step-by-step version is folded away in a native `<details>` (*Step-by-step instructions*), for
|
||||||
|
whoever wants it, instead of a block of text everyone has to read past. With more than one module
|
||||||
|
that uses displays, each address is labelled with its module.
|
||||||
|
|
||||||
|
When no installed module uses displays, the panel says so and that one installed from Discover will
|
||||||
|
appear there. It offers no **Add display**, which could only fail. Nothing names Signage. If the
|
||||||
|
setup address cannot be read, the panel says so and still offers **Add display**, since a screen may
|
||||||
|
already be showing its code. The paired list keeps working either way.
|
||||||
|
|
||||||
|
**Copy link** reaches the clipboard through the clicked button's own window, never a browser global
|
||||||
|
(ADR 0058). If the browser cannot copy, the button says so and the address stays selectable.
|
||||||
|
|
||||||
|
### Text controls are at least 16px on phones and touch screens
|
||||||
|
|
||||||
|
`@ekklesia/ui` keeps `.ek-control` at `typography.size-sm` on desktop. Under the Dashboard's compact
|
||||||
|
breakpoint (`max-width: 47.999em`) or on a coarse pointer, it uses
|
||||||
|
`max(typography.size-md, 16px)`. Every shared text field and select is affected, in the Dashboard and
|
||||||
|
in every module root, so later Settings, People, and module forms cannot reintroduce the zoom.
|
||||||
|
|
||||||
|
**The viewport is not touched.** There is no `user-scalable=no`, `maximum-scale`, or `minimum-scale`,
|
||||||
|
and a test holds both shell pages to that, so pinch and accessibility zoom stay available.
|
||||||
|
|
||||||
|
Opening and closing a dialog must also not move the page. The shared focus trap now returns focus to
|
||||||
|
the opening control with `preventScroll`, because that control was on screen when it was used.
|
||||||
|
Scrolling it into view again, past the phone header's scroll padding, moved the page by the header's
|
||||||
|
height. The Add display button focuses itself the same way before opening the dialog.
|
||||||
|
|
||||||
|
### The drawer locks the page behind it
|
||||||
|
|
||||||
|
While the phone drawer is open, `lockDocumentScroll` (`surfaces/shell/src/browser/scroll-lock.ts`)
|
||||||
|
does three things:
|
||||||
|
|
||||||
|
- **The root stops scrolling.** It sets `overflow: hidden` on `<html>` and `<body>`, which ends wheel,
|
||||||
|
keyboard, and scrollbar scrolling, and touch scrolling on current iOS Safari. It keeps the scroll
|
||||||
|
position in place. The older iOS workaround, `position: fixed` on the body, moves the page and its
|
||||||
|
sticky header, which would show through the backdrop as a jump.
|
||||||
|
- **Touches outside the drawer go nowhere.** A non-passive `touchmove` listener cancels a one-finger
|
||||||
|
drag that starts outside the drawer's list. It also cancels a drag inside the list that would only
|
||||||
|
overscroll it past its top or bottom. This covers an iOS that ignores the root's `overflow`, and it
|
||||||
|
is what stops pull-to-refresh. Two-finger gestures are never cancelled, so pinch zoom stays.
|
||||||
|
- **Overscroll does not chain.** It sets `overscroll-behavior: none` on the root while locked. The
|
||||||
|
drawer and its list carry `overscroll-behavior: contain`, and the backdrop carries
|
||||||
|
`touch-action: pinch-zoom`.
|
||||||
|
|
||||||
|
The drawer's own list still scrolls by touch when it is taller than the screen.
|
||||||
|
|
||||||
|
Closing restores the inline styles that were there before, and the exact scroll position, unless a
|
||||||
|
different page opened meanwhile. A page chosen in the drawer, or reached by Back or Forward, starts at
|
||||||
|
its own top, as before.
|
||||||
|
|
||||||
|
When the drawer is closed nothing is set: the page scrolls and refreshes normally. The desktop
|
||||||
|
sidebar never locks anything. The focus trap, Escape, focus return, `inert`, and safe areas are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Copy Signage's setup address into Settings.** Rejected: it names one module, and it is wrong on an
|
||||||
|
installation without it.
|
||||||
|
- **A public `/display` page listing the modules that use displays.** Rejected: it would tell anyone
|
||||||
|
on the network what is installed, which ADR 0036 deliberately prevents.
|
||||||
|
- **Pair first, choose the module later.** That is the right long-term model (ROADMAP §5.4). It is
|
||||||
|
deferred because it changes how a device's permission is granted, and needs its own decision.
|
||||||
|
- **Use the Dashboard navigation's `display.url`.** Rejected: the navigation lists only modules with
|
||||||
|
a Dashboard UI, and filters by module permissions. A display-only module, or one the administrator
|
||||||
|
cannot open, would be missing.
|
||||||
|
- **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and
|
||||||
|
hides the cause instead of fixing it.
|
||||||
|
- **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design.
|
||||||
|
- **`position: fixed` body scroll lock.** Rejected: it jumps the page, and the sticky phone header,
|
||||||
|
behind the backdrop.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- An administrator can set up a screen from Settings → Displays alone. Released Signage 0.1.5 keeps
|
||||||
|
its own instructions, which point to the same platform page, until its follow-up issue
|
||||||
|
(`Zayin/Signage#24`) is resolved.
|
||||||
|
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
|
||||||
|
included.
|
||||||
|
- A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's
|
||||||
|
focus-zoom or pull-to-refresh, so automated checks prove the computed font size and the cancelled
|
||||||
|
gestures, not Safari's own behaviour.
|
||||||
@@ -73,3 +73,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.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
|
- [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
|
- [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
|
||||||
|
|||||||
@@ -484,6 +484,32 @@ The Stage 1 review corrections
|
|||||||
- None of these defects showed until the product was assembled, so a change to the shell's layout
|
- 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.
|
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
|
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||||
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
|
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface FocusTrapOptions {
|
|||||||
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
|
* - 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.
|
* 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
|
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
|
||||||
* the surface — if that control is still in the document.
|
* the surface — if that control is still in the document, without scrolling the page.
|
||||||
*
|
*
|
||||||
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
|
* 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
|
* as well as in a document: it only ever looks inside its own container and at the container's own
|
||||||
@@ -68,7 +68,10 @@ export function useFocusTrap(
|
|||||||
const target = restoreTo.current;
|
const target = restoreTo.current;
|
||||||
restoreTo.current = null;
|
restoreTo.current = null;
|
||||||
if (target?.isConnected) {
|
if (target?.isConnected) {
|
||||||
target.focus();
|
// Without scrolling: the control was on screen when it opened the surface, and scrolling it
|
||||||
|
// into view again — past a sticky header's scroll padding — would move the page under a
|
||||||
|
// person who just closed a dialog (ADR 0065).
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [active, containerRef, initialFocus]);
|
}, [active, containerRef, initialFocus]);
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export const EKKLESIA_UI_STYLESHEET = `
|
|||||||
border-color: ${designToken("border.strong")};
|
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 {
|
.ek-control::placeholder {
|
||||||
color: ${designToken("text.muted")};
|
color: ${designToken("text.muted")};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,19 @@ describe("ConfirmationDialog", () => {
|
|||||||
expect(document.activeElement).toBe(byTestId("opener"));
|
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", () => {
|
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn();
|
||||||
openHarness({ dialog: { busy: true }, onCancel });
|
openHarness({ dialog: { busy: true }, onCancel });
|
||||||
|
|||||||
@@ -159,3 +159,21 @@ describe("choice indicators are drawn from tokens (ADR 0064)", () => {
|
|||||||
expect(EKKLESIA_UI_STYLESHEET).toMatch(/@media \(forced-colors: active\)/u);
|
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 {"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -600,16 +600,34 @@ check(
|
|||||||
),
|
),
|
||||||
"the sidebar is navigation only, and arriving at Displays focuses no field",
|
"the sidebar is navigation only, and arriving at Displays focuses no field",
|
||||||
);
|
);
|
||||||
await evaluate(`${selector("add-display")}.click()`);
|
// 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(
|
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(
|
await waitFor(
|
||||||
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
|
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
|
||||||
"the Add display dialog with its code field focused",
|
"the Add display dialog with its code field focused",
|
||||||
),
|
),
|
||||||
"Add display opens a dialog that focuses the pairing code only once asked",
|
"Add display opens a dialog that focuses the pairing code only once asked",
|
||||||
);
|
);
|
||||||
await evaluate(`${selector("add-display-cancel")}.click()`);
|
await evaluate(`${selector("add-display-cancel")}.click()`);
|
||||||
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
|
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`);
|
await navigate(`${dashboardOrigin}/settings/discover`);
|
||||||
check(
|
check(
|
||||||
await waitFor(
|
await waitFor(
|
||||||
@@ -652,11 +670,19 @@ check(
|
|||||||
),
|
),
|
||||||
"the phone header keeps the menu in reach after scrolling, with no horizontal overflow",
|
"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 evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
|
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
|
||||||
"Settings in the phone drawer",
|
"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()`);
|
await evaluate(`${selector("select-settings-system")}.click()`);
|
||||||
check(
|
check(
|
||||||
await waitFor(
|
await waitFor(
|
||||||
@@ -665,6 +691,27 @@ check(
|
|||||||
),
|
),
|
||||||
"the phone drawer offers the same Settings hierarchy and closes once a page is chosen",
|
"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", {
|
await send("Emulation.setDeviceMetricsOverride", {
|
||||||
deviceScaleFactor: 1,
|
deviceScaleFactor: 1,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@ekklesia/ui";
|
} from "@ekklesia/ui";
|
||||||
import {
|
import {
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
|
type MouseEvent,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -26,8 +27,10 @@ import {
|
|||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
type HostDeviceSummary,
|
type HostDeviceSummary,
|
||||||
|
type HostDisplaySetup,
|
||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
listHostDevices,
|
listHostDevices,
|
||||||
|
listHostDisplaySetup,
|
||||||
lookupDevicePairing,
|
lookupDevicePairing,
|
||||||
revokeHostDevice,
|
revokeHostDevice,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
@@ -35,9 +38,9 @@ import { calendarDate, lastSeenPhrase } from "./display-time.js";
|
|||||||
import { ShellIcon } from "./shell-icons.js";
|
import { ShellIcon } from "./shell-icons.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings → Displays (ADR 0064): the platform's own administration page for the screens paired
|
* Settings → Displays (ADR 0064, ADR 0065): the platform's own administration page for the screens
|
||||||
* with this installation — the paired displays, adding one with the code its screen shows, and
|
* paired with this installation — how to set up a new screen, the paired displays, adding one with
|
||||||
* revoking one.
|
* 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
|
* 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
|
* platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per
|
||||||
@@ -55,6 +58,11 @@ type ListState =
|
|||||||
| { readonly kind: "denied" }
|
| { readonly kind: "denied" }
|
||||||
| { readonly kind: "failed" };
|
| { readonly kind: "failed" };
|
||||||
|
|
||||||
|
type SetupState =
|
||||||
|
| { readonly kind: "loading" }
|
||||||
|
| { readonly kind: "ready"; readonly displays: readonly HostDisplaySetup[] }
|
||||||
|
| { readonly kind: "failed" };
|
||||||
|
|
||||||
type PairingStep =
|
type PairingStep =
|
||||||
| { readonly step: "code"; readonly error?: string }
|
| { readonly step: "code"; readonly error?: string }
|
||||||
| { readonly step: "finding" }
|
| { readonly step: "finding" }
|
||||||
@@ -237,6 +245,122 @@ function AddDisplayDialog(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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: {
|
function DisplayCard(props: {
|
||||||
readonly device: HostDeviceSummary;
|
readonly device: HostDeviceSummary;
|
||||||
readonly now: Date;
|
readonly now: Date;
|
||||||
@@ -280,6 +404,7 @@ function DisplayCard(props: {
|
|||||||
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
|
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
|
||||||
const { config } = props;
|
const { config } = props;
|
||||||
const [list, setList] = useState<ListState>({ kind: "loading" });
|
const [list, setList] = useState<ListState>({ kind: "loading" });
|
||||||
|
const [setup, setSetup] = useState<SetupState>({ kind: "loading" });
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [paired, setPaired] = useState<string | undefined>(undefined);
|
const [paired, setPaired] = useState<string | undefined>(undefined);
|
||||||
/** The display an administrator asked to revoke and has not yet confirmed. */
|
/** The display an administrator asked to revoke and has not yet confirmed. */
|
||||||
@@ -305,6 +430,23 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [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> => {
|
const revoke = async (deviceId: string): Promise<void> => {
|
||||||
setRevoking(true);
|
setRevoking(true);
|
||||||
try {
|
try {
|
||||||
@@ -332,24 +474,6 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
actions={
|
|
||||||
canAdd ? (
|
|
||||||
<Button
|
|
||||||
data-testid="add-display"
|
|
||||||
onClick={(event) => {
|
|
||||||
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a
|
|
||||||
// button that is clicked, so it is focused deliberately before the dialog opens.
|
|
||||||
event.currentTarget.focus();
|
|
||||||
setPaired(undefined);
|
|
||||||
setAdding(true);
|
|
||||||
}}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
<ShellIcon name="addDisplay" size={18} />
|
|
||||||
Add display
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
back={props.back}
|
back={props.back}
|
||||||
description="The screens paired with this installation. Each has one name, used by every module that shows content on it."
|
description="The screens paired with this installation. Each has one name, used by every module that shows content on it."
|
||||||
title="Displays"
|
title="Displays"
|
||||||
@@ -363,6 +487,20 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
</InlineNotice>
|
</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 === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
|
||||||
{list.kind === "denied" ? (
|
{list.kind === "denied" ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -391,7 +529,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
{list.devices.length === 0 ? (
|
{list.devices.length === 0 ? (
|
||||||
<div data-testid="device-list-empty">
|
<div data-testid="device-list-empty">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Choose Add display, then enter the code shown on the screen."
|
description="Set up a screen above, and it appears here once it's approved."
|
||||||
title="No displays are paired yet"
|
title="No displays are paired yet"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type NavigationPresentation,
|
type NavigationPresentation,
|
||||||
ShellBrand,
|
ShellBrand,
|
||||||
} from "./ShellNavigation.js";
|
} from "./ShellNavigation.js";
|
||||||
|
import { lockDocumentScroll } from "./scroll-lock.js";
|
||||||
import { ShellIcon } from "./shell-icons.js";
|
import { ShellIcon } from "./shell-icons.js";
|
||||||
import { SHELL_STYLESHEET } from "./shell-styles.js";
|
import { SHELL_STYLESHEET } from "./shell-styles.js";
|
||||||
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
|
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
|
||||||
@@ -142,6 +143,7 @@ function NavigationDrawer(props: {
|
|||||||
readonly settings: DashboardSettings | "unavailable";
|
readonly settings: DashboardSettings | "unavailable";
|
||||||
readonly account: HostAccount | "unavailable";
|
readonly account: HostAccount | "unavailable";
|
||||||
readonly route: DashboardRoute;
|
readonly route: DashboardRoute;
|
||||||
|
readonly scrollRegionRef: RefObject<HTMLDivElement | null>;
|
||||||
readonly onDismiss: () => void;
|
readonly onDismiss: () => void;
|
||||||
readonly onNavigate: () => void;
|
readonly onNavigate: () => void;
|
||||||
readonly onSignOut: () => void;
|
readonly onSignOut: () => void;
|
||||||
@@ -211,7 +213,11 @@ function NavigationDrawer(props: {
|
|||||||
<ShellIcon name="closeNavigation" />
|
<ShellIcon name="closeNavigation" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ek-shell-sidebar__scroll">
|
<div
|
||||||
|
className="ek-shell-sidebar__scroll"
|
||||||
|
data-testid="navigation-drawer-scroll"
|
||||||
|
ref={props.scrollRegionRef}
|
||||||
|
>
|
||||||
<NavigationList
|
<NavigationList
|
||||||
history={props.history}
|
history={props.history}
|
||||||
id={DRAWER_NAV_ID}
|
id={DRAWER_NAV_ID}
|
||||||
@@ -263,8 +269,11 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
|
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const drawerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const previousCompact = useRef(compact);
|
const previousCompact = useRef(compact);
|
||||||
const previousRoute = useRef(routeKey(route));
|
const previousRoute = useRef(routeKey(route));
|
||||||
|
const currentRoute = useRef(routeKey(route));
|
||||||
|
currentRoute.current = routeKey(route);
|
||||||
|
|
||||||
const toggleMode = (): void => {
|
const toggleMode = (): void => {
|
||||||
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
|
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
|
||||||
@@ -317,6 +326,20 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
|||||||
const showDrawer = compact && drawerOpen;
|
const showDrawer = compact && drawerOpen;
|
||||||
const pageInert = showDrawer ? true : undefined;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="ek-shell-root"
|
className="ek-shell-root"
|
||||||
@@ -401,6 +424,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
|||||||
onNavigate={() => closeDrawer("main")}
|
onNavigate={() => closeDrawer("main")}
|
||||||
onSignOut={props.onSignOut}
|
onSignOut={props.onSignOut}
|
||||||
route={route}
|
route={route}
|
||||||
|
scrollRegionRef={drawerScrollRef}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Keeps the page behind a modal surface — the phone navigation drawer — from scrolling while the
|
||||||
|
* surface is open (ADR 0065), and puts everything back exactly when it closes.
|
||||||
|
*
|
||||||
|
* Three things together, because no one of them is enough on iOS Safari:
|
||||||
|
*
|
||||||
|
* - **The root stops scrolling.** `overflow: hidden` on `<html>` and `<body>` ends wheel, keyboard,
|
||||||
|
* and scrollbar scrolling everywhere, and on current iOS Safari touch scrolling too. The page's
|
||||||
|
* scroll position is kept in place, not reset, so nothing jumps: the drawer opens over the page
|
||||||
|
* exactly where it was. (`position: fixed` on the body, the older iOS workaround, moves the page
|
||||||
|
* and its sticky header, which shows through the backdrop as a jump.)
|
||||||
|
* - **Touches outside the drawer go nowhere.** A non-passive `touchmove` listener cancels every
|
||||||
|
* move that starts outside the region that may scroll, and every move inside it that would only
|
||||||
|
* overscroll it — past its top or bottom. That is what keeps an older iOS, which ignores the
|
||||||
|
* root's `overflow`, from scrolling the page behind, and what stops pull-to-refresh.
|
||||||
|
* - **Overscroll does not chain.** `overscroll-behavior: none` on the root, only while locked, so the
|
||||||
|
* page cannot rubber-band or refresh; the region itself carries `overscroll-behavior: contain` in
|
||||||
|
* the stylesheet. When the drawer is closed, nothing here is set and the page behaves normally,
|
||||||
|
* pull-to-refresh included.
|
||||||
|
*
|
||||||
|
* Everything is reached through the document it is given; the shell touches no browser global. The
|
||||||
|
* returned function undoes exactly what was done, restoring the inline styles that were there
|
||||||
|
* before, and — unless the drawer closed because a new page is opening, which starts at its own top
|
||||||
|
* — the scroll position too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScrollLockRelease {
|
||||||
|
(options?: { readonly restoreScroll?: boolean }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
|
||||||
|
|
||||||
|
interface SavedStyles {
|
||||||
|
readonly element: HTMLElement;
|
||||||
|
readonly values: readonly (readonly [string, string, string])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(element: HTMLElement): SavedStyles {
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
values: LOCKED_PROPERTIES.map(
|
||||||
|
(property) =>
|
||||||
|
[
|
||||||
|
property,
|
||||||
|
element.style.getPropertyValue(property),
|
||||||
|
element.style.getPropertyPriority(property),
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(saved: SavedStyles): void {
|
||||||
|
for (const [property, value, priority] of saved.values) {
|
||||||
|
if (value === "") {
|
||||||
|
saved.element.style.removeProperty(property);
|
||||||
|
} else {
|
||||||
|
saved.element.style.setProperty(property, value, priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `element` can scroll any further in the direction of a finger moving by `deltaY` (a
|
||||||
|
* positive delta is the finger moving down, which scrolls the content up, towards its top). */
|
||||||
|
function canScroll(element: HTMLElement, deltaY: number): boolean {
|
||||||
|
if (element.scrollHeight <= element.clientHeight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return deltaY > 0
|
||||||
|
? element.scrollTop > 0
|
||||||
|
: element.scrollTop + element.clientHeight < element.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks `document`'s scrolling. `scrollRegion`, when given, is the one element inside the modal
|
||||||
|
* surface that may still scroll by touch.
|
||||||
|
*/
|
||||||
|
export function lockDocumentScroll(
|
||||||
|
document: Document,
|
||||||
|
scrollRegion?: HTMLElement | null,
|
||||||
|
): ScrollLockRelease {
|
||||||
|
const view = document.defaultView;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
const scrollX = view?.scrollX ?? 0;
|
||||||
|
const scrollY = view?.scrollY ?? 0;
|
||||||
|
const saved = [save(root), save(body)];
|
||||||
|
|
||||||
|
for (const element of [root, body]) {
|
||||||
|
element.style.setProperty("overflow", "hidden");
|
||||||
|
element.style.setProperty("overscroll-behavior", "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastY: number | undefined;
|
||||||
|
const onTouchStart = (event: TouchEvent): void => {
|
||||||
|
lastY = event.touches[0]?.clientY;
|
||||||
|
};
|
||||||
|
const onTouchMove = (event: TouchEvent): void => {
|
||||||
|
if (event.touches.length !== 1) {
|
||||||
|
// Pinch zoom stays the browser's: locking scroll never takes zoom away.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const y = event.touches[0]?.clientY;
|
||||||
|
const deltaY = y === undefined || lastY === undefined ? 0 : y - lastY;
|
||||||
|
lastY = y;
|
||||||
|
const target = event.target;
|
||||||
|
const inside =
|
||||||
|
scrollRegion !== undefined &&
|
||||||
|
scrollRegion !== null &&
|
||||||
|
target instanceof Node &&
|
||||||
|
scrollRegion.contains(target);
|
||||||
|
if (!inside || !canScroll(scrollRegion, deltaY)) {
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||||
|
|
||||||
|
let released = false;
|
||||||
|
return (options = {}) => {
|
||||||
|
if (released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
released = true;
|
||||||
|
document.removeEventListener("touchstart", onTouchStart);
|
||||||
|
document.removeEventListener("touchmove", onTouchMove);
|
||||||
|
for (const entry of saved) {
|
||||||
|
restore(entry);
|
||||||
|
}
|
||||||
|
const restoreScroll = options.restoreScroll ?? true;
|
||||||
|
if (restoreScroll && view !== null && (view.scrollX !== scrollX || view.scrollY !== scrollY)) {
|
||||||
|
view.scrollTo({ behavior: "instant", left: scrollX, top: scrollY });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -344,6 +344,70 @@ export const SHELL_STYLESHEET = `
|
|||||||
margin: 0;
|
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. */
|
/* The Settings overview (ADR 0064): what state the installation is in, a card per area. */
|
||||||
.ek-shell-overview {
|
.ek-shell-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -632,6 +696,8 @@ html:has(.ek-shell-mobile-bar) {
|
|||||||
background: ${designToken("background.overlay")};
|
background: ${designToken("background.overlay")};
|
||||||
inset: 0;
|
inset: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
/* A drag on the backdrop moves nothing behind it; a pinch still zooms (ADR 0065). */
|
||||||
|
touch-action: pinch-zoom;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,10 +715,17 @@ html:has(.ek-shell-mobile-bar) {
|
|||||||
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
|
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-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
|
||||||
padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px));
|
padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px));
|
||||||
|
overscroll-behavior: contain;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 20rem;
|
width: 20rem;
|
||||||
z-index: 41;
|
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). */
|
/* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -622,6 +622,46 @@ 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. */
|
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
|
||||||
export interface HostModuleLifecycleSummary {
|
export interface HostModuleLifecycleSummary {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ function json(body: unknown, status = 200): Response {
|
|||||||
interface ServerOptions {
|
interface ServerOptions {
|
||||||
readonly devices?: unknown[];
|
readonly devices?: unknown[];
|
||||||
readonly denied?: boolean;
|
readonly denied?: boolean;
|
||||||
|
/** What `GET /api/v1/devices/setup` answers: the display pages, or a failure. */
|
||||||
|
readonly setup?: unknown[] | "fails";
|
||||||
}
|
}
|
||||||
|
|
||||||
function server(options: ServerOptions = {}) {
|
function server(options: ServerOptions = {}) {
|
||||||
@@ -59,6 +61,16 @@ function server(options: ServerOptions = {}) {
|
|||||||
const path = new URL(String(input)).pathname;
|
const path = new URL(String(input)).pathname;
|
||||||
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
||||||
requests.push({ body, path });
|
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") {
|
if (path === "/api/v1/devices") {
|
||||||
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
|
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
|
||||||
}
|
}
|
||||||
@@ -162,6 +174,19 @@ describe("Settings → Displays", () => {
|
|||||||
expect(card.textContent).toContain("Paired");
|
expect(card.textContent).toContain("Paired");
|
||||||
expect(card.textContent).toContain("Last seen within the last hour");
|
expect(card.textContent).toContain("Last seen within the last hour");
|
||||||
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
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.
|
// No pairing form until someone asks for one, and nothing grabbed focus.
|
||||||
expect(container.querySelector("input")).toBeNull();
|
expect(container.querySelector("input")).toBeNull();
|
||||||
expect(document.activeElement).toBe(before);
|
expect(document.activeElement).toBe(before);
|
||||||
@@ -216,11 +241,72 @@ describe("Settings → Displays", () => {
|
|||||||
await click("add-display-cancel");
|
await click("add-display-cancel");
|
||||||
|
|
||||||
expect(query("add-display-dialog")).toBeNull();
|
expect(query("add-display-dialog")).toBeNull();
|
||||||
expect(platform.requests.map((r) => r.path)).toEqual(["/api/v1/devices"]);
|
expect(platform.requests.map((r) => r.path).sort()).toEqual([
|
||||||
|
"/api/v1/devices",
|
||||||
|
"/api/v1/devices/setup",
|
||||||
|
]);
|
||||||
expect(document.activeElement).toBe(required("add-display"));
|
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
|
* 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).
|
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { lockDocumentScroll } from "../src/browser/scroll-lock.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page behind the phone navigation drawer stays put while the drawer is open (ADR 0065): no
|
||||||
|
* scrolling, no pull-to-refresh, the drawer's own list still scrolls, and closing it puts the page
|
||||||
|
* back exactly as it was.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let region: HTMLDivElement;
|
||||||
|
let inside: HTMLSpanElement;
|
||||||
|
let outside: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
region = document.createElement("div");
|
||||||
|
inside = document.createElement("span");
|
||||||
|
region.append(inside);
|
||||||
|
outside = document.createElement("div");
|
||||||
|
document.body.append(region, outside);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
region.remove();
|
||||||
|
outside.remove();
|
||||||
|
document.documentElement.removeAttribute("style");
|
||||||
|
document.body.removeAttribute("style");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gives the region a scrollable size and position, which a DOM without layout does not compute. */
|
||||||
|
function scrollable(element: HTMLElement, scrollTop: number): void {
|
||||||
|
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1000 });
|
||||||
|
Object.defineProperty(element, "clientHeight", { configurable: true, value: 400 });
|
||||||
|
Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop });
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(target: EventTarget, type: "touchstart" | "touchmove", y: number, points = 1) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
||||||
|
touches: { clientY: number }[];
|
||||||
|
};
|
||||||
|
Object.defineProperty(event, "touches", {
|
||||||
|
value: Array.from({ length: points }, () => ({ clientY: y })),
|
||||||
|
});
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the document scroll lock", () => {
|
||||||
|
it("stops the root from scrolling and overscrolling, and puts the styles back as they were", () => {
|
||||||
|
document.documentElement.style.setProperty("overflow", "auto");
|
||||||
|
const release = lockDocumentScroll(document, region);
|
||||||
|
|
||||||
|
for (const element of [document.documentElement, document.body]) {
|
||||||
|
expect(element.style.getPropertyValue("overflow")).toBe("hidden");
|
||||||
|
expect(element.style.getPropertyValue("overscroll-behavior")).toBe("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
release();
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("overflow")).toBe("auto");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("overscroll-behavior")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("overflow")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("overscroll-behavior")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a touch drag that starts outside the drawer, which also stops pull-to-refresh", () => {
|
||||||
|
const release = lockDocumentScroll(document, region);
|
||||||
|
touch(outside, "touchstart", 100);
|
||||||
|
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(true);
|
||||||
|
release();
|
||||||
|
// Once released, the page scrolls normally again.
|
||||||
|
touch(outside, "touchstart", 100);
|
||||||
|
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the drawer's own list scroll, but never past its ends into the page", () => {
|
||||||
|
const release = lockDocumentScroll(document, region);
|
||||||
|
|
||||||
|
scrollable(region, 200);
|
||||||
|
touch(inside, "touchstart", 300);
|
||||||
|
expect(touch(inside, "touchmove", 200).defaultPrevented, "scrolling down").toBe(false);
|
||||||
|
touch(inside, "touchstart", 200);
|
||||||
|
expect(touch(inside, "touchmove", 300).defaultPrevented, "scrolling up").toBe(false);
|
||||||
|
|
||||||
|
// At its top, pulling down would only overscroll — the gesture behind pull-to-refresh.
|
||||||
|
scrollable(region, 0);
|
||||||
|
touch(inside, "touchstart", 200);
|
||||||
|
expect(touch(inside, "touchmove", 300).defaultPrevented, "pulled past the top").toBe(true);
|
||||||
|
|
||||||
|
// At its bottom, pushing up would only overscroll.
|
||||||
|
scrollable(region, 600);
|
||||||
|
touch(inside, "touchstart", 300);
|
||||||
|
expect(touch(inside, "touchmove", 200).defaultPrevented, "pushed past the bottom").toBe(true);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never takes pinch zoom away", () => {
|
||||||
|
const release = lockDocumentScroll(document, region);
|
||||||
|
touch(outside, "touchstart", 100, 2);
|
||||||
|
expect(touch(outside, "touchmove", 300, 2).defaultPrevented).toBe(false);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the exact scroll position on release, unless a new page opened", () => {
|
||||||
|
const view = document.defaultView as Window;
|
||||||
|
Object.defineProperty(view, "scrollY", { configurable: true, value: 1234 });
|
||||||
|
const scrollTo = vi.spyOn(view, "scrollTo").mockImplementation(() => undefined);
|
||||||
|
const release = lockDocumentScroll(document, region);
|
||||||
|
|
||||||
|
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
|
||||||
|
release();
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({ behavior: "instant", left: 0, top: 1234 });
|
||||||
|
|
||||||
|
scrollTo.mockClear();
|
||||||
|
const again = lockDocumentScroll(document, region);
|
||||||
|
Object.defineProperty(view, "scrollY", { configurable: true, value: 50 });
|
||||||
|
again({ restoreScroll: false });
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Releasing twice does nothing the second time.
|
||||||
|
again();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,6 +156,13 @@ async function render(
|
|||||||
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
|
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
|
||||||
const visible = options.settings ?? ADMINISTRATOR;
|
const visible = options.settings ?? ADMINISTRATOR;
|
||||||
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
|
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")) {
|
if (url.endsWith("/api/v1/devices")) {
|
||||||
return sections.includes("displays")
|
return sections.includes("displays")
|
||||||
? Response.json({ devices: options.devices ?? DEVICES })
|
? Response.json({ devices: options.devices ?? DEVICES })
|
||||||
@@ -721,3 +728,56 @@ describe("the phone header (ADR 0064)", () => {
|
|||||||
expect(document.activeElement).toBe(required("host-main-region"));
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom stays available to everyone who needs it (ADR 0065). iOS Safari's zoom into small form
|
||||||
|
* fields is fixed by the fields' own font size, never by locking the viewport.
|
||||||
|
*/
|
||||||
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
async function viewport(page: string): Promise<string> {
|
||||||
|
const html = await readFile(path.join(packageDirectory, page), "utf8");
|
||||||
|
const tag = /<meta\s+content="([^"]*)"\s+name="viewport"\s*\/?>/u.exec(html);
|
||||||
|
if (!tag?.[1]) {
|
||||||
|
throw new Error(`${page} has no viewport tag`);
|
||||||
|
}
|
||||||
|
return tag[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the shell's pages never disable zoom", () => {
|
||||||
|
for (const page of ["index.html", "display.html"]) {
|
||||||
|
it(`${page} allows pinch and accessibility zoom`, async () => {
|
||||||
|
const content = (await viewport(page)).replace(/\s+/gu, "");
|
||||||
|
expect(content).toContain("width=device-width");
|
||||||
|
expect(content).not.toMatch(/user-scalable=(no|0)/iu);
|
||||||
|
expect(content).not.toMatch(/maximum-scale/iu);
|
||||||
|
expect(content).not.toMatch(/minimum-scale/iu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the Dashboard lay itself out around a notch", async () => {
|
||||||
|
expect(await viewport("index.html")).toContain("viewport-fit=cover");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user