From 42814ba6a641c54b1e8ff4ce305734e3cf380f50 Mon Sep 17 00:00:00 2001 From: anikam13 Date: Mon, 26 Oct 2026 23:03:09 -0700 Subject: [PATCH 1/2] fix: budget dock height for answer controls and toolbar --- components/chat.tsx | 16 ++++++++++++++-- components/practice-dock.tsx | 2 +- components/question-dock.tsx | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index f8866f1..10ef099 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -2026,11 +2026,23 @@ function DockOverlay({ if (!el) return; const measure = () => { setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); - setDockMaxH(Math.max(el.clientHeight - 32, 160)); + const block = blockRef.current; + const question = block?.querySelector("[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 () => window.removeEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; }, [scrollRef]); // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index 4c53b59..3ade8f4 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -105,7 +105,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi cards' label row — owner round, 2026-10-05) + the statement, markdown + LaTeX rendered, capped by the measured column (--dock-max-h; the old fixed 40vh cut long problems short). */} -
+
Problem
{input.statement} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 08f5d23..56b3298 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -564,7 +564,7 @@ function QuestionDockForm({ {/* Question area — scrolls internally if very tall; the bottom row stays put. Capped by the measured column (--dock-max-h; the old fixed 55dvh cut long questions short — owner round, 2026-10-05). */} -
+
{toolName === "probe" ? "Probe" From d30acd4fddc40514b7c2d463f694ae3f745814f1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 15:27:08 +0200 Subject: [PATCH 2/2] fix: finish measured dock sizing and short-window scrolling --- components/chat.tsx | 125 +++++++----------- components/dock-height.ts | 157 ++++++++++++++++++++++ tests/dock-height.test.tsx | 259 +++++++++++++++++++++++++++++++++++++ 3 files changed, 462 insertions(+), 79 deletions(-) create mode 100644 components/dock-height.ts create mode 100644 tests/dock-height.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index 10ef099..cef946d 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,6 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; +import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -1972,6 +1973,17 @@ function DockOverlay({ const revealedRef = useRef(revealed); revealedRef.current = revealed; + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(null); + const mode = pending + ? `question:${pending.toolCallId}` + : practice + ? `practice:${practice.toolCallId}` + : "chat"; + // 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. Tracked LIVE by a ResizeObserver on the block @@ -2010,40 +2022,33 @@ function DockOverlay({ 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 // round 2): the scroller reserves symmetric gutters (scrollbar-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. - // The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round, - // 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems - // off absurdly short — the cap is now the AVAILABLE COLUMN (the - // scroller already starts below the top bar) minus a 2rem breathing - // gap, carried as --dock-max-h for the docks' scroll areas. + // Budget the whole solid dock, leaving only its question area flexible. + // The hook measures natural, visible content before the morph/reserve + // effect below. Short windows scroll the reading/question/answer body; + // the stats/model toolbar remains outside that overflow viewport. const [sbInset, setSbInset] = useState(0); - const [dockMaxH, setDockMaxH] = useState(null); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const measure = () => { - setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); - const block = blockRef.current; - const question = block?.querySelector("[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]); + useDockHeight({ + scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer, + }); // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a // live turn the transcript content grows and stick-to-bottom chases it; @@ -2079,48 +2084,11 @@ function DockOverlay({ const root = blockRef.current; const el = scrollRef.current; if (!root || !el) return; - const canScroll = (n: HTMLElement, dir: number) => { - 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); - }; + const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el); root.addEventListener("wheel", onWheel, { passive: false }); return () => root.removeEventListener("wheel", onWheel); }, [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(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 // hundreds of ms, during which the position is still legitimately inside // the collapse zone — the machine would instantly re-collapse the dock @@ -2222,11 +2190,6 @@ function DockOverlay({ 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 // reserved for mid-session transitions. const initialModeRef = useRef(mode); @@ -2234,11 +2197,9 @@ function DockOverlay({ // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. - const shellRef = useRef(null); // 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 // on the toggle — no remount, so the question form's state survives. - const contentRef = useRef(null); const prevExpandedForFadeRef = useRef(expanded); useIsoLayoutEffect(() => { if (prevExpandedForFadeRef.current === expanded) return; @@ -2286,8 +2247,10 @@ function DockOverlay({ return (
-
+
+
+
{setupCta} {/* The queued-steer indicator (owner spec 2026-10-03): quiet one-liners above the shell - right-aligned to where its corner @@ -2316,11 +2279,13 @@ function DockOverlay({ /> )}
+ {/* Include descendant margins (notably practice thinking's + bottom margin) in the natural content measurement. */}
+ ref={contentRef} + key={mode} + className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"} + > {pending ? (
+
+
{/* The stats row: beneath the composer/question shell, not flush to the screen bottom — static height, outside the morphing shell so it never joins a height animation. */} diff --git a/components/dock-height.ts b/components/dock-height.ts new file mode 100644 index 0000000..dd6f2ac --- /dev/null +++ b/components/dock-height.ts @@ -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("[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; + blockRef: RefObject; + viewportRef: RefObject; + bodyRef: RefObject; + shellRef: RefObject; + contentRef: RefObject; +} + +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); +} diff --git a/tests/dock-height.test.tsx b/tests/dock-height.test.tsx new file mode 100644 index 0000000..e6d9d3d --- /dev/null +++ b/tests/dock-height.test.tsx @@ -0,0 +1,259 @@ +import React, { act, useLayoutEffect, useRef, useState } from "react"; +(globalThis as Record).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(""); +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(); + 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 = {}; +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(null); + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(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 <>
+
+
+ {practice ? {}} onSubmit={() => {}} /> + : {}} onSubmit={() => {}} />} +
+
+
; +} + +async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps) => Promise) => Promise) { + resetSizes(); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const render = async (props = {}) => { await act(async () => root.render()); }; + try { await run(host, render); } + finally { await act(async () => root.unmount()); host.remove(); } +} +const part = (host: HTMLElement, name: string) => host.querySelector(`[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 = '
text
'; + 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); + }); +});