Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s

A second real-iPhone review of the Stage 1 Dashboard found three
defects. This fixes them, and ADR 0065 records the decisions.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-15 22:56:22 -05:00
co-authored by Claude Opus 5.5
parent 5f9b536bc6
commit 906c015ca8
20 changed files with 1072 additions and 35 deletions
@@ -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>
+25 -1
View File
@@ -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}
+136
View File
@@ -0,0 +1,136 @@
/**
* Keeps the page behind a modal surface — the phone navigation drawer — from scrolling while the
* surface is open (ADR 0065), and puts everything back exactly when it closes.
*
* Three things together, because no one of them is enough on iOS Safari:
*
* - **The root stops scrolling.** `overflow: hidden` on `<html>` and `<body>` ends wheel, keyboard,
* and scrollbar scrolling everywhere, and on current iOS Safari touch scrolling too. The page's
* scroll position is kept in place, not reset, so nothing jumps: the drawer opens over the page
* exactly where it was. (`position: fixed` on the body, the older iOS workaround, moves the page
* and its sticky header, which shows through the backdrop as a jump.)
* - **Touches outside the drawer go nowhere.** A non-passive `touchmove` listener cancels every
* move that starts outside the region that may scroll, and every move inside it that would only
* overscroll it — past its top or bottom. That is what keeps an older iOS, which ignores the
* root's `overflow`, from scrolling the page behind, and what stops pull-to-refresh.
* - **Overscroll does not chain.** `overscroll-behavior: none` on the root, only while locked, so the
* page cannot rubber-band or refresh; the region itself carries `overscroll-behavior: contain` in
* the stylesheet. When the drawer is closed, nothing here is set and the page behaves normally,
* pull-to-refresh included.
*
* Everything is reached through the document it is given; the shell touches no browser global. The
* returned function undoes exactly what was done, restoring the inline styles that were there
* before, and — unless the drawer closed because a new page is opening, which starts at its own top
* — the scroll position too.
*/
export interface ScrollLockRelease {
(options?: { readonly restoreScroll?: boolean }): void;
}
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
interface SavedStyles {
readonly element: HTMLElement;
readonly values: readonly (readonly [string, string, string])[];
}
function save(element: HTMLElement): SavedStyles {
return {
element,
values: LOCKED_PROPERTIES.map(
(property) =>
[
property,
element.style.getPropertyValue(property),
element.style.getPropertyPriority(property),
] as const,
),
};
}
function restore(saved: SavedStyles): void {
for (const [property, value, priority] of saved.values) {
if (value === "") {
saved.element.style.removeProperty(property);
} else {
saved.element.style.setProperty(property, value, priority);
}
}
}
/** Whether `element` can scroll any further in the direction of a finger moving by `deltaY` (a
* positive delta is the finger moving down, which scrolls the content up, towards its top). */
function canScroll(element: HTMLElement, deltaY: number): boolean {
if (element.scrollHeight <= element.clientHeight) {
return false;
}
return deltaY > 0
? element.scrollTop > 0
: element.scrollTop + element.clientHeight < element.scrollHeight;
}
/**
* Locks `document`'s scrolling. `scrollRegion`, when given, is the one element inside the modal
* surface that may still scroll by touch.
*/
export function lockDocumentScroll(
document: Document,
scrollRegion?: HTMLElement | null,
): ScrollLockRelease {
const view = document.defaultView;
const root = document.documentElement;
const body = document.body;
const scrollX = view?.scrollX ?? 0;
const scrollY = view?.scrollY ?? 0;
const saved = [save(root), save(body)];
for (const element of [root, body]) {
element.style.setProperty("overflow", "hidden");
element.style.setProperty("overscroll-behavior", "none");
}
let lastY: number | undefined;
const onTouchStart = (event: TouchEvent): void => {
lastY = event.touches[0]?.clientY;
};
const onTouchMove = (event: TouchEvent): void => {
if (event.touches.length !== 1) {
// Pinch zoom stays the browser's: locking scroll never takes zoom away.
return;
}
const y = event.touches[0]?.clientY;
const deltaY = y === undefined || lastY === undefined ? 0 : y - lastY;
lastY = y;
const target = event.target;
const inside =
scrollRegion !== undefined &&
scrollRegion !== null &&
target instanceof Node &&
scrollRegion.contains(target);
if (!inside || !canScroll(scrollRegion, deltaY)) {
if (event.cancelable) {
event.preventDefault();
}
}
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchmove", onTouchMove, { passive: false });
let released = false;
return (options = {}) => {
if (released) {
return;
}
released = true;
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
for (const entry of saved) {
restore(entry);
}
const restoreScroll = options.restoreScroll ?? true;
if (restoreScroll && view !== null && (view.scrollX !== scrollX || view.scrollY !== scrollY)) {
view.scrollTo({ behavior: "instant", left: scrollX, top: scrollY });
}
};
}
@@ -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). */
`;
+40
View File
@@ -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).
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lockDocumentScroll } from "../src/browser/scroll-lock.js";
/**
* The page behind the phone navigation drawer stays put while the drawer is open (ADR 0065): no
* scrolling, no pull-to-refresh, the drawer's own list still scrolls, and closing it puts the page
* back exactly as it was.
*/
let region: HTMLDivElement;
let inside: HTMLSpanElement;
let outside: HTMLDivElement;
beforeEach(() => {
region = document.createElement("div");
inside = document.createElement("span");
region.append(inside);
outside = document.createElement("div");
document.body.append(region, outside);
});
afterEach(() => {
region.remove();
outside.remove();
document.documentElement.removeAttribute("style");
document.body.removeAttribute("style");
vi.restoreAllMocks();
});
/** Gives the region a scrollable size and position, which a DOM without layout does not compute. */
function scrollable(element: HTMLElement, scrollTop: number): void {
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(element, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop });
}
function touch(target: EventTarget, type: "touchstart" | "touchmove", y: number, points = 1) {
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
touches: { clientY: number }[];
};
Object.defineProperty(event, "touches", {
value: Array.from({ length: points }, () => ({ clientY: y })),
});
target.dispatchEvent(event);
return event;
}
describe("the document scroll lock", () => {
it("stops the root from scrolling and overscrolling, and puts the styles back as they were", () => {
document.documentElement.style.setProperty("overflow", "auto");
const release = lockDocumentScroll(document, region);
for (const element of [document.documentElement, document.body]) {
expect(element.style.getPropertyValue("overflow")).toBe("hidden");
expect(element.style.getPropertyValue("overscroll-behavior")).toBe("none");
}
release();
expect(document.documentElement.style.getPropertyValue("overflow")).toBe("auto");
expect(document.documentElement.style.getPropertyValue("overscroll-behavior")).toBe("");
expect(document.body.style.getPropertyValue("overflow")).toBe("");
expect(document.body.style.getPropertyValue("overscroll-behavior")).toBe("");
});
it("cancels a touch drag that starts outside the drawer, which also stops pull-to-refresh", () => {
const release = lockDocumentScroll(document, region);
touch(outside, "touchstart", 100);
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(true);
release();
// Once released, the page scrolls normally again.
touch(outside, "touchstart", 100);
expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(false);
});
it("lets the drawer's own list scroll, but never past its ends into the page", () => {
const release = lockDocumentScroll(document, region);
scrollable(region, 200);
touch(inside, "touchstart", 300);
expect(touch(inside, "touchmove", 200).defaultPrevented, "scrolling down").toBe(false);
touch(inside, "touchstart", 200);
expect(touch(inside, "touchmove", 300).defaultPrevented, "scrolling up").toBe(false);
// At its top, pulling down would only overscroll — the gesture behind pull-to-refresh.
scrollable(region, 0);
touch(inside, "touchstart", 200);
expect(touch(inside, "touchmove", 300).defaultPrevented, "pulled past the top").toBe(true);
// At its bottom, pushing up would only overscroll.
scrollable(region, 600);
touch(inside, "touchstart", 300);
expect(touch(inside, "touchmove", 200).defaultPrevented, "pushed past the bottom").toBe(true);
release();
});
it("never takes pinch zoom away", () => {
const release = lockDocumentScroll(document, region);
touch(outside, "touchstart", 100, 2);
expect(touch(outside, "touchmove", 300, 2).defaultPrevented).toBe(false);
release();
});
it("restores the exact scroll position on release, unless a new page opened", () => {
const view = document.defaultView as Window;
Object.defineProperty(view, "scrollY", { configurable: true, value: 1234 });
const scrollTo = vi.spyOn(view, "scrollTo").mockImplementation(() => undefined);
const release = lockDocumentScroll(document, region);
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
release();
expect(scrollTo).toHaveBeenCalledWith({ behavior: "instant", left: 0, top: 1234 });
scrollTo.mockClear();
const again = lockDocumentScroll(document, region);
Object.defineProperty(view, "scrollY", { configurable: true, value: 50 });
again({ restoreScroll: false });
expect(scrollTo).not.toHaveBeenCalled();
// Releasing twice does nothing the second time.
again();
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(view, "scrollY", { configurable: true, value: 0 });
});
});
@@ -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("");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Zoom stays available to everyone who needs it (ADR 0065). iOS Safari's zoom into small form
* fields is fixed by the fields' own font size, never by locking the viewport.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function viewport(page: string): Promise<string> {
const html = await readFile(path.join(packageDirectory, page), "utf8");
const tag = /<meta\s+content="([^"]*)"\s+name="viewport"\s*\/?>/u.exec(html);
if (!tag?.[1]) {
throw new Error(`${page} has no viewport tag`);
}
return tag[1];
}
describe("the shell's pages never disable zoom", () => {
for (const page of ["index.html", "display.html"]) {
it(`${page} allows pinch and accessibility zoom`, async () => {
const content = (await viewport(page)).replace(/\s+/gu, "");
expect(content).toContain("width=device-width");
expect(content).not.toMatch(/user-scalable=(no|0)/iu);
expect(content).not.toMatch(/maximum-scale/iu);
expect(content).not.toMatch(/minimum-scale/iu);
});
}
it("lets the Dashboard lay itself out around a notch", async () => {
expect(await viewport("index.html")).toContain("viewport-fit=cover");
});
});