Skip to content

feat(flowchat): follow again when output catches up with a reader in the blank - #2263

Closed
wsp1911 wants to merge 1 commit into
GCWing:mainfrom
wsp1911:refactor-flowchat-v2
Closed

feat(flowchat): follow again when output catches up with a reader in the blank#2263
wsp1911 wants to merge 1 commit into
GCWing:mainfrom
wsp1911:refactor-flowchat-v2

Conversation

@wsp1911

@wsp1911 wsp1911 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Scrolling up by a pixel gave the follow away for good, and that is right only
for a reader who left the live region. The reserved blank is up to
tailHoldMaxGapPx under hold-tail and the whole gap under a pinned Turn, so
a small scroll up can leave the reader a few hundred pixels off the tail with
nothing hidden from them at all — until output grows past the bottom edge and
they silently stop seeing it, with no affordance saying so.

The snap back does not cover this. Its predicate is relative to the follow
target, and under a pin the target sits inside the blank, so a reader above it
is reported as having nothing to snap back from. scrollTop > contentEnd is
the other edge of the same region, and between them they now cover it.

So a watch runs for as long as the reader holds the viewport, keeping one bit
between samples: whether the blank was on screen at the previous one. A
crossing is that bit going from set to clear, and resolveTailDepartureCrossing
decides who keeps the viewport by which side moved further — the content end
rising to meet a stationary reader hands it back, a reader out-moving the
content end keeps it. Streaming does not stop because the reader scrolled, so
both sides usually move and the tie-break is doing real work.

Not scoped to one crossing. That was written first and is wrong: a reader who
climbs out of the blank, reads for a while and scrolls back down to sit in it
again is in exactly the position the rule exists for, and had already spent the
single crossing they were given. The watch also opens on any exit that finds
none open rather than only the one that took the follow — every wheel notch
exits, and only the first of them finds anything to give up.

Three guards, each measured:

  • A live gesture defers the crossing. Over one session's twenty departures,
    nine left blank on screen and two of those ended content-caught-up: one a
    reader still for 1.6s and overtaken, one 320ms into a live gesture with the
    reader climbing 200px while content grew 237. The tie-break called the second
    for the content, correctly, and acting on it would have taken the viewport
    back mid-scroll. The latch is kept rather than spent, so the next sample
    judges the same transition again — a reader who carries on climbing is let go
    by a verdict that never needed the veto, one who has stopped is followed once
    the claim lapses.
  • A travelling snap back is not the reader. It crosses the same line from
    the wrong side, downwards through the blank, so samples taken while one is in
    flight update the offsets and take no verdict.
  • Geometry read from two moments is not geometry. scrollTop is clamped to
    scrollHeight - clientHeight, so no settled viewport is more than the spacer
    past the content end (isTailBlankMeasurable). A history prepend shifts the
    viewport by the height it inserted before that height is in the scroll range:
    measured, 14252px of shift against a content end still reading 989, a blank of
    6239px in a transcript reserving a few hundred. Read at face value that is a
    reader deep in the blank, and the next sample would have pulled them out of
    the history they had just asked for.

Resuming does not scroll. The blank closing is the two offsets meeting, so what
remains is one sample of growth and the follow loop's ease covers it; a one-shot
scroll would be a snap the reader can see for a correction they cannot. It also
retires the pin, because the pin's reservation is the blank the reader just
scrolled out of and restoring it would pull them back down to the offset they
left.

The unmount cleanup no longer depends on closeTailWatch. It is rebuilt
whenever getTailSpacerPx changes identity, and a watch spans many renders by
construction, so an effect tearing down with the callback closed every one of
them a frame after it opened.

followOutput.tailWatch and tailWatchEnded bracket the watch, the latter
carrying crossings so one that resolved nothing can be told from one that
resolved against the reader; followOutput.tailCrossing records each transition
with its verdict, both raw deltas, and whether a gesture was live.

