Skip to content

OVFL-b: native scrolling (UIScrollView and an Android scroll view sized by the engine) - #231

Open
thejackshelton wants to merge 29 commits into
masterfrom
ovfl-b
Open

thejackshelton wants to merge 29 commits into
masterfrom
ovfl-b

Conversation

@thejackshelton

@thejackshelton thejackshelton commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

OVFL Phase B (T078, notes/T078-ovfl-spec.md R9, R10, R14): native scroll views for overflow: auto and scroll. Prepared on top of ovfl-a (#194, now on master); master merged in twice (a1b65ee, then 05ea6bf after #200 made GitHub report the PR conflicting); both merges were conflict-free in git (generated outputs kept by the merge driver, then regenerated on CI). Base master, not stacked.

What changed

  • Engine. overflow.ts gains scrollRanges (and scrollRangesWithFaults): every element scroll container's scroll offset range [minX, maxX, minY, maxY] in whole device px, in CSS scroll-offset terms (0 is the layout position; the start side is negative where the overflow extends past it, as in rtl or a reversed flex container). The scroll origin is floored to whole device px; contents and client sizes snap at the padding box origin. ScrollMetrics carries the padding box origin for it. scrollRanges is a translator root (generate.ts), so the device runs the same code.
  • Lowering. lower/paint/scroll.ts fills the scroll seam: a box whose used overflow-x/overflow-y is auto or scroll gets a scroll-container write {x, y} (a hidden axis is locked).
  • Emission. emit/paint/scroll.ts fills the scroll emitter:
    • iOS: DragonScrollView (UIScrollView) replaces the clip view over the padding box. contentInset gives the start side, contentSize the end; safe areas never adjust it; it rests at offset 0 (UIKit moves a resting view to minus its inset on layout, so it is set back until the user pans). Bounce, deceleration and indicators are native.
    • Android: DragonScrollView extends DragonClipView (now open), scrolled with View.scrollTo clamped to the range on both axes, OverScroller flings, platform touch slop and fling speeds, overlay scrollbars enabled at the first touch so a view at rest draws none. The drag uses DragonDragAxis (ScrollView's arithmetic, review fix): whole-px positions are differenced, so sub-pixel moves add up, and the move that starts the drag gives up the touch slop instead of jumping by it.
    • native-support.ts: DragonTree calls the translated overflow_scrollRanges after layout and hands each box its range before the after-layout hooks (a refused or missing result is fatal). The readback key dragonScroll.range is projected in expected-dump.ts from the TS engine (ExpectedEngine.scrollRanges).
    • scrollRanges decides each scroll container on its own and reads no viewport: the first CI regen failed at inline-mixed-sizes because the viewport pass refuses any inline box in the document (R16, INL1a). An undecided container is listed in refused; the expected dump and the native hooks stop only for a scroll view whose range was refused, naming the node and the reason (dd6ebd5).
    • runtime/state.ts: a scroll container inside a state program is refused by name (StateEmitError), like transforms.
  • Compiler. The T078 R14 stopgap is removed: checkNativeScroll, nativeScrollPending and the lane-only marking for auto/scroll are gone, so auto and scroll compile on iOS and Android and the lanes prove native rows for them. New checkStartOverflowBackground: an auto/scroll container whose overflow may extend past its start (any rtl one, and a reversed flex one: row-reverse, column-reverse, wrap-reverse) with a painted background-color is DRAGON_UNPROVEN_CONTEXT on ios and android. Chrome paints that background in the scrolling contents at the scroll origin's whole device px, so it stops up to one device px short of the padding box end (measured: overflow-flex-reverse a2 at DPR 2.625); the native views paint the whole padding box. rtl is refused too (review fix) because no fixture proves an overflowing rtl scroll container with a background.
  • Undecided scroll ranges are refused at build time (review fix). lower/scroll-decidable.ts mirrors, on the lowered tree, the engine's scroll-range refusals (overflow.ts refuseLineLevelBoxes through overflowOf/propagated, and readChild's percentage height whose end margins may collapse through it, taken whenever it may apply). The native lowering refuses each auto or scroll container it finds with DRAGON_UNPROVEN_CONTEXT on ios and android, naming the engine's reason, so a native scroll view never meets an engine refusal on the device. Hidden containers are no scroll views and are not refused.
  • Rounded scroll containers are refused on native (merge with PNT1-radius: border-radius, its corner longhands and -webkit-border-radius drawn natively on iOS and Android (supersedes #80, #81) #198 PNT1). checkRoundedScrollContainer: a rounded corner on an auto/scroll container is DRAGON_UNPROVEN_CONTEXT on ios and android. PNT1-radius: border-radius, its corner longhands and -webkit-border-radius drawn natively on iOS and Android (supersedes #80, #81) #198's rounded overflow clip masks the clip view in its own coordinates (iOS layer mask, Android outline or overlay mask), a DragonScrollView moves those with its scroll offset, and no fixture compares a rounded scroll container with Chrome.
  • Master merges. PNT1-radius: border-radius, its corner longhands and -webkit-border-radius drawn natively on iOS and Android (supersedes #80, #81) #198 and @charset "utf-8"; at the start of a sheet is a no-op on every target (Chrome drops it); other encodings and positions refused #208 (508db67) were merged in at ca1e729, and the conflicts were resolved keeping both sides:
  • Hit model (R13). auto and scroll leave OVERFLOW_AT_REST: they are native scroll views whose offsets rt-hit does not read yet (OVFL-B2), so an interaction rule beside them is refused on native as an unmodelled hit fact. The element the viewport took its overflow from uses visible and is no fact (propagatedFrom, now exported).
  • Proof. The scroll capture (scroll-metrics.ts) also records extents: each element scroll container's scrollLeft/scrollTop clamps after scrollTo far past each end, at DPR 1, 2, 3, 2.625; parseScrollCapture checks they are finite and are exactly the element scroll containers. ovfl-metrics.test.ts compares the engine's scrollRanges with them for every overflow case. The device lanes compare the native readback dragonScroll.range with the expected dump.
  • Regenerated (own commits, from pnpm regen then CI): profiles (iOS and Android gain caveat rows for overflow-x/overflow-y auto and scroll in the contexts the fixtures prove, under the Android support profile follows the iOS rule (music player Android 20 -> 165 declarations); UA bold/italic refusal on every native target #49/T075J paint-aspect cap), expected dumps, emitted CSS headers, captures, the tailwind snapshot, the WPT web expectations and the music-player north-star check (errors 194 -> 186: native DRAGON_UNSUPPORTED_VALUE 17 -> 13 on each of iOS and Android).

Changed tests, with reasons

  • dragon/test/scroll-decidable.test.ts (new): undecidedScrollContainers against the engine's scrollRanges on 1176 generated containers (14 child shapes in pairs, auto/scroll/hidden, static/relative). Every container the engine refuses is refused, and exactly those apart from the value-dependent percentage-height rule. Each reason is pinned, and so is the compiler diagnostic for an inline box, a <br> and a collapsing percentage height on ios and android. An atomic inline is refused on native earlier, by the lowering (DRAGON_LOWERING_FAILED, INL2 not landed). A decidable container and a hidden one compile.
  • dragon/test/native-backends.test.ts: new JVM test of DragonDragAxis. 100 moves of 0.3 px sum to 30 px less the slop, and no move (the start included) scrolls more than one move's worth. The old per-event truncation scrolled 0, and its start jumped by the slop.
  • dragon/test/overflow.test.ts: the "T078 R14: native refuses auto and scroll until OVFL-B" block is replaced, because that refusal is what this PR removes. The new block pins the lowering per axis (auto both axes, hidden+computed auto, scroll+hidden, and hidden/clip get no scroll write) and that native auto/scroll rows exist; a second block pins the reversed-flex background refusal (each reverse shape with auto and scroll, ios and android, located at the background) and the cases that must still compile (no, transparent or zero-alpha background, hidden, forward ltr flex, ltr block, rtl without a background, rtl hidden). The earlier pin that an rtl auto block with a background compiles was replaced by refusals (declared and inherited rtl, block and flex), because no Chrome comparison proves it.
  • dragon/test/s4a.test.ts: overflow-x: hidden alone (computing overflow-y: auto) now compiles with no diagnostics on ios; it was refused naming OVFL-B.
  • dragon/test/interaction.test.ts: auto, scroll and overflow-x: hidden alone with a :hover rule are now refused by the hit model on native (2 "needs Dragon hit testing" errors, nothing else), since the hit test does not read scroll offsets yet; html's propagated overflow-x: hidden, clip and hidden stay modelled. The old "outside the lanes, every error names OVFL-B" assertion went with the R14 refusal.
  • dragon/test/expected-dump.test.ts: the test engine passes scrollRanges (new required ExpectedEngine member).
  • parity/test/ovfl-metrics.test.ts: new "scroll offset range against Chrome" block (every container, every DPR, whole device px, offset 0 inside the range, >400 checked, >80 scrolling past the start side), the 2.625 floor case, and two parseScrollCapture refusals for bad extents. The capture fixture object gains extents: [].
  • layout/test/overflow.test.ts: new case, scrollRanges with an inline box outside every scroll container gives every range (scrollMetrics refuses), and an inline box inside one container refuses only that one.
  • dragon/test/expected-dump.test.ts (also): the scroll range readback, and an engine refusal stops the dump only for a scroll view, with its reason.
  • parity/test/ovfl-metrics.test.ts (also): a case with any refused container fails, so no container leaves the Chrome comparison.
  • Fixtures overflow-nested, overflow-auto-fits, overflow-hidden-x-auto-y and overflow-replaced: their auto/scroll containers no longer paint a background-color (their children keep theirs). Their rtl twins overflow past the start, and native now refuses a background there (review fix 2), but every layout case must lower on native (native-host nativeCases; the regen stopped at hit-capture on overflow-nested-rtl). This follows the precedent of overflow-flex-reverse's transparent .auto containers. All layout fixtures lower on native.
  • New fixture overflow-background, ltr only (layout(id, ['ltr'])): puts the scroll-container backgrounds back in ltr, so an auto/scroll container with a painted background is still proven against Chrome at DPR 1, 2, 3 and 2.625 (re-review finding). It holds auto and scroll overflowing both axes, auto that fits, a flex row, overflow-x hidden beside auto, and a nested inner auto container. ovfl-metrics.test.ts counts each overflow fixture's own directions (now 35 cases), and a new test fails if these six scroll views with an opaque background (b1–b5, b6a) drop out of the native lowering.
  • dragon/test/paint-radius.test.ts (from PNT1-radius: border-radius, its corner longhands and -webkit-border-radius drawn natively on iOS and Android (supersedes #80, #81) #198): its NodeGeometry literal gains scroll: null, the field this PR adds.
  • dragon/test/overflow.test.ts (also): the rounded scroll container refusal on ios and android at the radius, for three shapes. It fires beside the start-overflow background refusal on one element. A rounded hidden box, square corners and a corner with a zero component still compile.
  • No tolerance, floor or pin was lowered; no fixture was removed.

Outside the spec

  • None beyond the T078 file map.

Reviewed size

  • 134.4 KB against origin/master 981c068 (33 files outside .macroscope/ignore.md paths).

What passed

Head 059685f (the CI regen commit; 0 commits behind master 981c068):

  • pnpm ci:regen ovfl-b --head c1c53ba6d3db4a49b8e11b981f9b6d97e143830e: pass, regen commit 059685f, https://github.com/compiled-run/dragoncss/actions/runs/37881619635.
    • Earlier failures: 37839720346 (fixed in dd6ebd5) and 37858340394 (fixed in 7012175).
    • Earlier passes on older heads: 37842533126, 37849453312, 37859483362, 37864618916, 37873155483 and 37877146377.
  • pnpm typecheck: pass (local).
  • Local, platform-free, 9 files, 149/149 passed: dragon expected-dump, interaction, overflow, s4a, scroll-decidable, registry-claims, profile-index and paint-radius, plus layout overflow.
  • Every layout fixture lowers on native in both directions: 651 cases, checked locally.
  • pnpm ci:test-files on 059685f, https://github.com/compiled-run/dragoncss/actions/runs/37884749014: pass, 25 files, all passed, none skipped.
    • Native: dragon native-backends 12.
    • Chrome: parity ovfl-metrics 17, native-host 336, parity 534, p6a-dash-oracle 329, pnt1-radius 13, pnt1-radius-edges 1, hit-capture-identity 7, pixel-reference 12, chrome-ports 13, registry-claims 8, android-profile 5, dist 6, env-engine 8, fixture-reader 7, fonts-fixtures 26, inline 13, interaction-forced 3, media-runtime 8, media-sweep 19, phrasing-blockified 3, platform 12, replaced-identity 3, tree-faults 3, values 72.
  • pnpm pr:review 231 --once: exit 0. UNREVIEWED (Macroscope spending limit); 0 findings.
  • Device step pending: the landing driver runs the device lanes.

🤖 Generated with Claude Code


Generated by Claude Code

thejackshelton and others added 15 commits October 5, 2026 23:51
…ound (Chrome paints it in the scrolling contents at the scroll origin's whole device px); iOS scroll views rest at offset 0 after UIKit's layout moves them to minus the inset
…m -s run parity:devices, then pnpm regen through /tmp/job.sh; fixed point): dump lanes 0 failures on both platforms; device-pixels per case equals master's (ios 57, android 86) apart from the iPad capture-trust points the MQ lane is fixing
…ected-dump.ts imports both INL1a's InlineChild and OVFL-B's ScrollRangesResult
…ds no viewport

The regen after merging master failed at inline-mixed-sizes: scrollRanges went through scrollMetrics,
whose viewport pass refuses any inline box in the document (R16, INL1a), so every expected dump and
every native layout with an inline box anywhere refused, scroll container or not. scrollRanges now
computes each element scroll container alone and lists the undecided ones as refused; the expected
dump and the native hooks stop only for a scroll view whose range was refused, naming the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: dd6ebd5
Run: https://github.com/compiled-run/dragoncss/actions/runs/37842533126
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 05ea6bf
Run: https://github.com/compiled-run/dragoncss/actions/runs/37849453312
claude and others added 14 commits October 8, 2026 23:14
…croll backgrounds, and fix the Android drag arithmetic

1. lower/scroll-decidable.ts mirrors the engine's scroll-range refusals (line-level boxes in an inline
   formatting context, a percentage height whose end margins may collapse through it) on the lowered tree;
   the native lowering refuses each auto or scroll container it finds (DRAGON_UNPROVEN_CONTEXT, naming the
   engine's reason), so no native scroll view meets an engine refusal on the device. scroll-decidable.test.ts
   checks it against scrollRanges on 1176 generated containers.
2. The scrolling-contents background refusal covers every scroll container whose overflow may extend past
   its start: rtl as well as reversed flex. The unproven rtl clean-compile pin is replaced by refusals.
3. The Android scroll view drags with DragonDragAxis (ScrollView's arithmetic): whole-px positions are
   differenced, so sub-pixel moves add up, and the move that starts the drag gives up the touch slop.
   native-backends.test.ts runs it on the JVM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
…ansparent; drop a stray helpers.d.ts

The regen stopped at hit-capture: overflow-nested-rtl had no native programs, because the scroll container
background refusal now covers rtl (a fraction of a device px of scroll origin). As overflow-flex-reverse already
does for its reversed auto containers, the auto and scroll containers of overflow-nested, overflow-auto-fits,
overflow-hidden-x-auto-y and overflow-replaced paint no background; their children keep theirs. Every layout
case lowers on native again (checked over all 319 layout fixtures).

helpers.d.ts was emitted by a failed tsc build into packages/layout/test and committed by mistake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 7012175
Run: https://github.com/compiled-run/dragoncss/actions/runs/37859483362
… with a painted background

7012175 took the scroll-container backgrounds out of four both() fixtures, which left the ltr claim
(an auto or scroll container with a background compiles on native) with no Chrome comparison. The new
ltr-only fixture overflow-background restores those shapes with backgrounds: auto and scroll overflowing
both axes, auto that fits, a flex row, overflow-x hidden beside auto, and a nested inner auto container.
ovfl-metrics.test.ts counts each overflow fixture's own directions (35 cases) and fails if these six scroll
views with an opaque background disappear from the native lowering of the overflow cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: fbe732a
Run: https://github.com/compiled-run/dragoncss/actions/runs/37864618916
…into ovfl-b

Conflicts, each resolved keeping both sides:
- emit/expected-dump.ts: ExpectedEngine has both scrollRanges (OVFL-B) and paint (PNT1); NodeGeometry has both size and
  scroll; each node's geometry carries both, and the scroll-range refusal check stays.
- emit/native-support.ts (Swift and Kotlin): DragonBoxShape takes master's size argument, and the scroll range and refusal
  are still set on every box before the after-layout hooks.
- test/expected-dump.test.ts and parity/src/native-host.ts: the engine passes both scrollRanges and paint: { roundedShape }.
- test/paint-radius.test.ts (master's, no conflict): its NodeGeometry literal gains scroll: null, the field OVFL-B adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
#198's rounded overflow clip masks the clip view in its own coordinates (iOS layer mask, Android outline or overlay
mask), and a DragonScrollView moves those with its scroll offset; no fixture compares a rounded scroll container with
Chrome. checkRoundedScrollContainer refuses a rounded corner on an auto or scroll container on ios and android
(DRAGON_UNPROVEN_CONTEXT at the radius). overflow.test.ts pins it, that it fires beside the start-overflow background
refusal, and that rounded hidden boxes and square scroll containers still compile. Every layout fixture still lowers on
native (649 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 9f9c033
Run: https://github.com/compiled-run/dragoncss/actions/runs/37873155483
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 09932ff
Run: https://github.com/compiled-run/dragoncss/actions/runs/37877146377
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: c1c53ba
Run: https://github.com/compiled-run/dragoncss/actions/runs/37881619635
…vfl-b

project.ts conflict: master passes #218's GEN-b faults to checkComputed (kept) beside the T078 R14 checkNativeScroll call,
which OVFL-B removes (native scroll views replace that refusal; checkNativeScroll no longer exists). checkComputed keeps
OVFL-B's start-overflow background and rounded scroll container refusals; the scroll-decidable refusal stays in the native
lowering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSkSiXMPXTAsNpS9BD9LDR
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 9f86a44
Run: https://github.com/compiled-run/dragoncss/actions/runs/37889702905

This branch has not been deployed

No deployments
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.

2 participants