Marginalia resize round (owner): graceful shrink + phantom-fold mute

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.
This commit is contained in:
amosblomqvist
2026-09-14 11:39:57 +03:00
parent 93033fac77
commit 38d77204ed
+49 -16
View File
@@ -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 && (
<button
type="button"
data-testid="plan-marginalia"
@@ -1018,7 +1049,9 @@ function DockOverlay({
title="Open the full plan"
style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }}
className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${
expanded ? "pointer-events-auto opacity-100 blur-0" : "pointer-events-none opacity-0 blur-md"
expanded && flankW >= MARGIN_MIN_W
? "pointer-events-auto opacity-100 blur-0"
: "pointer-events-none opacity-0 blur-md"
}`}
>
{plan}