Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.
- /display/setup is the platform's own display page, served whenever
the deployment has device support, whatever is installed. It pairs a
platform display with no module. Once paired, it opens the module it
is set to show; with none set, it shows its name and waits, rechecking
so it stays alive and notices revocation. "setup" is now a reserved
module id.
- Migration core:0009_platform_display_pairing lets a pairing request
name no module. It relaxes module_id and permission_id and adds a
both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
display. Approval accepts an optional moduleId, so the administrator
can choose an installed display module and the device gets that
module's declared permission. A module display's own request can't be
redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
its name and the module displays it may show. It renews the device's
cookie.
- GET /api/v1/devices/setup is replaced by
GET /api/v1/devices/display-modules: the modules a display can be set
to show.
- Settings -> Displays always offers the /display/setup address, Copy
setup link and Add display. Add display has an optional "Show on this
screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
13 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).
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. - 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.
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. - 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.