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]>
18 KiB
0065: Displays pair at the platform's own /display/setup; phone forms never zoom; the drawer locks the page
- Status: Accepted
- Date: 2026-10-16
- Supersedes, in part: 0036 (every pairing request names a module display)
- Extends: 0033, 0039, 0045, 0058, 0064
Context
A second hand review on a real iPhone, after ADR 0064, found three defects:
- Settings → Displays did not say how to set up a screen. Add display took a pairing code, but nothing on the page told an administrator how to get a physical screen to show one. The only setup instructions were in Signage's own Displays panel.
- iOS Safari zoomed into the Add display form. Focusing the pairing code zoomed the page into the
dialog, and the page stayed zoomed after the dialog closed. The cause is the shared
.ek-controlfont size:typography.size-sm, 0.875rem, which is 14px. iOS Safari zooms the page into any focused text field whose font is smaller than 16px. - The page behind the phone drawer still scrolled, and pulling down behind it triggered the browser's pull-to-refresh.
The deeper problem behind the first defect was architectural. Displays are platform-owned (ROADMAP
§3.3, §5.4), but a screen could only start pairing on a module's display page, /display/<module-id>
(ADR 0033). Every pairing request named a module, the database required it (module_id and
permission_id were NOT NULL), and approval granted exactly that module's display permission (ADR
0036). So an installation without Signage, or any other module with a display, could not set up a
display at all, and the platform's display identity was something only a module could bring into
being.
The identity itself never needed a module. A device is its own principal, and its permissions live in their own table: a device with none is already representable.
Decision
A screen starts at the platform's own /display/setup
/display/setup is the platform's own display page. It is served whenever the deployment has device
support, whatever is installed, including no module at all. It is the same inert, module-agnostic
page every display is served. setup joins Core's reserved module ids in a third group,
RESERVED_DISPLAY_HOST_NAMES, so no module's display can take the address.
On it, the display shell asks the platform who this screen is (GET /api/v1/device-pairing/self,
below):
- Not paired, or revoked: it shows a pairing code. It asks to be paired with no module:
POST /api/v1/device-pairing/requestswith{}. - Paired and set to show a module: it opens that module's display page,
location.replace, which takes over exactly as if the screen had opened it directly. - Paired with nothing set yet: it shows "This screen is paired" and its name, and waits.
While waiting, it asks again every 30 seconds. That keeps its credential alive (ADR 0056: credentials last while in use), notices revocation, and opens a module as soon as the display holds one.
One display identity, with or without a module
There is no second identity system. A screen paired at /display/setup is an ordinary platform
device, in the same tables as any other, with the same credential, cookies, revocation, last-seen,
and listing. It simply holds no module display permission until it is given one.
Migration core:0009_platform_display_pairing lets a pairing request name no module. It relaxes
module_id and permission_id to nullable, and adds a check that they are set together or not at
all. It is additive, destroys nothing, and every existing request satisfies it unchanged. Like every
migration, an operator applies it explicitly.
- Requests.
{}creates a platform request.{ "moduleId": … }is unchanged: it resolves the module from the running display registry and derives the permission from its declaration. Any other key is ignored, as it always was, so a browser can never name a permission. - Lookup and approval answer
"moduleId": null, "permissionId": nullfor a platform request. - Choosing a module at approval. Approval accepts an optional
moduleId: the installed display module the administrator chose for a platform display to show. It must be a running display. The permission granted is that module's declared display permission, never one the request names. The choice is written in the same conditional update as the approval, and only where the request names no module or already names that one, so a module display's own request can never be redirected to another module's permission. Claiming grants the request's permission, or none.
GET /api/v1/devices/display-modules: what a display can show
This PR first added GET /api/v1/devices/setup, which listed each display module's page as a place
to start pairing. With /display/setup, setup needs no list, so that endpoint is removed. What remains
is a separate, real need: knowing which modules a display can be set to show. That need is met by
GET /api/v1/devices/display-modules:
{ "modules": [{ "moduleId": "signage", "name": "Signage", "permissionId": "signage.display" }] }
It requires core.devices.manage, like the device list, is no-store, and is never anonymous, so it
is not a way to enumerate what is installed. Settings uses it for the Show on this screen choice,
and to say what each paired display shows ("Shows Signage", "Not showing a module yet"). An empty list
is a real answer.
GET /api/v1/device-pairing/self: a display asking about itself
This endpoint answers only a device principal. It returns its name, and the module display pages it holds the permission for:
{ "name": "Foyer TV", "displays": [{ "moduleId": "signage", "url": "/display/signage" }] }
A person (even an administrator who opened the page on their own laptop), an unpaired screen, and a
revoked device are refused. It renews the device's cookie exactly as a module display's descriptor
does, and is no-store.
Settings → Displays: one flow, in one place, whatever is installed
Settings → Displays opens with a Set up a new screen panel, above the paired displays. Its contents are, in order:
- one sentence: Open this address on the new screen, then add it with the code it shows;
- the platform's setup address,
https://<api-origin>/display/setup, with Copy setup link; - Add display, right beneath it.
Each step is directly under the one before. Add display lives here and only here, and it is always
offered, whatever modules are installed. The step-by-step version is folded away in a native
<details>.
Add display looks the code up, then:
- A screen from
/display/setup: "A new screen is waiting to be paired". The administrator names it, and, if any installed module shows content on screens, may choose Show on this screen: Nothing yet (the default) or a module. With no such module, the dialog says the display is paired now and waits. - A screen from a module's own display page: "A display is asking to show Signage", as before, with no choice.
If the module list cannot be read, the choice is simply not offered and the display pairs showing 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/selfevery 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/selfevery 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
with that module's permission, and released Signage 0.1.5 is unaffected. It is simply no longer the
only way to bring a display into being. A screen paired at /display/setup and set to show Signage
holds signage.display, exactly what a screen paired at /display/signage holds, so Signage 0.1.5
sees and uses it the same way.
Text controls are at least 16px on phones and touch screens
@ekklesia/ui keeps .ek-control at typography.size-sm on desktop. Under the Dashboard's compact
breakpoint (max-width: 47.999em) or on a coarse pointer, it uses
max(typography.size-md, 16px). Every shared text field and select is affected, in the Dashboard and
in every module root, so later Settings, People, and module forms cannot reintroduce the zoom.
The viewport is not touched. There is no user-scalable=no, maximum-scale, or minimum-scale,
and a test holds both shell pages to that, so pinch and accessibility zoom stay available.
Opening and closing a dialog must also not move the page. The shared focus trap now returns focus to
the opening control with preventScroll, because that control was on screen when it was used.
Scrolling it into view again, past the phone header's scroll padding, moved the page by the header's
height. The Add display button focuses itself the same way before opening the dialog.
The drawer locks the page behind it
While the phone drawer is open, lockDocumentScroll (surfaces/shell/src/browser/scroll-lock.ts)
does three things:
- The root stops scrolling. It sets
overflow: hiddenon<html>and<body>, which ends wheel, keyboard, and scrollbar scrolling, and touch scrolling on current iOS Safari. It keeps the scroll position in place. The older iOS workaround,position: fixedon the body, moves the page and its sticky header, which would show through the backdrop as a jump. - Touches outside the drawer go nowhere. A non-passive
touchmovelistener cancels a one-finger drag that starts outside the drawer's list. It also cancels a drag inside the list that would only overscroll it past its top or bottom. This covers an iOS that ignores the root'soverflow, and it is what stops pull-to-refresh. Two-finger gestures are never cancelled, so pinch zoom stays. - Overscroll does not chain. It sets
overscroll-behavior: noneon the root while locked. The drawer and its list carryoverscroll-behavior: contain, and the backdrop carriestouch-action: pinch-zoom.
The drawer's own list still scrolls by touch when it is taller than the screen.
Closing restores the inline styles that were there before, and the exact scroll position, unless a different page opened meanwhile. A page chosen in the drawer, or reached by Back or Forward, starts at its own top, as before.
When the drawer is closed nothing is set: the page scrolls and refreshes normally. The desktop
sidebar never locks anything. The focus trap, Escape, focus return, inert, and safe areas are
unchanged.
Alternatives
- Keep pairing only on module display pages, and list them in Settings. This was this PR's first version. Rejected: it made a platform-owned display depend on a module existing, and an installation with no display module could not set one up at all.
- A public
/displaypage listing the modules that use displays. Rejected: it would tell anyone on the network what is installed, which ADR 0036 deliberately prevents./display/setuplists nothing; what a screen may show reaches only that screen, after it is paired. - A sentinel module (for example a
platformmodule id) instead of nullable columns. Rejected: a value meaning "no module" in a module column is a second, hidden meaning that every reader must know about. - Leaving assignment for later, so a display from
/display/setupshows nothing until a later 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. - 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.
SelectMenuis shared, so later choices get the same treatment. maximum-scale=1oruser-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.
position: fixedbody scroll lock. Rejected: it jumps the page, and the sticky phone header, behind the backdrop.
Consequences
- Any installation can set up and pair a display at
/display/setup, with or without a module that uses displays. A display is a platform resource; a module shows content on a display it is set to show, rather than creating one. - Migration
core:0009_platform_display_pairingmust be applied before this version runs. It is additive and non-destructive. - Every installation now serves the display host, so a missing display host build fails startup, as a missing Dashboard build already did.
setupcan no longer be a module id. No known module uses it.- Released Signage 0.1.5 keeps working unchanged. Its own setup text still sends administrators to
"Display devices in the Dashboard sidebar" (
Zayin/Signage#24). Its own/display/signagesetup address still works; Settings now sends administrators to/display/setup. - Text fields are 16px on phones and touch screens everywhere
@ekklesia/uiis used, module UIs included. @ekklesia/uigainsSelectMenu, additively. The nativeSelectremains 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.