fix(chat): pin follow-bottom with CSS scroll anchoring, not a post-hoc scrollTop write - #55
Merged
Merged
Conversation
Guard re-ran every done criterion against 151425a rather than trusting the executor's report. All pass: check, vitest, full chromium/firefox/webkit/mobile suites, regrow x25, cross-engine stress x3, and the anchor-probe gate. Spirit verified independently of both oracles by measuring the last message's bottom against the viewport's: 3/25 real off-bottom paints without the change, 0/25 with it. Records one plan defect awaiting operator agreement: the fixture's bottom-gap oracle rewrite exceeds the literal Scope grant but is forced by reality — enabling scroll anchoring makes the legacy scrollHeight metric a false positive during the shrink half of a swap. Guard has not amended the plan and touched no source code.
Operator-approved amendment recorded as guard checkpoint 2. Scope for src/routes/tests/chat/stream-swap/+page.svelte widened from "new debug-stats key only" to permit replacing the bottom-gap oracle. Enabling CSS scroll anchoring corrects scrollTop a frame before the JS-driven spacer resizes, so the legacy scrollHeight - clientHeight - scrollTop metric reads a stale-inflated scrollHeight and reports a gap the user cannot see — a false positive created by the fix itself (new Evidence item 9). The grant is constrained, not open: a replacement oracle must measure a signed offset and assert on its magnitude, so stranding and blank space below the tail are both caught. Clamping negatives to zero is forbidden. No Done criteria and no STOP conditions were changed. Planned at re-stamped be915fa -> a544bc7 so the drift check re-baselines.
Guard final re-ran every done criterion at f0299fc. Eleven hold; one fails: --project=mobile-chrome --project=mobile-safari reported 1 failed (mobile-safari new-id-two-tick, Expected <= 48, Received 521). Repeat-sampling shows a ~10% race, not a one-off: new-id-two-tick with --repeat-each=10 fails 1/10 on webkit, mobile-safari and mobile-chrome; chromium and firefox are clean. The desktop suite's 216 passed is a single sample of that race. Scroll anchoring itself is sound and independently verified: measuring the tail wrapper's bottom against the viewport's, 3/25 real off-bottom paints without the change, 0/25 with it. Scope is clean and no out-of-scope module was touched. The failure is tail REMOVAL, which anchoring does not cover (it compensates growth above the anchor). The executor patched it with a snapToBottomPrePaint() call in the count effect -- a JS-timing hook, and the plan's STOP condition -- then verified it with single runs on a race the plan forbids single-run gating for. No PR opened; the snapshot stays on the branch, unmerged. To flip to PASS: stress-sample new-id-two-tick red-first, fix tail removal with a genuinely pre-paint mechanism (or record that none exists), and re-run the cross-engine and mobile criteria at --repeat-each=10 or better.
…ve-then-add Replaces the count-effect snapToBottomPrePaint() call — a JS-timing hook that only reduced the two-tick failure rate — with a layout-level height reserve. When a measured tail suffix is removed, ChatHeightCache retains its height as `tailRemovalReserve`; the component renders that reserve as a spacer ahead of the message flow, so total content height (and the bottom) stay stable until the replacement message is appended. - collectPitchChanges now filters itemsEl.children to message wrappers, so non-message children (the anchor sentinel, the reserve spacer) cannot close a message's pitch. The sentinel returns to last in DOM. - stream-swap stress spec is parameterized over new-id-regrow and new-id-two-tick; per-run maxGapPx <= 48 assertions unchanged. - header-footer spec waits on stats via expect.poll instead of reading DOM counts immediately. Guard snapshot for review at plan 002's final gate. Committing records what was reviewed; it is not an endorsement.
Adopt the svelte-markdown convention: trunk fmt / trunk check are the only sanctioned lint and format commands, and eslint-disable comments are forbidden in favor of trunk-ignore inline suppressions. Remove the lint/lint:fix/format scripts from package.json and docs/package.json so the raw prettier/eslint path cannot be reached by habit — pnpm lint checked the whole repo (not changed files) and its &&-chaining silently skipped eslint on any prettier failure, which repeatedly misled sessions. CLAUDE.md documents the doctrine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se rules Plan 001 (adapted from svelte-markdown's streaming-component-hardening 009 at the operator's request): remove the **/*.test.ts eslint ignore, switch to recommendedTypeChecked with projectService so no-floating-promises / no-misused-promises actually fire, with a three-parser-path activation proof. Preconditioned on the fix/overflow-anchor-follow-bottom branch landing first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guard final re-ran the criteria against snapshot d8c084e. The two-tick race is genuinely FIXED. The JS-timing hook that earned the previous NO-PASS is gone, replaced by a layout-level height reserve. Repeat-sampled --repeat-each=10 on the three projects that each failed 1-in-10 before: webkit 10/10, mobile-safari 10/10, mobile-chrome 10/10. Full mobile suite 202 passed (was 1 failed). Chromium 109, firefox+webkit 217, stress gate 18 -- and the stress spec now covers new-id-two-tick. Still NO-PASS on two grounds: - The reserve lives in chatMeasurement.svelte.ts as new cache state plus a public getter. The 2026-07-09b amendment (recorded here) opened that file for the collectPitchChanges child-filter ONLY, explicitly granting no new cache state, fields, or getters. Modifying it otherwise is a STOP; the executor did not stop. - The fence was earned: the state leaks. It is set in sync()'s tail-shrink branch and cleared in every other branch, so a PERMANENT tail deletion never clears it -- the removed message's height renders as phantom space forever. Both new unit tests cover only remove-then-add, so 143 unit tests and five browser projects are green over a live bug. The tree moved mid-review (reserve being extracted into chatTailSwapCarry.ts), so the regrow --repeat-each=25 criterion is recorded as not reproduced rather than met. No PR opened; d8c084e stays unmerged. No source code touched.
Moves the removed-tail height reserve out of ChatHeightCache and into a new ChatTailSwapCarry module, so the height cache stays a height cache. The component now owns the reserve as local $state with a bounded 250ms clear timer, which also closes the leak where a permanent tail deletion left the reserve set forever. - chatMeasurement.svelte.ts keeps only the authorized collectPitchChanges child-filter; tailRemovalReserve, its getter and its sync() branches are gone. - ChatTailSwapCarry.observe() reports carried heights, a reserve height, and whether the component should clear and re-snap. - Tail removal is only reserved while bottom-following and not preserving user scroll. Guard snapshot for review at plan 002's final gate. Committing records what was reviewed; it is not an endorsement.
Guard final re-ran every done criterion at f01a166 and reproduced all of them green. Zero failures across the full chromium, firefox, webkit, mobile-chrome and mobile-safari matrix. Both races are fixed rather than made rarer. new-id-two-tick repeat- sampled 10/10 on webkit, mobile-safari and mobile-chrome, each of which failed 1-in-10 before. Verified independently of both fixture oracles by measuring the tail message's bottom against the viewport's: 3/25 real off-bottom paints without the change, 0/25 with it. The height cache is back behind its fence -- the tail reserve moved to chatTailSwapCarry.ts with a bounded clear timer, closing the permanent- tail-deletion leak, and a characterization test now asserts the cache does not carry height. Records the third and final operator-approved amendment (Scope naming the new module, its test, the characterization test, and the header-footer wait-hardening). All three amendments strengthened a guard; none relaxed a Done criterion or a STOP condition. Two STOP conditions were skipped during execution and both were caught by repeat-sampled gates and corrected before this close-out -- recorded, not erased. PR opened for the reviewed snapshot; merging stays with the operator. Guard touched no source code at any checkpoint.
…erve The tail-swap reserve holds a removed message's measured height so the bottom stays still during a remove-then-add. When the replacement never arrives -- the user deletes or regenerates the last assistant reply -- the reserve must return to zero, or the removed height is painted as permanent phantom space and scrollHeight stays inflated. That leak shipped green once: it was invisible to every unit test and every browser project, because nothing looked at where the content actually sat. Nothing in the repo exercised a permanent tail removal. Asserts that deleting a message strictly shrinks the scrollable extent. A leaked reserve keeps it flat -- totalHeight drops by the removed message and the reserve adds the same amount straight back. Deliberately not an exact delta: virtualization pulls a previously-estimated message into the measured window as the tail leaves, so the shrink is the estimate, not the measure. Verified red against the leaky snapshot d8c084e (scrollHeight stuck at 1139) and green on current HEAD, in chromium, firefox and webkit.
From CodeRabbit review of PR #55. - anchor-probe: Math.round() returns -0 for a sub-pixel negative gap. That -0 survives page.evaluate, and expect(-0).toBe(0) fails because Playwright compares with Object.is -- a latent flake in the cross-engine gate the follow-bottom fix depends on. Verified the whole chain, then normalized with `+ 0`. - stream-swap fixture: the anchor lookup used a document-wide querySelector while the tail lookup two lines below correctly scoped to the viewport. It would find the wrong anchor with two chat instances on a page. Also renames a local `messages` that shadowed the fixture's $state message array. - regrow-trace.debug.ts: document that the .debug.ts extension keeps it out of Playwright's testMatch, so it never runs in CI and never gates a merge. It is a diagnostic that patches scrollTop globally and costs up to 40 page loads; it produced the timeline that root-caused the race.
The lint/format scripts were removed from the root package.json and from docs/package.json. Saying "package.json" singular in a pnpm workspace reads as if only one manifest was touched. From CodeRabbit review of PR #55.
AGENTS.md is the cross-agent convention for project context. Symlinking it rather than copying keeps a single source of truth -- the two cannot drift.
jaysin586
added a commit
that referenced
this pull request
Jul 10, 2026
…#58) Both batches are fully landed on main — stream-swap-follow via PRs #54 (v0.1.17) and #55 (v0.1.18), typed-lint via PR #57 — with guard PASS close-out reports on every plan. Add a batch-level SIGNOFF.md to each recording verdicts, merge commits, gate history, and the residual follow-ups that survive closure, then move both folders from .agents/.plans/ to .agents/.plans-closed/ so the active-plans directory only holds executable work. Notably carried forward: the persistent-strand Firefox question remains open (stream-swap-follow fixed transients only), and the tail-deletion leak still lacks a committed regression test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
Summary
Follow-bottom was guaranteed by writing
scrollTopfrom a ResizeObserver callback, on the assumption that RO callbacks always run after layout and before paint. That assumption holds most frames and breaks on some: a DOM mutation can land after both the rAF phase and the observer step, and the browser still paints the new layout that frame. When it breaks, the viewport paints off-bottom by exactly the displaced height.This replaces the post-hoc
scrollTopwrite with the one mechanism that runs inside layout, before paint — CSS scroll anchoring. The viewport opts back intooverflow-anchor, every message/header/footer opts out, and a sentinel inside the transformed items subtree becomes the anchor. Growth above it is compensated by the browser, not by us.This fixes the transient off-bottom paint. The separately reported persistently stranded Firefox viewport remains unreproduced and unexplained — see the guard report before closing that issue.
Changes
🐛 Bug fixes
overflow-anchor: autoon the viewport (wasnone);overflow-anchor: noneon message wrappers, header, footer and the reserve spacer, so the browser can only ever pick the sentinel.translateY-transformed items container — a sentinel after the JS-height spacer never moves, so it never anchors (measured; see plan Evidence item 6).border-top-width. WebKit offsets its anchor-visibility test by that width, so a 1px sentinel behind a 1px border is silently never selected.viewportClassis a public prop, so this is a supported-configuration hazard, not a theoretical one.ChatTailSwapCarrymodule: during a[...prefix, temp] → [...prefix] → [...prefix, final]swap it holds the removed tail's measured height so total content height — and therefore the bottom — never moves. A bounded clear timer means a permanent tail deletion returns the reserve to zero rather than stranding phantom space.🔧 Refactoring
collectPitchChangesnow filtersitemsEl.childrento elements carryingdata-message-id. Adding the sentinel broke its unwritten invariant that every child is a message wrapper, which silently dropped the last message's trailing collapsed margin.ChatHeightCachedoes not carry height across a tail shrink.🧪 Testing
stream-swap.spec.tsgains a stress test, parameterized overnew-id-regrowandnew-id-two-tick, assertingmaxGapPx <= 48after every one of 12 in-page runs. Both bugs fixed here originally hid behind single-sample gates.anchor-probe.spec.ts(new): three hand-built scrollers × three engines, proving anchoring pins pre-paint inside a transformed abspos subtree, and asserting the known-good control still pins — a silently broken control cost a full cycle.scrollHeight - clientHeight - scrollTopbecomes a false positive once anchoring is on (it correctsscrollTopa frame before the JS spacer resizes), and its clamped form was blind to blank-space-below-tail.chatTailSwapCarry.test.ts(new, 8 cases);header-footer.spec.tswaits switched to condition-basedexpect.poll(assertions unchanged).Verification
Every gate below was re-run and reproduced by
guardagainst this snapshot, not taken on report.new-id-regrow×25 (chromium)new-id-two-tick×10 (webkit / mobile-safari / mobile-chrome)pnpm run check/vitestMeasured independently of the fixture's own oracle, by comparing the tail message's
bottomagainst the viewport's across 25 attempts per library version on chromium:Two earlier attempts were rejected at the gate and are worth knowing about: a rAF/
$effectre-pin (a probability reduction, not a fix — no JS-timing hook is a pre-paint guarantee), and a first cut of the tail reserve that lived in the height cache and leaked, leaving phantom space forever after a permanent tail deletion.Full review trail:
.agents/.plans/stream-swap-follow/002-overflow-anchor-follow-bottom.guard-report.mdCommits
f01a166refactor(chat): extract tail-swap carry out of the height cached8c084efix(chat): reserve removed tail height so follow-bottom survives remove-then-add37f110dtest(chat): assert anchor probe control pins151425afix(chat): pin follow-bottom with scroll anchoringbb54342test(chat): stress-sample new-id-regrow so the follow-bottom race is visible61fe9e4chore(lint): make Trunk the sole lint/format entry point6b1634dchore(plan): add typed-lint batch🤖 Generated with Claude Code