Skip to content

Two-axis canvas scrolling with axis-aware routing - #190

Merged
ctate merged 18 commits into
mainfrom
feat/horizontal-scroll
Jul 24, 2026
Merged

ctate merged 18 commits into
mainfrom
feat/horizontal-scroll

Conversation

@ctate

@ctate ctate commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Canvas scrolling becomes two-axis end to end. Scroll regions can declare horizontal, vertical (default, unchanged), or both; wheel and trackpad deltas route per axis to the nearest region that scrolls that axis — a diagonal gesture over a horizontal shelf nested in a vertical page scrolls both, each component to its own region. The soundboard example's album pages gain a "From the collection" shelf as the reference use.

Design

  • Axis-aware route resolution: each axis resolves independently to its nearest capable scrollable; saturated regions hand off outward to the deepest ancestor that can still move. Vertical-only trees behave exactly as before.
  • Native macOS scroll drivers, engine-exact: one owner takes a whole event natively (clamping like the engine's consume-and-drop rule); anything ambiguous — split-owner diagonals, no-owner deltas, overlay-occluded regions — rides the wire where the engine's per-axis routing decides. Rubber-band elasticity follows the outermost-elastic rule; overlays (modals, menus) block native drivers through a view-local occluder model with fail-safe overflow.
  • ScrollState widens to two axes — a conscious API break: per-axis offset/velocity/viewport/content fields replace the one-axis record. Apps declaring the old four-field shape get a checker teaching naming the new fields; the changelog states the break with the one-line migration. The legacy shape is recognized only to teach, never dispatched.
  • Keyboard and accessibility follow the axes: Left/Right/Home/End on horizontal-capable regions, semantic increment/decrement stepping the live axis on both-axis regions.
  • Replay exact: two-axis scroll sessions record and replay byte-identically; the journal layout fingerprint moves with the new record shape and older recordings refuse loudly.

Registry

Attributes 86 (axis declaration) and 87 (horizontal value binding); no new elements or events.

Tests

Routing tests for the nested-axes cases and handoff rules, native-driver parity against the engine walk, occluder-model coverage, replay round-trips, keyboard/semantic stepping, contract-tooling round-trips for the widened record (dispatch pins for the eight-field shape and teach-only handling of the legacy quartet), and the soundboard shelf end to end. Verified live on hardware: shelf feel, diagonal routing, saturation handoff.

ctate added 18 commits July 24, 2026 00:43
- Widen ScrollState to eight per-axis fields (ScrollAxisState carries the physics), route each wheel axis independently to the nearest ancestor scrollable on that axis, and step kinetic motion per axis.
- Add the scroll axis declaration (attr 86 axis, attr 87 value-x, Widget.scroll_axes/value_x) through schema, markup validation, both engines, layout, clamping, reconcile, semantics, and the two-bar scrollbar renderer.
- Break the one-axis on-scroll record consciously: the reflect vocabulary, contract class, and both engines now teach the per-axis migration by field name, and the scroll-driver ABI/journal carry offset_x.
- Widen the macOS native scroll driver ABI to offset_x with a horizontal overlay scroller; GTK and Windows wheel handlers already forward delta_x.
- Update the TS SDK ScrollState to the eight per-axis fields and regenerate the markup vocabulary and scroll reference for axis/value-x.
- State the ScrollState break and its one-sentence migration in the changelog fragment.
- Cover the nested independent-axis routing case (dy to the vertical child, dx to the horizontal ancestor), the horizontal wheel/keymap/scrollbar paths, and per-axis driver sync with pinned ranges on ungranted axes.
- Ride a diagonal wheel through the reference record/replay session so a journaled two-axis scroll replays to identical per-axis offsets.
- Migrate the example apps to the two-axis ScrollState fields.
- Rail every other album under the track list on an axis="horizontal" scroll region: the grid's quiet-hover cover tiles, one press from record to record.
- Echo the shelf's offset_x into value_x (the controlled-scroll shape on the sideways axis) and reset the rail when an album opens.
- Teach the widget-wheel automation verb an optional delta-x token so drivers can scroll both axes like a real trackpad gesture.
- Keep the vertical scrollbar's thickness formula byte-identical (per-axis derivation), exclude anchored/clip-scope/concealed-disclosure subtrees from horizontal extents, and stop region-anchored surfaces riding scrolled content.
- Route macOS native wheel gestures by dominant-axis capability with an engine-wire fallback, and rewrite a queued driver report when a programmatic offset lands.
- Pick scroll semantics by live range on both-axes regions, page every granted axis on assistive steps, carry fling velocity per axis only while its offset survived, refine the axis/virtualized validation to the horizontal grant, and migrate bench_render.
- Forward the axis a locked native driver cannot travel to the wire, so one diagonal gesture scrolls a vertical list natively while its delta_x reaches the horizontal ancestor through per-axis routing.
- Carry the axis grants on the driver spec: elasticity and scroller chrome arm only on granted axes, so a horizontal-only region can never bounce vertically or report an offset the runtime would fight.
- Accept the optional delta-x token in the automation CLI, and warn at the builder seam when a DYNAMIC value pairs a horizontal grant with virtualization or value_x with a vertical-only region (both engines share the seam, so the diagnostic stays engine-uniform where validation cannot see the resolved value).
…t per axis

- Stop the driver view claiming wheel events at hit test: the surface's dominant-axis selection, gesture lock, and residual-axis split now own all wheel routing, so a diagonal gesture over a nested vertical list cannot swallow the horizontal component its ancestor owns.
- Make the driver set-offset flag per axis end to end, so a programmatic vertical write can never push a stale horizontal offset over native motion whose coalesced report is still in flight.
- Teach the a11y focus-reachability audit the axis grants (offscreen-right tiles on a horizontal shelf are reachable, below-viewport content there is not) and bump the model-contract format to 2 - a format-1 artifact classified the retired one-axis scroll record as a scroll_state payload, which would be a false pass today.
- A horizontal shelf's offscreen-right tile is reachable by design while a below-viewport button inside it stays a finding.
- Route each gesture axis to the deepest native driver that can consume its delta right now (direction-aware, so a saturated inner region hands an outward swipe to its ancestor), falling back to the outermost elastic region — rubber-band regions bounce even with short content — and reconcile the driver array in layout pre-order so keyed reorders keep the walk honest.
- Forward a residual axis only when the locked driver can neither move nor bounce on it.
- Assistive increment/decrement on a both-axes region whose only live axis is horizontal now step sideways instead of paging a zero-range vertical axis; pinned through the accessibility action path.
…reach latches

- Replace the single-winner gesture lock with per-axis owner resolution at the gesture's anchor point, re-evaluated per event against live scroller offsets: saturation hands an axis to its ancestor (elastic-take never outranks a consumer), cross-owner diagonals whose native recipient would eat the other axis ride the wire whole, and the residual is MEASURED (delta minus what the scroller actually absorbed) so a nearly saturated region can never double-spend.
- Flush the coalesced driver report before any residual or cross-owner wire emission (offsets first, one clock), and route wheels over a visible overlay scroller through the same splitter instead of the knob.
- Key reach-end/reach-start hysteresis latches by (id, axis) so a region whose primary axis changes re-arms honestly.
- Every wire emission now rides the offsets-first clock, including wheel events no native region owns.
- 168-point tiles keep seven sibling covers wider than the regular shell's content row, so the rail scrolls where it ships.
- Restrict wheel-owner resolution to the hit region's ancestor chain (parent driver ids through the ABI), anchor wire hand-offs at the gesture point, carry sub-half-point residuals across events, and wire-bind any region the engine scrolled this gesture so a later absolute native report can never erase relative wire motion.
- Skip subtrees anchored directly to the region in every VERTICAL extent walker (a stationary surface counted against a moving offset grew the range without bound), decide the assistive step axis from the semantics metrics (concealed-disclosure and anchored exclusions included), and page exactly one axis on widget-level semantic steps.
- Flag content stranded before a horizontal region's origin in both audits (offsets clamp at zero), double the reach-latch capacity for the (id, axis) key space, and fire reach signals only when the latch stores.
- Legacy (phase-less) events reset the wire bindings and residual carries per event, so a binding can never demote a region to the wire forever.
- A quiet gap (250ms, well past the input queue's one-frame coalescing) is the gesture boundary for discrete streams: residual carries keep accumulating sub-half-point deltas and wire bindings outlive any in-flight relative hand-off, while a fresh burst still resets both so no binding lives forever.
- Push OCCLUDER rects (anchored floating surfaces at their frames, modal catchers as the whole view) with per-driver masks through the driver sync, so the host's geometric wheel routing declines exactly the points the engine's hit test would give to an overlay's branch; drivers inside the overlay stay exempt. Pinned through the null platform.
- Forward zero-delta phase events (begins, the terminal Ended/Cancelled) to the gesture's last native recipient so the scroller's overscroll bookkeeping always terminates, and scope residual carries to the hit region so fractional motion never leaks onto whatever chain the pointer wanders to next.
- Pin REVOKED axes home on native-driven regions too (the range clamp stays the OS scroller's), so an axis flip behaves identically on every host instead of resurrecting a stale echoed offset on re-grant.
- One owner takes the WHOLE event natively and clamps at the edge exactly like the engine's consume-and-drop rule; split owners, dead axes, and wire-scrolled owners ride the wire whole — the residual measurement, fractional carries, and partial-clamp forwarding are gone, and consumption uses the engine's exact bounds.
- Zero-delta phase events forward only the gesture bookkeeping to the last native recipient; nonzero no-owner deltas go wire-only, so an overlay opening mid-momentum can no longer keep moving the obscured region.
- Fix the occluder model on every reported edge: view-local modal rects, tooltip passthrough, scrim=false previews, hidden/concealed surfaces, render transforms, self- and paint-order exemptions for anchored scroll regions, and a fail-safe whole-view occluder when surfaces outnumber the budget; both-axes semantic steps read child-frame range so the stepped axis matches the exposed semantics.
- dispatch_scroll_state now carries the eight per-axis scalars in declaration order; the retired one-axis quartet rides the generic record entry
- pin the new routing and the quartet's retirement in emit tests, and match the stub core's export to the widened symbol
@vercel

vercel Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
native-sdk Ready Ready Preview, Comment Jul 24, 2026 6:12am

@ctate
ctate merged commit 501b59e into main Jul 24, 2026
22 checks passed

This branch was successfully deployed

1 active deployment
Preview — f1aa2f15 Deployed Jul 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant