Stage 1: display setup in Settings, no iOS form zoom, drawer locks the page behind it #113
@@ -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
|
||||
each module's settings at `/settings/<module-id>`. Discover is where modules are found and installed
|
||||
(`/settings/discover`), with Installed and Updates beneath it (`/settings/discover/installed`,
|
||||
`/updates`); configuring a module is its own Settings page. **Settings → Displays** pairs and revokes
|
||||
display screens: the paired displays with when each was last seen, and an *Add display* flow for the
|
||||
code a screen shows ([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)).
|
||||
`/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.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
|
||||
/**
|
||||
* Host-owned device identity transport: pairing a display, and managing what has been paired
|
||||
@@ -315,6 +316,32 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* How a new screen starts pairing (ADR 0065): the platform display page for every running module
|
||||
* that shows content on screens, for Settings → Displays to tell an administrator what to open on
|
||||
* the screen. Each page is the generic display host at `/display/<module-id>` (ADR 0033), which
|
||||
* shows a pairing code until the screen is approved.
|
||||
*
|
||||
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
|
||||
* this does not become a way to enumerate what is installed — an unpaired screen still learns
|
||||
* nothing beyond the page it was told to open. It answers names and root-relative page paths only;
|
||||
* the browser resolves them against the API origin, which is where the display host lives in both
|
||||
* topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet.
|
||||
*/
|
||||
routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => {
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
displays: options.moduleUi.listDisplays().map((display) => ({
|
||||
moduleId: display.moduleId,
|
||||
name: display.name,
|
||||
url: moduleDisplayPageUrl(display.moduleId),
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||
try {
|
||||
const devices = await options.devices.listDevices();
|
||||
|
||||
@@ -419,6 +419,27 @@ describe.sequential("display device pairing, end to end", () => {
|
||||
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||
|
||||
// How an administrator learns which page to open on that screen (ADR 0065): the platform
|
||||
// display page of every module that uses displays, named, and only for an administrator. The
|
||||
// screen itself — still anonymous — is refused, so setup never enumerates what is installed.
|
||||
const setup = await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/devices/setup",
|
||||
);
|
||||
expect(setup.status).toBe(200);
|
||||
expect(setup.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await setup.json()).toEqual({
|
||||
displays: [
|
||||
{
|
||||
moduleId: "announcements",
|
||||
name: "Announcements fixture",
|
||||
url: "/display/announcements",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
|
||||
|
||||
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
@@ -501,6 +522,7 @@ describe.sequential("display device pairing, end to end", () => {
|
||||
});
|
||||
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
||||
expect((await device.request("/api/v1/devices")).status).toBe(403);
|
||||
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
|
||||
|
||||
// 9. The administrator revokes it.
|
||||
const devices = (await (
|
||||
|
||||
@@ -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
|
||||
- [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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface FocusTrapOptions {
|
||||
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
|
||||
* first wraps to the last, so focus cannot walk out into the page behind.
|
||||
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
|
||||
* the surface — if that control is still in the document.
|
||||
* the surface — if that control is still in the document, without scrolling the page.
|
||||
*
|
||||
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
|
||||
* as well as in a document: it only ever looks inside its own container and at the container's own
|
||||
@@ -68,7 +68,10 @@ export function useFocusTrap(
|
||||
const target = restoreTo.current;
|
||||
restoreTo.current = null;
|
||||
if (target?.isConnected) {
|
||||
target.focus();
|
||||
// Without scrolling: the control was on screen when it opened the surface, and scrolling it
|
||||
// into view again — past a sticky header's scroll padding — would move the page under a
|
||||
// person who just closed a dialog (ADR 0065).
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
}, [active, containerRef, initialFocus]);
|
||||
|
||||
@@ -177,6 +177,19 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
border-color: ${designToken("border.strong")};
|
||||
}
|
||||
|
||||
/*
|
||||
* Text a person types is never smaller than 16px on a phone or a touch screen. iOS Safari zooms the
|
||||
* whole page into any focused text field whose font is smaller than that, and leaves it zoomed after
|
||||
* the field closes, so the compact desktop size would turn every form on an iPhone into a zoomed-in
|
||||
* page (ADR 0065). The fix is the font, never the viewport: zoom stays available to everyone who
|
||||
* needs it. Desktop keeps its compact size.
|
||||
*/
|
||||
@media (max-width: 47.999em), (pointer: coarse) {
|
||||
.ek-control {
|
||||
font-size: max(${designToken("typography.size-md")}, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.ek-control::placeholder {
|
||||
color: ${designToken("text.muted")};
|
||||
}
|
||||
|
||||
@@ -251,6 +251,19 @@ describe("ConfirmationDialog", () => {
|
||||
expect(document.activeElement).toBe(byTestId("opener"));
|
||||
});
|
||||
|
||||
it("returns focus without scrolling the page (ADR 0065)", () => {
|
||||
openHarness({ onCancel: vi.fn() });
|
||||
const opener = byTestId("opener");
|
||||
const focus = vi.spyOn(opener, "focus");
|
||||
|
||||
press(dialog(), "Escape");
|
||||
|
||||
// The opener was on screen when it opened the dialog; scrolling it into view again would move a
|
||||
// page with a sticky header under the person who just closed the dialog.
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
|
||||
const onCancel = vi.fn();
|
||||
openHarness({ dialog: { busy: true }, onCancel });
|
||||
|
||||
@@ -159,3 +159,21 @@ describe("choice indicators are drawn from tokens (ADR 0064)", () => {
|
||||
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",
|
||||
);
|
||||
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(
|
||||
await waitFor(
|
||||
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
|
||||
"the Add display dialog with its code field focused",
|
||||
`${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",
|
||||
),
|
||||
"Add display opens a dialog that focuses the pairing code only once asked",
|
||||
"Settings → Displays explains how to set up a new screen, from the platform's own display page",
|
||||
);
|
||||
await evaluate(`${selector("add-display-cancel")}.click()`);
|
||||
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
|
||||
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(
|
||||
@@ -652,11 +670,19 @@ check(
|
||||
),
|
||||
"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(
|
||||
@@ -665,6 +691,27 @@ check(
|
||||
),
|
||||
"the phone drawer offers the same Settings hierarchy and closes once a page is chosen",
|
||||
);
|
||||
check(
|
||||
await evaluate(
|
||||
`getComputedStyle(document.documentElement).overflowY !== "hidden" && document.documentElement.style.getPropertyValue("overscroll-behavior") === ""`,
|
||||
),
|
||||
"choosing a page from the drawer releases the page lock",
|
||||
);
|
||||
// ADR 0065: on a phone the pairing code is typed at 16px or more, so iOS Safari does not zoom.
|
||||
if (displayModuleInstalled) {
|
||||
await navigate(`${dashboardOrigin}/settings/displays`);
|
||||
await waitFor(selector("add-display"), "Settings → Displays on a phone");
|
||||
await evaluate(`${selector("add-display")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`,
|
||||
"the pairing code field's size on a phone",
|
||||
),
|
||||
"the pairing code field is at least 16px on a phone, so iOS does not zoom into it",
|
||||
);
|
||||
await evaluate(`${selector("add-display-cancel")}.click()`);
|
||||
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone");
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@ekklesia/ui";
|
||||
import {
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,8 +27,10 @@ import {
|
||||
approveDevicePairing,
|
||||
type EkklesiaHostConfig,
|
||||
type HostDeviceSummary,
|
||||
type HostDisplaySetup,
|
||||
HostProtocolError,
|
||||
listHostDevices,
|
||||
listHostDisplaySetup,
|
||||
lookupDevicePairing,
|
||||
revokeHostDevice,
|
||||
} from "../host-protocol.js";
|
||||
@@ -35,9 +38,9 @@ import { calendarDate, lastSeenPhrase } from "./display-time.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* Settings → Displays (ADR 0064): the platform's own administration page for the screens paired
|
||||
* with this installation — the paired displays, adding one with the code its screen shows, and
|
||||
* revoking one.
|
||||
* 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
|
||||
@@ -55,6 +58,11 @@ type ListState =
|
||||
| { 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" }
|
||||
@@ -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: {
|
||||
readonly device: HostDeviceSummary;
|
||||
readonly now: Date;
|
||||
@@ -280,6 +404,7 @@ function DisplayCard(props: {
|
||||
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. */
|
||||
@@ -305,6 +430,23 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
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 {
|
||||
@@ -332,24 +474,6 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
actions={
|
||||
canAdd ? (
|
||||
<Button
|
||||
data-testid="add-display"
|
||||
onClick={(event) => {
|
||||
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a
|
||||
// button that is clicked, so it is focused deliberately before the dialog opens.
|
||||
event.currentTarget.focus();
|
||||
setPaired(undefined);
|
||||
setAdding(true);
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
<ShellIcon name="addDisplay" size={18} />
|
||||
Add display
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
back={props.back}
|
||||
description="The screens paired with this installation. Each has one name, used by every module that shows content on it."
|
||||
title="Displays"
|
||||
@@ -363,6 +487,20 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
</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
|
||||
@@ -391,7 +529,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
{list.devices.length === 0 ? (
|
||||
<div data-testid="device-list-empty">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type NavigationPresentation,
|
||||
ShellBrand,
|
||||
} from "./ShellNavigation.js";
|
||||
import { lockDocumentScroll } from "./scroll-lock.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
import { SHELL_STYLESHEET } from "./shell-styles.js";
|
||||
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
|
||||
@@ -142,6 +143,7 @@ function NavigationDrawer(props: {
|
||||
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;
|
||||
@@ -211,7 +213,11 @@ function NavigationDrawer(props: {
|
||||
<ShellIcon name="closeNavigation" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__scroll">
|
||||
<div
|
||||
className="ek-shell-sidebar__scroll"
|
||||
data-testid="navigation-drawer-scroll"
|
||||
ref={props.scrollRegionRef}
|
||||
>
|
||||
<NavigationList
|
||||
history={props.history}
|
||||
id={DRAWER_NAV_ID}
|
||||
@@ -263,8 +269,11 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const drawerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const previousCompact = useRef(compact);
|
||||
const previousRoute = useRef(routeKey(route));
|
||||
const currentRoute = useRef(routeKey(route));
|
||||
currentRoute.current = routeKey(route);
|
||||
|
||||
const toggleMode = (): void => {
|
||||
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
|
||||
@@ -317,6 +326,20 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
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"
|
||||
@@ -401,6 +424,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
onNavigate={() => closeDrawer("main")}
|
||||
onSignOut={props.onSignOut}
|
||||
route={route}
|
||||
scrollRegionRef={drawerScrollRef}
|
||||
settings={props.settings}
|
||||
/>
|
||||
) : 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -632,6 +696,8 @@ html:has(.ek-shell-mobile-bar) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -649,10 +715,17 @@ html:has(.ek-shell-mobile-bar) {
|
||||
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). */
|
||||
`;
|
||||
|
||||
@@ -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. */
|
||||
export interface HostModuleLifecycleSummary {
|
||||
readonly id: string;
|
||||
|
||||
@@ -41,6 +41,8 @@ function json(body: unknown, status = 200): Response {
|
||||
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 = {}) {
|
||||
@@ -59,6 +61,16 @@ function server(options: ServerOptions = {}) {
|
||||
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 });
|
||||
}
|
||||
@@ -162,6 +174,19 @@ describe("Settings → Displays", () => {
|
||||
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);
|
||||
@@ -216,11 +241,72 @@ describe("Settings → Displays", () => {
|
||||
await click("add-display-cancel");
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
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 })
|
||||
@@ -721,3 +728,56 @@ describe("the phone header (ADR 0064)", () => {
|
||||
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