Files
Ekklesia/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md
T
ekklesiaandClaude Opus 5.5 300d9eab51
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
Pair displays at the platform's own /display/setup, with or without modules
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]>
2026-10-16 16:43:02 -05:00

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-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/<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/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:

{ "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:

  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://<api-origin>/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 <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: hidden on <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: 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.
  • 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=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.
  • 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.