fix(desktop): engine-order-independent scroll compensation for WebKit (stacked on #1650)#1662
Draft
tlongwell-block wants to merge 29 commits into
Draft
fix(desktop): engine-order-independent scroll compensation for WebKit (stacked on #1650)#1662tlongwell-block wants to merge 29 commits into
tlongwell-block wants to merge 29 commits into
Conversation
Tyler's report: scrolling UP a fully-loaded channel is subtly jumpy; DOWN is smooth. Root cause (Eva H1 + L-C): rows above the opening viewport have never painted, so under content-visibility:auto they sit at estimateRowHeight()'s contain-intrinsic-size reserve (60px, utilities.css:17). Scrolling up, each realizes at its true height; the reserve-vs-true delta shifts the reading position. Shipped WKWebView has no overflow-anchor, so it corrects nothing — every delta lands raw. This adds a deterministic gate that is RED at tip 77bd0e7 and that the converged owned-compensation fix (overflow-anchor:none + same-frame scrollBy(realized-reserved)) turns green: - e2eBridge: a dedicated `jitter-corpus` mock channel (400 structurally heterogeneous rows — headings/lists/blockquotes/code fences/long prose whose true height estimateRowHeight is known to miss), its own channel so the uniform deep-history seed the load-older specs use is untouched. - upscroll-jitter.perf.ts: forces overflow-anchor:none on the scroller (mirrors shipped WKWebView; logs CSS.supports per engine), then wheels up in notches tracking a row held INSIDE the viewport (SAFE_MARGIN band from both edges, re-picked each step, scored only if the same id stayed in-band before+after). Residual = row's visual delta-top minus the scroll delta: 0 == smooth. Gate is peak <=2px, rms <=0.6px. Validity: within a single cold run the already-realized rows read residual 0.0 exactly while realizing rows drift -27..-75px — an artifact would drift on both. Negative sign shows the estimator over-reserves. At tip: peak 75px, rms 40px over 23 steps → RED. A correct fill of scrollTop absorbs the drift into the scroll delta, driving residual->0. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…ive) Dawn found the RED gate from 33e7351 was mathematically invariant to the fix: its residual subtracted the REALIZED scrollTop delta, which includes the fix-writer's compensating scrollBy, so the writer cancelled out and the gate scored raw estimate error R (only T3 can move it), not motion smoothness. Quinn and Eva re-derived and confirmed. Co-visible and any row-vs-row differential share the disease: compensation is a uniform global offset, so it cancels out of any quantity not referenced to the input. Reframe (converged with Dawn/Quinn/Eva; ratified event 63f5e6e1): - Metric is now per-notch MOTION CONSISTENCY: the reading row's viewport motion (rowMove = after.top - before.top; NO scrollTop reference) scored as deviation from the run median. This references the row's own motion ACROSS notches (temporal self-reference), never a neighbour (spatial) or scrollTop, so the writer's scrollBy survives into the metric and is SEEN. - Actuation is a fixed synchronous step (scrollTop -= STEP) so the input delta is constant by construction, bypassing Blink's wheel-scaling (a wheel notch applies 218/220/222...; median-of-run would misread that per-notch scaling as jitter). Dawn independently confirmed even the actuated scrollTop delta is post-writer contaminated (48-75px spread under sync) — rowMove is the only clean measurable. - Anti-cheat floor: mean rowMove must be > STEP*0.75 so a frozen or half-applying scroller (near-zero variance, would false-green) is caught. - Old scrollTop-referenced residual kept as a printed NON-GATING diagnostic (= estimate error R = T3's estimator acceptance number). - Second non-gating diagnostic: one wheel-actuated pass of the same metric (Tyler's real input is a wheel), on the record every run. - RED-at-tip is a HARD gate with a VOID comment: median-of-run is only valid while the corpus produces varying realization, and the tip run being RED IS the proof of that dispersion. RED at tip: peak-dev 41px / rms 24px (gate <=2 / <=0.6). GREEN target is Dawn's real T2 writer: peak-dev 0.00 / rms 0.00, reproducible over 3 sync runs. A synthetic per-notch-varying oracle drives 74/77 notches to exactly STEP; the real writer re-pins in the same ResizeObserver cycle and closes the synthetic-only outliers. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Scrolling UP a fully-loaded channel was subtly jumpy while scrolling down was smooth. Root cause (Eva's H1): rows above the opening viewport have never painted, so they sit at their `contain-intrinsic-size` reserve under `content-visibility: auto`. Scrolling up, each row realizes at its true height the instant it enters the realization band; the delta (true - reserve) shifts content above the reading row. On shipped WKWebView there is no `overflow-anchor` to correct it, so every realization lands raw on the reading position — one lurch per row. The fix makes the conversation scroller own its reading position through a single same-frame compensation writer: - Snapshot the reading anchor (first fully-visible row + its top offset) on every scroll event, before the ResizeObserver fires. The RO runs after layout and before paint, so the "before" position can only come from the last scroll event — never from inside the RO callback. - Observe each `.timeline-row-cv` row (CV realization is a resize of the row's own box and does not surface as an ancestor resize in Blink), seeded at observe time with the browser's current layout height read from `contain-intrinsic-block-size` — the reserve for an unpainted row. Seeding from the reserve is what turns the first realization into a measurable `realized - reserve` delta instead of an unmeasurable first sighting. - On a real height change, re-pin the reading anchor to its saved offset with a same-frame `scrollBy(drift)`. A single measured correction: the layout engine already summed the above-anchor deltas into the row's top, and rows resizing below the anchor don't move it, so they're excluded for free. Re-baseline after the correction. - Force `overflow-anchor: none` on the scroller so the writer is the single scroll authority on every engine — Chromium's native anchoring can't double-correct behind the writer's back, and WKWebView has none anyway. This also aligns CI (Chromium) with macOS (WKWebView), which ran different scroll contracts — how the jitter shipped unseen. Verified against the committed T1.1 gate (upscroll-jitter.perf.ts) on jitter-corpus cold-load, sync fixed-step actuation, overflow-anchor forced off: RED without this writer (peak-dev 46 / rms 23), GREEN with it (peak-dev 0.00 / rms 0.00, reproducible), and the non-gating wheel felt-mode pass also 0.00/0.00. The non-gating estimate-error R diagnostic stays at 75 (T3's lane; this writer compensates motion, it does not shrink the reserve error). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
…-contract guard Two of the deferred scroll-jitter seams, on top of the integrated fix train (T1.1 gate -> T2 writer -> T3 estimator @ 1fa4551): - columnWidthPx: measure the timeline row column once (useElementWidth) and thread it into timelineRowReserveStyle so row-height reserves use the real wrap width instead of the 64-char fallback. Pre-measure zero passes undefined to preserve the estimator's own fallback rather than tripping its min-chars floor. On jitter-corpus this shrinks estimate-error R from 75 -> 55 peak (better reserves = smaller realized-vs-reserved delta) while the motion gate stays GREEN 0.00/0.00 -- R moves only in the estimator lane, the gate only in the writer lane, cross-lane invariant intact. - Wren #4 anchor-contract guard: a companion perf test that reads the PRODUCTION computed overflow-anchor on the real conversation scroller (no test override) and asserts it resolves to none. The jitter gate forces the property for its measurement; this proves the shipped stylesheet actually ships it, so a regression that hands correction back to Chromium's native anchoring (masking a WKWebView-only on-device break) fails loudly. The onRealizedHeight seam is intentionally NOT wired: T4 ground truth (RESEARCH/GUI_SCROLL_RECLAIM_T4.md) shows the upscroll path is append-only with no unmount, so it needs no JS height cache; a per-event-id cache is only the deferred, narrowly-scoped T4b for the head-refetch case. Adding an unused callback now would be dead abstraction. Validation: - pnpm --dir desktop typecheck: clean - biome check on both changed files: clean - rowHeightEstimate.test.mjs 16/16, useAnchoredScroll.test.mjs 2/2 - perf: upscroll-jitter GREEN 0.00/0.00 (R 55), anchor-contract PASS Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The committed gate certified only the CV-realization half of H1: it terminated at the ~600px fetchOlder sentinel band, so the pagination half — cold rows paged in ABOVE the viewport by scrollback — was proven only by Quinn's throwaway probe, not by the gate as it runs (the coverage gap Quinn [9] and Dawn [10] flagged, land-now/fast-follow ratified by Eva [11]). Root cause: when a prepend lands, scrollTop jumps UP (~100 older rows of height added above), so `appliedTop = before - after` goes negative and the loop's top-of-history break fired at the sentinel — before scoring any paged-in row. On the RED baseline this break was deterministic; on GREEN it was flaky (the prepend was scored only when the re-anchor happened to leave the tracked row in the safe band). Fix: distinguish the two causes of a non-decreasing scrollTop by mounted-count. A prepend grows it; the true top does not. On a prepend re-anchor, skip scoring that step (its motion is the multi-thousand-px jump, not a fixed STEP notch — scoring it would poison the metric) and CONTINUE; the next iteration re-baselines a fresh safe-band row in the paged-in window and scoring resumes across the prepend. Assert `prependObserved` so the gate exercises BOTH jitter sources every run. Verified on the T3 integration tip 1fa4551 (GREEN) and the T1.1 base 32e41e7 (RED), both rebuilt: - GREEN: prepend scored (50->200, walks all crossings), gate 0.00/0.00 PASS, 77-78 scored steps, deterministic across 4 runs. - RED: prepend scored (50->150), coverage assert PASSES, gate still FAILS on jitter (peak 41px > 2.0) — reds for the right reason. The dual-sided RED/GREEN contract and the anti-cheat floors are preserved; the change is scoped to the gate file only. Note: gate-run R is window-dependent (Eva/Max [8]); scoring the deeper paged-in population raises the non-gating R diagnostic from 75 to ~85. This is NOT a cross-lane leak — the T2 writer's reserve is untouched; R moved only because the sample window grew, exactly as the window-sensitivity caveat predicts. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Two live probes that run the built GUI against a real relay (port-forward + Host rewrite) and walk real scrollback in a real channel: - upscroll-1px-live.perf.ts: settled steps (BUZZ_PERF_STEP_PX), scores per-step row movement through real fetchOlder loads, measures the felt jump at each prepend commit. - upscroll-trackpad-live.perf.ts: macOS trackpad replica — wheel events at ~8ms spacing in finger-ramp + momentum-decay swipe profiles, no settling, with a per-RAF sampler scoring tracked-row motion vs applied scroll. Plus playwright.perf-webkit.config.ts: the same probes on Playwright WebKit. This is the config that exposed the T2 compensation being near-ineffective on WebKit-family engines under momentum input while Chromium scored clean — Chromium-only probing cannot see the defect class the shipped WKWebView app exhibits. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…bKit) The T2 realization-compensation writer in useAnchoredScroll.ts was near-perfect on Chromium and near-ineffective on WebKit (39 felt lurches up to 204px per 30-swipe momentum pass). Root cause: the reading-anchor baseline was snapshotted in the scroll handler, on the assumption that scroll fires before ResizeObserver in the same frame. That holds on Chromium; WebKit dispatches scroll asynchronously off its scrolling thread and coalesces it, so under momentum the RO consumed a stale snapshot and the drift measurement folded the user's own wheel delta into the correction — the writer fought the wheel. Make the path independent of scroll-event timing: - Baseline the reading anchor per-rAF (single writer, module rAF loop), not per-scroll-event. rAF runs before layout/RO on every engine, so the baseline is the freshest pre-reflow read cross-engine. - Compute the correction scroll-invariantly from the anchor row's document position (scrollTop + topOffset), which changes only when content above reflows — isolating the layout shift from user scroll. Extracted as computeAnchorCorrection(), unit-tested for sign and invariance. - Write an absolute scrollTop target from this frame's own late reads, not scrollBy against a possibly-stale offset (blunts the write-race). - Staleness is a first-class SKIP: if scroll moved more than COMPENSATION_SCROLL_SKIP_PX since baseline, momentum is in flight and the reads may straddle a compositor commit — skip and let the next quiet frame catch it. Under-correcting is invisible; fighting the wheel is the lurch. - Derive at-bottom vs mid-history from synchronous geometry (isAtBottomNow) in both the rAF sampler and the RO callback, never from the scroll-maintained anchorRef, so the branch and the baseline can't disagree under momentum. - Pick the reading anchor a safe margin below the fold (READING_ANCHOR_SAFE_MARGIN_PX) so it never sits inside the realization band it's meant to measure against. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…ines The live trackpad probe (upscroll-trackpad-live.perf.ts) proved the WebKit compensation defect against real #buzz-bugs but needs staging + a port-forward + a member nsec, so it can never gate a PR merge. This gate reproduces the SAME felt-lurch metric under the SAME momentum actuation on the deterministic mock jitter-corpus bridge — no network, greppable in CI, on BOTH engines. Covers both lurch sources (W3 trace, thread): - CV-realization lurch: jitter-corpus is the heterogeneous 400-row seed whose estimateRowHeight mismatch T1.1/T1.2 already gate. - prepend-commit-under-momentum: 400 rows > 300 CHANNEL_HISTORY_LIMIT, so fetchOlder pages behind an until-cursor. useLoadOlderOnScroll fires off an IntersectionObserver on a 600px-margin top sentinel — pure geometry, input-cadence-independent — so momentum triggers it like live. channelWindowDelayMs=1000 defers the commit so it lands frames later, under momentum, reproducing the commit race (not an instant same-frame commit). Engine fidelity mirrors T1.1/T1.2: overflow-anchor forced 'none' so Chromium reproduces the shipped WKWebView (no native scroll anchoring); under playwright.perf-webkit.config.ts this is the real WebKit family. Three assertions (Eva's thresholds + Quinn's W4 caveat): - peak felt lurches (|dev|>10px, non-commit) == 0 — the wheel-fight spike. - smooth frames (|dev|<=1px) >= 99%. - RMS deviation across the run <= placeholder — the skip-forever guard: a too-tight staleness skip-bound never fires the correction under momentum, showing as a run of sub-10px deviations that dodge the peak gate but sum to main's raw-shift drift. Peak is blind to that shape. RED at contract 6b9203c under the WebKit mirror is mandatory and verified: WebKit 132 lurches / 94.42% smooth / RMS 4.24px; Chromium-with-mirror 131 lurches / 97.87% smooth. Dawn's engine-order-independent W1 fix turns it green on both engines. Exact lurch and RMS ceilings pinned against Dawn's first green candidate, same discipline as T1.2. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…-compensation @ 6b04aa1) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * commit '6b04aa1e': fix(desktop): make upscroll compensation engine-order-independent (WebKit)
…ate @ 7c25874) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * commit '7c258747': W3 trackpad-momentum upscroll GATE: merge-blocking, offline, both engines
The RO compensation callback re-snapshotted `readingAnchorRef` right after its `scrollTo`, but the per-rAF loop is documented as the single writer of that ref and re-snapshots from live geometry next frame (~16ms), already reflecting the correction. The extra write made the "single writer" invariant false for no behavioral gain and cost one `snapshotReadingAnchor` row-walk per correction. Remove it so the invariant holds literally. No behavior change: the next rAF re-baselines regardless. Unit tests (computeAnchorCorrection sign + invariance) unaffected, 8/8 green. Follow-up to Quinn's W1 cross-review (hole #2). Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…r-independent) The upscroll compensation writer diffed the reading anchor's document position against a per-rAF baseline. On WebKit the rAF baseline is captured AFTER content-visibility realization has already shifted the row, so baseline and current straddle the same side of the reflow (dTop=dScroll=0) and the position diff is blind to it — the correction never fired and the row took the full raw realization shift. Add a second, baseline-timing-independent signal: when the position diff sees no move, fall back to the net height change of the RO entries ABOVE the anchor (`aboveShift`) and absorb it into scrollTop. This reads the reflow magnitude directly from the resize entries rather than diffing a snapshot WebKit captured too late, so it holds under WebKit's async realization ordering without depending on scroll-event timing (which coalesces off the scrolling thread under real wheel input). Validated (each run x2, stable): - WebKit felt/wheel (real input): 0.00px peak deviation (was ~200px) - Chromium felt + sync: 0.00px, no regression - 8/8 unit tests, tsc, biome clean The WebKit synchronous-actuation gate still reads ~44px peak; that is an `el.scrollTop=x`-vs-CV-realization artifact of the imperative test path, not felt jitter — the felt-mode (wheel) gate under design supersedes it. Co-authored-by: Dawn <dawn@sprout-oss.stage.blox.sqprod.co> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…ompensation @ c9e5bf3) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/dawn/webkit-compensation: fix(desktop): drop redundant post-correction re-baseline (single-writer)
… fallback (dawn/webkit-compensation @ fe786ea) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/dawn/webkit-compensation: fix(desktop): hold reading row across WebKit realization (engine-order-independent)
…ctory) Tyler's on-device verdict at 3d1e91e: better but not perfect. This probe actuates the shape of his input — small (12px) wheel deltas at slow (~32ms) cadence, no settling — and samples the tracked reading row's rect.top, scrollTop and mounted count EVERY rAF, so one-frame events are visible. Flags frames whose rowMove escapes the slow-wheel staircase envelope (reversal <= -3px, or shove >= 2*delta+3px), pairs escapes that cancel within 3 frames (one-frame flash of a deferred correction) vs unpaired (felt lurch), pure rect.top in the flag condition per the W4 framing. At tip b170680 (includes W1r3 fe786ea): Chromium 0 escapes / 3447 frames; WebKit 81 escapes / 1754 frames — 11 flash pairs (-27.5 shove then +28 correction one frame later) + 59 unpaired single-frame jumps of exactly 35px / 41px recurring every ~6 rows. Diagnostic, non-gating. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The prior fallback fired `scrollTo(currentScrollTop + aboveShift)` whenever
the baseline-relative position diff read 0 and `|aboveShift| > 0.5`. On WebKit
that condition is not specific to a real reflow: the per-rAF baseline is
captured post-realization, so `baseline` and `current` straddle the SAME side
of the reflow (dTop=dScroll=0) on EVERY frame, and `aboveShift` — summed from
the RO entries — can be nonzero on a frame where the anchor never moved,
because a row straddling the anchor boundary is miscounted. The fallback then
fabricated a downward shove: the "Shape B" slow-trackpad lurch
(`e=0, rowMove=+35..41px, dScroll=0`) — firing the correction WAS the visible
jump.
Add a second, baseline-timing-independent signal and fire only when the two
instruments agree. Carry the anchor snapshot from the PREVIOUS rAF tick
(`prevReadingAnchorRef`), which — unlike the same-frame baseline — was captured
before this frame's realization and so spans the reflow. Its document-position
shift `observedShift = (scrollTop+topOffset) − prev(scrollTop+topOffset)` is
scroll-invariant (user scroll moves scrollTop and topOffset equal-and-opposite),
so it isolates the reflow alone (Eva's probe `e`, from one coherent snapshot;
Quinn's scroll-isolated `R_obs`). Fire only when `prev.id === baseline.id` AND
`|aboveShift − observedShift| ≤ 0.5`, and correct by `observedShift` (never raw
screen motion — that would refold the user's scroll into the pin).
Agreement is the classifier boundary. Two-population split of fallback frames
on the WebKit slow-trackpad probe:
population | aboveShift vs observedShift | old writer | gated writer
-------------------|-----------------------------|--------------|-------------
real reflow | agree (Δ ≤ 0.5px) | corrects OK | corrects OK
straddler miscount | disagree (Δ = row height) | fabricates | suppressed
| | +35..41 shove| (no write)
Suppressing the miscount branch removes the fabricated shoves without touching
the frames where the RO sum is genuine.
Validated (each run x2, stable):
- Shape-B fabricated `e=0` shoves: eliminated (was the dominant felt defect)
- Chromium slow-trackpad: 0 escapes, sync 0.00, felt 0.00 (no regression)
- WebKit felt/wheel gate: 0.91px peak (<< 2px threshold), was 0.00 pre-gate —
the 0.91 is the frame-late CV-realization flash the miscount was masking
- WebKit slow-trackpad escapes: 67 -> 54
- 2167/2167 unit tests, tsc, biome clean
Residual (54 escapes) is a different phenomenon with a different owner: ~15
self-correcting frame-late flashes + ~12 multi-frame-blind under-corrections,
both rooted in WebKit delivering the CV-realization RO callback one frame after
the realization paints (documented in
RESEARCH/WEBKIT_RO_REALIZATION_FRAME_LATE_W1.md; overturns W4a doc lines 96-98,
which assumed the RO write is same-frame-visible on WebKit). That is a W4a
architecture item (Quinn owns direction; Max's W2 pre-realization band is the
A/B candidate), not a W1 branch-logic defect.
Co-authored-by: Dawn <dawn@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…/webkit-compensation @ e729784) Fallback fires only when |aboveShift - observedShift| <= 0.5 against a coherent prev-rAF snapshot, corrects by observedShift. Kills the fabricated e=0 rowMove=+35/41 shoves (Shape B). Residual frame-late / multi-frame-blind escapes are W4a (RO lands post-paint on WebKit CV realization). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/dawn/webkit-compensation: fix(desktop): gate WebKit upscroll fallback on prev-rAF anchor agreement
…ment Replaces the original deviation-vs-appliedScroll W3 metric with the ratified W4 three-leg jerk contract (RESEARCH/FELT_WHEEL_GATE_METRIC_W4.md, Quinn) and adds the whole-run cumulative anti-scroll drift instrument Eva/Quinn asked for as the W4a A/B reference scale. Metric (all on the reading row's per-frame rect.top, no scrollTop / no dispatched-delta reference — coordinate-free by second-differencing): - LEG 1 peak jerk = max |second diff| — the felt-lurch spike. Gate <= 8px. - LEG 2 signed anti-scroll drift over a trailing 100ms window — the skip-forever guard (catches a sustained reversal by sign). Gate <= 12px. - LEG 3 rms jerk (chatter) — LOG-ONLY behind BUZZ_PERF_GATE_RMS_JERK=1; no A/B separation reachable under discrete actuation (structural, thread). - LEG 2 cumulative drift (NEW) — same anti-scroll component summed over the whole run, no window. LOG-ONLY, never gated. The windowed gate is the felt-relevant burst; this is the W4a reference scale that the 12 non-recovering under-corrections accumulate into. The pre-realization band (W2) should collapse THIS number. Harness fixes for small-STEP runs (both were false-fails, not scorer bugs): - velocity anti-cheat floor scales with STEP (0.008*STEP_PX) — was a fixed 0.1 pinned at STEP=12, false-failed STEP=2. - test timeout 300s -> 900s (408 events/swipe at STEP=2 is slow). Verified both engines against Dawn's shipped writer e729784 (SWIPES=12, STEP=2): Chromium 2.00 / 0.00 / cumulative 0.00 (clean PASS, control); WebKit peak 37.55 / windowed 27.55 / cumulative 1042.81 (exposed W4a frame-late layer). Chromium cumulative = 0.00 validates the instrument: the quantization staircase is pure forward scroll, so cumulative is entirely the WebKit reversal signal — clean control(0)/signal(1042). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…lative drift (sami/webkit-comp-gate @ 45500ed) Lands the ratified W4 gate in-tree: peak-jerk + windowed signed-drift asserting, rms-jerk logged, whole-run cumulative anti-scroll drift as the locked W4a A/B reference (WebKit 1042.81 vs Chromium 0.00 control). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * commit '45500ed1': W4 felt gate: jerk metric rewrite + whole-run cumulative drift instrument
…nti-scroll motion, not the ~220px non-recovering subset Quinn's correctness flag: the shipped comment framed totalDrift as the non-recovering-under-correction accumulation (~220px), which the measured 1042.81 baseline disproves — it includes recovering-flash reversal halves and multi-frame realizations too. Comment now states what it measures. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
… engines
Realizing upscroll made the reading row jump down then snap back — a
reversal frame pair, most visible on WebKit where the ResizeObserver for
a content-visibility realization delivers one frame LATE (paint N, RO
N+1). This lands design-of-record (c): a single ungated RO mid-history
corrector plus a per-rAF sampler, coordinating ONLY through a shared
row-height cache — no engine branch. The on-time observer per engine is
the sole writer:
- Chromium: the RO delivers pre-paint, so the ungated RO corrects at N
(by the anchor's own measured drift) and refreshes the cache; the
rAF then sees residual ~0 and no-ops.
- WebKit: the RO is late, so the rAF (whose synchronous
getBoundingClientRect forces this frame's layout) corrects at N; the
late RO no-ops against the rAF-refreshed cache.
The RO path is deliberately UNGATED by the rAF path's observed/above
agreement cross-check: that gate suppresses the rAF band walk's
straddler-miscount fabrication, a risk the RO does not have (its entries
are ground truth for which rows resized). Applying the gate to the RO
strangled the on-time Chromium observer and regressed it to 16
reversals. Straddler safety on the RO path comes instead from the
safe-margin reading anchor (snapshotReadingAnchor +
READING_ANCHOR_SAFE_MARGIN_PX), which sits a notch below the realization
band so a row realizing across the fold is above the anchor and summed
into the drift correctly — documented as a load-bearing invariant at the
site so a later refactor cannot strip it thinking the `changed` trigger
covers straddlers (it does not; the margin does).
Own red/green E2E fixture (upscroll-raf-correction.perf.ts), verified on
both Playwright engines against a stamped build:
Chromium 0 reversals max 0.0px RO 21 rAF 0
WebKit 2 reversals max 14.5px rAF 24 RO 0 (residual 0.0)
Asserts are a layered stack so a vacuous pass cannot slip through: a
build-stamp stale-`dist` guard, LIVENESS (some correction fired),
PRIMARY correctness (reversals <= 4, maxReversalPx <= 34, engine-
agnostic), and per-engine MECHANISM in both directions (chromium
roFires>0 && rafFires===0; webkit rafFires>0 && maxRoResidual<=0.5).
Layering self-test confirmed: disabling the ungated-RO write flips
PRIMARY red at 16 while LIVENESS stays green.
The hook is the sole cross-engine reading-anchor scroll writer and its
correctness lives in the per-site provenance comments, so it crossed the
1000-line file-size limit (888 at HEAD); added a documented, narrowly-
scoped override queued to split the mid-history corrector into a sibling.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…-decay) Drives the low-velocity + momentum-tail regime Tyler reports the residual in, which the constant-12px/32ms gate does not cover. Characterization only — logs the reversal distribution and still-frame count, no pass/fail ceiling, since the slow-regime floor is what we are measuring. Keeps liveness + stale-dist guards. Honesty bound documented in-file: Playwright mouse.wheel is synthetic and cannot reproduce a real trackpad's compositor-coalesced momentum, so a green run means 'the correction is not a large felt jump at low synthetic velocity', not 'reproduces everything Tyler feels'. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…be field Adds the discriminator Sami specced (thread 5b46582e): the escape counter is pure rendered rowMove and residual is |aboveShift|, so neither can recover the grow-vs-shrink topology that decides the fix. Thread signed aboveShift (rAF path) / signed drift (RO path) through the correction probe as a diagnostic-only 'signedShift' field alongside wouldFire; production behavior unchanged. The slow-scroll classifier joins each reversal to the correction attempt(s) in its frame window (append-count join, order-robust across the two rAF loops) and classifies: SKIP (gate/xcheck suppressed the write -> uncorrected reflow), GROW (fired, signedShift>0 -> write is the felt snap, absorption's topology), SHRINK (fired, signedShift<0 -> reflow renders it pre-write, only smaller per-frame realization helps). Bumps ANCHOR_BUILD_STAMP to w4a-classifier-1 so the stale-dist guard rejects any bundle missing signedShift. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The two rAF loops (fixture sampler + hook) interleave in an order we don't control, and a late WebKit RO appends a frame after the reflow paints. A tight [prev, this) window mis-labeled all 4 fast-corpus survivors UNATTRIBUTED when the attempt had appended one frame late. Widening to [prev, next) resolves them to their true class. Fast jitter corpus, WebKit: all 4 survivors classify SKIP (fired=false, signedShift=0.0, dScroll=0.0) — the momentum-skip gate suppressed the write on a coalesced-momentum still frame, so the reflow renders uncorrected. Neither GROW (absorption) nor SHRINK (Max's band): the lever is the skip gate. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
… chase The reading-anchor hook is the sole cross-engine scroll writer and the survivor of the GUI-latency sweep's upscroll-reversal chase. The W4a instrumentation legitimately inflated it past 1140: the signed-shift grow/shrink probe (classifier arm) and the renderedScroll gate-clock rekey (w4a-gate-1 arm), both carrying load-bearing provenance inline. Per Eva's ruling, take the scoped override NOW and hold the structural split until the arms in flight against this file settle — splitting a hook whose provenance comments are themselves load-bearing (the anchor-margin lesson lives in it) mid-chase is how a guard gets stripped. Exit condition recorded at the override site: once #1662's fate is decided, the diagnostic emits get stripped or gated and the mid-history corrector + band walk split into a sibling module. This also retires the --no-verify size bypass the classifier commit (9df6d92, 1156 lines) shipped under: the limit now covers it honestly, and Sami independently flagged the same blocker on the branch. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…gate-1) The mid-history correction's momentum-skip gate keyed on the raw `scrollTop` delta since baseline. On WebKit, momentum is coalesced into `scrollTop` on the compositor's own async clock, so a frame that PAINTED still can read a large raw delta and wrongly skip a genuine above-anchor reflow — the reversal survivor bin (the 204px-class lurch of #1662). Rekey the gate onto the RENDERED scroll — the painted wheel motion this frame — `renderedScroll = aboveShift - ΔtopOffset`: the anchor's painted on-screen move with the reflow's own push removed, both terms read from the painted DOM (getBoundingClientRect), not the possibly-ahead `scrollTop`. A rendered-still frame now reads ~0 and the reflow is corrected instead of dropped. Because the gate now needs `aboveShift` to decide, the band walk runs before the gate. To keep "under-correcting is invisible; the next quiet frame catches up," `sumAboveAnchorShift` no longer refreshes the height cache inline — it STAGES the refresh and returns `{ aboveShift, commit }`. The caller applies `commit()` only past the gate, so a momentum skip leaves the cache for the next frame; a kept correction refreshes it as one indivisible pass (the "second observer no-ops" invariant is intact). Arbiter contract, fresh w4a-gate-1 build, both engines: - raf-correction HARD gate: Chromium 0 reversals, rAF fires 0 / RO 21 (RO sole writer); WebKit 2 reversals max 14.5px (<=34 quantum floor), rAF fires 24 / RO no-op (residual 0.0). Ratified invariants hold. - slow-scroll: 0 reversals both engines (felt low-velocity regime clean). - fast-classify DECOMPOSITION self-test (WebKit): pure-reflow attempts median |signedShift|=18.5 but median |renderedScroll|=0.0 — the reflow does NOT leak into the gated quantity, and 19/28 fire. Pure-scroll |renderedScroll| runs up to 28. The decomposition is proven, not asserted. Guarded WebKit-only: on Chromium the rAF is the passive loser observer (RO corrects first), so renderedScroll there tracks signedShift by construction — characterized, not asserted. KNOWN RESIDUAL (surfaced, not hidden): 2 WebKit survivors remain, but they are a DIFFERENT class — signedShift=0.0 (the band walk sees no above-anchor reflow) while the row still lurches 14.5px. The momentum-gate survivors the rekey targeted are gone; these are cross-check/null-target skips where the reflow is invisible to the band walk. Separate diagnosis. Diagnostic-only `renderedScroll` is threaded through the shared result type + emitted on the rAF probe so the fixture can prove the decomposition; stripped/gated before merge per the file-size exit condition. Raises the useAnchoredScroll.ts size exception 1180->1210 to cover the arm (biome reflows the 4-prop returns); flagged in the override comment for Eva as heavy-for-a-diagnostic. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…ss under w4a-gate-1 Answers Eva's leg question (thread 0a496379): are the felt −240-class media reflow snaps on scroll-up momentum-class (killed by w4a-gate-1), walk-blind (RO-path, a separate leg), or SHRINK-class (needs Max's pre-realization band)? Adds a committed media corpus + classifier so the answer is reproducible from the tip on a fresh build, mirroring the jitter/fast-classify arm. Corpus (e2eBridge `mediaCorpusBody`, channel `media-corpus`, 400 rows, 8 kinds). Row-height error is MEDIA reserve-vs-true mismatch, not text mis-estimation, from three in-source-verified divergence sources: - Link-preview cards reserved flat at PREVIEW_CARD=70 but rendered taller (GROW). - Width-clamp images: CORRECT dims, but the estimator hardcodes MEDIA_MAX_WIDTH=384 while render clamps to max-w-[min(24rem,100%)]; in a column < 384px the render is width-limited shorter than reserved (SHRINK, no lying dim, no decode timing). - Dim-mismatch images in BOTH directions: overstate height (SHRINK) and understate (GROW), so the corpus CAN produce SHRINK — the GROW-dominant prediction is falsifiable, not baked in. No dim-less band: that path is pinned to a fixed 256px box (reserve==mount) and provably cannot reflow. Images use a tiny inline PNG data-URL so they realize without network. (Eva verified the mechanism chain independently in mainbase 2cc0eb5.) Fixture (upscroll-media.perf.ts) clones the fast classifier verbatim — probeLen append-count join, SKIP/GROW/SHRINK/UNATTRIBUTED discriminator, dev, ±2-frame firedNear admission — and only: - navigates to channel-media-corpus, then narrows the viewport to 450px so the timeline column lands below 384px; asserts the measured media-container width is < 384 so the width-clamp SHRINK source provably bites (else it fails loudly); - adds a MEDIA-REFLOW CENSUS: for every above-anchor reflow (|signedShift|>3px), whether it landed on a tracked in-band row and the worst (most-backward) rowMove across it, collapsing a persistent gate-held reflow's per-rAF re-measure into one run so a stuck 198px GROW isn't inflated into hundreds; - asserts MEDIA LIVENESS (>=1 in-band reflow) so a zero-reversal result cannot be a dead corpus; drops the fast arm's decomposition self-test (that re-proves the gate mechanism the jitter arm already ratifies — off-charter here). Result, fresh w4a-gate-1 build, BOTH engines, reproducible from this tip: - Chromium: 0 reversals. 9 reflow runs (5 in-band), 7 GROW / 2 SHRINK. Every in-band reflow worstRowMove >= 0 — incl. a persistent +198 GROW held 170 frames at rowMove 0.0. - WebKit: 0 reversals. 8 reflow runs (4 in-band), 6 GROW / 2 SHRINK. GROW runs gate-SKIPed to rowMove 0.0; SHRINK runs (−14.5) fired-and-corrected to +0.5 (forward). CLASSIFICATION: the media reserve-vs-true reflows are momentum/absorbed-class — the landed w4a-gate-1 fix + correction eat them; not one renders a felt backward snap on either engine. NOT walk-blind, NOT needing Max's pre-realization band. Neither Dawn's GROW-likely nor Eva's SHRINK-likely prediction "wins" in the felt sense: both classes fire, both are absorbed. HONESTY BOUND: this is a deterministic proxy for media reflow, not a claim to reproduce Tyler's exact frames. His live trackpad still owns acceptance; #1662 stays draft. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WebKit scroll compensation fix (stacked on #1650)
Status: DRAFT, pullable/testable at
f196ff7a. Fixes the upscroll jumping Tyler feels on trackpad in the shipped macOS app (WKWebView), which #1650 does not remove.The defect
The #1650 compensation writer is near-perfect on Chromium and near-ineffective on WebKit — the engine family the Tauri app ships. Root causes found across this arc:
What's landed at this tip
useAnchoredScroll.ts: engine-order-independent compensation — single-clockgetBoundingClientRectdeltas; per-rAF loop is the single writer of the reading-anchor baseline; R_obs agreement gate (r4): the fallback fires only when|aboveShift − observedShift| ≤ 0.5against a coherent prev-rAF snapshot and corrects by the observed shift — straddler miscounts are suppressed instead of fabricated into shoves. Two-population instrument proof in thee7297849commit body.upscroll-jitter.perf.ts: merge-blocking sync gate (per-notch motion-vs-median) + wheel-actuated felt gate (peak-jerk + signed-drift asserting; rms logged behindBUZZ_PERF_GATE_RMS_JERK=1), both engines viaplaywright.perf.config.ts/playwright.perf-webkit.config.ts.upscroll-slow-trackpad.perf.ts, diagnostic): 12px wheel @32ms for 30s, per-rAF rect.top trajectory — the instrument that split the escapes into Shape A (frame-late flash) vs Shape B (fabricated shove) and proved B dead.upscroll-1px-live,upscroll-trackpad-live) + the WebKit perf config that exposed the defect.Numbers at
f196ff7a(fresh dist, same-shell HEAD verified)Reading the WebKit reds honestly: the fabricated Shape-B layer (the felt defect) is dead — cross-validated by the envelope probe (67→54/55) and Sami's jerk scorer (55→37.55). The residual is a different, pre-existing phenomenon now exposed: on WebKit the ResizeObserver callback for a content-visibility realization lands one frame after the realization paints (Dawn's frame trace, overturning the prior W4a doc assumption), so a correct correction arrives a frame late, and multi-frame realizations under-read the shift. That is an RO-delivery-ordering problem (tracked as W4a, Quinn directing), not a branch-logic gap — Quinn proved no baseline choice inside the writer can recover it. The sync-gate 42→57 movement is the same stratigraphy: suppressing the miscount fires removed their accidental masking on the imperative path.
Fix candidate on the bench: Max's W2 pre-realization band (
max/webkit-pre-realization-band-w1@1734aa24, stacked on this tip) attacks the root — rows realizing before entering the visible band leaves nothing for a correction to arrive late to. Data-gated: merges only if it cuts the 55 residual escapes without long-frame/draw regression (A/B in flight).Acceptance before undraft