# 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](0036-managed-device-identity-and-pairing.md) (every pairing request names a module display) - Extends: [0033](0033-display-kiosk-host-contract.md), [0039](0039-shared-design-tokens-and-elements.md), [0045](0045-split-dashboard-and-api-origins.md), [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), [0064](0064-stage-1-shell-review-corrections.md) ## 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-control` font 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/` (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/requests` with `{}`. - **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": null` for 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`: ```json { "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: ```json { "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: 1. one sentence: *Open this address on the new screen, then add it with the code it shows*; 2. the platform's setup address, `https:///display/setup`, with **Copy setup link**; 3. **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 `
`. **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 removed display leaves it at once — the page asks for the list again right after removing it — and the page says so, with focus on that notice because the display's own card is gone. The device row and its permission rows stay in the database, marked revoked, as security history; a history view can show them later. **Administrators remove a display; the platform revokes its credential.** The interface says *Remove*, *Remove display?*, and *Display removed*, and never shows "revoked". The domain does not change for presentation: the device and its permissions are revoked, the endpoint is still `POST /api/v1/devices/:id/revoke`, the fields are still `revoked`/`revokedAt`, and security and audit history still describe a credential revocation. 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 **Remove**. ### 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//display` with `{ "moduleId": "" | 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/` 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: hidden` on `` and ``, 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: fixed` on 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 `touchmove` listener 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's `overflow`, 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: none` on the root while locked. The drawer and its list carry `overscroll-behavior: contain`, and the backdrop carries `touch-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 `/display` page listing the modules that use displays.** Rejected: it would tell anyone on the network what is installed, which ADR 0036 deliberately prevents. `/display/setup` lists nothing; what a screen may show reaches only that screen, after it is paired. - **A sentinel module (for example a `platform` module 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/setup` shows 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. `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. - **`position: fixed` body 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_pairing` must 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. - `setup` can 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/signage` setup 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.