Implement scroll preservation contract #20

Open
opened 2026-05-16 22:23:15 +02:00 by arne · 1 comment
Owner

What to build

Implement the scroll preservation contract defined in CONTEXT.md § Scroll preservation contract.

Add decideScroll(state) → {scrollTo: number} as a pure function on window.__liveUpdates, where state carries pre-swap and post-swap measurements:

  • scrollTopBefore, scrollHeightBefore, clientHeightBefore
  • scrollHeightAfter, clientHeightAfter
  • trigger: one of cold-load, live-update, load-older, resume

Wire htmx:beforeSwap (stash measurements) and htmx:afterSwap (apply verdict) for #main. Write goja tests for each row of the contract table.

The contract rows (from CONTEXT.md):

Situation Behavior
Cold load of a thread Scroll to bottom
Live update arrives, user at bottom (within 96px) Stay pinned to the new bottom
Live update arrives, user scrolled up Preserve scrollTop, no jump
User clicks "Load older messages" Preserve viewport (anchor on visible content)
Tab returns from OS suspend Scroll to bottom

Acceptance criteria

  • decideScroll covers every row of the contract
  • goja tests assert each row's behavior
  • Manual: open a thread, send a message from another device, scroll position is correct per contract
  • Manual: load older messages, the user's viewport content stays under their eye
  • Manual: background the tab, foreground it; thread scrolls to bottom on resume
  • "At bottom" tolerance is scrollTop + clientHeight >= scrollHeight - 96 exactly (per CONTEXT.md)

Blocked by

  • #19 (pure-function bridge refactor + goja harness) — this issue uses that harness
## What to build Implement the scroll preservation contract defined in `CONTEXT.md § Scroll preservation contract`. Add `decideScroll(state) → {scrollTo: number}` as a pure function on `window.__liveUpdates`, where `state` carries pre-swap and post-swap measurements: - `scrollTopBefore`, `scrollHeightBefore`, `clientHeightBefore` - `scrollHeightAfter`, `clientHeightAfter` - `trigger`: one of `cold-load`, `live-update`, `load-older`, `resume` Wire `htmx:beforeSwap` (stash measurements) and `htmx:afterSwap` (apply verdict) for `#main`. Write goja tests for each row of the contract table. The contract rows (from CONTEXT.md): | Situation | Behavior | |---|---| | Cold load of a thread | Scroll to bottom | | Live update arrives, user at bottom (within 96px) | Stay pinned to the new bottom | | Live update arrives, user scrolled up | Preserve `scrollTop`, no jump | | User clicks "Load older messages" | Preserve viewport (anchor on visible content) | | Tab returns from OS suspend | Scroll to bottom | ## Acceptance criteria - [ ] `decideScroll` covers every row of the contract - [ ] goja tests assert each row's behavior - [ ] Manual: open a thread, send a message from another device, scroll position is correct per contract - [ ] Manual: load older messages, the user's viewport content stays under their eye - [ ] Manual: background the tab, foreground it; thread scrolls to bottom on resume - [ ] "At bottom" tolerance is `scrollTop + clientHeight >= scrollHeight - 96` exactly (per CONTEXT.md) ## Blocked by - #19 (pure-function bridge refactor + goja harness) — this issue uses that harness
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Implement the scroll preservation contract from CONTEXT.md § Scroll preservation contract. Add a decideScroll pure function on window.__liveUpdates and wire it into htmx's swap hooks for the thread main pane. Add goja test cases for every row of the contract.

Current behavior:
When htmx swaps the thread's <main> (hx-swap="outerHTML"), the scroll container is replaced wholesale and the previous scroll position is lost. No code captures or restores it. Users scrolled up reading history lose their place when a live update arrives; users at the bottom may or may not stay pinned. On cold load and OS-suspend resume the behavior is similarly arbitrary.

Desired behavior:

The bridge honors the contract published in CONTEXT.md § Scroll preservation contract:

Situation Behavior
Cold load of a thread Scroll to bottom
Live update arrives, user at bottom (within 96px) Stay pinned to the new bottom
Live update arrives, user scrolled up Preserve scrollTop, no jump
User clicks "Load older messages" Preserve viewport (anchor on visible content)
Tab returns from OS suspend Scroll to bottom

"At bottom" means scrollTop + clientHeight >= scrollHeight - 96. The tolerance constant is exactly 96, sourced from CONTEXT.md; any change to it changes CONTEXT.md first.

Implementation shape:

A pure decision function decideScroll(state) lives on window.__liveUpdates (the pattern established by the bridge refactor). It receives a plain-data state describing the swap:

  • pre-swap measurements: scrollTopBefore, scrollHeightBefore, clientHeightBefore
  • post-swap measurements: scrollHeightAfter, clientHeightAfter
  • trigger: one of cold-load, live-update, load-older, resume

It returns {scrollTo: <number>}, where the number is the scrollTop value to apply.

The DOM-touching shell listens for htmx:beforeSwap on #main (capture pre-swap measurements), then htmx:afterSwap on #main (call decideScroll, apply the verdict). Tab-resume triggers a swap manually if no swap was already in flight; the agent decides the cleanest mechanism that doesn't double-fire.

Cold load is handled by a one-shot scroll on initial paint that calls decideScroll with trigger: cold-load and the relevant measurements.

