Pair displays at the platform's own /display/setup, with or without modules
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
+126
-47
@@ -1,9 +1,10 @@
|
||||
# 0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page
|
||||
# 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),
|
||||
[0036](0036-managed-device-identity-and-pairing.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),
|
||||
@@ -15,8 +16,7 @@ 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. A platform-owned display has to be set up from
|
||||
the platform, including on an installation without Signage.
|
||||
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
|
||||
@@ -24,58 +24,122 @@ A second hand review on a real iPhone, after ADR 0064, found three defects:
|
||||
- **The page behind the phone drawer still scrolled,** and pulling down behind it triggered the
|
||||
browser's pull-to-refresh.
|
||||
|
||||
The display host is already generic. `/display/<module-id>` is the platform's own page (ADR 0033): it
|
||||
shows a pairing code, waits for approval, then hosts that module's display artifact. A pairing request
|
||||
names the module because approval grants exactly that module's display permission (ADR 0036).
|
||||
However, nothing told an administrator which of these pages exist. ADR 0033's display routes noted:
|
||||
"There is deliberately no display inventory in this pass. A management UI may need one later." That
|
||||
management UI now exists.
|
||||
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
|
||||
|
||||
### `GET /api/v1/devices/setup`: where a new screen starts
|
||||
### A screen starts at the platform's own `/display/setup`
|
||||
|
||||
The API answers, for an administrator holding `core.devices.manage`, the platform display page of
|
||||
every running module that shows content on screens:
|
||||
`/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
|
||||
{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] }
|
||||
{ "modules": [{ "moduleId": "signage", "name": "Signage", "permissionId": "signage.display" }] }
|
||||
```
|
||||
|
||||
It reads the same eligible, running display registry as the display routes. It answers nothing to
|
||||
anyone else: it is never anonymous, so an unpaired screen still cannot enumerate what is installed.
|
||||
It is `no-store`. The URL is root-relative, and the browser resolves it against the API origin,
|
||||
because the display host lives there in both topologies (ADR 0045).
|
||||
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.
|
||||
|
||||
**No module-independent display page is added.** A page that let a screen pair before choosing a
|
||||
module would need a device to exist without a module permission. That is the ROADMAP §5.4 global
|
||||
display model, and it needs its own decision about assigning devices to modules. Until then, every
|
||||
screen pairs through a module's display page, and this endpoint is how the platform names those
|
||||
pages.
|
||||
### `GET /api/v1/device-pairing/self`: a display asking about itself
|
||||
|
||||
### Setting up a screen is one flow, in one place
|
||||
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 absolute display page, with **Copy link**;
|
||||
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, so nobody has to go back up the page. **Add display**
|
||||
lives here and only here, not in the page header.
|
||||
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>`.
|
||||
|
||||
The step-by-step version is folded away in a native `<details>` (*Step-by-step instructions*), for
|
||||
whoever wants it, instead of a block of text everyone has to read past. With more than one module
|
||||
that uses displays, each address is labelled with its module.
|
||||
**Add display** looks the code up, then:
|
||||
|
||||
When no installed module uses displays, the panel says so and that one installed from Discover will
|
||||
appear there. It offers no **Add display**, which could only fail. Nothing names Signage. If the
|
||||
setup address cannot be read, the panel says so and still offers **Add display**, since a screen may
|
||||
already be showing its code. The paired list keeps working either way.
|
||||
- **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.
|
||||
|
||||
**Copy link** reaches the clipboard through the clicked button's own window, never a browser global
|
||||
(ADR 0058). If the browser cannot copy, the button says so and the address stays selectable.
|
||||
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
|
||||
|
||||
@@ -121,15 +185,22 @@ unchanged.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Copy Signage's setup address into Settings.** Rejected: it names one module, and it is wrong on an
|
||||
installation without it.
|
||||
- **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.
|
||||
- **Pair first, choose the module later.** That is the right long-term model (ROADMAP §5.4). It is
|
||||
deferred because it changes how a device's permission is granted, and needs its own decision.
|
||||
- **Use the Dashboard navigation's `display.url`.** Rejected: the navigation lists only modules with
|
||||
a Dashboard UI, and filters by module permissions. A display-only module, or one the administrator
|
||||
cannot open, would be missing.
|
||||
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.
|
||||
@@ -138,9 +209,17 @@ unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An administrator can set up a screen from Settings → Displays alone. Released Signage 0.1.5 keeps
|
||||
its own instructions, which point to the same platform page, until its follow-up issue
|
||||
(`Zayin/Signage#24`) is resolved.
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user