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:
+73
-18
@@ -1330,6 +1330,33 @@ export function revealArrival(wasQuestion: boolean): { expanded: boolean; reveal
|
|||||||
return wasQuestion ? { expanded: true, revealed: true } : { expanded: false, revealed: false };
|
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({
|
function DockOverlay({
|
||||||
pending,
|
pending,
|
||||||
requiresReveal,
|
requiresReveal,
|
||||||
@@ -1375,6 +1402,11 @@ function DockOverlay({
|
|||||||
setupCta?: React.ReactNode;
|
setupCta?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
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
|
// 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.
|
// as the collapsed invitation too — the check must not pop open on reload.
|
||||||
const [expanded, setExpanded] = useState(!requiresReveal);
|
const [expanded, setExpanded] = useState(!requiresReveal);
|
||||||
@@ -1405,20 +1437,24 @@ function DockOverlay({
|
|||||||
const arrival = revealArrival(wasQuestion);
|
const arrival = revealArrival(wasQuestion);
|
||||||
setExpanded(arrival.expanded);
|
setExpanded(arrival.expanded);
|
||||||
setRevealed(arrival.revealed);
|
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
|
// New question MID-SESSION (flicker round): the open/closed state itself
|
||||||
// the bottom (scroll to reveal it); reading history → arrive as the bar.
|
// is decided DURING RENDER above; this effect only owns the reveal
|
||||||
// Teach-phase arrivals are handled above, during render. On session
|
// scroll for the at-bottom arrival — and now under the SAME fold
|
||||||
// mount the dock simply starts in its initial state (expanded for
|
// suppression the teach reveal uses (the arrival's growth used to
|
||||||
// probe, invitation for teach) — no scroll, no morph.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!pendingId || pendingId === initialPendingRef.current) return;
|
if (!pendingId || pendingId === initialPendingRef.current) return;
|
||||||
if (!requiresReveal) {
|
if (!requiresReveal && atBottomRef.current) void suppressFoldDuringReveal();
|
||||||
setExpanded(isAtBottom);
|
|
||||||
if (isAtBottom) void scrollToBottom();
|
|
||||||
}
|
|
||||||
// fire only when a NEW question lands
|
// fire only when a NEW question lands
|
||||||
}, [pendingId]);
|
}, [pendingId]);
|
||||||
|
|
||||||
@@ -1449,17 +1485,18 @@ function DockOverlay({
|
|||||||
// stamps this — the fold machine ignores scroll events for a beat after,
|
// stamps this — the fold machine ignores scroll events for a beat after,
|
||||||
// because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll
|
// because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll
|
||||||
// events mid-transient (the box flickered to the bar and back).
|
// 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);
|
const geometryAtRef = useRef(0);
|
||||||
useIsoLayoutEffect(() => {
|
useIsoLayoutEffect(() => {
|
||||||
const block = blockRef.current;
|
const block = blockRef.current;
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP);
|
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) {
|
if (Math.abs(next - flankWRef.current) > 1) {
|
||||||
flankWRef.current = next;
|
flankWRef.current = next;
|
||||||
setFlankW(next);
|
setFlankW(next);
|
||||||
@@ -1524,6 +1561,17 @@ function DockOverlay({
|
|||||||
const offer = (blockH: number) => {
|
const offer = (blockH: number) => {
|
||||||
const next = blockH + GRADIENT_H;
|
const next = blockH + GRADIENT_H;
|
||||||
if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity
|
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;
|
reserveRef.current = next;
|
||||||
onReserve(next);
|
onReserve(next);
|
||||||
};
|
};
|
||||||
@@ -1533,21 +1581,28 @@ function DockOverlay({
|
|||||||
// the collapse zone — the machine would instantly re-collapse the dock
|
// the collapse zone — the machine would instantly re-collapse the dock
|
||||||
// (open → snap shut → reopen at the bottom, the flicker). Suppress the
|
// (open → snap shut → reopen at the bottom, the flicker). Suppress the
|
||||||
// collapse branch until the reveal scroll settles or is interrupted.
|
// 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 expandIntentRef = useRef(false);
|
||||||
const intentTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const intentTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const expandAndReveal = () => {
|
const suppressFoldDuringReveal = () => {
|
||||||
expandIntentRef.current = true;
|
expandIntentRef.current = true;
|
||||||
setRevealed(true); // the teach-check invitation was accepted (W5)
|
|
||||||
setExpanded(true);
|
|
||||||
clearTimeout(intentTimerRef.current);
|
clearTimeout(intentTimerRef.current);
|
||||||
intentTimerRef.current = setTimeout(() => {
|
intentTimerRef.current = setTimeout(() => {
|
||||||
expandIntentRef.current = false;
|
expandIntentRef.current = false;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
void Promise.resolve(scrollToBottom()).finally(() => {
|
return Promise.resolve(scrollToBottom()).finally(() => {
|
||||||
clearTimeout(intentTimerRef.current);
|
clearTimeout(intentTimerRef.current);
|
||||||
expandIntentRef.current = false;
|
expandIntentRef.current = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const expandAndReveal = () => {
|
||||||
|
setRevealed(true); // the teach-check invitation was accepted (W5)
|
||||||
|
setExpanded(true);
|
||||||
|
void suppressFoldDuringReveal();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
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
|
* 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
|
* height permanently (fold immunity refused the give-back), and the
|
||||||
* transcript scrolled as if the collapsed question had opened.
|
* 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", () => {
|
describe("revealArrival — how a teach question lands", () => {
|
||||||
it("fresh arrival after the composer: collapsed behind the reveal button", () => {
|
it("fresh arrival after the composer: collapsed behind the reveal button", () => {
|
||||||
assert.deepEqual(revealArrival(false), { expanded: false, revealed: false });
|
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", () => {
|
it("queued behind another question: open — the reading already happened", () => {
|
||||||
assert.deepEqual(revealArrival(true), { expanded: true, revealed: true });
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user