CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m30s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 8m55s
Administrators now remove a display: the card's Remove, "Remove display?", and "Display removed". The platform still revokes the device's credential; the endpoint, fields, and security history keep revoke/revoked (ADR 0065). The default accent is a deep purple anchored on #210050, set in the shared design tokens only (ADR 0066, superseding ADR 0060's teal in part). The anchor is the light primary action with white on it; the light accent text, focus ring, and selection, and the dark theme's lavender accent, are variants on its hue chosen for contrast. The teal-tinted neutrals move to a faint violet-grey at the same lightness, so every contrast pairing holds. The contrast test gains six pairings the accent depends on, and pins the anchor. Co-Authored-By: Claude Opus 5.5 <[email protected]>
316 lines
19 KiB
Markdown
316 lines
19 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).
|
|
|
|
**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/<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/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/<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.
|
|
- **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.
|