Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s

Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 18:29:15 -05:00
co-authored by Claude Opus 5.5
parent 300d9eab51
commit d9dafcb0f7
18 changed files with 1892 additions and 197 deletions
+80 -8
View File
@@ -639,14 +639,22 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
);
return result.exceptionDetails ? undefined : result.result.value;
};
const kioskWaitFor = async (expression) => {
const deadline = Date.now() + timeoutMs;
const kioskWaitFor = async (expression, within = timeoutMs) => {
const deadline = Date.now() + within;
while (Date.now() < deadline) {
if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
// What the screen was doing instead: its page, which display state it shows, and what the
// platform says it may show. Paths and test ids only, never a cookie or a body beyond that.
const state = await kioskEvaluate(`(async () => {
const shown = [...document.querySelectorAll("[data-testid]")].map((element) => element.dataset.testid).filter((id) => id.startsWith("display-")).slice(0, 8);
const self = await fetch("/api/v1/device-pairing/self", { credentials: "include" }).then(async (response) => response.status + " " + JSON.stringify((await response.json()).displays ?? null)).catch(() => "unreachable");
return { path: location.pathname, shown, self, visibility: document.visibilityState };
})()`).catch(() => "unavailable");
console.log(`info kiosk ${JSON.stringify(state)}`);
return false;
};
await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession);
@@ -672,23 +680,37 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
),
"Add display finds the screen from /display/setup as a new platform display, with no module",
);
// "Show on this screen" is Ekklesia's own select (a combobox and listbox), never a native one:
// it is opened, and an option pressed, as a person would.
check(
!(await evaluate(`Boolean(${selector("add-display-dialog")}.querySelector("select"))`)),
"Add display uses the themed select, not the browser's native one",
);
const chosen = await evaluate(`(() => {
const name = ${selector("device-pairing-name")};
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display");
name.dispatchEvent(new Event("input", { bubbles: true }));
const shows = ${selector("device-pairing-shows")};
const option = shows ? [...shows.options].find((candidate) => candidate.value !== "") : undefined;
if (option) {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(shows, option.value);
shows.dispatchEvent(new Event("change", { bubbles: true }));
if (!shows) {
return "";
}
return option ? option.value : "";
shows.click();
const list = document.getElementById(shows.getAttribute("aria-controls"));
const option = [...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value !== "");
if (!option) {
return "";
}
option.click();
return option.dataset.value;
})()`);
await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled");
await evaluate(`${selector("device-pairing-approve")}.click()`);
// The display's own card — keyed by its device id — not the success notice or the "waiting for
// the screen" placeholder, which carry its name before the display exists.
const newestCard = `[...document.querySelectorAll('li[data-testid^="device-"]')].filter((card) => /^device-[0-9a-f-]{36}$/.test(card.dataset.testid) && card.textContent.includes("Smoke platform display")).at(-1)`;
check(
await waitFor(
`!${selector("add-display-dialog")} && [...document.querySelectorAll('[data-testid^="device-"]')].some((card) => card.textContent.includes("Smoke platform display"))`,
`!${selector("add-display-dialog")} && ${newestCard}`,
"the new platform display in the list",
),
"the paired platform display appears under Paired displays without a reload",
@@ -703,6 +725,56 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
? "with no module to show, the paired screen shows its name and waits"
: "the paired screen opens the module display it was set to show, by itself",
);
// What it shows can change later, on the same display: from Settings, through Change.
const cardId = await evaluate(`${newestCard}?.dataset.testid ?? ""`);
check(cardId !== "", "the paired display's own card is listed, to manage");
if (chosen !== "" && cardId !== "") {
await evaluate(`${selector(cardId.replace("device-", "device-change-"))}.click()`);
await waitFor(selector("device-change-shows"), "the Change dialog");
await evaluate(`(() => {
const shows = ${selector("device-change-shows")};
shows.click();
const list = document.getElementById(shows.getAttribute("aria-controls"));
[...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value === "").click();
})()`);
await evaluate(`${selector("device-change-save")}.click()`);
check(
await waitFor(
`!${selector("device-change-dialog")} && ${selector(`${cardId}-showing`)}?.textContent === "Showing: Nothing yet"`,
"the display showing nothing",
),
"Change sets what a paired display shows, and the list says so at once",
);
check(
await kioskWaitFor(
`location.pathname === "/display/setup" && ${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"`,
75_000,
),
"the screen notices the change on its own and leaves the module, as the same paired display",
);
}
// Revoked, it leaves the normal list at once, and its screen returns to a pairing code.
if (cardId !== "") {
await evaluate(`${selector(cardId.replace("device-", "device-revoke-"))}.click()`);
await waitFor(selector("device-revoke-confirm"), "the revoke confirmation");
await evaluate(`${selector("device-revoke-confirm")}.click()`);
check(
await waitFor(
`!${selector(cardId)} && ${selector("device-revoke-success")}`,
"the revoked display gone from the list",
),
"a revoked display leaves Paired displays at once, without a reload",
);
check(
await kioskWaitFor(
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
75_000,
),
"the revoked screen returns to a pairing code on its own",
);
}
}
await cdp.send("Target.closeTarget", { targetId: kiosk.targetId });
await cdp.send("Target.disposeBrowserContext", { browserContextId });