Stage 1: display setup in Settings, no iOS form zoom, drawer locks the page behind it #113

Open
ekklesia-bot wants to merge 1 commits from fix/stage1-displays-setup-mobile into main
Member

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:

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

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)
ekklesia-bot added 1 commit 2026-10-16 04:12:39 +00:00
Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
906c015ca8
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]>
All checks were successful
CI / verify (pull_request) Successful in 1m12s
Required
Details
CI / infrastructure-smoke (pull_request) Successful in 49s
Required
Details
CI / package-release (pull_request) Successful in 47s
Required
Details
CI / migration-smoke (pull_request) Successful in 2m18s
Required
Details
CI / image-build (pull_request) Successful in 1m3s
Required
Details
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
Required
Details
CI / package-publication (pull_request) Successful in 54s
Required
Details
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
Required
Details
This pull request doesn't have enough required approvals yet. 0 of 1 official approvals granted.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/stage1-displays-setup-mobile:fix/stage1-displays-setup-mobile
git checkout fix/stage1-displays-setup-mobile
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#113