This is a small follow-up to #112 for the three defects found in the second real-iPhone review. The decisions are recorded in ADR 0065.
#112's Discover hierarchy, collapsed-rail alignment, Appearance radios and Settings overview are unchanged, and their tests still pass. Nothing in Signage changes, and this PR has nothing to do with #111.
1. Settings → Displays now explains how to set up a screen
What existed. The display host is already generic: /display/<module-id> is the platform's own page. It shows a pairing code, waits for approval, then hosts that module's display (ADR 0033). A pairing request names the module because approval grants exactly that module's display permission (ADR 0036). But nothing told an administrator which pages exist. ADR 0033 even says "There is deliberately no display inventory in this pass. A management UI may need one later."
What was added is the smallest platform piece needed: GET /api/v1/devices/setup.
It requires core.devices.manage, like the device list, and responds with no-store.
It lists every running display module's page from the same registry the display routes use:
It is never answered anonymously, so an unpaired screen still can't enumerate what is installed.
The browser resolves the page against the API origin, which is where the display host lives in both topologies (ADR 0045).
The page. Following your feedback, setup is one flow in one place: a Set up a new screen panel above the paired displays. It contains:
one sentence: "Open this address on the new screen, then add it with the code it shows";
the address, e.g. https://<api-host>/display/signage, with Copy link;
+ Add display directly beneath it. It has moved here from the page header and appears nowhere else;
Step-by-step instructions, folded into a native <details>.
The other states:
Several display modules: each address is labelled with its module.
No display module installed: the panel says "No installed module shows content on screens yet. Once one is installed from Discover, its setup address appears here." It offers no Add display, which could only fail, and names nothing Signage-specific.
Setup addresses can't be read: the panel says so but still offers Add display, and the paired list keeps working.
The paired list, its statuses, revoke, and the permissions are unchanged. Signage 0.1.5 keeps its own instructions, which point to the same platform page (Zayin/Signage#24).
Not done: a module-independent "pair first, assign a module later" page. That is ROADMAP §5.4's global display model, and it changes how a device gets its permission, so it needs its own ADR. ADR 0065 records why it's deferred.
2. iOS Safari zoomed into Add display
Cause. The shared .ek-control (TextInput, Select, typed confirmation) renders at typography.size-sm, which is 14px. iOS Safari zooms into any focused text field under 16px and stays zoomed after the field closes.
Fix. In @ekklesia/ui, .ek-control uses max(typography.size-md, 16px) under (max-width: 47.999em), (pointer: coarse).
Desktop keeps 14px.
It covers every shared form in the Dashboard and every module root, so later Settings and People forms inherit it.
Sign-in and recovery fields already inherit the 16px page font.
Viewport. No user-scalable=no, maximum-scale or minimum-scale is added. A new test holds both shell pages to that.
Dialog scroll. Opening and closing the dialog also moved the page by about 30px, from two plain focus() calls:
Add display focusing itself before opening;
the shared focus trap returning focus on close.
Both scrolled the button clear of the sticky header's scroll padding. Both now use preventScroll. In the browser the page stays exactly where it was through open, Escape and Cancel.
3. The page behind the phone drawer no longer scrolls
lockDocumentScroll (surfaces/shell/src/browser/scroll-lock.ts) runs while the drawer is open:
Root overflow:<html> and <body> get overflow: hidden. The scroll position is kept, so nothing jumps. I didn't use the position: fixed body trick because it moves the page and its sticky header behind the backdrop.
Touch guard: a non-passive touchmove listener cancels one-finger drags that start outside the drawer's list. It also cancels drags inside the list that would only overscroll past its top or bottom. This covers an iOS that ignores root overflow, and stops pull-to-refresh. Pinch is never cancelled.
No overscroll chaining: the root gets overscroll-behavior: none. The drawer and its list get overscroll-behavior: contain, and the backdrop gets touch-action: pinch-zoom.
The drawer's own list still scrolls by touch. Closing restores the previous inline styles and the exact scroll position, unless a different page opened (chosen from the drawer, or by Back or Forward), which starts at its own top.
With the drawer closed nothing is set: normal scrolling and pull-to-refresh work. Desktop never locks. The focus trap, Escape, focus return, inert and safe areas are unchanged.
Tests
apps/api/tests/device-pairing.integration.ts:GET /api/v1/devices/setup returns the named page with no-store; an unpaired screen and a paired device are both refused.
surfaces/shell/tests/displays-settings.test.tsx:
the setup panel on the API origin, with Add display inside it and the steps folded;
one Add display only;
Copy link with and without a clipboard;
several display modules;
none installed, with no button and no mention of Signage;
a failed read;
a refused non-root-relative URL;
the existing Add display and revoke flows.
surfaces/shell/tests/scroll-lock.test.ts (new):
root locked and previous styles restored;
drags outside cancelled, drags inside allowed except past the ends;
pinch untouched;
scroll restored unless a new page opened;
double release.
surfaces/shell/tests/settings-navigation.test.tsx: the drawer locks and releases on Escape; a background drag is cancelled; choosing a page releases the lock; wide screens never lock.
surfaces/shell/tests/viewport.test.ts (new): no zoom-disabling viewport rule on index.html or display.html.
packages/ui/tests/tokens.test.ts: the 16px rule for phone and touch, and that it comes after the base rule.
packages/ui/tests/interaction.test.tsx: the focus trap returns focus with preventScroll.
scripts/lib/deploy-smoke-browser.mjs:
the setup panel on the API origin, with Add display in it, or no button when no display module exists;
the page locked behind the open drawer, and released after choosing a page;
the pairing field at 16px or more on a phone.
Verification
pnpm verify: passed (25/25 tasks).
pnpm test:migrations:integration (live PostgreSQL, including the new setup endpoint): passed, 428 tests in 16 files.
split topology, released Signage 0.1.5: passed, 375 ok, 0 failed;
same-origin: passed, 49 ok, 0 failed. This run has no display module and no browser stage.
Every new check passed in both split runs.
Visual and browser pass (real Chromium, against a mock-API harness of this branch)
Desktop Settings → Displays: with a display module, with the steps expanded, and with none installed.
Phone Settings → Displays, in Light and Dark: no horizontal overflow.
Add display: the code field is 16px on the phone and 14px on desktop, and has focus once the dialog opens. Opening, Escape and Cancel keep the page's exact scroll position, the visual-viewport scale stays at 1, and focus returns to Add display.
Drawer, with real touch drags:
scrolled to 1500px, then opened the drawer: it stayed at 1500 through backdrop drags up and down, and the mouse wheel;
the drawer's list scrolled by touch (0 → 163px), and pulling it past its top didn't move the page;
Escape from 1234px returned exactly 1234, with focus on the trigger;
after closing, the page scrolled normally again;
navigating through the drawer landed at the top with focus on <main>.
#112 spot checks: Discover still has only Installed and Updates, the child is current, and /browse still redirects to /settings/discover.
Needs a real-iPhone recheck
Headless Chromium can't reproduce iOS Safari's own focus-zoom or pull-to-refresh. The checks above prove that the computed size is ≥16px and that the gestures are cancelled, not Safari's own behaviour. Please recheck on the iPhone:
that Add display doesn't zoom;
that nothing behind the open drawer scrolls or refreshes;
that the drawer list scrolls when it's taller than the screen.
This is a small follow-up to #112 for the three defects found in the second real-iPhone review. The decisions are recorded in **ADR 0065**.
#112's Discover hierarchy, collapsed-rail alignment, Appearance radios and Settings overview are unchanged, and their tests still pass. Nothing in Signage changes, and this PR has nothing to do with #111.
## 1. Settings → Displays now explains how to set up a screen
**What existed.** The display host is already generic: `/display/<module-id>` is the platform's own page. It shows a pairing code, waits for approval, then hosts that module's display (ADR 0033). A pairing request names the module because approval grants exactly that module's display permission (ADR 0036). But nothing told an administrator which pages exist. ADR 0033 even says *"There is deliberately no display inventory in this pass. A management UI may need one later."*
**What was added** is the smallest platform piece needed: `GET /api/v1/devices/setup`.
- It requires `core.devices.manage`, like the device list, and responds with `no-store`.
- It lists every running display module's page from the same registry the display routes use:
```json
{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] }
```
- It is never answered anonymously, so an unpaired screen still can't enumerate what is installed.
- The browser resolves the page against the API origin, which is where the display host lives in both topologies (ADR 0045).
**The page.** Following your feedback, setup is one flow in one place: a **Set up a new screen** panel above the paired displays. It contains:
- one sentence: *"Open this address on the new screen, then add it with the code it shows"*;
- the address, e.g. `https://<api-host>/display/signage`, with **Copy link**;
- **+ Add display** directly beneath it. It has moved here from the page header and appears nowhere else;
- **Step-by-step instructions**, folded into a native `<details>`.
The other states:
- **Several display modules:** each address is labelled with its module.
- **No display module installed:** the panel says *"No installed module shows content on screens yet. Once one is installed from Discover, its setup address appears here."* It offers no **Add display**, which could only fail, and names nothing Signage-specific.
- **Setup addresses can't be read:** the panel says so but still offers **Add display**, and the paired list keeps working.
The paired list, its statuses, revoke, and the permissions are unchanged. Signage 0.1.5 keeps its own instructions, which point to the same platform page (Zayin/Signage#24).
**Not done:** a module-independent "pair first, assign a module later" page. That is ROADMAP §5.4's global display model, and it changes how a device gets its permission, so it needs its own ADR. ADR 0065 records why it's deferred.
## 2. iOS Safari zoomed into Add display
**Cause.** The shared `.ek-control` (`TextInput`, `Select`, typed confirmation) renders at `typography.size-sm`, which is 14px. iOS Safari zooms into any focused text field under 16px and stays zoomed after the field closes.
**Fix.** In `@ekklesia/ui`, `.ek-control` uses `max(typography.size-md, 16px)` under `(max-width: 47.999em), (pointer: coarse)`.
- Desktop keeps 14px.
- It covers every shared form in the Dashboard and every module root, so later Settings and People forms inherit it.
- Sign-in and recovery fields already inherit the 16px page font.
**Viewport.** No `user-scalable=no`, `maximum-scale` or `minimum-scale` is added. A new test holds both shell pages to that.
**Dialog scroll.** Opening and closing the dialog also moved the page by about 30px, from two plain `focus()` calls:
- **Add display** focusing itself before opening;
- the shared focus trap returning focus on close.
Both scrolled the button clear of the sticky header's scroll padding. Both now use `preventScroll`. In the browser the page stays exactly where it was through open, Escape and Cancel.
## 3. The page behind the phone drawer no longer scrolls
`lockDocumentScroll` (`surfaces/shell/src/browser/scroll-lock.ts`) runs while the drawer is open:
- **Root overflow:** `<html>` and `<body>` get `overflow: hidden`. The scroll position is kept, so nothing jumps. I didn't use the `position: fixed` body trick because it moves the page and its sticky header behind the backdrop.
- **Touch guard:** a non-passive `touchmove` listener cancels one-finger drags that start outside the drawer's list. It also cancels drags inside the list that would only overscroll past its top or bottom. This covers an iOS that ignores root `overflow`, and stops pull-to-refresh. Pinch is never cancelled.
- **No overscroll chaining:** the root gets `overscroll-behavior: none`. The drawer and its list get `overscroll-behavior: contain`, and the backdrop gets `touch-action: pinch-zoom`.
The drawer's own list still scrolls by touch. Closing restores the previous inline styles and the exact scroll position, unless a different page opened (chosen from the drawer, or by Back or Forward), which starts at its own top.
With the drawer closed nothing is set: normal scrolling and pull-to-refresh work. Desktop never locks. The focus trap, Escape, focus return, `inert` and safe areas are unchanged.
## Tests
- **`apps/api/tests/device-pairing.integration.ts`:** `GET /api/v1/devices/setup` returns the named page with `no-store`; an unpaired screen and a paired device are both refused.
- **`surfaces/shell/tests/displays-settings.test.tsx`:**
- the setup panel on the API origin, with **Add display** inside it and the steps folded;
- one **Add display** only;
- **Copy link** with and without a clipboard;
- several display modules;
- none installed, with no button and no mention of Signage;
- a failed read;
- a refused non-root-relative URL;
- the existing Add display and revoke flows.
- **`surfaces/shell/tests/scroll-lock.test.ts`** (new):
- root locked and previous styles restored;
- drags outside cancelled, drags inside allowed except past the ends;
- pinch untouched;
- scroll restored unless a new page opened;
- double release.
- **`surfaces/shell/tests/settings-navigation.test.tsx`:** the drawer locks and releases on Escape; a background drag is cancelled; choosing a page releases the lock; wide screens never lock.
- **`surfaces/shell/tests/viewport.test.ts`** (new): no zoom-disabling viewport rule on `index.html` or `display.html`.
- **`packages/ui/tests/tokens.test.ts`:** the 16px rule for phone and touch, and that it comes after the base rule.
- **`packages/ui/tests/interaction.test.tsx`:** the focus trap returns focus with `preventScroll`.
- **`scripts/lib/deploy-smoke-browser.mjs`:**
- the setup panel on the API origin, with **Add display** in it, or no button when no display module exists;
- the page locked behind the open drawer, and released after choosing a page;
- the pairing field at 16px or more on a phone.
## Verification
- `pnpm verify`: passed (25/25 tasks).
- `pnpm test:migrations:integration` (live PostgreSQL, including the new setup endpoint): passed, 428 tests in 16 files.
- `pnpm test:package-release`: passed. `@ekklesia/ui` changed (additively).
- Deploy smokes, one image built from `906c015`, real Chromium:
- **split topology, module template:** passed, 437 ok, 0 failed;
- **split topology, released Signage 0.1.5:** passed, 375 ok, 0 failed;
- **same-origin:** passed, 49 ok, 0 failed. This run has no display module and no browser stage.
- Every new check passed in both split runs.
## Visual and browser pass (real Chromium, against a mock-API harness of this branch)
- **Desktop Settings → Displays:** with a display module, with the steps expanded, and with none installed.
- **Phone Settings → Displays,** in Light and Dark: no horizontal overflow.
- **Add display:** the code field is 16px on the phone and 14px on desktop, and has focus once the dialog opens. Opening, Escape and Cancel keep the page's exact scroll position, the visual-viewport scale stays at 1, and focus returns to **Add display**.
- **Drawer, with real touch drags:**
- scrolled to 1500px, then opened the drawer: it stayed at 1500 through backdrop drags up and down, and the mouse wheel;
- the drawer's list scrolled by touch (0 → 163px), and pulling it past its top didn't move the page;
- Escape from 1234px returned exactly 1234, with focus on the trigger;
- after closing, the page scrolled normally again;
- navigating through the drawer landed at the top with focus on `<main>`.
- **#112 spot checks:** Discover still has only Installed and Updates, the child is current, and `/browse` still redirects to `/settings/discover`.
## Needs a real-iPhone recheck
Headless Chromium can't reproduce iOS Safari's own focus-zoom or pull-to-refresh. The checks above prove that the computed size is ≥16px and that the gestures are cancelled, not Safari's own behaviour. Please recheck on the iPhone:
- that Add display doesn't zoom;
- that nothing behind the open drawer scrolls or refreshes;
- that the drawer list scrolls when it's taller than the screen.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A second real-iPhone review of the Stage 1 Dashboard found three
defects. This fixes them, and ADR 0065 records the decisions.
- Settings -> Displays now sets up a screen in one place. A "Set up a
new screen" panel shows the platform display page to open on the
screen, a Copy link button, and Add display directly beneath it; the
step-by-step version is folded into a <details>. The addresses come
from a new GET /api/v1/devices/setup, which requires
core.devices.manage and lists each running display module's
/display/<module-id> page. Nothing names Signage. With no display
module installed, the panel says so and offers no Add display.
- iOS Safari zoomed into the pairing field because the shared
.ek-control rendered at 14px. On phones and touch screens it now
renders at max(1rem, 16px). The viewport is untouched, so zoom stays
available, and a test guards that. The shared focus trap now returns
focus with preventScroll, so closing a dialog no longer moves the page
under the phone header.
- While the phone drawer is open, the page behind it no longer scrolls.
The root's overflow is hidden, a non-passive touchmove listener
cancels drags outside the drawer's list and overscroll past its ends,
and root overscroll is turned off. The list itself still scrolls.
Closing restores the exact scroll position unless another page opened.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
This is a small follow-up to #112 for the three defects found in the second real-iPhone review. The decisions are recorded in ADR 0065.
#112's Discover hierarchy, collapsed-rail alignment, Appearance radios and Settings overview are unchanged, and their tests still pass. Nothing in Signage changes, and this PR has nothing to do with #111.
1. Settings → Displays now explains how to set up a screen
What existed. The display host is already generic:
/display/<module-id>is the platform's own page. It shows a pairing code, waits for approval, then hosts that module's display (ADR 0033). A pairing request names the module because approval grants exactly that module's display permission (ADR 0036). But nothing told an administrator which pages exist. ADR 0033 even says "There is deliberately no display inventory in this pass. A management UI may need one later."What was added is the smallest platform piece needed:
GET /api/v1/devices/setup.It requires
core.devices.manage, like the device list, and responds withno-store.It lists every running display module's page from the same registry the display routes use:
It is never answered anonymously, so an unpaired screen still can't enumerate what is installed.
The browser resolves the page against the API origin, which is where the display host lives in both topologies (ADR 0045).
The page. Following your feedback, setup is one flow in one place: a Set up a new screen panel above the paired displays. It contains:
https://<api-host>/display/signage, with Copy link;<details>.The other states:
The paired list, its statuses, revoke, and the permissions are unchanged. Signage 0.1.5 keeps its own instructions, which point to the same platform page (Zayin/Signage#24).
Not done: a module-independent "pair first, assign a module later" page. That is ROADMAP §5.4's global display model, and it changes how a device gets its permission, so it needs its own ADR. ADR 0065 records why it's deferred.
2. iOS Safari zoomed into Add display
Cause. The shared
.ek-control(TextInput,Select, typed confirmation) renders attypography.size-sm, which is 14px. iOS Safari zooms into any focused text field under 16px and stays zoomed after the field closes.Fix. In
@ekklesia/ui,.ek-controlusesmax(typography.size-md, 16px)under(max-width: 47.999em), (pointer: coarse).Viewport. No
user-scalable=no,maximum-scaleorminimum-scaleis added. A new test holds both shell pages to that.Dialog scroll. Opening and closing the dialog also moved the page by about 30px, from two plain
focus()calls:Both scrolled the button clear of the sticky header's scroll padding. Both now use
preventScroll. In the browser the page stays exactly where it was through open, Escape and Cancel.3. The page behind the phone drawer no longer scrolls
lockDocumentScroll(surfaces/shell/src/browser/scroll-lock.ts) runs while the drawer is open:<html>and<body>getoverflow: hidden. The scroll position is kept, so nothing jumps. I didn't use theposition: fixedbody trick because it moves the page and its sticky header behind the backdrop.touchmovelistener cancels one-finger drags that start outside the drawer's list. It also cancels drags inside the list that would only overscroll past its top or bottom. This covers an iOS that ignores rootoverflow, and stops pull-to-refresh. Pinch is never cancelled.overscroll-behavior: none. The drawer and its list getoverscroll-behavior: contain, and the backdrop getstouch-action: pinch-zoom.The drawer's own list still scrolls by touch. Closing restores the previous inline styles and the exact scroll position, unless a different page opened (chosen from the drawer, or by Back or Forward), which starts at its own top.
With the drawer closed nothing is set: normal scrolling and pull-to-refresh work. Desktop never locks. The focus trap, Escape, focus return,
inertand safe areas are unchanged.Tests
apps/api/tests/device-pairing.integration.ts:GET /api/v1/devices/setupreturns the named page withno-store; an unpaired screen and a paired device are both refused.surfaces/shell/tests/displays-settings.test.tsx:surfaces/shell/tests/scroll-lock.test.ts(new):surfaces/shell/tests/settings-navigation.test.tsx: the drawer locks and releases on Escape; a background drag is cancelled; choosing a page releases the lock; wide screens never lock.surfaces/shell/tests/viewport.test.ts(new): no zoom-disabling viewport rule onindex.htmlordisplay.html.packages/ui/tests/tokens.test.ts: the 16px rule for phone and touch, and that it comes after the base rule.packages/ui/tests/interaction.test.tsx: the focus trap returns focus withpreventScroll.scripts/lib/deploy-smoke-browser.mjs:Verification
pnpm verify: passed (25/25 tasks).pnpm test:migrations:integration(live PostgreSQL, including the new setup endpoint): passed, 428 tests in 16 files.pnpm test:package-release: passed.@ekklesia/uichanged (additively).906c015, real Chromium:Visual and browser pass (real Chromium, against a mock-API harness of this branch)
<main>./browsestill redirects to/settings/discover.Needs a real-iPhone recheck
Headless Chromium can't reproduce iOS Safari's own focus-zoom or pull-to-refresh. The checks above prove that the computed size is ≥16px and that the gestures are cancelled, not Safari's own behaviour. Please recheck on the iPhone:
🤖 Generated with Claude Code
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.