fix(timeline): opacity-gate channel intro until pinned and at top

The header-first flash survived three CSS rewrites because no overflow-container CSS reorder fixes it: a standard scroll container rests at scrollTop 0 during the whole estimate->measure->settle window, so the in-flow intro is painted at the top before the bottom pin (a post-paint layout effect) drives it off-top. tho watches that intermediate frame.

Keep the intro reserving its space (scrollMargin math unchanged) but gate its VISUAL reveal on hasInitialized && isAtTop: hidden on first paint, hidden while pinned off-top, revealed only when the user genuinely reaches the top (or a short channel where top is also bottom). New pure isAtTopMetrics/isAtTop helpers carry unit coverage (jsdom can't catch the layout bug; the threshold math is testable).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 23:49:29 -07:00
co-authored by Taylor Ho
parent 67e39e4635
commit 807c761bca
4 changed files with 92 additions and 3 deletions
@@ -3,7 +3,9 @@ import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
TOP_THRESHOLD_PX,
buildDayGroupBoundaries,
isAtTopMetrics,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
@@ -77,6 +79,20 @@ test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => {
);
});
// --- earned channel-intro header (at-top reveal gate) -------------------------
test("isAtTopMetrics: true when within threshold of the top", () => {
assert.equal(isAtTopMetrics({ scrollTop: 4 }), true);
});
test("isAtTopMetrics: true exactly at the threshold boundary", () => {
assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX }), true);
});
test("isAtTopMetrics: false when scrolled down beyond the threshold", () => {
assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX + 1 }), false);
});
test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => {
assert.equal(selectLatestMessageKey([]), undefined);
assert.equal(
@@ -43,6 +43,32 @@ export function isNearBottom(container: HTMLDivElement): boolean {
});
}
/** Distance (px) from the top within which the timeline counts as "at top". */
export const TOP_THRESHOLD_PX = 8;
/**
* Is the timeline scrolled close enough to the top to count as "at top"?
*
* This gates the channel-intro header's VISUAL reveal. The intro is the
* terminal header of a bottom-anchored list — it must surface only once the
* user has genuinely arrived at the true top, never get painted up front while
* the list is still streaming in from the bottom (a standard overflow container
* rests at scrollTop 0 during the estimate→measure→settle window, so "scrollTop
* is 0" alone is NOT a trustworthy at-top signal until the first-load bottom pin
* has landed). Pure over geometry so the threshold math is unit-testable without
* a DOM — the surrounding flexbox layout is not, jsdom does no layout.
*/
export function isAtTopMetrics(
metrics: Pick<ScrollMetrics, "scrollTop">,
): boolean {
return metrics.scrollTop <= TOP_THRESHOLD_PX;
}
/** Reads live scroll geometry off a container and applies the top-threshold rule. */
export function isAtTop(container: HTMLDivElement): boolean {
return isAtTopMetrics({ scrollTop: container.scrollTop });
}
/**
* Identity of the last message in a snapshot, used to detect "a new latest
* message arrived" for autoscroll. Prefers `renderKey` (stable across optimistic
@@ -252,6 +252,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const {
highlightedMessageId,
introRevealed,
isAtBottom,
newMessageCount,
scrollToBottom,
@@ -419,7 +420,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
aria-hidden={!introRevealed}
className={cn(
"mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left transition-opacity",
// Reserve the intro's space (feeds scrollMargin) but only
// REVEAL it once the bottom pin has landed AND we're at the
// genuine top — never painted up front while the list
// streams in from the bottom.
introRevealed ? "opacity-100" : "opacity-0",
)}
data-testid="message-channel-intro"
>
<div
@@ -6,6 +6,7 @@ import {
type VirtualTimelineRow,
} from "@/features/messages/lib/buildVirtualTimelineRows";
import {
isAtTop,
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageKey,
@@ -78,6 +79,14 @@ export function useVirtualTimelineScroll({
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
// Drives the channel-intro header's VISUAL reveal. The intro is the terminal
// header of a bottom-anchored list: it reserves its space (scrollMargin), but
// must only become VISIBLE once the first-load bottom pin has landed AND the
// user has genuinely arrived at the true top — never painted up front while
// the list streams in from the bottom. A standard overflow container rests at
// scrollTop 0 during the estimate→measure→settle window, so we gate on
// `hasInitialized && isAtTop`, not "scrollTop is 0" alone.
const [introRevealed, setIntroRevealed] = React.useState(false);
const lastRowIndex = rows.length - 1;
@@ -110,8 +119,19 @@ export function useVirtualTimelineScroll({
setIsAtBottom(true);
setNewMessageCount(0);
setHighlightedMessageId(null);
setIntroRevealed(false);
}, [channelId]);
// Recompute whether the channel intro should be visible: only once the
// first-load pin has landed (`hasInitialized`) AND the container is genuinely
// at the top. Cheap geometry read, only flips state on a real change.
const syncIntroRevealed = React.useCallback(() => {
const container = scrollContainerRef.current;
const revealed =
hasInitializedRef.current && container !== null && isAtTop(container);
setIntroRevealed((current) => (current === revealed ? current : revealed));
}, [scrollContainerRef]);
// Track bottom-pinned state off the native scroll event. The virtualizer owns
// the scrollTop; we only read it to decide whether to keep auto-following.
const syncScrollState = React.useCallback(() => {
@@ -125,7 +145,8 @@ export function useVirtualTimelineScroll({
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef]);
syncIntroRevealed();
}, [scrollContainerRef, syncIntroRevealed]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
@@ -152,6 +173,10 @@ export function useVirtualTimelineScroll({
hasInitializedRef.current = true;
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
// The first-load pin just landed: recompute reveal so a short channel
// (everything fits → top is genuinely also the bottom) surfaces its intro,
// while a long channel pinned off-top stays hidden.
syncIntroRevealed();
return;
}
@@ -191,6 +216,7 @@ export function useVirtualTimelineScroll({
messages.length,
scrollMarginReady,
scrollToBottom,
syncIntroRevealed,
targetMessageId,
]);
@@ -219,7 +245,18 @@ export function useVirtualTimelineScroll({
}
lastPinnedTotalSizeRef.current = totalSize;
virtualizer.scrollToIndex(lastRowIndex, { align: "end" });
}, [totalSize, isLoading, targetMessageId, lastRowIndex, virtualizer]);
// Re-anchor changed the scroll position: recompute reveal so the intro
// tracks the new resting place (stays hidden off-top, surfaces only if the
// grown content still leaves us genuinely at the top).
syncIntroRevealed();
}, [
totalSize,
isLoading,
targetMessageId,
lastRowIndex,
syncIntroRevealed,
virtualizer,
]);
// Deep-link jump-to-message. Drives the virtualizer to mount and center the
// target row, replacing the bespoke querySelector + scrollIntoView path that
@@ -303,6 +340,7 @@ export function useVirtualTimelineScroll({
return {
highlightedMessageId,
introRevealed,
isAtBottom,
newMessageCount,
scrollToBottom,