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>
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>
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>
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>
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>
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 1fa4551a (GREEN) and the T1.1 base
32e41e78 (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 of the deferred scroll-jitter seams, on top of the integrated fix train
(T1.1 gate -> T2 writer -> T3 estimator @ 1fa4551a):
- 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>
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>
Dawn found the RED gate from 33e73511 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>
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 77bd0e70 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>