fix: question dock flicker — arrival state at render, growth-stamped fold mute

Two owner-reported symptoms, one mechanism (verified frame-by-frame on a
zero-model-call fixture): the position machine read its OWN growth-driven
scroll lag as the learner leaving.

1. Probe arrival at the bottom: the form opened, the reserve grew, the
   smooth catch-up lagged (d peaked ~405px > COLLAPSE_DIST 96) → phantom
   fold → bar shrink morph → re-expand at bottom. FORM→BAR→FORM, ~600ms.
   Fix: the at-bottom arrival now runs under the SAME fold suppression
   the teach reveal uses (suppressFoldDuringReveal, extracted) — the
   arrival's open and its scroll-to-bottom are the same motion class.

2. Probe arrival scrolled up: one stale-open frame painted before the
   after-paint effect collapsed it. Fix: probeArrivalExpanded — the
   open/closed state decided DURING RENDER (open iff at bottom), like
   the teach seam; the effect keeps only the reveal scroll.

3. Dictation stop flush into the pending question: the field grows on
   its own render (capped 192px), the reserve lands on the NEXT
   DockOverlay render (~1s later live), and the spacer's growth spiked
   d≈152 → fold → bar → re-expand. Fix: the offer stamps the geometry
   mute when the reserve GROWS while pending — the spacer is the
   phantom-scroll source, so the mute covers exactly its scroll events.
   Shrinks never stamp (the resize round's stuck-collapsed regression).

Live-verified on :3001 (job-settle fixture, abort-channel settle, no
model calls): A at-bottom arrival FORM-only; B scrolled-up arrival
straight to BAR with zero FORM frames and no scroll-down; C dictation
flush FORM-only through the +152px reserve growth.
This commit is contained in:
amosblomqvist
2026-09-26 23:53:43 +03:00
parent 876185eb6e
commit e9b9f52a7e
2 changed files with 115 additions and 19 deletions
+73 -18
View File
@@ -1330,6 +1330,33 @@ export function revealArrival(wasQuestion: boolean): { expanded: boolean; reveal
return wasQuestion ? { expanded: true, revealed: true } : { expanded: false, revealed: false };
}
/**
* A PROBE question's arrival state (flicker round, 2026-09-26): open iff
* the learner is at the bottom following along; scrolled up in history →
* arrive as the collapsed bar (owner ruling: "it should just remain
* closed"). Decided DURING RENDER like the teach seam above — the old
* after-paint effect painted one stale-open frame first (the box "opened
* and immediately closed" when scrolled up). Pure for tests.
*/
export function probeArrivalExpanded(isAtBottom: boolean): boolean {
return isAtBottom;
}
/**
* The geometry-mute's growth rule (flicker round, 2026-09-26): a reserve
* GROWTH while a question is pending is the dock stretching (the dictation
* flush landing many lines at once) — the spacer lands in that commit, the
* smooth catch-up lags by exactly the growth, and that lag is our own
* motion, not the learner leaving, so the fold machine ignores scroll
* events for the mute window after (reproduced d≈152 > COLLAPSE_DIST).
* Shrinks never stamp — that path self-muted the at-bottom re-expand and
* left the box stuck collapsed (resize round, verified live). Pure for
* tests.
*/
export function shouldStampGeometryMute(nextH: number, prevH: number, pending: boolean): boolean {
return pending && nextH > prevH + 1;
}
function DockOverlay({
pending,
requiresReveal,
@@ -1375,6 +1402,11 @@ function DockOverlay({
setupCta?: React.ReactNode;
}) {
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Render-fresh bottom state (flicker round): the arrival block below reads
// it DURING RENDER — the same value the after-paint effect used to read a
// frame too late. Assigned per render like the other refs below.
const atBottomRef = useRef(isAtBottom);
atBottomRef.current = isAtBottom;
// Teach-phase mount (session reload with a pending check): the dock starts
// as the collapsed invitation too — the check must not pop open on reload.
const [expanded, setExpanded] = useState(!requiresReveal);
@@ -1405,20 +1437,24 @@ function DockOverlay({
const arrival = revealArrival(wasQuestion);
setExpanded(arrival.expanded);
setRevealed(arrival.revealed);
} else if (pendingId) {
// Probe arrival (flicker round): open iff following at the bottom.
// Painted in THIS render — no stale-open frame, no after-paint flip.
setExpanded(probeArrivalExpanded(atBottomRef.current));
}
}
// New question MID-SESSION: open expanded if the learner is following at
// the bottom (scroll to reveal it); reading history → arrive as the bar.
// Teach-phase arrivals are handled above, during render. On session
// mount the dock simply starts in its initial state (expanded for
// probe, invitation for teach) — no scroll, no morph.
// New question MID-SESSION (flicker round): the open/closed state itself
// is decided DURING RENDER above; this effect only owns the reveal
// scroll for the at-bottom arrival — and now under the SAME fold
// suppression the teach reveal uses (the arrival's growth used to
// out-run the scroll catch-up, the position machine read the lag as the
// learner leaving, and the dock flashed open → bar → open; reproduced
// frame-by-frame 2026-09-26, d peaked ~405px). On session mount the dock
// starts in its initial state — no scroll, no morph.
useEffect(() => {
if (!pendingId || pendingId === initialPendingRef.current) return;
if (!requiresReveal) {
setExpanded(isAtBottom);
if (isAtBottom) void scrollToBottom();
}
if (!requiresReveal && atBottomRef.current) void suppressFoldDuringReveal();
// fire only when a NEW question lands
}, [pendingId]);
@@ -1449,17 +1485,18 @@ function DockOverlay({
// stamps this — the fold machine ignores scroll events for a beat after,
// because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll
// events mid-transient (the box flickered to the bar and back).
// Width changes stamp HERE (column resizes); height growth stamps at
// the OFFER (see offer below) — the spacer growing is the phantom-scroll
// source, not the field stretching. SHRINKS never stamp anywhere: the
// fold's own morph shrinks the block and gives back the reserve, and
// stamping that self-mutes the at-bottom re-expand — the box got stuck
// collapsed (resize round).
const geometryAtRef = useRef(0);
useIsoLayoutEffect(() => {
const block = blockRef.current;
if (!block) return;
const measure = () => {
const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP);
// Stamp the mute on WIDTH changes ONLY: column resizes are the
// phantom-scroll source. Height changes are the dock's own doing
// (morphs, question rewraps) — stamping those would self-mute the
// fold machine and swallow the at-bottom auto re-expand right
// after a fold (verified live: the box got stuck collapsed).
if (Math.abs(next - flankWRef.current) > 1) {
flankWRef.current = next;
setFlankW(next);
@@ -1524,6 +1561,17 @@ function DockOverlay({
const offer = (blockH: number) => {
const next = blockH + GRADIENT_H;
if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity
// Reserve GROWTH is the phantom-scroll SOURCE (flicker round,
// 2026-09-26): the spacer's height lands in this commit, the smooth
// catch-up lags it by the growth amount, and the position machine
// used to read that lag as the learner leaving — form → bar → form
// (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the
// mute right where the spacer grows: it covers the scroll events of
// THIS growth no matter how long after the field stretched (the
// field grows on its own render; the offer lands on the NEXT
// DockOverlay render — ~1s apart live). Shrinks never stamp (the
// fold's own give-back must not self-mute the re-expand).
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
reserveRef.current = next;
onReserve(next);
};
@@ -1533,21 +1581,28 @@ function DockOverlay({
// the collapse zone — the machine would instantly re-collapse the dock
// (open → snap shut → reopen at the bottom, the flicker). Suppress the
// collapse branch until the reveal scroll settles or is interrupted.
// (Flicker round, 2026-09-26: extracted — the probe arrival at the
// bottom is the SAME motion class and needs the same cover; the owner's
// phrasing: the arrival's open and its scroll-to-bottom "should be the
// same kind of motion".)
const expandIntentRef = useRef(false);
const intentTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const expandAndReveal = () => {
const suppressFoldDuringReveal = () => {
expandIntentRef.current = true;
setRevealed(true); // the teach-check invitation was accepted (W5)
setExpanded(true);
clearTimeout(intentTimerRef.current);
intentTimerRef.current = setTimeout(() => {
expandIntentRef.current = false;
}, 2000);
void Promise.resolve(scrollToBottom()).finally(() => {
return Promise.resolve(scrollToBottom()).finally(() => {
clearTimeout(intentTimerRef.current);
expandIntentRef.current = false;
});
};
const expandAndReveal = () => {
setRevealed(true); // the teach-check invitation was accepted (W5)
setExpanded(true);
void suppressFoldDuringReveal();
};
useEffect(() => {
const el = scrollRef.current;
+42 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { revealArrival } from "../components/chat.tsx";
import { probeArrivalExpanded, revealArrival, shouldStampGeometryMute } from "../components/chat.tsx";
/**
* Teach-question arrival states (owner ruling, scroll-honesty round
@@ -10,6 +10,31 @@ import { revealArrival } from "../components/chat.tsx";
* height permanently (fold immunity refused the give-back), and the
* transcript scrolled as if the collapsed question had opened.
*/
/**
* The geometry-mute's growth rule (flicker round, 2026-09-26): reserve
* GROWTH while a question is pending is the dock stretching itself (the
* dictation flush landing many dictated lines at once, reproduced d≈152 >
* COLLAPSE_DIST) — the spacer's scroll catch-up must not read as the
* learner leaving. Shrinks are the fold's own give-back and never stamp.
*/
describe("shouldStampGeometryMute — when a block resize is our own stretch", () => {
it("growth while a question is pending stamps (dictation flush, arrival stretch)", () => {
assert.equal(shouldStampGeometryMute(545, 393, true), true);
});
it("sub-pixel growth does not stamp (rounding noise)", () => {
assert.equal(shouldStampGeometryMute(394, 393, true), false);
});
it("shrink never stamps — the fold's own morph must not self-mute the re-expand", () => {
assert.equal(shouldStampGeometryMute(40, 545, true), false);
});
it("no growth stamps without a pending question (composer life)", () => {
assert.equal(shouldStampGeometryMute(545, 393, false), false);
});
});
describe("revealArrival — how a teach question lands", () => {
it("fresh arrival after the composer: collapsed behind the reveal button", () => {
assert.deepEqual(revealArrival(false), { expanded: false, revealed: false });
@@ -18,4 +43,20 @@ describe("revealArrival — how a teach question lands", () => {
it("queued behind another question: open — the reading already happened", () => {
assert.deepEqual(revealArrival(true), { expanded: true, revealed: true });
});
});
/**
* Probe-question arrival state (flicker round, 2026-09-26): open iff the
* learner is following at the bottom; scrolled up in history → arrive as
* the collapsed bar, decided DURING RENDER (the old after-paint effect
* painted one stale-open frame first).
*/
describe("probeArrivalExpanded — how a probe question lands", () => {
it("at the bottom following along: open", () => {
assert.equal(probeArrivalExpanded(true), true);
});
it("scrolled up in history: arrive already-collapsed (owner ruling)", () => {
assert.equal(probeArrivalExpanded(false), false);
});
});