ui: intent-gated fold, measured height caps, the Problem eyebrow, fold fade

Owner round (2026-10-05), Slice D — the dock mechanics:

- INTENT-GATED FOLD: a ResizeObserver on the scroller's content stamps
  the existing 400ms mute whenever the transcript GROWS — growth frames
  (a live turn streaming under stick-to-bottom's chase) can never fold
  the dock; and a fold now needs the distance AND stick-to-bottom's
  isAtBottom agreeing the learner left. Fixes the generation churn
  (dock disappearing and reappearing while the agent works). Shrinks
  stamp nothing (the fold's own morph must not self-mute re-expand).
- MEASURED HEIGHT CAPS: the question (55dvh) and problem (40vh) areas
  now cap at the available column (scroller clientHeight − 2rem,
  re-measured on resize) via --dock-max-h — questions stretch to the
  top bar with sensible padding and scroll internally only when
  genuinely huge.
- The practice dock's statement area carries the 'Problem' eyebrow —
  the quiz cards' label row, mirrored (dock and transcript card read
  identically).
- The fold's CONTENT fade: a 160ms WAAPI fade/rise on the bar↔form
  toggle — no remount, so the question form's state survives; the
  shell height morph was already animated.
This commit is contained in:
amosblomqvist
2026-10-05 02:31:55 +03:00
parent 5d253a933c
commit a5a554582e
4 changed files with 75 additions and 8 deletions
+60 -4
View File
@@ -1770,16 +1770,48 @@ function DockOverlay({
// 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,
// 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.
const [sbInset, setSbInset] = useState(0); const [sbInset, setSbInset] = useState(0);
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); const measure = () => {
setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
setDockMaxH(Math.max(el.clientHeight - 32, 160));
};
measure(); measure();
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure); return () => window.removeEventListener("resize", measure);
}, [scrollRef]); }, [scrollRef]);
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
// live turn the transcript content grows and stick-to-bottom chases it;
// the transient distance-from-bottom used to read as the learner
// leaving, folding the dock open↔bar ("disappear and appear again").
// A ResizeObserver on the scroller's content stamps the SAME mute the
// dock's own resizes use — growth frames can never fold. Shrinks stamp
// nothing (the fold's own morph shrinks the reserve; muting that would
// self-mute the at-bottom re-expand).
const growthAtRef = useRef(0);
useEffect(() => {
const el = scrollRef.current;
const content = el?.firstElementChild as HTMLElement | null | undefined;
if (!el || !content) return;
let prevH = content.getBoundingClientRect().height;
const ro = new ResizeObserver(() => {
const h = content.getBoundingClientRect().height;
if (h > prevH + 0.5) growthAtRef.current = Date.now();
prevH = Math.max(prevH, h);
});
ro.observe(content);
return () => ro.disconnect();
}, [scrollRef]);
// Wheel over the dock must still scroll the transcript (owner, scroll // Wheel over the dock must still scroll the transcript (owner, scroll
// round 2): the overlay is a SIBLING of the scroller, so the browser's // round 2): the overlay is a SIBLING of the scroller, so the browser's
// native scroll chaining stops at the overlay (the old in-flow dock // native scroll chaining stops at the overlay (the old in-flow dock
@@ -1871,9 +1903,16 @@ function DockOverlay({
// reading the transient position folded the box to the bar for a // reading the transient position folded the box to the bar for a
// flicker. Real scrolling resumes after the brief mute window. // flicker. Real scrolling resumes after the brief mute window.
if (Date.now() - geometryAtRef.current < 400) return; if (Date.now() - geometryAtRef.current < 400) return;
// Growth mute (owner round, 2026-10-05): the transcript growing
// under a live turn is not the learner leaving.
if (Date.now() - growthAtRef.current < 400) return;
const d = el.scrollHeight - el.clientHeight - el.scrollTop; const d = el.scrollHeight - el.clientHeight - el.scrollTop;
if (expandedRef.current) { if (expandedRef.current) {
if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); // INTENT-GATED FOLD (owner ruling, 2026-10-05): distance alone
// folded on stick-to-bottom's catch-up transients; a real fold
// needs the distance AND stick-to-bottom agreeing the learner
// left the bottom.
if (d > COLLAPSE_DIST && !expandIntentRef.current && !atBottomRef.current) setExpanded(false);
} else if (d < EXPAND_DIST) { } else if (d < EXPAND_DIST) {
// Teach checks stay collapsed until the learner opens them (W5): the // Teach checks stay collapsed until the learner opens them (W5): the
// at-bottom auto-expand would pop the form over the teaching. // at-bottom auto-expand would pop the form over the teaching.
@@ -1907,6 +1946,19 @@ function DockOverlay({
// 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); const shellRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
const prevExpandedForFadeRef = useRef(expanded);
useIsoLayoutEffect(() => {
if (prevExpandedForFadeRef.current === expanded) return;
prevExpandedForFadeRef.current = expanded;
contentRef.current?.animate(
[{ opacity: 0, transform: "translateY(6px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 160, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
);
}, [expanded]);
const stateKey = `${mode}:${pending ? expanded : "-"}`; const stateKey = `${mode}:${pending ? expanded : "-"}`;
const prevRef = useRef<{ key: string; height: number } | null>(null); const prevRef = useRef<{ key: string; height: number } | null>(null);
useIsoLayoutEffect(() => { useIsoLayoutEffect(() => {
@@ -1941,7 +1993,7 @@ 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"> <div ref={blockRef} className="relative bg-background pb-5" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}>
<div className={DOCK}> <div className={DOCK}>
{setupCta} {setupCta}
{/* The queued-steer indicator (owner spec 2026-10-03): quiet {/* The queued-steer indicator (owner spec 2026-10-03): quiet
@@ -1962,7 +2014,11 @@ 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">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div
ref={contentRef}
key={mode}
className={mode === initialModeRef.current ? undefined : "dock-variant"}
>
{pending ? ( {pending ? (
<QuestionDock <QuestionDock
toolName={pending.tool as "quiz" | "ask_user_question" | "explain"} toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
+6 -2
View File
@@ -92,8 +92,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
/* ── the expanded form ── */ /* ── the expanded form ── */
return ( return (
<div data-testid="practice-dock" data-problem={problemCallId}> <div data-testid="practice-dock" data-problem={problemCallId}>
{/* The agent's half: the statement, markdown + LaTeX rendered. */} {/* The agent's half: the 'Problem' eyebrow (mirroring the quiz
<div className="max-h-[40vh] overflow-y-auto px-4 pt-3 pb-2"> 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). */}
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]"> <div className="lh-prose text-[15px]">
<MessageResponse>{input.statement}</MessageResponse> <MessageResponse>{input.statement}</MessageResponse>
</div> </div>
+4 -2
View File
@@ -510,8 +510,10 @@ function QuestionDockForm({
return ( return (
<div ref={rootRef} data-testid="question-dock"> <div ref={rootRef} data-testid="question-dock">
{/* Question area — scrolls internally if very tall; the bottom row stays put. */} {/* Question area — scrolls internally if very tall; the bottom row stays put.
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3"> Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
long questions short — owner round, 2026-10-05). */}
<div className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"} {kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"} {qd.mode === "multi-select" && " · select all that apply"}
+5
View File
@@ -50,6 +50,11 @@ describe("PracticeDock", () => {
/>, />,
); );
assert.match(html, /Show that the sum is even\./); assert.match(html, /Show that the sum is even\./);
// The 'Problem' eyebrow (owner round, 2026-10-05): the quiz cards'
// label row, mirrored — dock and transcript card read identically.
assert.match(html, /Problem/);
// The measured column cap (the old fixed 40vh is only the fallback).
assert.match(html, /--dock-max-h/);
assert.match(html, /Your thinking/); assert.match(html, /Your thinking/);
assert.match(html, /data-testid="practice-nudge"/); assert.match(html, /data-testid="practice-nudge"/);
assert.match(html, /data-testid="practice-commit"/); assert.match(html, /data-testid="practice-commit"/);