Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f9b536bc6
commit
906c015ca8
@@ -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