Test coverage (via the goja harness from the previous issue):

  • Each contract row drives one or more test cases against decideScroll. Specifically:
    • cold-load → returns scrollHeightAfter - clientHeightAfter (bottom)
    • live-update, was at bottom → returns scrollHeightAfter - clientHeightAfter (new bottom)
    • live-update, was scrolled up → returns scrollTopBefore
    • live-update, was within 96px of bottom but not at it → still treated as "at bottom" (boundary case)
    • load-older → returns scrollTopBefore + (scrollHeightAfter - scrollHeightBefore) (viewport anchor)
    • resume → returns scrollHeightAfter - clientHeightAfter (bottom)

Acceptance criteria:

  • decideScroll is callable from goja and covers every contract row
  • Test cases exist for each row plus the 96px boundary on live-update
  • htmx:beforeSwap captures pre-swap measurements for #main swaps
  • htmx:afterSwap applies the decideScroll verdict for #main swaps
  • Cold load of a thread lands at the bottom
  • Manual: open a thread, scroll up, have a peer send a message — your scroll position does not change
  • Manual: stay at the bottom, have a peer send a message — you remain at the bottom with the new bubble visible
  • Manual: click "Load older messages" — the content you were looking at stays under your eye
  • Manual: background and refocus a tab — the thread scrolls to the bottom
  • The "at bottom" tolerance constant is exactly 96 and is sourced/named clearly in the code

Out of scope:

  • The per-bubble append refactor (this contract is intentionally swap-strategy-agnostic; a future per-bubble migration changes the wiring, not the contract)
  • Adding a "↓ new message" affordance for users scrolled up (separate UX concern; not part of the scroll contract)
  • Any change to the live-updates wire protocol
  • Tuning the 96px constant — that's a CONTEXT.md change, not an issue-level decision
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Implement the scroll preservation contract from `CONTEXT.md § Scroll preservation contract`. Add a `decideScroll` pure function on `window.__liveUpdates` and wire it into htmx's swap hooks for the thread main pane. Add goja test cases for every row of the contract. **Current behavior:** When htmx swaps the thread's `<main>` (`hx-swap="outerHTML"`), the scroll container is replaced wholesale and the previous scroll position is lost. No code captures or restores it. Users scrolled up reading history lose their place when a live update arrives; users at the bottom may or may not stay pinned. On cold load and OS-suspend resume the behavior is similarly arbitrary. **Desired behavior:** The bridge honors the contract published in `CONTEXT.md § Scroll preservation contract`: | Situation | Behavior | |---|---| | Cold load of a thread | Scroll to bottom | | Live update arrives, user at bottom (within 96px) | Stay pinned to the new bottom | | Live update arrives, user scrolled up | Preserve `scrollTop`, no jump | | User clicks "Load older messages" | Preserve viewport (anchor on visible content) | | Tab returns from OS suspend | Scroll to bottom | "At bottom" means `scrollTop + clientHeight >= scrollHeight - 96`. The tolerance constant is exactly 96, sourced from `CONTEXT.md`; any change to it changes `CONTEXT.md` first. **Implementation shape:** A pure decision function `decideScroll(state)` lives on `window.__liveUpdates` (the pattern established by the bridge refactor). It receives a plain-data state describing the swap: - pre-swap measurements: `scrollTopBefore`, `scrollHeightBefore`, `clientHeightBefore` - post-swap measurements: `scrollHeightAfter`, `clientHeightAfter` - `trigger`: one of `cold-load`, `live-update`, `load-older`, `resume` It returns `{scrollTo: <number>}`, where the number is the `scrollTop` value to apply. The DOM-touching shell listens for `htmx:beforeSwap` on `#main` (capture pre-swap measurements), then `htmx:afterSwap` on `#main` (call `decideScroll`, apply the verdict). Tab-resume triggers a swap manually if no swap was already in flight; the agent decides the cleanest mechanism that doesn't double-fire. Cold load is handled by a one-shot scroll on initial paint that calls `decideScroll` with `trigger: cold-load` and the relevant measurements. **Test coverage** (via the goja harness from the previous issue): - Each contract row drives one or more test cases against `decideScroll`. Specifically: - cold-load → returns `scrollHeightAfter - clientHeightAfter` (bottom) - live-update, was at bottom → returns `scrollHeightAfter - clientHeightAfter` (new bottom) - live-update, was scrolled up → returns `scrollTopBefore` - live-update, was within 96px of bottom but not at it → still treated as "at bottom" (boundary case) - load-older → returns `scrollTopBefore + (scrollHeightAfter - scrollHeightBefore)` (viewport anchor) - resume → returns `scrollHeightAfter - clientHeightAfter` (bottom) **Acceptance criteria:** - [ ] `decideScroll` is callable from goja and covers every contract row - [ ] Test cases exist for each row plus the 96px boundary on live-update - [ ] `htmx:beforeSwap` captures pre-swap measurements for `#main` swaps - [ ] `htmx:afterSwap` applies the `decideScroll` verdict for `#main` swaps - [ ] Cold load of a thread lands at the bottom - [ ] Manual: open a thread, scroll up, have a peer send a message — your scroll position does not change - [ ] Manual: stay at the bottom, have a peer send a message — you remain at the bottom with the new bubble visible - [ ] Manual: click "Load older messages" — the content you were looking at stays under your eye - [ ] Manual: background and refocus a tab — the thread scrolls to the bottom - [ ] The "at bottom" tolerance constant is exactly 96 and is sourced/named clearly in the code **Out of scope:** - The per-bubble append refactor (this contract is intentionally swap-strategy-agnostic; a future per-bubble migration changes the wiring, not the contract) - Adding a "↓ new message" affordance for users scrolled up (separate UX concern; not part of the scroll contract) - Any change to the live-updates wire protocol - Tuning the 96px constant — that's a CONTEXT.md change, not an issue-level decision
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/chat#20
No description provided.