← Component Specifications

D65 — Content-Only Pinch Zoom

Decision D65 Work item AB#2177 Supersedes part of AB#2391 Status Implemented

The ask

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.

Why native pinch cannot do this

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 accessibility constraint this reopens

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.

Mechanism: scoped interception, not a global switch

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.

Applying the zoom

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:

A scaled child DOES contribute to its scroll container's extents: viewport clientWidth 440 -> scrollWidth 880 at 2x => the browser handles scroll extents; no manual sizer element is needed. Scoping the existing type scale to a subtree does NOT work: baseline 13px --alpa-text-scale:2 on .alpa-screen-content 13px (no effect) --alpa-text-scale:2 on :root 21.55px => the 20 --fs-* steps are declared at :root and substituted there, so descendants inherit an already-resolved value. Driving the existing text scale was therefore rejected as a mechanism.

Decision

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.

Why not the alternatives

OptionWhy rejectedNote
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.

Coverage inventory

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.

BucketPagesZoom behaviour
Covered — renders <AlpaScreen>36Chrome-locked zoom, 1×–4×
Gap — does not render <AlpaScreen>15Native pinch, unchanged from today
Total routable51

NotFoundFallback.razor also renders <AlpaScreen> and is therefore covered, but has no @page route so it is outside the 51.

The 15 pages with the gap

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.

PageOwn chromeReviewAssessment
FTDT/FTDTRoot.razorNone — redirect onlyN/ARenders no UI; immediately redirects to the dashboard. Nothing to zoom.
DevDashboard.razor (/).dev-dashIntentionalInternal 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.razorIntentional
DevFontSpecimen.razorIntentional
DevThemeScopeDemo.razorIntentional
UiTestReturnNativePage.razor.dev-dashIntentional
FTDT/FTDTTestScenariosPage.razorIntentional
LoginPage.razor.alpa-login-screenReviewMember-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-screenReview
FTDT/FTDTAddFlightSegment.razor.ftdt-pageReviewFTDT 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-pageReview
FTDT/FTDTAddRest.razor.ftdt-pageReview
FTDT/FTDTDutyPeriodForm.razor.ftdt-pageReview
FTDT/FTDTEditDutyPeriod.razor.ftdt-pageReview
FTDT/FTDTSelectOpType.razor.ftdt-pageReview

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.

Verification

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).

Chrome geometry, top,height — before vs during a 2x zoom: topNav 0,116 -> 0,116 titleBar 116,58 -> 116,58 subNav 174,42 -> 174,42 bottomNav 842,114 -> 842,114 chromeUnchanged: true Body scaled: pill 131x31 -> 261x62 canvas transform: scale(2) Transform at rest: (none) no stacking context when unzoomed Viewport h-room at rest: 0 cannot steal horizontal gestures Nested carousel: 904px of its own scroll retained

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.

Interactive verification with real two-finger input

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.

real pinch visualViewport 1.0 (was 1.6 before the fix) chrome pixel-identical, body scaled => native zoom genuinely suppressed proportional small pinch -> 1.66x large pinch -> 4.00x clamp double-tap 1.66x -> 1x, transform removed visualViewport stays 1.00 and scrollY stays 0, so the reset no longer triggers native double-tap zoom (was 1.07 / 59px before touch-action: manipulation) one-finger drag scrollTop 0 -> 163 => scrolling unaffected control same pinch on a page with NO ZoomableRegion still zooms natively (visualViewport 1.6) => scoping confirmed

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.

Files

FileRole
ALPAMobile/wwwroot/js/alpa-zoom.jsGesture interception, focal-point maths, double-tap reset, Android pointer fallback.
ALPAMobile/Components/ZoomableRegion.razorViewport + canvas markup; attaches and detaches the handler.
ALPAMobile/Components/AlpaScreen.razorWraps 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.htmlLoads the script. Viewport meta deliberately unchanged.