From 38d77204edf0016cb2e619473416d1ce57ef6bdc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 11:39:57 +0300 Subject: [PATCH] Marginalia resize round (owner): graceful shrink + phantom-fold mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the owner's resize report: - Continuous shrink: the marginalia now mounts whenever a question owns the band and its box width is pure CSS, so it shrinks continuously as the display narrows. Below MARGIN_MIN_W (220, the owner's floor) it FADES over the normal 300ms transition instead of hard-unmounting; widening past the floor fades it back in. No mid-question unmount, no pop, no invisible limbo. - Live flank tracking: a ResizeObserver on the dock block replaces the render-time measurement — the fade floor reacts instantly at any width (window resize, sidebar toggle, scrollbar changes). The old measurement went stale during drag-resizes (nothing re-rendered on macOS until the question rewrapped), so the mount decision popped late and left a sub-floor sliver visible. - Phantom-fold mute: resizing rewraps the question text, the browser re-clamps scrollTop and emits the clamp as a scroll event — the fold machine read the transient position and flickered the box to the bar and back. Scroll events within 400ms of a WIDTH change are now ignored (width-only: stamping the dock's own height morphs self-muted the machine and swallowed the at-bottom auto re-expand, leaving the box stuck collapsed — caught in live verification). Scroll-up/scroll-down fold behavior itself unchanged (verified live: mute-window scroll ignored, post-mute scroll folds, bottom re-expand works). Gates: typecheck, lint, 423/423 tests. --- components/chat.tsx | 65 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index d338368..8606b35 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -44,11 +44,14 @@ const DOCK = "mx-auto w-full max-w-[46rem] px-4"; // The DOCK container's fixed width (46rem) and the plan marginalia's // geometry (owner, plan-placement round): the bare diagram sits in the // dock band's RIGHT flank — MARGIN_GAP from the DOCK container's edge and -// the same from the column's right edge. Rendered only when the flank -// holds a real diagram (no slivers); otherwise the top-bar chip alone. +// the same from the column's right edge. The box width is pure CSS, so +// the diagram shrinks CONTINUOUSLY with the flank; MARGIN_MIN_W is only +// the fade floor (owner, resize round) — below it the marginalia fades +// out over the normal transition instead of popping away, and it stays +// mounted while the question owns the band so widening fades it back. const DOCK_W = 46 * 16; const MARGIN_GAP = 16; -const MARGIN_MIN_W = 240; +const MARGIN_MIN_W = 220; // The dock overlay's gradient band (h-24) — part of the reserved tail. const GRADIENT_H = 96; // Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance @@ -810,21 +813,40 @@ function DockOverlay({ // The plan marginalia's flank width (owner, plan-placement round): from // the DOCK container's right edge to the block's right edge — the bare - // diagram's canvas. Re-measured every render (cheap offsetWidth read): - // window resizes flow through sbInset's listener, and the sidebar toggle - // re-renders the column without any event at all. Below MARGIN_MIN_W the - // margin is no margin — the chip alone carries the plan. + // diagram's canvas. Tracked LIVE by a ResizeObserver on the block + // (owner, resize round): every geometry change — window resize, sidebar + // toggle, scrollbar appearance, the box's own rewrap — reports the real + // width instantly, so the fade floor never lags behind reality (the old + // render-time measurement went stale during drag-resizes and the mount + // decision popped late). const [flankW, setFlankW] = useState(0); const flankWRef = useRef(0); + // Part A of the same round (the resize mute): any block size change + // 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). + const geometryAtRef = useRef(0); useIsoLayoutEffect(() => { const block = blockRef.current; if (!block) return; - const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP); - if (Math.abs(next - flankWRef.current) > 1) { - flankWRef.current = next; - setFlankW(next); - } - }); + 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); + geometryAtRef.current = Date.now(); + } + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(block); + return () => ro.disconnect(); + }, []); // The overlay must not paint over the scroller's scrollbar (owner, scroll // round 2): the scroller reserves symmetric gutters (scrollbar-gutter: @@ -908,6 +930,11 @@ function DockOverlay({ if (!el) return; const onScroll = () => { if (!pendingRef.current) return; + // Resize mute (owner, resize round): a geometry change re-clamps + // scrollTop and the browser emits the clamp as a scroll event — + // reading the transient position folded the box to the bar for a + // flicker. Real scrolling resumes after the brief mute window. + if (Date.now() - geometryAtRef.current < 400) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; if (expandedRef.current) { if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); @@ -1009,8 +1036,12 @@ function DockOverlay({ of the lesson's page rather than a floating widget. Mounted only while a question owns the band (its reserved height is the canvas); folding the dock fades + blurs it out of existence, so reading - the transcript in full wipes the margin clean. Click → popup. */} - {pending && plan && flankW >= MARGIN_MIN_W && ( + the transcript in full wipes the margin clean. Click → popup. + Width (owner, resize round): the box shrinks continuously with + the flank; below MARGIN_MIN_W it FADES (never pops away — no + mid-question unmount), and widening past the floor fades it + back in. */} + {pending && plan && (