fix: finish measured dock sizing and short-window scrolling
This commit is contained in:
+46
-79
@@ -26,6 +26,7 @@ import { Composer } from "@/components/composer";
|
|||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||||
import { dockStateKey } from "@/components/dock-state-key";
|
import { dockStateKey } from "@/components/dock-state-key";
|
||||||
|
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
|
||||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||||
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
||||||
import { useFieldCycle } from "@/components/field-cycle";
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
@@ -1972,6 +1973,17 @@ function DockOverlay({
|
|||||||
const revealedRef = useRef(revealed);
|
const revealedRef = useRef(revealed);
|
||||||
revealedRef.current = revealed;
|
revealedRef.current = revealed;
|
||||||
|
|
||||||
|
const blockRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mode = pending
|
||||||
|
? `question:${pending.toolCallId}`
|
||||||
|
: practice
|
||||||
|
? `practice:${practice.toolCallId}`
|
||||||
|
: "chat";
|
||||||
|
|
||||||
// The plan marginalia's flank width (owner, plan-placement round): from
|
// 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
|
// the DOCK container's right edge to the block's right edge — the bare
|
||||||
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
||||||
@@ -2010,40 +2022,33 @@ function DockOverlay({
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The reserve grows immediately, but never shrinks while a question or
|
||||||
|
// practice problem owns the dock: shrinking flow space could pin a nearby
|
||||||
|
// learner back to the bottom. Composer mode may give space back.
|
||||||
|
const reserveRef = useRef(0);
|
||||||
|
const offer = useCallback((blockH: number) => {
|
||||||
|
const next = blockH + GRADIENT_H;
|
||||||
|
if (next <= reserveRef.current + 0.5 && pendingRef.current) return;
|
||||||
|
// Stamp growth at the source, including child-only field resizes, so
|
||||||
|
// stick-to-bottom's catch-up is not mistaken for the learner leaving.
|
||||||
|
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
|
||||||
|
reserveRef.current = next;
|
||||||
|
onReserve(next);
|
||||||
|
}, [onReserve]);
|
||||||
|
|
||||||
// The overlay must not paint over the scroller's scrollbar (owner, scroll
|
// The overlay must not paint over the scroller's scrollbar (owner, scroll
|
||||||
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
||||||
// stable both-edges), so inset the overlay's right edge by the gutter —
|
// stable both-edges), so inset the overlay's right edge by the gutter —
|
||||||
// zero on macOS overlay scrollbars, the real width on classic platforms.
|
// zero on macOS overlay scrollbars, the real width on classic platforms.
|
||||||
// The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round,
|
// Budget the whole solid dock, leaving only its question area flexible.
|
||||||
// 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems
|
// The hook measures natural, visible content before the morph/reserve
|
||||||
// off absurdly short — the cap is now the AVAILABLE COLUMN (the
|
// effect below. Short windows scroll the reading/question/answer body;
|
||||||
// scroller already starts below the top bar) minus a 2rem breathing
|
// the stats/model toolbar remains outside that overflow viewport.
|
||||||
// gap, carried as --dock-max-h for the docks' scroll areas.
|
|
||||||
const [sbInset, setSbInset] = useState(0);
|
const [sbInset, setSbInset] = useState(0);
|
||||||
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
|
useDockHeight({
|
||||||
useEffect(() => {
|
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
const el = scrollRef.current;
|
expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer,
|
||||||
if (!el) return;
|
});
|
||||||
const measure = () => {
|
|
||||||
setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
|
|
||||||
const block = blockRef.current;
|
|
||||||
const question = block?.querySelector<HTMLElement>("[data-dock-scroll-area]");
|
|
||||||
// The height budget belongs to the whole dock, including answer
|
|
||||||
// controls, stats, and padding. Only the question may consume the
|
|
||||||
// remaining space; otherwise the overlay grows behind the header.
|
|
||||||
const chrome = block && question ? block.offsetHeight - question.offsetHeight : 0;
|
|
||||||
setDockMaxH(Math.max(el.clientHeight - 32 - chrome, 0));
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(el);
|
|
||||||
if (blockRef.current) observer.observe(blockRef.current);
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
window.removeEventListener("resize", measure);
|
|
||||||
};
|
|
||||||
}, [scrollRef]);
|
|
||||||
|
|
||||||
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
|
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
|
||||||
// live turn the transcript content grows and stick-to-bottom chases it;
|
// live turn the transcript content grows and stick-to-bottom chases it;
|
||||||
@@ -2079,48 +2084,11 @@ function DockOverlay({
|
|||||||
const root = blockRef.current;
|
const root = blockRef.current;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!root || !el) return;
|
if (!root || !el) return;
|
||||||
const canScroll = (n: HTMLElement, dir: number) => {
|
const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el);
|
||||||
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
|
|
||||||
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
|
|
||||||
};
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (e.deltaY === 0) return;
|
|
||||||
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
|
|
||||||
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
|
|
||||||
};
|
|
||||||
root.addEventListener("wheel", onWheel, { passive: false });
|
root.addEventListener("wheel", onWheel, { passive: false });
|
||||||
return () => root.removeEventListener("wheel", onWheel);
|
return () => root.removeEventListener("wheel", onWheel);
|
||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
// ── The reserve: the spacer's height, reported upward ──
|
|
||||||
// Grows immediately (typing, new variants). NEVER drops while a question
|
|
||||||
// is open — a shrinking reserved tail is scroll geometry and the browser
|
|
||||||
// clamp would pin a nearby learner to the bottom (the old oscillation).
|
|
||||||
// In chat mode drops apply (the composer's typing shrink is today's
|
|
||||||
// harmless in-flow behavior; nothing auto-expands there).
|
|
||||||
const reserveRef = useRef(0);
|
|
||||||
const blockRef = useRef<HTMLDivElement>(null);
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Explicit expand (bar click): the smooth scroll to the bottom takes
|
// Explicit expand (bar click): the smooth scroll to the bottom takes
|
||||||
// hundreds of ms, during which the position is still legitimately inside
|
// hundreds of ms, during which the position is still legitimately inside
|
||||||
// the collapse zone — the machine would instantly re-collapse the dock
|
// the collapse zone — the machine would instantly re-collapse the dock
|
||||||
@@ -2222,11 +2190,6 @@ function DockOverlay({
|
|||||||
if (b) offer(b.getBoundingClientRect().height);
|
if (b) offer(b.getBoundingClientRect().height);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mode = pending
|
|
||||||
? `question:${pending.toolCallId}`
|
|
||||||
: practice
|
|
||||||
? `practice:${practice.toolCallId}`
|
|
||||||
: "chat";
|
|
||||||
// The opening variant of a session appears instantly — the fade/rise is
|
// The opening variant of a session appears instantly — the fade/rise is
|
||||||
// reserved for mid-session transitions.
|
// reserved for mid-session transitions.
|
||||||
const initialModeRef = useRef(mode);
|
const initialModeRef = useRef(mode);
|
||||||
@@ -2234,11 +2197,9 @@ function DockOverlay({
|
|||||||
// Height morph: animate the shell from its previous height to its new one
|
// Height morph: animate the shell from its previous height to its new one
|
||||||
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
||||||
// growth keeps the same stateKey and is NOT animated.
|
// growth keeps the same stateKey and is NOT animated.
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
|
||||||
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height
|
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height
|
||||||
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
|
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
|
||||||
// on the toggle — no remount, so the question form's state survives.
|
// on the toggle — no remount, so the question form's state survives.
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const prevExpandedForFadeRef = useRef(expanded);
|
const prevExpandedForFadeRef = useRef(expanded);
|
||||||
useIsoLayoutEffect(() => {
|
useIsoLayoutEffect(() => {
|
||||||
if (prevExpandedForFadeRef.current === expanded) return;
|
if (prevExpandedForFadeRef.current === expanded) return;
|
||||||
@@ -2286,8 +2247,10 @@ function DockOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
||||||
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div ref={blockRef} className="relative bg-background pb-5" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}>
|
<div ref={blockRef} className="relative bg-background pb-5">
|
||||||
<div className={DOCK}>
|
<div className={DOCK}>
|
||||||
|
<div ref={viewportRef} data-dock-viewport="" className="pointer-events-auto">
|
||||||
|
<div ref={bodyRef}>
|
||||||
{setupCta}
|
{setupCta}
|
||||||
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
||||||
one-liners above the shell - right-aligned to where its corner
|
one-liners above the shell - right-aligned to where its corner
|
||||||
@@ -2316,11 +2279,13 @@ function DockOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||||
|
{/* Include descendant margins (notably practice thinking's
|
||||||
|
bottom margin) in the natural content measurement. */}
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
key={mode}
|
key={mode}
|
||||||
className={mode === initialModeRef.current ? undefined : "dock-variant"}
|
className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"}
|
||||||
>
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<QuestionDock
|
<QuestionDock
|
||||||
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
||||||
@@ -2355,6 +2320,8 @@ function DockOverlay({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* The stats row: beneath the composer/question shell, not
|
{/* The stats row: beneath the composer/question shell, not
|
||||||
flush to the screen bottom — static height, outside the
|
flush to the screen bottom — static height, outside the
|
||||||
morphing shell so it never joins a height animation. */}
|
morphing shell so it never joins a height animation. */}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react";
|
||||||
|
|
||||||
|
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export interface DockHeightMeasurements {
|
||||||
|
columnHeight: number;
|
||||||
|
/** Stats/toolbar and block padding, outside the scrolling viewport. */
|
||||||
|
outsideHeight: number;
|
||||||
|
/** Everything in the viewport except the animated shell. */
|
||||||
|
beforeShellHeight: number;
|
||||||
|
shellBorderHeight: number;
|
||||||
|
/** Natural content height, not the shell's animated/clamped height. */
|
||||||
|
contentHeight: number;
|
||||||
|
/** Null when collapsed, in chat mode, or showing malformed input. */
|
||||||
|
questionHeight: number | null;
|
||||||
|
questionPaddingHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dockHeightBudget(m: DockHeightMeasurements) {
|
||||||
|
const available = Math.max(m.columnHeight - 32, 0);
|
||||||
|
const viewportMaxHeight = Math.max(available - m.outsideHeight, 0);
|
||||||
|
const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0);
|
||||||
|
const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight;
|
||||||
|
const questionMaxHeight = m.questionHeight === null
|
||||||
|
? null
|
||||||
|
: Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0);
|
||||||
|
// A cap smaller than the question's padding leaves no content viewport.
|
||||||
|
// Deliberately uncap the question and scroll the whole content instead;
|
||||||
|
// do not confuse a measured zero with an unmeasured CSS fallback.
|
||||||
|
const scrollWholeContent = questionMaxHeight === null
|
||||||
|
? naturalHeight > viewportMaxHeight
|
||||||
|
: questionMaxHeight <= m.questionPaddingHeight;
|
||||||
|
return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockHeightElements {
|
||||||
|
column: HTMLElement;
|
||||||
|
block: HTMLElement;
|
||||||
|
viewport: HTMLElement;
|
||||||
|
body: HTMLElement;
|
||||||
|
shell: HTMLElement;
|
||||||
|
content: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixels = (value: string) => Number.parseFloat(value) || 0;
|
||||||
|
|
||||||
|
/** Read natural geometry before writing any constraints. Cancel the shell's
|
||||||
|
* animated height out of both ancestor measurements, then substitute its
|
||||||
|
* natural content and borders. This stays valid during a WAAPI height morph
|
||||||
|
* and when the outer viewport is already capped. */
|
||||||
|
export function syncDockHeight(elements: DockHeightElements, expanded: boolean) {
|
||||||
|
const { column, block, viewport, body, shell, content } = elements;
|
||||||
|
// QuestionDock keeps a laid-out form in an invisible h-0 wrapper;
|
||||||
|
// PracticeDock removes its form. Neither hidden form is a height input.
|
||||||
|
const question = expanded ? content.querySelector<HTMLElement>("[data-dock-scroll-area]") : null;
|
||||||
|
const height = (el: HTMLElement) => el.getBoundingClientRect().height;
|
||||||
|
const shellStyle = getComputedStyle(shell);
|
||||||
|
const questionStyle = question ? getComputedStyle(question) : null;
|
||||||
|
const budget = dockHeightBudget({
|
||||||
|
columnHeight: column.clientHeight,
|
||||||
|
outsideHeight: Math.max(height(block) - height(viewport), 0),
|
||||||
|
beforeShellHeight: Math.max(height(body) - height(shell), 0),
|
||||||
|
shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth),
|
||||||
|
contentHeight: height(content),
|
||||||
|
questionHeight: question ? height(question) : null,
|
||||||
|
questionPaddingHeight: questionStyle
|
||||||
|
? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom)
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h");
|
||||||
|
else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`);
|
||||||
|
viewport.style.maxHeight = `${budget.viewportMaxHeight}px`;
|
||||||
|
// Overflow is visible in normal layouts: keep the shell's shadow intact.
|
||||||
|
// The stats/model toolbar is a sibling, never inside this overflow box.
|
||||||
|
viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible";
|
||||||
|
// Clamp animated starting heights too, so a previous tall variant cannot
|
||||||
|
// paint above the column while morphing into a constrained layout.
|
||||||
|
shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`;
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockHeightRefs {
|
||||||
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
|
blockRef: RefObject<HTMLDivElement | null>;
|
||||||
|
viewportRef: RefObject<HTMLDivElement | null>;
|
||||||
|
bodyRef: RefObject<HTMLDivElement | null>;
|
||||||
|
shellRef: RefObject<HTMLDivElement | null>;
|
||||||
|
contentRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDockHeight({
|
||||||
|
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
|
expanded, enabled, mode, onScrollbarInset, onHeight,
|
||||||
|
}: DockHeightRefs & {
|
||||||
|
expanded: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
/** Reconnect observers when the keyed content node/scroll area changes. */
|
||||||
|
mode: string;
|
||||||
|
onScrollbarInset: (inset: number) => void;
|
||||||
|
/** Settled visible block height, fed through the existing reserve policy. */
|
||||||
|
onHeight: (height: number) => void;
|
||||||
|
}) {
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const column = scrollRef.current;
|
||||||
|
const block = blockRef.current;
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const body = bodyRef.current;
|
||||||
|
const shell = shellRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!column || !block || !viewport || !body || !shell || !content) return;
|
||||||
|
onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2));
|
||||||
|
if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded);
|
||||||
|
else {
|
||||||
|
block.style.removeProperty("--dock-max-h");
|
||||||
|
viewport.style.removeProperty("max-height");
|
||||||
|
viewport.style.removeProperty("overflow-y");
|
||||||
|
shell.style.removeProperty("max-height");
|
||||||
|
}
|
||||||
|
// ResizeObserver runs even when only a child field changed and React
|
||||||
|
// did not rerender DockOverlay. Keep its transcript reserve current,
|
||||||
|
// but never report a mid-morph height.
|
||||||
|
if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height);
|
||||||
|
}, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]);
|
||||||
|
|
||||||
|
// Imperative constraints land before the overlay's later layout effect
|
||||||
|
// measures its morph target/reserve, and before paint (including reveal).
|
||||||
|
useIsoLayoutEffect(measure);
|
||||||
|
useIsoLayoutEffect(() => {
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) {
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
}
|
||||||
|
const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null;
|
||||||
|
if (question) observer.observe(question);
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
};
|
||||||
|
}, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlay and transcript are siblings. Keep native scrolling in an actual
|
||||||
|
* scroll container (including the short-window viewport), then forward only
|
||||||
|
* at its edge. overflow:hidden/visible boxes cannot consume the wheel. */
|
||||||
|
export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) {
|
||||||
|
if (event.deltaY === 0) return;
|
||||||
|
for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
|
||||||
|
if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue;
|
||||||
|
if (n.scrollHeight - n.clientHeight < 2) continue;
|
||||||
|
if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import React, { act, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { QuestionDock } from "../components/question-dock.tsx";
|
||||||
|
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||||
|
|
||||||
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
});
|
||||||
|
const observers: TestObserver[] = [];
|
||||||
|
class TestObserver {
|
||||||
|
elements = new Set<Element>();
|
||||||
|
disconnected = false;
|
||||||
|
constructor(readonly callback: () => void) { observers.push(this); }
|
||||||
|
observe(el: Element) { this.elements.add(el); }
|
||||||
|
disconnect() { this.disconnected = true; this.elements.clear(); }
|
||||||
|
}
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
ResizeObserver: TestObserver,
|
||||||
|
getComputedStyle: (el: HTMLElement) => ({
|
||||||
|
borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px",
|
||||||
|
borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px",
|
||||||
|
paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Import after installing window so the hook uses its browser layout effect.
|
||||||
|
const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts");
|
||||||
|
|
||||||
|
const normal = {
|
||||||
|
columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0,
|
||||||
|
shellBorderHeight: 2, contentHeight: 800, questionHeight: 600,
|
||||||
|
questionPaddingHeight: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("dockHeightBudget — whole dock, not just its question", () => {
|
||||||
|
it("subtracts answer controls, toolbar, borders and breathing gap", () => {
|
||||||
|
assert.deepEqual(dockHeightBudget(normal), {
|
||||||
|
viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("accounts for growing answer fields and the separate reading draft", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 });
|
||||||
|
assert.equal(result.questionMaxHeight, 156);
|
||||||
|
assert.equal(result.shellMaxHeight, 438);
|
||||||
|
});
|
||||||
|
it("preserves a computed zero and deliberately selects whole-content scrolling", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, columnHeight: 292 });
|
||||||
|
assert.equal(result.questionMaxHeight, 0);
|
||||||
|
assert.equal(result.scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => {
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true);
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false);
|
||||||
|
});
|
||||||
|
it("never creates negative caps, even if the toolbar exceeds the column", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, columnHeight: 20 });
|
||||||
|
assert.equal(result.viewportMaxHeight, 0);
|
||||||
|
assert.equal(result.shellMaxHeight, 0);
|
||||||
|
assert.equal(result.questionMaxHeight, 0);
|
||||||
|
assert.equal(result.scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => {
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false);
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout.
|
||||||
|
// Live-browser checks separately exercise actual field sizing and WAAPI.
|
||||||
|
const sizes: Record<string, number> = {};
|
||||||
|
let animating = false;
|
||||||
|
dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : [];
|
||||||
|
const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 });
|
||||||
|
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||||
|
dom.window.HTMLElement.prototype.getBoundingClientRect = function () {
|
||||||
|
const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part;
|
||||||
|
if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} };
|
||||||
|
return originalRect.call(this);
|
||||||
|
};
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } });
|
||||||
|
|
||||||
|
const ignoreHeight = () => {};
|
||||||
|
function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: {
|
||||||
|
expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void;
|
||||||
|
onHeight?: (height: number) => void;
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const blockRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [inset, setInset] = useState(0);
|
||||||
|
useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
|
expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight });
|
||||||
|
// Like DockOverlay's later morph/reserve layout effect: constraints must
|
||||||
|
// already be applied in this same commit, not an after-paint correction.
|
||||||
|
useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); });
|
||||||
|
return <><div ref={scrollRef} data-part="column" /><div ref={blockRef} data-part="block" data-inset={inset}>
|
||||||
|
<div ref={viewportRef} data-part="viewport"><div ref={bodyRef} data-part="body">
|
||||||
|
<div ref={shellRef} data-part="shell"><div ref={contentRef} data-part="content" key={practice ? "practice" : "question"}>
|
||||||
|
{practice ? <PracticeDock problemCallId="p1" input={{ statement: "Long practice problem" }} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
|
||||||
|
: <QuestionDock toolName="probe" toolCallId="q1" input={{ question: "Long teaching question", options: [{ label: "A" }, { label: "B" }], correctAnswer: "A", explanation: "A is correct." }} expanded={expanded} dictationClaim={false} onExpand={() => {}} onSubmit={() => {}} />}
|
||||||
|
</div></div>
|
||||||
|
</div></div><div data-testid="toolbar" />
|
||||||
|
</div></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps<typeof Fixture>) => Promise<void>) => Promise<void>) {
|
||||||
|
resetSizes();
|
||||||
|
const host = document.createElement("div"); document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const render = async (props = {}) => { await act(async () => root.render(<Fixture {...props} />)); };
|
||||||
|
try { await run(host, render); }
|
||||||
|
finally { await act(async () => root.unmount()); host.remove(); }
|
||||||
|
}
|
||||||
|
const part = (host: HTMLElement, name: string) => host.querySelector<HTMLElement>(`[data-part="${name}"]`)!;
|
||||||
|
|
||||||
|
describe("measured question/practice DOM and observer lifecycle", () => {
|
||||||
|
it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
|
||||||
|
assert.equal(part(host, "block").dataset.inset, "10");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 });
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 });
|
||||||
|
await render({ expanded: false });
|
||||||
|
assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted");
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
resetSizes();
|
||||||
|
await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("reconnects to practice's newly mounted question after collapsing and revealing", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 });
|
||||||
|
await render({ practice: true, expanded: false });
|
||||||
|
const collapsedObserver = observers.at(-1)!;
|
||||||
|
assert.equal(host.querySelector('[data-dock-scroll-area]'), null);
|
||||||
|
resetSizes();
|
||||||
|
await render({ practice: true, expanded: true });
|
||||||
|
assert.equal(collapsedObserver.disconnected, true);
|
||||||
|
assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!));
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
sizes.column = 292;
|
||||||
|
await render();
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "auto");
|
||||||
|
assert.equal(part(host, "viewport").style.maxHeight, "200px");
|
||||||
|
assert.equal(part(host, "shell").style.maxHeight, "none");
|
||||||
|
sizes.column = 700;
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("removes constraints in composer mode and disconnects observers on unmount", async () => {
|
||||||
|
let last: TestObserver;
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
await render({ enabled: false });
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
|
||||||
|
assert.equal(part(host, "shell").style.maxHeight || "", "");
|
||||||
|
last = observers.at(-1)!;
|
||||||
|
});
|
||||||
|
assert.equal(last!.disconnected, true);
|
||||||
|
});
|
||||||
|
it("reports child-only resize heights to the reserve, but never mid-morph values", async () => {
|
||||||
|
await withFixture(async (_host, render) => {
|
||||||
|
const heights: number[] = [];
|
||||||
|
await render({ onHeight: h => heights.push(h) });
|
||||||
|
assert.equal(heights.at(-1), 862);
|
||||||
|
animating = true;
|
||||||
|
try {
|
||||||
|
sizes.block = 900;
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(heights.at(-1), 862, "animated block height was not reserved");
|
||||||
|
} finally { animating = false; }
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("reconnects to a new keyed mode even when expansion is unchanged", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
const previous = observers.at(-1)!;
|
||||||
|
const oldContent = part(host, "content");
|
||||||
|
await render({ practice: true });
|
||||||
|
assert.equal(previous.disconnected, true);
|
||||||
|
assert.notEqual(part(host, "content"), oldContent);
|
||||||
|
assert.ok(observers.at(-1)!.elements.has(part(host, "content")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"),
|
||||||
|
body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") };
|
||||||
|
assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wheel routing — question, short-window content, then transcript", () => {
|
||||||
|
it("keeps native wheel input in the question and then the whole-content viewport", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = '<div data-viewport style="overflow-y:auto"><div data-question style="overflow-y:auto"><span>text</span></div></div>';
|
||||||
|
const viewport = root.firstElementChild as HTMLElement;
|
||||||
|
const question = viewport.firstElementChild as HTMLElement;
|
||||||
|
const transcript = document.createElement("div"); transcript.scrollTop = 100;
|
||||||
|
for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; }
|
||||||
|
const wheel = (deltaY: number, deltaMode = 0) => {
|
||||||
|
let prevented = false;
|
||||||
|
forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript);
|
||||||
|
return prevented;
|
||||||
|
};
|
||||||
|
assert.equal(wheel(-20), false, "question can scroll up");
|
||||||
|
question.scrollTop = 0;
|
||||||
|
assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge");
|
||||||
|
viewport.scrollTop = 0;
|
||||||
|
assert.equal(wheel(-20), true);
|
||||||
|
assert.equal(transcript.scrollTop, 80);
|
||||||
|
question.scrollTop = viewport.scrollTop = 300;
|
||||||
|
assert.equal(wheel(1, 1), true);
|
||||||
|
assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized");
|
||||||
|
viewport.style.overflowY = "visible"; viewport.scrollTop = 50;
|
||||||
|
assert.equal(wheel(-20), false, "question still consumes upward movement");
|
||||||
|
question.style.overflowY = "hidden";
|
||||||
|
assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers");
|
||||||
|
assert.equal(wheel(0), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user