Pinching anywhere in the Blazor Hybrid UI zoomed the entire window — top nav, sub-nav and bottom tab bar included. The requirement is that chrome stays locked and only the inner page body zooms, applied consistently through the shared page template rather than per screen.
Native pinch in WKWebView zooms the visual viewport: a
compositor-level transform that sits above the whole page. It is not a CSS-layer effect, so no
stylesheet can exempt an element from it — even position: fixed chrome scales with
everything else.
That single fact decides the design. Chrome-locked zoom is only reachable by suppressing the native gesture and applying the zoom ourselves. Everything below is a consequence of that.
The host page carries an explicit prior decision, recorded in index.html under
AB#2391:
“maximum-scale=1.0 and user-scalable=no removed (AB#2391):
together they disabled pinch-zoom entirely, which is a flagged accessibility anti-pattern and
removed the only manual fallback when text could not be enlarged. Pinch-zoom is now the
backstop behind the OS text-size bridge.”
So pinch is load-bearing for accessibility, not incidental. Any replacement has to be at least as capable — WCAG 1.4.4 asks for 200% — and, critically, it must not silently strip zoom from pages the replacement does not cover.
The obvious implementation — putting user-scalable=no back in the viewport meta —
is global. It would disable pinch on every route, including the 15 routable
pages that do not use AlpaScreen and therefore have nowhere to host the
replacement. Those pages would lose zoom outright, regressing AB#2391.
Instead the gesture is intercepted per region: the listeners live on the region, so a pinch beginning anywhere else still zooms the page exactly as today. Uncovered pages keep the AB#2391 backstop untouched and no accessibility regression is introduced.
Suppression happens on touchmove, not gesturestart.
The first implementation cancelled WebKit's gesturestart, which reads like the
right hook and passes a synthetic-event test. On device it does not work: a real two-finger
pinch still drove the visual viewport to 1.6× while our handler ran, so the page zoomed
and the body zoomed. Cancelling the multi-touch touchmove is what
actually stops WKWebView. Single-touch events are never cancelled, so scrolling is unaffected.
There is exactly one gesture path. An earlier revision drove scale from WebKit gesture events and kept a pointer-events fallback for Android. On iOS both fired for the same pinch and the scale was applied twice, hitting the 4× clamp almost instantly. Touch events are supported by both WKWebView and Android WebView, so one touch implementation serves both platforms and cannot double-count. The gesture listeners that remain only cancel; they never scale.
The region also sets touch-action: manipulation. Without it the
double-tap that resets our zoom also triggered WKWebView's own double-tap-to-zoom —
so the gesture whose entire job is restoring the page left the visual viewport at 1.07×
and the document panned 59px, shifting the chrome under the status bar. manipulation
disables the browser's double-tap zoom while still permitting pan-x/pan-y, so scrolling is
unaffected.
Zoom is a transform: scale() on an inner canvas with
transform-origin: 0 0, applied inline by js/alpa-zoom.js and removed
entirely at 1× so an idle page carries no stacking context, compositing layer or
containing block it did not have before.
Two behaviours were measured in WKWebView rather than assumed, because both would otherwise have forced a much more invasive implementation:
Introduce ZoomableRegion, a wrapper that intercepts pinch within its own subtree
and applies transform: scale() to an inner canvas.
AlpaScreen wraps its ChildContent in one, designating
.alpa-screen-content as “the body”. Every sibling — top nav, page banner, title
bar, FixedContent sub-nav, FooterContent and
BottomNav — is outside the region and therefore locked.
Range is 1×–4×, clearing the WCAG 1.4.4 bar of 200% with headroom. Double-tap resets to exactly 1×, because a pinch can strand a page at 1.03× with content subtly clipped and no way back.
| Option | Why rejected | Note |
|---|---|---|
Drive --alpa-text-scale |
Cannot be scoped to a subtree — proven by measurement above. Would require duplicating
all 20 damped calc() steps onto the canvas. |
Also only resizes text: fixed-px padding, icons and images stay put, so proportions distort and images never magnify. |
CSS zoom |
Supported and layout-aware, but reflows rather than magnifies — it changes the page a member is looking at instead of enlarging it. | Measured 440 → 656 scrollWidth at 2× (reflow) vs 880 for transform (magnification). |
Global user-scalable=no |
Strips zoom from the 15 uncovered pages, regressing AB#2391. | The reason interception is scoped. |
| Per-page opt-in | Breaks the consistent page model the shared template exists to provide. | — |
Every routable Blazor page, and whether it gets chrome-locked zoom. A page is covered if it
renders <AlpaScreen>, since that is where ZoomableRegion lives.
| Bucket | Pages | Zoom behaviour |
|---|---|---|
Covered — renders <AlpaScreen> | 36 | Chrome-locked zoom, 1×–4× |
Gap — does not render <AlpaScreen> | 15 | Native pinch, unchanged from today |
| Total routable | 51 | — |
NotFoundFallback.razor also renders <AlpaScreen> and is
therefore covered, but has no @page route so it is outside the 51.
None of them lose anything. Because interception is scoped to the region, a
pinch on these pages still zooms natively exactly as it does today — verified on device by
pinching a page with no ZoomableRegion and watching the visual viewport go to
1.6×. The open question per page is only whether its own chrome should also be locked.
| Page | Own chrome | Review | Assessment |
|---|---|---|---|
FTDT/FTDTRoot.razor | None — redirect only | N/A | Renders no UI; immediately redirects to the dashboard. Nothing to zoom. |
DevDashboard.razor (/) | .dev-dash | Intentional | Internal test and diagnostic surfaces, not member-facing. Excluding them is deliberate; native pinch remains available for anyone inspecting them. DevDashboard declines AlpaScreen on purpose — adopting it would also pull in the PageBanner slot. |
DevAuthStatusPage.razor | — | Intentional | |
DevFontSpecimen.razor | — | Intentional | |
DevThemeScopeDemo.razor | — | Intentional | |
UiTestReturnNativePage.razor | .dev-dash | Intentional | |
FTDT/FTDTTestScenariosPage.razor | — | Intentional | |
LoginPage.razor | .alpa-login-screen | Review | Member-facing with their own nav row. Highest-value candidates for coverage, since login is the first screen a member with low vision meets. |
LoginNonMembersPage.razor | .alpa-login-screen | Review | |
FTDT/FTDTAddFlightSegment.razor | .ftdt-page | Review | FTDT forms carry their own .ftdt-nav-controls header. Covering them means either migrating to AlpaScreen or wrapping each body individually — a decision best taken with the FTDT extraction work rather than ahead of it. |
FTDT/FTDTAddNote.razor | .ftdt-page | Review | |
FTDT/FTDTAddRest.razor | .ftdt-page | Review | |
FTDT/FTDTDutyPeriodForm.razor | .ftdt-page | Review | |
FTDT/FTDTEditDutyPeriod.razor | .ftdt-page | Review | |
FTDT/FTDTSelectOpType.razor | .ftdt-page | Review |
Regenerate this list with
comm -23 <(rg -l '^@page' --glob '*.razor' . | sort) <(rg -l '^\s*<AlpaScreen' --glob '*.razor' . | sort).
Match the element at line start, not the bare string: DevDashboard and
UiTestReturnNativePage were initially miscounted as covered because a comment
mentioning <AlpaScreen> matched a looser search.
Validated live on an iPhone 17 Pro Max simulator against /jumpseat/search, the
densest chrome case in the app (top nav + title bar + sub-nav + footer CTA row + tab bar).
Regression-checked on /home-preview: page banner, horizontal magazine carousel and
list rows all render unchanged, and .alpa-feed-track keeps its own horizontal
scroll rather than having it captured by the zoom viewport.
The measurements above were taken with synthesised events, which drive our handlers but never exercise WebKit's own gesture recognition — and that gap hid three defects. The run below used genuine two-finger input driven through the Simulator (Option-drag), which is what surfaced them.
The control line is the one that matters for AB#2391: it is direct evidence that the 15 uncovered pages still have working zoom, rather than an argument that they should.
Still unverified: Android. The touch path is shared, so Android WebView should behave the same, but it has not been exercised on an emulator or device. Everything above was measured on an iPhone 17 Pro Max simulator.
| File | Role |
|---|---|
ALPAMobile/wwwroot/js/alpa-zoom.js | Gesture interception, focal-point maths, double-tap reset, Android pointer fallback. |
ALPAMobile/Components/ZoomableRegion.razor | Viewport + canvas markup; attaches and detaches the handler. |
ALPAMobile/Components/AlpaScreen.razor | Wraps ChildContent, designating the body as the zoomable region. |
ALPAMobile.Presentation/wwwroot/css/alpa-components.css | .alpa-zoom-canvas transform origin, .alpa-zoom-viewport horizontal room. |
ALPAMobile/wwwroot/index.html | Loads the script. Viewport meta deliberately unchanged. |