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
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]>
228 lines
13 KiB
Markdown
228 lines
13 KiB
Markdown
# 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/<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`:
|
|
|
|
```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://<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.
|