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
+130
View File
@@ -1239,3 +1239,133 @@ describe("display host — continuity across reconnects and revocation", () => {
expect(display.counts.mounts).toBe(2);
});
});
describe("display host — following what the screen is set to show (ADR 0065)", () => {
async function wait(ms: number): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
}
/**
* A paired display (`viewer.kind === "device"`) on the announcements page. `shows` is what the
* platform says it may show now; `null` means it is not a paired display at all (403).
*/
function assignedServer(initial: { shows: string[] | null; descriptor?: number }) {
const state = { descriptor: initial.descriptor ?? 200, shows: initial.shows };
const calls: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input)).pathname;
calls.push(url);
if (url === "/api/v1/module-displays/announcements") {
return state.descriptor === 200
? json(
descriptorPayload("announcements", "1.2.0", { deviceId: "device-1", kind: "device" }),
)
: json({ code: "AUTHORIZATION_DENIED" }, state.descriptor);
}
if (url === "/api/v1/device-pairing/self") {
return state.shows === null
? json({ code: "AUTHORIZATION_DENIED" }, 403)
: json({
displays: state.shows.map((moduleId) => ({ moduleId, url: `/display/${moduleId}` })),
name: "Foyer TV",
});
}
if (url === "/api/v1/device-pairing/requests") {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url === "/api/v1/device-pairing/claims") {
return json({ status: "pending" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { calls, fetch, state };
}
async function renderAssigned(server: ReturnType<typeof assignedServer>) {
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplayApp
assignmentCheckMs={20}
config={{ apiBaseUrl, fetch: server.fetch }}
importModule={async () => artifact()}
moduleId="announcements"
offlineIndicatorDelayMs={0}
openDisplay={openDisplay}
/>,
);
});
await wait(0);
return openDisplay;
}
it("goes to the platform setup page when a running screen is set to show something else", async () => {
const server = assignedServer({ shows: ["announcements"] });
const openDisplay = await renderAssigned(server);
expect(query("display-module-container")).not.toBeNull();
// Still this module: nothing happens, however often it asks.
await wait(60);
expect(openDisplay).not.toHaveBeenCalled();
// Settings → Displays moves it to another module (or to nothing).
server.state.shows = ["bulletin"];
await wait(60);
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
expect(query("display-host-moving")).not.toBeNull();
// It was never asked to pair again.
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
});
it("goes to setup, not to a new pairing code, when its descriptor is refused but it is still paired", async () => {
const server = assignedServer({ descriptor: 403, shows: [] });
const openDisplay = await renderAssigned(server);
await wait(10);
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
expect(query("display-pairing")).toBeNull();
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
});
it("still shows a pairing code when it is not a paired display at all", async () => {
const server = assignedServer({ descriptor: 403, shows: null });
const openDisplay = await renderAssigned(server);
await wait(10);
expect(openDisplay).not.toHaveBeenCalled();
expect(query("display-pairing")).not.toBeNull();
});
it("never moves a person previewing the page", async () => {
const server = assignedServer({ shows: [] });
const fetch = server.fetch;
// The descriptor reports a person, not a device.
const personFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload());
}
return fetch(input, init);
}) as unknown as typeof globalThis.fetch;
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplayApp
assignmentCheckMs={20}
config={{ apiBaseUrl, fetch: personFetch }}
importModule={async () => artifact()}
moduleId="announcements"
offlineIndicatorDelayMs={0}
openDisplay={openDisplay}
/>,
);
});
await wait(80);
expect(openDisplay).not.toHaveBeenCalled();
expect(server.calls).not.toContain("/api/v1/device-pairing/self");
});
});