Known gap added for the reader who scrolls out of the blank and stops at the
content end — measured at four of nine watched departures, readerMovedPx
tracking the blank height almost exactly — which neither this nor the snap back
covers. Acting on it means reading intent from a resting position that has
content in it, which the snap back's licence explicitly does not extend to.

…the blank

Scrolling up by a pixel gave the follow away for good, and that is right only
for a reader who left the live region. The reserved blank is up to
`tailHoldMaxGapPx` under `hold-tail` and the whole gap under a pinned Turn, so
a small scroll up can leave the reader a few hundred pixels off the tail with
nothing hidden from them at all — until output grows past the bottom edge and
they silently stop seeing it, with no affordance saying so.

The snap back does not cover this. Its predicate is relative to the follow
target, and under a pin the target sits inside the blank, so a reader above it
is reported as having nothing to snap back from. `scrollTop > contentEnd` is
the other edge of the same region, and between them they now cover it.

So a watch runs for as long as the reader holds the viewport, keeping one bit
between samples: whether the blank was on screen at the previous one. A
crossing is that bit going from set to clear, and `resolveTailDepartureCrossing`
decides who keeps the viewport by which side moved further — the content end
rising to meet a stationary reader hands it back, a reader out-moving the
content end keeps it. Streaming does not stop because the reader scrolled, so
both sides usually move and the tie-break is doing real work.

Not scoped to one crossing. That was written first and is wrong: a reader who
climbs out of the blank, reads for a while and scrolls back down to sit in it
again is in exactly the position the rule exists for, and had already spent the
single crossing they were given. The watch also opens on any exit that finds
none open rather than only the one that took the follow — every wheel notch
exits, and only the first of them finds anything to give up.

Three guards, each measured:

- **A live gesture defers the crossing.** Over one session's twenty departures,
  nine left blank on screen and two of those ended `content-caught-up`: one a
  reader still for 1.6s and overtaken, one 320ms into a live gesture with the
  reader climbing 200px while content grew 237. The tie-break called the second
  for the content, correctly, and acting on it would have taken the viewport
  back mid-scroll. The latch is kept rather than spent, so the next sample
  judges the same transition again — a reader who carries on climbing is let go
  by a verdict that never needed the veto, one who has stopped is followed once
  the claim lapses.
- **A travelling snap back is not the reader.** It crosses the same line from
  the wrong side, downwards through the blank, so samples taken while one is in
  flight update the offsets and take no verdict.
- **Geometry read from two moments is not geometry.** `scrollTop` is clamped to
  `scrollHeight - clientHeight`, so no settled viewport is more than the spacer
  past the content end (`isTailBlankMeasurable`). A history prepend shifts the
  viewport by the height it inserted before that height is in the scroll range:
  measured, 14252px of shift against a content end still reading 989, a blank of
  6239px in a transcript reserving a few hundred. Read at face value that is a
  reader deep in the blank, and the next sample would have pulled them out of
  the history they had just asked for.

Resuming does not scroll. The blank closing is the two offsets meeting, so what
remains is one sample of growth and the follow loop's ease covers it; a one-shot
scroll would be a snap the reader can see for a correction they cannot. It also
retires the pin, because the pin's reservation is the blank the reader just
scrolled out of and restoring it would pull them back down to the offset they
left.

The unmount cleanup no longer depends on `closeTailWatch`. It is rebuilt
whenever `getTailSpacerPx` changes identity, and a watch spans many renders by
construction, so an effect tearing down with the callback closed every one of
them a frame after it opened.

`followOutput.tailWatch` and `tailWatchEnded` bracket the watch, the latter
carrying `crossings` so one that resolved nothing can be told from one that
resolved against the reader; `followOutput.tailCrossing` records each transition
with its verdict, both raw deltas, and whether a gesture was live.

Known gap added for the reader who scrolls out of the blank and stops at the
content end — measured at four of nine watched departures, `readerMovedPx`
tracking the blank height almost exactly — which neither this nor the snap back
covers. Acting on it means reading intent from a resting position that has
content in it, which the snap back's licence explicitly does not extend to.
@wsp1911 wsp1911 closed this Aug 13, 2026
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