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
@@ -133,6 +133,77 @@ If the module list cannot be read, the choice is simply not offered and the disp
nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never
a browser global (ADR 0058).
**Show on this screen** is `@ekklesia/ui`'s `SelectMenu`, not the browser's native select. That is
a shared, Ekklesia-drawn single choice: the WAI-ARIA select-only combobox (a `role="combobox"` button
and a `role="listbox"` of options).
- **Look:** a token-themed list surface, a chevron inside the control's own padding, and hover,
active, and selected states in both themes. The control is an `ek-control`, so it keeps the 16px
phone size.
- **Keyboard:** the arrows, Home, End, and type-ahead move; Enter, Space, or Tab chooses; Escape
closes without closing a dialog it sits in.
- **Pointer and touch:** a press chooses, and a press outside closes.
- **Focus:** stays on the control throughout.
### The normal list shows active displays, current as they change
The Paired displays list shows **active** displays only. A revoked display leaves it at once — the
page asks for the list again right after revoking — and the page says so, with focus on that notice
because the display's own card is gone. The revoked device row and its permission rows stay in the
database, marked revoked, as security history; a history view can show them later.
A newly paired display appears without a reload. Approval does not create the display: the screen
does, when it collects its credential on its next poll, a few seconds later (ADR 0036). So after
approving, the page shows a "Waiting for the screen to connect…" placeholder and asks for the list
again every second until a display it did not have appears, for up to a minute. Each answer replaces
the list, so the display can never appear twice. The page also refreshes the list every 30 seconds
on its own.
Each active display shows:
- its one name;
- **Online** (seen in the last 30 minutes), **Offline**, or **Not connected yet**. This is coarse on
purpose: last-seen is recorded at most every 15 minutes;
- **Showing:** a module, or *Nothing yet*;
- last seen and paired dates;
- **Change** and **Revoke**.
### Changing what a display shows: one target, the active display permission
In this Stage 1 model a display shows **at most one** module, and its current target **is** the one
active display permission it holds. There is no second "assignment" record: authorization ("may it
show Signage?") and assignment ("is it set to show Signage?") cannot disagree, because they are the
same row. No new migration is needed.
**Change** opens *Change what Foyer TV shows*, with the same **Show on this screen** choice. Saving
calls `PUT /api/v1/devices/<id>/display` with `{ "moduleId": "<id>" | null }`. That route requires
`core.devices.manage`, an exact trusted `Origin`, and a bounded body. The module must be a running
display, and the permission is that module's declared display permission.
The store applies the change in one transaction on the same device row (locked):
- every other active permission row is revoked — kept, with its `revokedAt`, as history;
- the new one is granted, unless the display already holds it.
The device's identity, name, and credential never change, and a revoked or unknown display is a
`404`. Nothing yet → module, module A → module B, and module → nothing are all this one operation.
**The screen notices on its own,** within about 30 seconds, whichever page it is on:
- **On `/display/setup`, waiting:** it rechecks `/api/v1/device-pairing/self` every 30 seconds, and
opens the module as soon as it holds one.
- **On a module's display page:** a paired device asks `/api/v1/device-pairing/self` every 30
seconds. If it is no longer set to show that module, it goes to `/display/setup`, which opens what
it shows now or waits, named. A refused descriptor for a screen that is still paired does the same,
instead of asking to be paired again. Only an unpaired or revoked screen shows a pairing code, as
before.
- **A person previewing a display page is never moved.**
This does not decide how a screen is shared between modules later. Showing more than one module, or
arbitrating between them (DESIGN.md §11.6), may add a separate target or arbitration record beside
the permissions then. What stays fixed is the ownership: the platform owns the physical display
identity and its current target, and a module never creates its own display identity.
### Module display routes are unchanged
`/display/<module-id>` keeps working exactly as it did: a screen that opens it directly still pairs
@@ -198,9 +269,16 @@ unchanged.
PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to
a screen that could not show Signage, while Signage's page still worked. Choosing at approval
closes the loop with the permission table that already exists.
- **Changing or reassigning a paired display's module after pairing.** Deferred: it needs revocation
and arbitration rules between modules (DESIGN.md §11.6). Today a display's module is chosen once, at
approval, or by the module page it was opened on.
- **A separate "current target" column beside the permissions.** Rejected for the single-target Stage
1 model: it would be a second source of truth that must be kept equal to the permission that
actually authorizes the screen. If displays later show more than one module, a target or
arbitration record can be added then.
- **Revoking and re-pairing to change what a display shows.** Rejected: it would mint a new identity
for the same physical screen, and send someone to it with a new code.
- **Deleting revoked displays.** Rejected: revocation is security history. They leave the normal list
but stay in the database.
- **Polishing the native select.** Rejected: its open menu is drawn by the browser and cannot be
themed. `SelectMenu` is shared, so later choices get the same treatment.
- **`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.
@@ -222,6 +300,10 @@ unchanged.
address still works; Settings now sends administrators to `/display/setup`.
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
included.
- `@ekklesia/ui` gains `SelectMenu`, additively. The native `Select` remains for anything that wants
the browser's own control.
- A display's target changes without re-pairing. A paired screen on a module page now asks the
platform every 30 seconds whether it should still show that module.
- 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.
+1
View File
@@ -1007,6 +1007,7 @@ sets, never the code):
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. |
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
| `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. |
| `PUT /api/v1/devices/:deviceId/display` | Body `{moduleId}` (a running display module, or `null`). Sets what the display shows: its other active permissions are revoked (kept as history) and that module's declared display permission is granted. Same identity, name, and credential. `404` for a revoked or unknown display. |
| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. |
The pairing code travels in request **bodies**, never a path or query, so it does not land in access
+17
View File
@@ -497,6 +497,19 @@ proven here:
- `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with
zero display modules, and only with device support.
- `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements.
- `apps/api/tests/device-pairing.integration.ts` also changes a display from nothing to a module,
between two modules, and back. It checks that the display keeps one identity, name, and credential,
that the module descriptors follow, that the history rows are kept, and that revoked displays and
bad requests are refused.
- `packages/ui/tests/select-menu.test.tsx` covers `SelectMenu`:
- its combobox and listbox semantics, and no native select;
- the keyboard: arrows, Home, End, type-ahead, Enter, Space, and Tab;
- Escape closing it without closing a surrounding dialog;
- pointer and outside presses, the disabled state, and token-only styling.
- `surfaces/shell/tests/display-app.test.tsx` covers a running module display:
- moved to another module, or refused while still paired, it goes to `/display/setup`;
- an unpaired screen still shows a code;
- a person previewing the page is never moved.
- `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page:
- it pairs with no module and waits, named;
- it opens the module it is set to show;
@@ -504,6 +517,10 @@ proven here:
- it returns to a code when revoked;
- it never pairs on a server failure.
- `surfaces/shell/tests/displays-settings.test.tsx` covers:
- active displays only, with status and what each shows;
- a newly paired display appearing without a reload, and never twice;
- **Change** moving a display between nothing and modules as the same display;
- a revoked display leaving the list at once;
- the platform setup address, and **Copy setup link**;
- **Add display** with zero display modules;
- the **Show on this screen** choice;