Skip to content

fix(desktop): engine-order-independent scroll compensation for WebKit (stacked on #1650)#1662

Draft
tlongwell-block wants to merge 29 commits into
mainfrom
eva/webkit-compensation
Draft

fix(desktop): engine-order-independent scroll compensation for WebKit (stacked on #1650)#1662
tlongwell-block wants to merge 29 commits into
mainfrom
eva/webkit-compensation

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. Mixed-clock reads (W1r1/r2): the writer assumed Chromium's scroll→ResizeObserver same-frame ordering; WebKit fires scroll async from its scrolling thread, so corrections were computed from rAF-baseline vs RO reads straddling a compositor commit.
  2. Fabricated fallback lurches, "Shape B" (W1r4): the aboveShift fallback fired on straddler-miscount frames and pushed the row itself (rowMove=+35/41 with zero real reflow). This was the dominant felt defect under slow trackpad input.

What's landed at this tip

  • W1 (Dawn) useAnchoredScroll.ts: engine-order-independent compensation — single-clock getBoundingClientRect deltas; 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.5 against 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 the e7297849 commit body.
  • W3 (Sami) upscroll-jitter.perf.ts: merge-blocking sync gate (per-notch motion-vs-median) + wheel-actuated felt gate (peak-jerk + signed-drift asserting; rms logged behind BUZZ_PERF_GATE_RMS_JERK=1), both engines via playwright.perf.config.ts / playwright.perf-webkit.config.ts.
  • Slow-trackpad mismatch probe (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.
  • Live-staging probes (upscroll-1px-live, upscroll-trackpad-live) + the WebKit perf config that exposed the defect.

Numbers at f196ff7a (fresh dist, same-shell HEAD verified)

metric Chromium WebKit (Playwright)
slow-trackpad escapes (diagnostic) 0 ✅ 55 (was 67 pre-r4; fabricated-shove shape: 0)
felt/wheel peak dev 0.00 ✅ 0.91px (≪ 2px floor)
felt jerk gate Leg 1 peak (≤8) 2.00 ✅ 37.55 (was 55.55) — W4a baseline
felt jerk gate Leg 2 windowed drift (≤12) 0.00 ✅ 27.55 — W4a baseline
sync gate peak dev (≤2px, imperative path) 0.00 ✅ 57.55 ❌ known

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

  1. Chromium: all gates green, zero regression — met
  2. WebKit felt/wheel path ≪ 2px — met (0.91)
  3. Fabricated-shove escapes: 0 both engines — met
  4. WebKit sync gate red is a known imperative-path W4a artifact; stays merge-blocking pending the W2 A/B / W4a direction call
  5. On-device WKWebView trackpad session (last-mile — Playwright WebKit ≈ WKWebView, not identical). On-device feel reports at this tip are the load-bearing validation.

npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc and others added 29 commits July 8, 2026 15:01
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>
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