From fdeacc42f579f18f418d52939062735ae6911d98 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 06:22:26 +0300 Subject: [PATCH 01/18] Plan-graph refinement round 1 (owner): chat-font zoom cap + stroke-safe padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-chat full plan block: - TEXT_CAP_SCALE (16.5/12.5): node text renders at text-xs in svg units; the block now carries maxWidth = extent × cap + mx-auto, so it still fills the column whenever the column is narrower than the cap, but never scales node text past the chat prose size (--lh-prose-size). - GRAPH_PAD (8 svg units) around the full-extent viewBox: centered strokes (half outside the geometry) and the −4 glow halo were clipping at the frame edges — the padded origin (-8,-8) keeps all ink visible. Only the full-extent render pads; the plan window's override viewport and the popup's fit margins are untouched. Test pins updated: mx-auto + max-width:232.32px + viewBox='-8 -8 176 152'. --- components/plan-block.tsx | 43 +++++++++++++++++++++++++++++--- tests/render-plan-block.test.tsx | 13 ++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 77db98b..0015962 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -129,6 +129,17 @@ export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] return lines; } +// ─── Geometry caps (owner, plan-graph refinement round 1) ───────────────────── + +/** SVG-unit padding around the layout extent: strokes are centered on the + * geometry (half bleeds outside the extent) and the glow halo reaches −4 — + * without this pad the outermost node borders clip at the svg edge. */ +export const GRAPH_PAD = 8; +/** Node titles render at text-xs (12.5px) in svg units. The in-chat block + * scales up to fill the column but NEVER past the chat prose size + * (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ +export const TEXT_CAP_SCALE = 16.5 / 12.5; + // ─── Status styling (the retired panel's color language, unchanged) ────────── const BOX: Record = { @@ -151,6 +162,8 @@ export function PlanDiagram({ glowId, markerId, viewBoxOverride, + pad: padUnits, + textCapScale, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; @@ -159,12 +172,26 @@ export function PlanDiagram({ markerId: string; /** A windowed viewBox (the plan window) instead of the full extent. */ viewBoxOverride?: string; + /** Pads the viewBox by this many svg units so centered strokes and the + * glow halo never clip at the extent edges (full-extent renders only). */ + pad?: number; + /** Caps the rendered width at extent × scale — the chat-font-size zoom + * ceiling for the in-chat block (fills the column, never past prose). */ + textCapScale?: number; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); + // Pad only the full-extent render — the plan window's override viewBox is + // a deliberate viewport crop, and the popup keeps its own fit margins. + const pad = viewBoxOverride ? 0 : (padUnits ?? 0); + const w = Math.max(layout.width, 1); + const h = Math.max(layout.height, 1); + const vbW = w + 2 * pad; + const vbH = h + 2 * pad; return ( @@ -254,7 +281,17 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => title="Open the full plan" className="block w-full text-left" > - + {/* pad keeps centered strokes + the glow halo inside the frame; the + cap keeps node text at or below chat prose size while still filling + the column when it can (owner, plan-graph refinement round 1). */} + ); } diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 92a271b..4824b0e 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -82,11 +82,14 @@ describe("plan block render", () => { assert.match(html, /data-testid="plan-block-full"/); // Borderless: the wrapper carries no border/rounded chrome. assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome"); - // Fills the column: the svg is w-full with the layout extent as viewBox - // (leftmost/rightmost nodes hug the edges), height natural. - assert.match(html, /]*class="block h-auto w-full"/); - // Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column. - assert.match(html, /viewBox="0 0 160 136"/); + // Fills the column UP TO the chat-font cap: the svg is w-full + + // mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never + // renders past the 16.5px prose size (owner, plan-graph round 1). + assert.match(html, /]*class="block h-auto w-full mx-auto"/); + assert.match(html, /max-width:232\.32px/); // (160+16) × 1.32 + // Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on + // every side (strokes/glow never clip at the edges). + assert.match(html, /viewBox="-8 -8 176 152"/); // Snapshot statuses render as carried. assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); From 6464b34a793181682719c1d839704723fea748e5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 06:26:55 +0300 Subject: [PATCH 02/18] Plan-graph refinement round 2 (owner): the popup fits, centers, and never clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the popup's fit/center failure: PlanDiagram rendered w-full inside the popup's shrink-to-fit absolute wrapper — the svg resolved to the popup's width regardless of its viewBox extent, so the transform math (1 svg unit = 1 px at zoom 1) was off and the 'fit floor' neither fitted nor centered. - naturalSize: the popup's diagram renders at the viewBox width in px, so fit/pan/zoom math holds. - The popup's fit box is now the PADDED ink box (GRAPH_PAD all around): centered strokes and the glow halo never clip at the frame edge. - fitZoomFor lost its 0.15 artificial floor — for wide graphs it sat ABOVE the true fit and broke 'fully zoomed out = the whole graph visible, whichever axis binds' (owner's rule). The fit itself is the floor; a 0.02 epsilon only guards degenerate viewports. --- components/plan-block.tsx | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 0015962..c707d4d 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -12,8 +12,9 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; * the right sidebar is retired. * * - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG - * scales to the full column width (viewBox = layout extent, so the - * leftmost/rightmost nodes hug the edges), natural height, no cap. + * scales to the full column width (viewBox = layout extent + GRAPH_PAD, + * so strokes/glow never clip), natural height, capped at the chat prose + * font size (TEXT_CAP_SCALE — fills the column, never oversizes text). * - Clicking opens the full plan in a full-screen popup overlay — * the LIVE plan: the latest tree from the transcript with statuses * derived at click time (contracts/plan.ts derivePlanStatuses). Blocks @@ -164,6 +165,7 @@ export function PlanDiagram({ viewBoxOverride, pad: padUnits, textCapScale, + naturalSize, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; @@ -178,6 +180,10 @@ export function PlanDiagram({ /** Caps the rendered width at extent × scale — the chat-font-size zoom * ceiling for the in-chat block (fills the column, never past prose). */ textCapScale?: number; + /** Renders at the viewBox width in px (1 svg unit = 1 px at zoom 1) — + * the popup's transform math depends on it; w-full inside the popup's + * shrink-to-fit wrapper resolved to the popup width and broke fit. */ + naturalSize?: boolean; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); // Pad only the full-extent render — the plan window's override viewBox is @@ -190,8 +196,14 @@ export function PlanDiagram({ return ( @@ -318,9 +330,11 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null { return null; } -/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */ +/** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole + * graph visible, whichever axis binds — owner, plan-graph round 2), never + * above 2×. The old 0.15 floor could sit ABOVE a true fit for wide graphs + * and break that rule; the fit itself is the floor now. */ const ZOOM_MAX = 2; -const ZOOM_FLOOR = 0.15; const FIT_PAD = 16; const FIT_ZOOM_MAX = 1.5; @@ -332,10 +346,9 @@ interface GraphView { function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number { if (!viewport.w || !viewport.h || box.w === 0) return 1; - return Math.max( - ZOOM_FLOOR, - Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX), - ); + // No artificial floor: a wide graph must fit even below 0.15× — the + // epsilon only guards degenerate viewports. + return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX)); } function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView { @@ -391,7 +404,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa }, []); const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]); - const box = useMemo(() => (layout ? { w: layout.width, h: layout.height } : { w: 0, h: 0 }), [layout]); + // The fit box is the PADDED ink box — the diagram renders with GRAPH_PAD + // so strokes/glow never clip, and the fit must account for it. + const box = useMemo( + () => (layout ? { w: layout.width + 2 * GRAPH_PAD, h: layout.height + 2 * GRAPH_PAD } : { w: 0, h: 0 }), + [layout], + ); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); @@ -507,6 +525,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa statusOf={statusOf} glowId={statuses?.current ?? null} markerId={markerId} + pad={GRAPH_PAD} + naturalSize />
From b92237fd56768182d0077b9c4f0819871aa8f68d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 06:30:05 +0300 Subject: [PATCH 03/18] Plan-graph refinement round 3 (owner): higher zoom ceiling + eased zooming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZOOM_MAX 2× → 4×: node titles reach ~50px rendered, comfortable for close reading. Single tunable constant. - Smooth zoom: a 180ms ease-out CSS transition on the transform wrapper — CSS retargets from the current computed value, so chained wheel ticks and the fit/button zooms all glide instead of snapping. Disabled while dragging (per-frame pans must not rubber-band). panning is React state toggled at drag start/end. --- components/plan-block.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index c707d4d..5480951 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -332,9 +332,10 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null { /** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole * graph visible, whichever axis binds — owner, plan-graph round 2), never - * above 2×. The old 0.15 floor could sit ABOVE a true fit for wide graphs - * and break that rule; the fit itself is the floor now. */ -const ZOOM_MAX = 2; + * above ZOOM_MAX (raised 2×→4× for reading node titles closely, owner, + * plan-graph round 3). The old 0.15 floor could sit ABOVE a true fit for + * wide graphs; the fit itself is the floor now. */ +const ZOOM_MAX = 4; const FIT_PAD = 16; const FIT_ZOOM_MAX = 1.5; @@ -441,12 +442,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa return () => el.removeEventListener("wheel", onWheel); }, [fitZoom, box, size]); + // Smooth zoom: the transform eases between states (CSS retargets from the + // current computed value, so chained wheel ticks stay fluid). Panning is + // per-frame — the transition is off while dragging or it rubber-bands. + const [panning, setPanning] = useState(false); const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); const startDrag = (e: React.PointerEvent) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button")) return; if (atFloor) return; dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; + setPanning(true); e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.setPointerCapture(e.pointerId); }; @@ -464,6 +470,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa dragRef.current = null; if (d.moved) userAdjustedRef.current = true; } + setPanning(false); e.currentTarget.classList.remove("cursor-grabbing"); }; const stepZoom = (factor: number) => { @@ -517,7 +524,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa <>
Date: Mon, 14 Sep 2026 06:55:10 +0300 Subject: [PATCH 04/18] Plan-graph refinement round 4 (owner): the simple white look + no entrance fly-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Look (rectangular, white language — owner spec): - Boxes: no corner rounding (rx dropped everywhere). - Visited: fill-none, white stroke, white text. Upcoming: today's gray (fill-card, border stroke, muted text) — kept per owner. Current: white stroke/text + a faint yellow highlight — a blurred yellow fill (feGaussianBlur) that bleeds out over the node's edges, still breathing. - Edges + interim arrowhead: white (marker swaps to the owner's SVG when it lands: right-pointing, solid fill, tight viewBox, tip at right edge). - The IN-CHAT plan block renders monochrome — every node white, no status language, no glow; the popup/plan-window show the progression. Popup entrance fix: the transition from round 3 animated the first fit (graph flew in from the top-left zoom:1 state). The transition now stays off until the first fit has been applied — the popup paints settled. --- components/plan-block.tsx | 74 ++++++++++++++++++++++++-------- tests/render-plan-block.test.tsx | 6 ++- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 5480951..fd18911 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -143,16 +143,24 @@ export const TEXT_CAP_SCALE = 16.5 / 12.5; // ─── Status styling (the retired panel's color language, unchanged) ────────── +// The simple look (owner, plan-graph round 4): rectangular boxes, white +// outlines/arrows/text; no fill on visited, today's gray look on upcoming, +// and the current node carries a faint yellow glow (a blurred yellow fill +// bleeding over the edges). The IN-CHAT block is monochrome white instead +// — no status language; the popup/window show the progression. const BOX: Record = { - visited: "fill-phase-teach/10 stroke-phase-teach/50", - current: "fill-primary/10 stroke-primary", + visited: "fill-none stroke-white", + current: "fill-none stroke-white", + // The grayed-out not-yet-visited look stays as-is (owner). upcoming: "fill-card stroke-border", }; const TEXT: Record = { - visited: "fill-muted-foreground text-xs", - current: "fill-foreground text-xs font-medium", + visited: "fill-white text-xs", + current: "fill-white text-xs font-medium", upcoming: "fill-muted-foreground/80 text-xs", }; +const MONO_BOX = "fill-none stroke-white"; +const MONO_TEXT = "fill-white text-xs"; /** The diagram itself: positioned nodes + bezier edges. Pure-ish render — * every input is data, so SSR render tests pin the whole thing. */ @@ -166,6 +174,7 @@ export function PlanDiagram({ pad: padUnits, textCapScale, naturalSize, + monochrome, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; @@ -184,6 +193,9 @@ export function PlanDiagram({ * the popup's transform math depends on it; w-full inside the popup's * shrink-to-fit wrapper resolved to the popup width and broke fit. */ naturalSize?: boolean; + /** The in-chat block's mode: every node white (fill-none, white stroke + * and text, no glow) — statuses never show in the transcript (owner). */ + monochrome?: boolean; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); // Pad only the full-extent render — the plan window's override viewBox is @@ -217,8 +229,16 @@ export function PlanDiagram({ markerHeight="6" orient="auto-start-reverse" > - + {/* Interim head — swapped for the owner's arrowhead SVG when it + lands (right-pointing, tight viewBox, tip at the right edge). */} + + {/* The current node's highlight: a faint yellow fill BLURRED so it + bleeds out over the node's edges (owner). Generous filter region + so the blur never clips at its own bounds. */} + + + {edges.map((e) => { const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; @@ -230,7 +250,7 @@ export function PlanDiagram({ ${e.to}`} d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} - className="fill-none stroke-muted-foreground/40" + className="fill-none stroke-white" strokeWidth={1.5} markerEnd={`url(#${markerId})`} /> @@ -238,7 +258,7 @@ export function PlanDiagram({ })} {nodes.map((n) => { const status = statusOf(n.id); - const glow = n.id === glowId; + const glow = n.id === glowId && !monochrome; const lines = wrapTitle(n.title); return ( {glow && ( - // The glow target: a soft primary halo breathing around the box. + // The current node's highlight: faint yellow, blurred so it + // bleeds over the edges (owner, plan-graph round 4). )} - + {/* Rectangular — no corner rounding (owner). */} + {lines.map((line, i) => ( - + {line} ))} @@ -303,6 +334,7 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => markerId={markerId} pad={GRAPH_PAD} textCapScale={TEXT_CAP_SCALE} + monochrome /> ); @@ -416,6 +448,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); const atFloor = view.zoom <= fitZoom + 1e-9; + // The transition stays off until the FIRST fit has been applied — the + // popup paints at the fit directly instead of flying in from the initial + // top-left zoom:1 state (owner, plan-graph round 4). + const [settled, setSettled] = useState(false); useEffect(() => { if (atFloor) userAdjustedRef.current = false; @@ -423,6 +459,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); + setSettled(true); }, [fitZoom, size, box]); useEffect(() => { @@ -526,7 +563,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa className="absolute top-0 left-0 origin-top-left" style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`, - transition: panning ? "none" : "transform 180ms ease-out", + transition: + panning || !settled ? "none" : "transform 180ms ease-out", }} > { assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-b" data-status="current"/); assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/); - // The current node is the glow target. - assert.match(html, /stroke-primary\/70 animate-pulse/); + // The current node is the glow target: a faint yellow fill, blurred + // so it bleeds over the edges (owner, plan-graph round 4). + assert.match(html, /fill-yellow-400\/25 animate-pulse/); + assert.match(html, /feGaussianBlur/); assert.match(html, /live — statuses derive from your check outcomes/); }); From 43ada6ce2355de973d8f5386b473e722dfb046b5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 06:59:38 +0300 Subject: [PATCH 05/18] Plan-graph refinement round 5 (owner): the owner's arrowhead lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arrowhead_1.svg (the CM/MJX long-arrow head) baked in — transform chain collapsed to a clean scale 0.2 + y-flip: viewBox 0 0 50.2 104, tip at (50.2, 52), notch mouth at x 27.2, tail opening 9.6 tall (≈10 at the owner's full height 104). Sizing per the owner's spec: line width stays 1.5; head height 16 makes the mouth opening tuck the line flush (the LaTeX proportion). markerUnits userSpaceOnUse, refX at the mouth (27.2) so the line terminates at the notch — never at the narrow tip, where it would show through. Edges shorten by the tip overhang (≈3.54 units) so the head's tip lands exactly on the target node's border. --- components/plan-block.tsx | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index fd18911..4b94910 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -141,6 +141,25 @@ export const GRAPH_PAD = 8; * (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ export const TEXT_CAP_SCALE = 16.5 / 12.5; +// ─── The owner's arrowhead (plan-graph round 5) ────────────────────────────── + +/** Baked from arrowhead_1.svg (the CM/MJX long-arrow head), transform chain + * collapsed: viewBox 0 0 50.2 104, tip at (50.2, 52), notch mouth at + * x 27.2 with a 9.6-tall opening — at full height 104 the tail opening + * is ≈10, per the owner's spec. Solid white fill, no padding. */ +export const ARROW_HEAD = "M48,55.2C49.4,54.6 50.2,53.4 50.2,52C50.2,50.6 49.4,49.4 48,48.8C38.2,45.6 29.2,38.8 21,28.4C15.2,21.4 11.4,13.2 9.6,3.8C9,1.2 7.4,0 4.8,0C1.6,0 0,1.6 0,5L0.2,5.6C3.6,23 12.6,36.8 27.2,47.2L27.2,56.8C12.6,67.2 3.6,81 0.2,98.4L0,99C0,102.4 1.6,104 4.8,104C7.4,104 9,102.8 9.6,100.2C11.4,90.8 15.2,82.6 21,75.6C29.2,65.2 38.2,58.4 48,55.2Z"; +/** Head height 16: the tail opening (9.6/104 of height) then tucks the + * 1.5-wide line flush — the LaTeX proportion. The owner's rule: keep the + * current line width; size the head from the 104→10 ratio. */ +const HEAD_H = 16; +const HEAD_SCALE = HEAD_H / 104; +const HEAD_W = 50.2 * HEAD_SCALE; +/** The drawn line terminates at the notch mouth (refX 27.2) — it never + * reaches the narrow tip, where it would show through (owner). The tip + * overhangs the line's end by this much, so edges shorten by it to land + * the tip exactly on the target node's border. */ +const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE; + // ─── Status styling (the retired panel's color language, unchanged) ────────── // The simple look (owner, plan-graph round 4): rectangular boxes, white @@ -222,16 +241,17 @@ export function PlanDiagram({ - {/* Interim head — swapped for the owner's arrowhead SVG when it - lands (right-pointing, tight viewBox, tip at the right edge). */} - + {/* The owner's arrowhead. The mouth (x 27.2) sits at refX so the + line ends AT the notch — the tip beyond it is solid fill. */} + {/* The current node's highlight: a faint yellow fill BLURRED so it bleeds out over the node's edges (owner). Generous filter region @@ -244,7 +264,9 @@ export function PlanDiagram({ const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; - const y2 = layout.y.get(e.to) ?? 0; + // Shortened by the tip overhang so the head's TIP lands on the + // node border while the line stops at the notch mouth. + const y2 = (layout.y.get(e.to) ?? 0) - HEAD_TIP_OVERHANG; const mid = (y1 + y2) / 2; return ( Date: Mon, 14 Sep 2026 07:06:25 +0300 Subject: [PATCH 06/18] Plan-graph refinement round 6 (owner): grayed arrows, static glow, glow-sized frame pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arrows into not-yet-visited nodes are grayed out like the nodes (stroke-border line + a fill-border copy of the arrowhead); the in-chat block stays all-white. - The yellow glow is static — the pulse is gone (owner). - Smaller blur: stdDeviation 7 → 5, glow rect margin 10 → 8. - The glow clipped at the frame on narrow graphs: its visible reach (GLOW_MARGIN + 3·GLOW_BLUR = 23 units) far exceeded GRAPH_PAD (8). Surfaces that show the glow now pad by GLOW_REACH — the popup's viewBox AND its fit box both use POPUP_PAD = GLOW_REACH, so the blur never clips; the monochrome in-chat block stays tight at GRAPH_PAD. --- components/plan-block.tsx | 55 ++++++++++++++++++++++++-------- tests/render-plan-block.test.tsx | 7 ++-- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 4b94910..e6a5c4c 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -136,6 +136,14 @@ export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] * geometry (half bleeds outside the extent) and the glow halo reaches −4 — * without this pad the outermost node borders clip at the svg edge. */ export const GRAPH_PAD = 8; +/** The current node's yellow glow: a fill blurred so it bleeds over the + * node's edges (owner). A Gaussian's visible spread is ≈3σ, so the glow + * reaches GLOW_MARGIN + 3·GLOW_BLUR past the node — any surface that + * shows the glow must pad its viewBox by GLOW_REACH or the blur clips + * at the frame edge (owner: extend the container padding to fit it). */ +export const GLOW_MARGIN = 8; +export const GLOW_BLUR = 5; +export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR; /** Node titles render at text-xs (12.5px) in svg units. The in-chat block * scales up to fill the column but NEVER past the chat prose size * (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ @@ -253,11 +261,25 @@ export function PlanDiagram({ line ends AT the notch — the tip beyond it is solid fill. */} + {/* Same head, grayed — arrows into not-yet-visited nodes are + grayed out like the nodes themselves (owner, round 6). */} + + + {/* The current node's highlight: a faint yellow fill BLURRED so it bleeds out over the node's edges (owner). Generous filter region so the blur never clips at its own bounds. */} - + {edges.map((e) => { @@ -268,13 +290,16 @@ export function PlanDiagram({ // node border while the line stops at the notch mouth. const y2 = (layout.y.get(e.to) ?? 0) - HEAD_TIP_OVERHANG; const mid = (y1 + y2) / 2; + // An arrow into a not-yet-visited node is grayed out like the + // node (owner, round 6); the in-chat block stays all-white. + const dim = !monochrome && statusOf(e.to) === "upcoming"; return ( ${e.to}`} d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} - className="fill-none stroke-white" + className={dim ? "fill-none stroke-border" : "fill-none stroke-white"} strokeWidth={1.5} - markerEnd={`url(#${markerId})`} + markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`} /> ); })} @@ -291,13 +316,13 @@ export function PlanDiagram({ > {glow && ( // The current node's highlight: faint yellow, blurred so it - // bleeds over the edges (owner, plan-graph round 4). + // bleeds over the edges — static, no pulse (owner, round 6). )} @@ -391,6 +416,9 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null { * wide graphs; the fit itself is the floor now. */ const ZOOM_MAX = 4; const FIT_PAD = 16; +/** The popup shows the glow, so its frame pads by the glow's reach — + * the in-chat block (monochrome, no glow) stays tight at GRAPH_PAD. */ +const POPUP_PAD = GLOW_REACH; const FIT_ZOOM_MAX = 1.5; interface GraphView { @@ -459,10 +487,11 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa }, []); const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]); - // The fit box is the PADDED ink box — the diagram renders with GRAPH_PAD - // so strokes/glow never clip, and the fit must account for it. + // The fit box is the PADDED ink box — the diagram renders with POPUP_PAD + // (= the glow's reach) so the blurred glow never clips at the frame, and + // the fit must account for that padding. const box = useMemo( - () => (layout ? { w: layout.width + 2 * GRAPH_PAD, h: layout.height + 2 * GRAPH_PAD } : { w: 0, h: 0 }), + () => (layout ? { w: layout.width + 2 * POPUP_PAD, h: layout.height + 2 * POPUP_PAD } : { w: 0, h: 0 }), [layout], ); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); @@ -595,7 +624,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa statusOf={statusOf} glowId={statuses?.current ?? null} markerId={markerId} - pad={GRAPH_PAD} + pad={POPUP_PAD} naturalSize />
diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 7734240..0043a4d 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -139,9 +139,12 @@ describe("plan popup (live derivation)", () => { assert.match(html, /data-testid="plan-node-b" data-status="current"/); assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/); // The current node is the glow target: a faint yellow fill, blurred - // so it bleeds over the edges (owner, plan-graph round 4). - assert.match(html, /fill-yellow-400\/25 animate-pulse/); + // so it bleeds over the edges — static, no pulse (owner, round 6). + assert.match(html, /fill-yellow-400\/25/); assert.match(html, /feGaussianBlur/); + assert.doesNotMatch(html, /animate-pulse/); + // Arrows into not-yet-visited nodes are grayed out (owner, round 6). + assert.match(html, /marker-end="url\(#.*-dim\)"/); assert.match(html, /live — statuses derive from your check outcomes/); }); From 7c766753cc32a0aed051f1b26064f541ee1d8e53 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:12:38 +0300 Subject: [PATCH 07/18] Plan-graph refinement round 7 (owner): no fly-in on open, nodes never truncate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fly-in, take two: round 4 kept settled OFF until the first fit, but the flag flipped in the SAME commit as the transform change — the browser saw the new transform + newly-added transition in one style update and animated from the initial top-left zoom:1 state. The transition now enables one paint AFTER the fit (requestAnimationFrame), so the popup opens settled and only glides for user input afterwards. Nodes show their FULL content (owner): wrapTitle no longer caps lines or ellipsizes — oversized words are hard-chunked, nothing is dropped. Boxes grow with their wrapped lines (NODE_H + LINE_H per extra line); layout rows take their tallest node, edges leave from the source's real bottom, the glow rect and the plan-window's centering follow the grown height, and the text block centers in the grown box. --- components/plan-block.tsx | 68 +++++++++++++++++++++----------- components/plan-window.tsx | 2 +- tests/render-plan-block.test.tsx | 8 +++- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index e6a5c4c..ef56a8c 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -32,6 +32,8 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; export const NODE_W = 160; export const NODE_H = 44; +/** Baseline-to-baseline distance of node titles. */ +export const LINE_H = 13; export const GAP_X = 24; export const GAP_Y = 48; @@ -95,36 +97,48 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); const x = new Map(); const y = new Map(); - layers.forEach((l, d) => { + // Per-node heights: boxes grow to show the FULL wrapped content — + // nothing is ever truncated (owner, plan-graph round 7). + const nodeH = new Map(); + let cursor = 0; + layers.forEach((l) => { + // The row's height = its tallest node. + let rowH = NODE_H; + for (const n of l) { + const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H; + nodeH.set(n.id, h); + rowH = Math.max(rowH, h); + } const start = (maxWidth - rowWidth(l)) / 2; l.forEach((n, i) => { x.set(n.id, start + i * (NODE_W + GAP_X)); - y.set(n.id, d * (NODE_H + GAP_Y)); + y.set(n.id, cursor); }); + cursor += rowH + GAP_Y; }); - const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y; - return { x, y, width: maxWidth, height }; + const height = layers.length > 0 ? cursor - GAP_Y : 0; + return { x, y, nodeH, width: maxWidth, height }; } -/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an - * ellipsis, a single oversized word is hard-clamped. Pure. */ -export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] { +/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the + * node's box grows to fit every line (owner: show the full content of + * each node). A single oversized word is hard-chunked across lines at + * maxChars. Pure. */ +export function wrapTitle(title: string, maxChars = 20): string[] { const words = title.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return [""]; - const lines: string[] = []; + // Oversized words can never fit a line — chunk them so no content is lost. + const atoms: string[] = []; for (const w of words) { + for (let i = 0; i < w.length; i += maxChars) atoms.push(w.slice(i, i + maxChars)); + } + const lines: string[] = []; + for (const a of atoms) { const last = lines[lines.length - 1]; - const fits = last !== undefined && last.length + 1 + w.length <= maxChars; - if (fits) { - lines[lines.length - 1] = `${last} ${w}`; - } else if (lines.length < maxLines) { - lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w); + if (last !== undefined && last.length + 1 + a.length <= maxChars) { + lines[lines.length - 1] = `${last} ${a}`; } else { - const lastLine = lines[lines.length - 1]; - if (lastLine !== undefined && !lastLine.endsWith("…")) { - lines[lines.length - 1] = - lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`; - } + lines.push(a); } } return lines; @@ -284,7 +298,7 @@ export function PlanDiagram({ {edges.map((e) => { const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; - const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; + const y1 = (layout.y.get(e.from) ?? 0) + (layout.nodeH.get(e.from) ?? NODE_H); const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; // Shortened by the tip overhang so the head's TIP lands on the // node border while the line stops at the notch mouth. @@ -307,6 +321,9 @@ export function PlanDiagram({ const status = statusOf(n.id); const glow = n.id === glowId && !monochrome; const lines = wrapTitle(n.title); + // The box grows with its wrapped content; text centers in it. + const h = layout.nodeH.get(n.id) ?? NODE_H; + const y0 = (h - lines.length * LINE_H) / 2 + 10; return ( @@ -329,7 +346,7 @@ export function PlanDiagram({ {/* Rectangular — no corner rounding (owner). */} @@ -337,7 +354,7 @@ export function PlanDiagram({ {line} @@ -510,7 +527,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); - setSettled(true); + // Enable the transition only AFTER the fit has painted: flipping + // settled in the SAME commit lets the browser animate from the + // initial top-left zoom:1 state — the fly-in the owner keeps + // seeing (round 4's gate wasn't enough; round 7 fix). + const raf = requestAnimationFrame(() => setSettled(true)); + return () => cancelAnimationFrame(raf); }, [fitZoom, size, box]); useEffect(() => { diff --git a/components/plan-window.tsx b/components/plan-window.tsx index 385b3bd..fdc3b76 100644 --- a/components/plan-window.tsx +++ b/components/plan-window.tsx @@ -52,7 +52,7 @@ export function planWindowViewBox(tree: PlanTree, current: string | null): strin : Math.max(layout.width, 1) / 2; const cy = focusId !== null - ? (layout.y.get(focusId) ?? 0) + NODE_H / 2 + ? (layout.y.get(focusId) ?? 0) + (layout.nodeH.get(focusId) ?? NODE_H) / 2 : Math.max(layout.height, 1) / 2; const clamp = (c: number, extent: number, view: number) => extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2); diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 0043a4d..59b0be3 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -58,10 +58,14 @@ describe("plan block layout (pure)", () => { assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); }); - it("wrapTitle wraps greedily and ellipsizes overflow", () => { + it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => { assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]); - assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped + // Oversized word hard-chunked, nothing lost (owner, round 7). + assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [ + "supercalifragilistic", + "expialidocious word", + ]); assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits }); }); From 4d985253388ed33be865fc2f8561e6cc8de53a75 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:30:53 +0300 Subject: [PATCH 08/18] =?UTF-8?q?Plan-graph=20round=208=20(owner):=20place?= =?UTF-8?q?ment=20now=20runs=20on=20dagre=20=E2=80=94=20mermaid's=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner chose: adopt the same layout engine regular mermaid uses, keep our SVG skin. That engine is dagre (Sugiyama-style layered layout) — and mermaid 11 ships dagre-d3-es 7.0.14, so we import exactly that fork, via the same deep subpath mermaid itself uses. No new code shipped; pinned verbatim in package.json (lane touch — flagged to master). blockLayout keeps its signature ({ x, y, nodeH, width, height }, all consumers unchanged): it feeds dagre our geometry (NODE_W boxes, GAP_X/ GAP_Y separations, zero margin, per-node grown heights from round 7), runs the layout (deterministic, synchronous, DOM-free — SSR and render tests stay exact), and converts dagre's node centers back to top-lefts. Dangling edges are ignored defensively (a ghost endpoint would spawn an unmeasured node and break the measurer). topoFor stays exported for order/depth semantics (tests); placement no longer calls it. dagre's network-simplex ranker + crossing minimization now handle multi-parent DAGs, so future plan shapes route like mermaid diagrams do. On the existing tree fixtures dagre's numbers agree exactly with the old hand-rolled layout — every render pin survives verbatim. --- components/plan-block.tsx | 85 +++++++++++++++++++++++---------------- next-env.d.ts | 4 +- package.json | 1 + 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index ef56a8c..7c31d7f 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -3,6 +3,12 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +// The placement engine — literally mermaid's: dagre-d3-es, the fork +// mermaid 11 ships with (owner, plan-graph round 8: same engine as the +// mermaid graphs, our SVG skin stays). The deep subpath is how mermaid +// itself imports it. +import { graphlib } from "dagre-d3-es"; +import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; @@ -21,9 +27,10 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; * are frozen snapshots rendered exactly as carried (never re-derived); * the popup is the live view (user decision, 2026-09). * - * Hand-built SVG everywhere (user decision): a deterministic layered - * layout fills exactly and stays SSR-testable — no force simulation, no - * coupling to the graph stream's sigma internals. Pan/zoom in the popup + * Hand-built SVG everywhere (user decision): placement runs on dagre + * (mermaid's engine — deterministic, synchronous, DOM-free, so SSR and + * render tests stay exact); the skin is ours — no coupling to the graph + * stream's internals. Pan/zoom in the popup * is harvested from the retired tree panel's graph (fit-floor semantics: * fully zoomed out IS the fit; panning locks at the floor). */ @@ -43,7 +50,9 @@ export interface LayoutNode { title: string; } -/** Topological order + longest-path depth (Kahn, authoring tie-break). */ +/** Topological order + longest-path depth (Kahn, authoring tie-break) — + * order + depth semantics kept for tests; placement itself runs on dagre + * now (owner, round 8). */ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { const index = new Map(nodes.map((n, i) => [n.id, i] as const)); const indegree = new Map(nodes.map((n) => [n.id, 0] as const)); @@ -82,42 +91,48 @@ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ fro return { order, depth }; } -/** Layered positions: depth = row (top-down), authoring order within the - * row, each row centered on the widest one. */ +/** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style + * layered layout, TB, with our geometry (NODE_W boxes, GAP_X/GAP_Y + * separations, zero margin — the caller adds GRAPH_PAD/POPUP_PAD). + * Node sizes are fed in (boxes grow with their wrapped content, round + * 7); dagre returns node CENTERS, we hand back top-lefts like before, + * so every consumer (diagram, popup fit, plan window) is unchanged. + * Deterministic + synchronous + DOM-free: SSR and render tests stay + * exact. Dangling edges are ignored defensively — a ghost endpoint + * would spawn an unmeasured node and break the measurer. */ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { - const { order, depth } = topoFor(nodes, edges); - const layers: LayoutNode[][] = []; - for (const n of order) { - const d = depth.get(n.id) ?? 0; - if (!layers[d]) layers[d] = []; - layers[d].push(n); - } - const rowWidth = (l: LayoutNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X; - let maxWidth = 0; - for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); const x = new Map(); const y = new Map(); - // Per-node heights: boxes grow to show the FULL wrapped content — - // nothing is ever truncated (owner, plan-graph round 7). const nodeH = new Map(); - let cursor = 0; - layers.forEach((l) => { - // The row's height = its tallest node. - let rowH = NODE_H; - for (const n of l) { - const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H; - nodeH.set(n.id, h); - rowH = Math.max(rowH, h); + if (nodes.length === 0) return { x, y, nodeH, width: 0, height: 0 }; + const ids = new Set(nodes.map((n) => n.id)); + const g = new graphlib.Graph({ compound: false, multigraph: true }); + g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 }); + for (const n of nodes) { + const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H; + nodeH.set(n.id, h); + g.setNode(n.id, { width: NODE_W, height: h }); + } + for (const e of edges) { + if (ids.has(e.from) && ids.has(e.to)) g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 }); + } + // (the d.ts marks opts required; the runtime ignores it) + dagreLayout(g, undefined); + for (const n of nodes) { + const pos = g.node(n.id); + const h = nodeH.get(n.id) ?? NODE_H; + if (pos) { + x.set(n.id, pos.x - NODE_W / 2); + y.set(n.id, pos.y - h / 2); } - const start = (maxWidth - rowWidth(l)) / 2; - l.forEach((n, i) => { - x.set(n.id, start + i * (NODE_W + GAP_X)); - y.set(n.id, cursor); - }); - cursor += rowH + GAP_Y; - }); - const height = layers.length > 0 ? cursor - GAP_Y : 0; - return { x, y, nodeH, width: maxWidth, height }; + } + return { + x, + y, + nodeH, + width: Math.max(g.graph().width ?? 0, 1), + height: Math.max(g.graph().height ?? 0, 1), + }; } /** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the diff --git a/next-env.d.ts b/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 5eb7a45..29b261f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre-d3-es": "7.0.14", "embla-carousel-react": "^8.6.0", "graphology": "^0.26.0", "graphology-layout-forceatlas2": "^0.10.1", From b97a34aea4742805a8e00bc15028f69e40ba629a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:33:00 +0300 Subject: [PATCH 09/18] =?UTF-8?q?Plan-graph=20round=209=20(owner):=20white?= =?UTF-8?q?=20nodes=20dim=20=E2=80=94=20no=20longer=20fully=20transparent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrows routed behind a node showed straight through the fill-none boxes. The white (visited/current/monochrome) boxes now carry a 50% opacity fill of the standard app background color (fill-background/50): an edge passing behind composites UNDER the fill — dimmed to half, not shown through — while the no-fill look survives on the background surface. Upcoming (grayed) nodes keep their solid card fill. --- components/plan-block.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 7c31d7f..24a33e3 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -205,8 +205,11 @@ const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE; // bleeding over the edges). The IN-CHAT block is monochrome white instead // — no status language; the popup/window show the progression. const BOX: Record = { - visited: "fill-none stroke-white", - current: "fill-none stroke-white", + // 50%-opacity app background instead of true transparency: a routed + // edge passing behind the box composites UNDER the fill — dimmed to + // half, not shown straight through (owner, round 9). + visited: "fill-background/50 stroke-white", + current: "fill-background/50 stroke-white", // The grayed-out not-yet-visited look stays as-is (owner). upcoming: "fill-card stroke-border", }; @@ -215,7 +218,7 @@ const TEXT: Record = { current: "fill-white text-xs font-medium", upcoming: "fill-muted-foreground/80 text-xs", }; -const MONO_BOX = "fill-none stroke-white"; +const MONO_BOX = "fill-background/50 stroke-white"; const MONO_TEXT = "fill-white text-xs"; /** The diagram itself: positioned nodes + bezier edges. Pure-ish render — From 90ed65f208ba6001a04908eae3718fd6ab776c72 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:34:01 +0300 Subject: [PATCH 10/18] Plan-graph round 10 (owner): the glow paints above the box fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint order inside a node group: box fill/stroke → yellow glow → text. The round-9 background fill no longer dims the glow inside the current node's box, edges behind boxes still dim under the fill (round 9 intact), and the title text stays crisp above the glow. --- components/plan-block.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 24a33e3..d681d27 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -349,9 +349,19 @@ export function PlanDiagram({ data-testid={`plan-node-${n.id}`} data-status={status} > + {/* Rectangular — no corner rounding (owner). */} + {glow && ( // The current node's highlight: faint yellow, blurred so it // bleeds over the edges — static, no pulse (owner, round 6). + // Paints ABOVE the box fill (owner, round 10): the round-9 + // background fill no longer dims the glow inside the box, + // while edges behind the box still dim under that fill. )} - {/* Rectangular — no corner rounding (owner). */} - {lines.map((line, i) => ( Date: Mon, 14 Sep 2026 07:38:13 +0300 Subject: [PATCH 11/18] =?UTF-8?q?Plan-graph=20round=2011=20(owner):=20merm?= =?UTF-8?q?aid's=20edge=20drawing=20=E2=80=94=20dagre=20routes,=20curveBas?= =?UTF-8?q?is=20draws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old lines were our hand-rolled shortcut: every arrow from the source's bottom-center to the target's top-center as a single S-bezier. Dagre actually routes each edge as border-intersected waypoints that leave/enter nodes on whatever side the route needs — mermaid's behavior. We now keep that routing (owner: adopt it). blockLayout also returns edgePts — dagre's per-edge waypoint lists. edgePathD draws them through d3's curveBasis, the exact recipe dagre-d3's own renderer uses (and mermaid's default flowchart curve): endpoints repeat so the line starts at the source border and the END pulls back by the tip overhang so the arrowhead's tip lands on the target border with its mouth at the path's end. Pinned in tests. Dangling/unroutable edges draw nothing. d3-shape added as a direct dependency (^3.2.0, already in the tree via dagre-d3-es — lane touch, flagged to master with dagre-d3-es). --- components/plan-block.tsx | 60 ++++++++++++++++++++++++++------ package.json | 1 + tests/render-plan-block.test.tsx | 11 ++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index d681d27..dd8ff7d 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -9,6 +9,9 @@ import { cn } from "@/lib/utils"; // itself imports it. import { graphlib } from "dagre-d3-es"; import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js"; +// Mermaid's default flowchart edge curve: dagre's waypoints drawn through +// d3's curveBasis (the exact recipe dagre-d3's own renderer uses). +import { curveBasis, line } from "d3-shape"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; @@ -104,8 +107,14 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ const x = new Map(); const y = new Map(); const nodeH = new Map(); - if (nodes.length === 0) return { x, y, nodeH, width: 0, height: 0 }; + /** Dagre's per-edge waypoint routes — each starts/ends at the node + * border intersection on whatever SIDE the edge leaves/enters + * (round 11: the mermaid line drawing, no more fixed bottom-center → + * top-center). Keyed `${from}->${to}`. */ + const edgePts = new Map>(); + if (nodes.length === 0) return { x, y, nodeH, edgePts, width: 0, height: 0 }; const ids = new Set(nodes.map((n) => n.id)); + const routed: Array<{ from: string; to: string }> = []; const g = new graphlib.Graph({ compound: false, multigraph: true }); g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 }); for (const n of nodes) { @@ -114,7 +123,10 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ g.setNode(n.id, { width: NODE_W, height: h }); } for (const e of edges) { - if (ids.has(e.from) && ids.has(e.to)) g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 }); + if (ids.has(e.from) && ids.has(e.to)) { + g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 }); + routed.push(e); + } } // (the d.ts marks opts required; the runtime ignores it) dagreLayout(g, undefined); @@ -126,10 +138,15 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ y.set(n.id, pos.y - h / 2); } } + for (const e of routed) { + const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points; + if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y }))); + } return { x, y, nodeH, + edgePts, width: Math.max(g.graph().width ?? 0, 1), height: Math.max(g.graph().height ?? 0, 1), }; @@ -196,6 +213,30 @@ const HEAD_W = 50.2 * HEAD_SCALE; * overhangs the line's end by this much, so edges shorten by it to land * the tip exactly on the target node's border. */ const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE; +const edgeLine = line<{ x: number; y: number }>() + .x((p) => p.x) + .y((p) => p.y) + .curve(curveBasis); + +/** The edge path — mermaid's own line drawing: dagre's border-intersected + * waypoints through d3's curveBasis (mermaid's default flowchart curve — + * the same recipe dagre-d3's renderer uses). The final point pulls back + * along the last segment by the tip overhang so the head's TIP lands on + * the target border while the mouth sits at the path's end. Pure, + * exported for tests. */ +export function edgePathD(points: Array<{ x: number; y: number }>): string { + const pts = points.map((p) => ({ ...p })); + if (pts.length >= 2) { + const last = pts[pts.length - 1]!; + const prev = pts[pts.length - 2]!; + const dx = last.x - prev.x; + const dy = last.y - prev.y; + const len = Math.hypot(dx, dy) || 1; + const t = Math.min(HEAD_TIP_OVERHANG / len, 0.5); + pts[pts.length - 1] = { x: last.x - dx * t, y: last.y - dy * t }; + } + return edgeLine(pts) ?? ""; +} // ─── Status styling (the retired panel's color language, unchanged) ────────── @@ -315,20 +356,19 @@ export function PlanDiagram({ {edges.map((e) => { - const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; - const y1 = (layout.y.get(e.from) ?? 0) + (layout.nodeH.get(e.from) ?? NODE_H); - const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; - // Shortened by the tip overhang so the head's TIP lands on the - // node border while the line stops at the notch mouth. - const y2 = (layout.y.get(e.to) ?? 0) - HEAD_TIP_OVERHANG; - const mid = (y1 + y2) / 2; + // Dagre's route for this edge: border-intersected waypoints — + // the line leaves/enters nodes on whatever side dagre chose + // (mermaid's drawing, owner round 11). Unroutable edges + // (dangling endpoints) draw nothing. + const pts = layout.edgePts.get(`${e.from}->${e.to}`); + if (!pts) return null; // An arrow into a not-yet-visited node is grayed out like the // node (owner, round 6); the in-chat block stays all-white. const dim = !monochrome && statusOf(e.to) === "upcoming"; return ( ${e.to}`} - d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} + d={edgePathD(pts)} className={dim ? "fill-none stroke-border" : "fill-none stroke-white"} strokeWidth={1.5} markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`} diff --git a/package.json b/package.json index 29b261f..55c4e63 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-shape": "^3.2.0", "dagre-d3-es": "7.0.14", "embla-carousel-react": "^8.6.0", "graphology": "^0.26.0", diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 59b0be3..8fca43f 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -6,6 +6,7 @@ import { PlanPopup, blockLayout, topoFor, + edgePathD, wrapTitle, GAP_X, NODE_H, @@ -58,6 +59,16 @@ describe("plan block layout (pure)", () => { assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); }); + it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => { + // Straight 3-point route (source border → mid → target border): the + // basis curve starts exactly at the border point and the END pulls + // back by HEAD_TIP_OVERHANG so the arrowhead's TIP lands on the + // border while its mouth sits at the path's end (owner, round 11). + const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]); + assert.ok(d.startsWith("M80,44"), "starts at the source border point"); + assert.ok(d.endsWith("L80,88.462"), "ends pulled back by the tip overhang"); + }); + it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => { assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]); From 847caf48d375551990bf7d15add630ddbab7206e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:53:54 +0300 Subject: [PATCH 12/18] Plan-graph round 12 (owner): docking gap + straight shaft stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two edge-drawing refinements (owner), both pure post-processing in edgePathD on dagre's waypoints: - Docking gap (4 units ≈ half a head length): the head's tip floats short of the target face instead of touching it — the mouth pulls back by tip overhang + gap, so the head kisses nothing and tilted entries stop biting into the box. - Shaft stub (12 units ≈ 1.5 head lengths): from Q, every control point sits exactly on the approach line, so the basis spline renders that stretch perfectly straight and the head lands on a straight shaft; the corner where the route squares up is rounded by the curve, and the repeated mouth point makes the end tangent exactly the approach direction. Collinear routes render identically (pinned: on-axis start to mouth). Angle alignment/perpendicular entry deliberately NOT touched yet — owner wants to evaluate this first (round 13 candidate). --- components/plan-block.tsx | 31 ++++++++++++++++++++++++++----- tests/render-plan-block.test.tsx | 13 +++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index dd8ff7d..014b3e3 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -213,6 +213,16 @@ const HEAD_W = 50.2 * HEAD_SCALE; * overhangs the line's end by this much, so edges shorten by it to land * the tip exactly on the target node's border. */ const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE; +/** The head floats this far off the target face — the tip kisses nothing + * (owner, round 12). ~half a head length: clearly docked-off, still + * visually attached. */ +const DOCK_GAP = 4; +/** The shaft stub — the straight run before the head (owner, round 12): + * control points from Q on sit exactly on the approach line, so the + * basis spline renders that stretch perfectly straight and the head + * lands on a straight shaft; the corner where the route squares up onto + * the line is rounded by the curve. ~1.5 head lengths of shaft. */ +const STRAIGHT_RUN = 12; const edgeLine = line<{ x: number; y: number }>() .x((p) => p.x) .y((p) => p.y) @@ -221,9 +231,10 @@ const edgeLine = line<{ x: number; y: number }>() /** The edge path — mermaid's own line drawing: dagre's border-intersected * waypoints through d3's curveBasis (mermaid's default flowchart curve — * the same recipe dagre-d3's renderer uses). The final point pulls back - * along the last segment by the tip overhang so the head's TIP lands on - * the target border while the mouth sits at the path's end. Pure, - * exported for tests. */ + * along the last segment by the tip overhang + the docking gap so the + * head's TIP floats DOCK_GAP short of the target border with its mouth + * at the path's end, on a straight run (the shaft stub). Straight + * collinear routes render identically. Pure, exported for tests. */ export function edgePathD(points: Array<{ x: number; y: number }>): string { const pts = points.map((p) => ({ ...p })); if (pts.length >= 2) { @@ -232,8 +243,18 @@ export function edgePathD(points: Array<{ x: number; y: number }>): string { const dx = last.x - prev.x; const dy = last.y - prev.y; const len = Math.hypot(dx, dy) || 1; - const t = Math.min(HEAD_TIP_OVERHANG / len, 0.5); - pts[pts.length - 1] = { x: last.x - dx * t, y: last.y - dy * t }; + const ux = dx / len; + const uy = dy / len; + // The mouth: tip overhang + the docking gap back from the border. + const back = HEAD_TIP_OVERHANG + DOCK_GAP; + const M = { x: last.x - ux * back, y: last.y - uy * back }; + // From Q every control point lies on the approach line — the basis + // spline renders Q→M perfectly straight (a B-spline span with + // collinear control points IS the line), and the repeated M makes + // the end tangent exactly the approach direction, so the head + // squares onto its shaft. + const Q = { x: M.x - ux * STRAIGHT_RUN, y: M.y - uy * STRAIGHT_RUN }; + pts.splice(pts.length - 1, 1, Q, M, M); } return edgeLine(pts) ?? ""; } diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 8fca43f..2b92766 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -61,12 +61,17 @@ describe("plan block layout (pure)", () => { it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => { // Straight 3-point route (source border → mid → target border): the - // basis curve starts exactly at the border point and the END pulls - // back by HEAD_TIP_OVERHANG so the arrowhead's TIP lands on the - // border while its mouth sits at the path's end (owner, round 11). + // basis curve starts exactly at the border point; the END pulls + // back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the + // head's TIP floats 4 short of the border with its mouth at the + // path's end, on a straight shaft (owner, rounds 11–12). const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]); assert.ok(d.startsWith("M80,44"), "starts at the source border point"); - assert.ok(d.endsWith("L80,88.462"), "ends pulled back by the tip overhang"); + assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border"); + // The whole run stays on-axis (every x is 80): a collinear route + // renders straight — the shaft stub never bends a straight edge. + const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]); + assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis"); }); it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => { From d7790f895eb7b31afd7358abf75bb7e90911f13c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:56:01 +0300 Subject: [PATCH 13/18] =?UTF-8?q?Plan-graph=20round=2013=20(owner):=20arro?= =?UTF-8?q?wheads=20point=20again=20=E2=80=94=20no=20zero-length=20final?= =?UTF-8?q?=20segment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-12 regression: manually repeating the mouth point as a control point made d3 emit a degenerate final path command (a zero-length L). orient="auto" derives the marker angle from the direction of the LAST path segment; a zero-length segment has none, so every head fell back to 0° — pointing right. Fix: drop the manual repeat — curveBasis already repeats the endpoint internally, so controls ending [Q, M] keep the exact end tangent (the approach direction) AND a real final segment (the straight Q→M stub). Left a comment in edgePathD so this never regresses again. --- components/plan-block.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 014b3e3..5b3f469 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -248,13 +248,16 @@ export function edgePathD(points: Array<{ x: number; y: number }>): string { // The mouth: tip overhang + the docking gap back from the border. const back = HEAD_TIP_OVERHANG + DOCK_GAP; const M = { x: last.x - ux * back, y: last.y - uy * back }; - // From Q every control point lies on the approach line — the basis + // From Q the control points lie on the approach line — the basis // spline renders Q→M perfectly straight (a B-spline span with - // collinear control points IS the line), and the repeated M makes - // the end tangent exactly the approach direction, so the head - // squares onto its shaft. + // collinear control points IS the line), and curveBasis's internal + // endpoint repetition makes the end tangent exactly the approach + // direction. NOTE: do NOT repeat M manually — a duplicated final + // control point renders a zero-length last segment, and + // orient="auto" on a zero-length segment falls back to 0° (every + // head pointing right — the round-12 regression, fixed round 13). const Q = { x: M.x - ux * STRAIGHT_RUN, y: M.y - uy * STRAIGHT_RUN }; - pts.splice(pts.length - 1, 1, Q, M, M); + pts.splice(pts.length - 1, 1, Q, M); } return edgeLine(pts) ?? ""; } From a207e6b4a29ac6258a63b99bfdf35cadaef795fb Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:04:31 +0300 Subject: [PATCH 14/18] =?UTF-8?q?Plan-graph=20round=2014=20(owner):=20entr?= =?UTF-8?q?y-angle=20clamp=20=E2=80=94=20heads=20enter=20within=20=C2=B140?= =?UTF-8?q?=C2=B0=20of=20the=20face=20perpendicular?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner measured the arrowhead in Photoshop: past ~40° off the perpendicular of the box edge, one barb crosses the tip's level and the silhouette breaks. edgePathD now takes the target box and clamps the final approach: the entry direction may tilt at most ±ENTRY_ANGLE_LIMIT (40°) off the entry face's outward normal — a steeper natural approach swings to the cone edge (same rotation side as the route), the shaft stub and head follow the clamped direction. Docking refined: the tip floats DOCK_GAP past the border along the FACE NORMAL (constant gap at any tilt; perpendicular entries are bit-identical to before — pinned). Faces are derived from where dagre's border point sits on the target box (top/bottom/left/right all supported). Edges without a known target box keep the unclamped geometry. Pinned: cone-edge swing at 40° exactly, normal-gap docking, unclamped shallow entries. --- components/plan-block.tsx | 90 +++++++++++++++++++++++++++----- tests/render-plan-block.test.tsx | 26 +++++++++ 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 5b3f469..e696ea1 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -223,19 +223,51 @@ const DOCK_GAP = 4; * lands on a straight shaft; the corner where the route squares up onto * the line is rounded by the curve. ~1.5 head lengths of shaft. */ const STRAIGHT_RUN = 12; +/** The tilt limit at the termination point, measured off the entry + * face's perpendicular: past ±40° one of the head's barbs crosses the + * tip's level and the silhouette breaks (owner measured it in + * Photoshop, round 14). The final run swings to the cone edge instead. */ +const ENTRY_ANGLE_LIMIT = (40 * Math.PI) / 180; const edgeLine = line<{ x: number; y: number }>() .x((p) => p.x) .y((p) => p.y) .curve(curveBasis); +/** The entry face's outward unit normal — whichever face of the target + * box dagre's border point sits on. Pure. */ +function faceNormal( + border: { x: number; y: number }, + box: { x: number; y: number; w: number; h: number }, +): { x: number; y: number } { + const d = { top: border.y - box.y, bottom: box.y + box.h - border.y, left: border.x - box.x, right: box.x + box.w - border.x }; + const face = (Object.keys(d) as Array).reduce((a, b) => (d[a] <= d[b] ? a : b)); + switch (face) { + case "top": + return { x: 0, y: -1 }; + case "bottom": + return { x: 0, y: 1 }; + case "left": + return { x: -1, y: 0 }; + default: + return { x: 1, y: 0 }; + } +} + /** The edge path — mermaid's own line drawing: dagre's border-intersected * waypoints through d3's curveBasis (mermaid's default flowchart curve — - * the same recipe dagre-d3's renderer uses). The final point pulls back - * along the last segment by the tip overhang + the docking gap so the - * head's TIP floats DOCK_GAP short of the target border with its mouth - * at the path's end, on a straight run (the shaft stub). Straight - * collinear routes render identically. Pure, exported for tests. */ -export function edgePathD(points: Array<{ x: number; y: number }>): string { + * the same recipe dagre-d3's renderer uses). The head docks off the + * target: its TIP floats DOCK_GAP past the border along the FACE NORMAL + * (constant gap at any tilt), its mouth sits at the path's end on a + * straight run (the shaft stub), and — when the target box is given — + * the entry direction is clamped to ±ENTRY_ANGLE_LIMIT of the face's + * perpendicular (owner, round 14): a steeper natural approach swings to + * the 40° cone edge instead of breaking the head's silhouette. Without + * a target box the unclamped geometry applies. Straight collinear + * routes render identically. Pure, exported for tests. */ +export function edgePathD( + points: Array<{ x: number; y: number }>, + target?: { x: number; y: number; w: number; h: number }, +): string { const pts = points.map((p) => ({ ...p })); if (pts.length >= 2) { const last = pts[pts.length - 1]!; @@ -243,11 +275,38 @@ export function edgePathD(points: Array<{ x: number; y: number }>): string { const dx = last.x - prev.x; const dy = last.y - prev.y; const len = Math.hypot(dx, dy) || 1; - const ux = dx / len; - const uy = dy / len; - // The mouth: tip overhang + the docking gap back from the border. - const back = HEAD_TIP_OVERHANG + DOCK_GAP; - const M = { x: last.x - ux * back, y: last.y - uy * back }; + let ux = dx / len; + let uy = dy / len; + // The tip: DOCK_GAP outside the face along its normal (or along the + // approach when no box is known). + let tipx: number; + let tipy: number; + if (target) { + const n = faceNormal(last, target); + tipx = last.x + n.x * DOCK_GAP; + tipy = last.y + n.y * DOCK_GAP; + // Clamp the approach to the ±LIMIT cone around the normal: if + // the outgoing direction -u tilts past ENTRY_ANGLE_LIMIT, swing + // it to the cone edge (same rotation side as the route). + const v = { x: -ux, y: -uy }; + const vn = v.x * n.x + v.y * n.y; + if (vn < Math.cos(ENTRY_ANGLE_LIMIT)) { + const t = { x: v.x - vn * n.x, y: v.y - vn * n.y }; + const tl = Math.hypot(t.x, t.y); + const that = tl > 0 ? { x: t.x / tl, y: t.y / tl } : { x: -n.y, y: n.x }; + const c = Math.cos(ENTRY_ANGLE_LIMIT); + const s = Math.sin(ENTRY_ANGLE_LIMIT); + v.x = c * n.x + s * that.x; + v.y = c * n.y + s * that.y; + ux = -v.x; + uy = -v.y; + } + } else { + tipx = last.x - ux * DOCK_GAP; + tipy = last.y - uy * DOCK_GAP; + } + // The mouth: overhang back from the tip along the (clamped) approach. + const M = { x: tipx - ux * HEAD_TIP_OVERHANG, y: tipy - uy * HEAD_TIP_OVERHANG }; // From Q the control points lie on the approach line — the basis // spline renders Q→M perfectly straight (a B-spline span with // collinear control points IS the line), and curveBasis's internal @@ -386,13 +445,20 @@ export function PlanDiagram({ // (dangling endpoints) draw nothing. const pts = layout.edgePts.get(`${e.from}->${e.to}`); if (!pts) return null; + // The target's box — drives the docking gap + entry-angle clamp. + const tx = layout.x.get(e.to); + const ty = layout.y.get(e.to); + const target = + tx !== undefined && ty !== undefined + ? { x: tx, y: ty, w: NODE_W, h: layout.nodeH.get(e.to) ?? NODE_H } + : undefined; // An arrow into a not-yet-visited node is grayed out like the // node (owner, round 6); the in-chat block stays all-white. const dim = !monochrome && statusOf(e.to) === "upcoming"; return ( ${e.to}`} - d={edgePathD(pts)} + d={edgePathD(pts, target)} className={dim ? "fill-none stroke-border" : "fill-none stroke-white"} strokeWidth={1.5} markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`} diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 2b92766..f48ce7d 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -72,6 +72,32 @@ describe("plan block layout (pure)", () => { // renders straight — the shaft stub never bends a straight edge. const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]); assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis"); + // A perpendicular entry with the target box is the SAME geometry. + const pd = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }], { x: 0, y: 92, w: 160, h: 44 }); + assert.equal(pd, d); + }); + + it("edgePathD clamps the entry to ±40° of the face perpendicular (owner, round 14)", () => { + const box = { x: 20, y: 100, w: 160, h: 44 }; + // A 60° tilt into the top face swings to the 40° cone edge: the + // mouth lands at (97.726, 93.289) and the final segment runs at + // exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the + // face along the normal: border (100,100) → tip (100,96). + const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box); + assert.ok(clamped.endsWith("L97.726,93.289"), "swung to the cone edge"); + // final L's from-point is the previous number pair; recompute direction + const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1])); + const ax = nums[nums.length - 4]!; + const ay = nums[nums.length - 3]!; + const bx = nums[nums.length - 2]!; + const by = nums[nums.length - 1]!; + assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge"); + // Tip = mouth + u'·overhang floats 4 above the face. + const tipY = by + 0.766 * 3.538; + assert.ok(Math.abs(tipY - 96) < 0.01, "docking gap along the face normal"); + // A shallow (~9.5°) tilt is WITHIN the limit: no swing. + const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box); + assert.ok(free.endsWith("L99.418,92.51"), "unclamped within the cone"); }); it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => { From 6505f12d3701c32cd2a18bc611531d5d8c176a61 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:10:27 +0300 Subject: [PATCH 15/18] Stats-control round 1 (owner): bare labels + intuitive dropdown close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model + thinking control refinements (components/model-picker.tsx): - Drop the '·' separator between the two buttons. - The thinking button shows just the level ('High', 'Max') — same doctrine as the model button showing just the model name; '—' when unknown. Pinned in render-stats-bar (label + no-dot assertions). - The dropdowns now close on any pointerdown outside the control (the toggle buttons stay inside the container and keep their own toggling) and on Esc — the intuitive dropdown contract, instead of the button being the only way out. Note: file is graph-stream-owned per SCOPE; touching it per the owner's direct request (same as cd518a4) — flagged for master. --- components/model-picker.tsx | 32 ++++++++++++++++++++++++++++---- tests/render-stats-bar.test.tsx | 7 +++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 48c0c8a..9dbba2c 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -101,6 +101,7 @@ export function ModelControl({ const [query, setQuery] = useState(""); const [error, setError] = useState(null); const searchRef = useRef(null); + const containerRef = useRef(null); const defaults = catalog?.defaults; const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId; @@ -114,6 +115,28 @@ export function ModelControl({ if (open === "model") searchRef.current?.focus(); }, [open]); + // The intuitive dropdown contract (owner, stats-control round 1): any + // click outside the open menu — or an Esc keypress — closes it. Only the + // menu's own surface keeps it open (the toggle buttons live inside the + // same container and handle their own toggling in onClick). + useEffect(() => { + if (open === null) return; + const onPointerDown = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(null); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(null); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + const save = useCallback( async (next: ChatSettings) => { setError(null); @@ -153,7 +176,7 @@ export function ModelControl({ .map((r) => r.m); return ( -
+
- · diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 57e184b..869f429 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -34,9 +34,12 @@ describe("stats bar", () => { assert.match(html, /data-testid="stats-bar"/); assert.match(html, /data-testid="stats-context">——]*>\s*]*>—<\/span>/); - assert.match(html, /Thinking —]*>\s*]*>—<\/span>/); + assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls"); // the control reads as a button: pill border + dropdown chevron assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/); assert.match(html, /▾/); From 07415048dff14b79b5a1a86fd24fa57ef2bc8ed1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:28:07 +0300 Subject: [PATCH 16/18] =?UTF-8?q?Scroll=20round=201=20(owner):=20overlay?= =?UTF-8?q?=20dock=20+=20spacer=20=E2=80=94=20the=20fold=20fires=20as=20so?= =?UTF-8?q?on=20as=20you=20leave=20the=20bottom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question box folded far too late because the old dock was IN FLOW inside the scroller: folding it shrank the scroll content, so the fold had to wait until the learner was a full dock-height past the bottom — any closer and the browser's scrollTop clamp pinned them back to the bottom, re-expanding the box (the oscillation the old hysteresis comments worked around). The dock now overlays the column bottom (absolute — a sibling of the scroller, the use-stick-to-bottom pattern, like the scroll button), and a spacer at the transcript's end reserves its height (DockOverlay measures its settled block and reports upward; ChatView's dockReserve state feeds the spacer). At-rest geometry is identical: the spacer reserves exactly the overlay footprint. The fold is now VISUAL ONLY: COLLAPSE_DIST 64px (was dock-height+64), EXPAND_DIST 16px, position-based, no velocity. The reserved tail GROWS immediately (typing, new variants) but never drops while a question is open — a shrinking tail would reintroduce the clamp; in chat mode drops apply (the composer typing-shrink is the old harmless behavior). expandIntent (2s bar-click suppression) and the teach reveal gate are unchanged. Verified live (headless chromium, dev 3001, pending-quiz chat): teach invitation mounts collapsed; click → expand + bottom; scroll up 200px → folds to the bar at d=200 with the spacer held (no geometry change, no yank); back to bottom → re-expands; spacer ≡ overlay footprint. Note: the overlay spans the column's full width, so on platforms with classic (non-overlay) scrollbars its gradient/background strip covers the bottom of the scrollbar track — invisible on macOS overlay scrollbars; flagged for master. --- components/chat.tsx | 186 ++++++++++++++++++++++++++++---------------- 1 file changed, 121 insertions(+), 65 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 71495e6..93549f9 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -40,6 +40,13 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; */ const COLUMN = "mx-auto w-full max-w-none px-6"; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; +// The dock overlay's gradient band (h-24) — part of the reserved tail. +const GRADIENT_H = 96; +// Fold thresholds (owner, scroll round 1): pixel distance from the bottom — +// position, not velocity. Leave the bottom → the box folds (visual only); +// reach it again → it re-opens. The dead band absorbs trackpad drift. +const COLLAPSE_DIST = 64; +const EXPAND_DIST = 16; /** * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat @@ -264,6 +271,13 @@ export function Chat({ // The per-chat model control state (model-picker writes via PUT; the // record is the source of truth, lifted here so the stats bar re-renders). const [settings, setSettings] = useState(initialSettings); + // The dock's reserved tail height (DockOverlay reports it; the spacer at + // the end of the transcript renders it). Lives here because the spacer is + // inside the scroller while the dock overlays OUTSIDE it — two parents. + // 208 ≈ gradient(96) + composer block(~112): the SSR/pre-hydration + // guess so the static paint has a sane tail. The first layout effect + // replaces it with the measured truth before paint. + const [dockReserve, setDockReserve] = useState(208); const syncTurns = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); @@ -458,7 +472,8 @@ export function Chat({ the plan-tree panel as full-height SIBLINGS — the panel spans top to bottom exactly like the left sidebar, and the header (name + phase chip) spans from the left sidebar's edge to the panel's edge. The - dock stays sticky inside the scroller, untouched. */} + dock overlays the column bottom (outside the scroller); a spacer in + the transcript reserves its height. */}
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only @@ -600,45 +615,56 @@ export function Chat({ )}
- stop()} - canSend={canSend} - verdict={ - planVerdict - ? { - onAccept: () => sendVerdict(true), - onDecline: () => sendVerdict(false), - } - : undefined - } - /> + {/* The dock's reserved tail (DockOverlay reports its height): + keeps at-rest transcript content scrolling fully clear of + the overlay — the flow-space replacement for the old + sticky dock. Zero only pre-measurement (the first layout + effect reports before paint). */} +
+ + {/* The dock: an overlay sibling of the scroller (the + use-stick-to-bottom pattern — like the scroll button). Before + the button in DOM order so the button keeps painting above + the dock, as it always has. */} + stop()} + canSend={canSend} + verdict={ + planVerdict + ? { + onAccept: () => sendVerdict(true), + onDecline: () => sendVerdict(false), + } + : undefined } - onSubmitAnswer={(output) => { - if (!pending) return; - void addToolOutput({ - tool: pending.tool as "quiz" | "ask_user_question", - toolCallId: pending.toolCallId, - output, - }); - }} /> - - + } + onSubmitAnswer={(output) => { + if (!pending) return; + void addToolOutput({ + tool: pending.tool as "quiz" | "ask_user_question", + toolCallId: pending.toolCallId, + output, + }); + }} + /> +
@@ -652,14 +678,27 @@ export function Chat({ } /** - * The floating blob: ONE persistent shell, sticky at the bottom of the - * scroller (in flow — the transcript scrolls fully clear of it, and it never - * covers the scrollbar). Contents swap between the chat composer and the - * question dock; shell height morphs between variants (WAAPI, quick). + * The floating blob: ONE persistent shell, OVERLAID at the bottom of the + * column (absolute — a sibling of the scroller, the use-stick-to-bottom + * pattern; it replaced the old in-flow sticky dock, which died of the + * fold-latency problem below). Contents swap between the chat composer and + * the question dock; shell height morphs between variants (WAAPI, quick). A + * spacer at the end of the transcript (rendered by ChatView from onReserve + * reports) holds the dock's flow-space, so at-rest content scrolls fully + * clear of the overlay. * - * Collapse: scroll up and the dock folds into a slim bar; click it (or scroll - * back to the bottom) to re-expand. The question form stays MOUNTED while - * collapsed (hidden, not unmounted) so a half-made selection survives. + * Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry + * — folding it shrinks the scroll content, so the fold had to wait until the + * learner was a full dock-height past the bottom (any closer and the browser + * clamp pinned them back, re-expanding the box: the oscillation). Decoupled + * from the flow, the fold is VISUAL ONLY: it fires as soon as the learner + * leaves the bottom, and the reserved tail only ever GROWS while a question + * is open (a shrinking tail would reintroduce the clamp). + * + * Collapse: scroll up past COLLAPSE_DIST and the dock folds into a slim bar; + * click it (or scroll back to the bottom) to re-expand. The question form + * stays MOUNTED while collapsed (hidden, not unmounted) so a half-made + * selection survives. */ function DockOverlay({ pending, @@ -671,6 +710,7 @@ function DockOverlay({ chatId, settings, onSettingsSaved, + onReserve, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -685,6 +725,9 @@ function DockOverlay({ chatId: string; settings?: ChatSettings; onSettingsSaved: (settings: ChatSettings) => void; + /** Reports the dock's settled height (block + gradient) — ChatView's + * dockReserve state feeds the transcript's spacer from it. */ + onReserve: (h: number) => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); // Teach-phase mount (session reload with a pending check): the dock starts @@ -718,13 +761,10 @@ function DockOverlay({ // fire only when a NEW question lands }, [pendingId]); - // ── Collapse state machine with hysteresis ── - // Collapsing frees ~Δpx of flow height. If it happened while the learner is - // within Δ of the bottom, the browser clamps scrollTop to the new max — - // landing them AT the bottom, which re-expands the dock, which re-collapses… - // the oscillation. So: collapse only once scrolled a full dock-height past - // the bottom (clamp-proof), expand only within 16px of the bottom, and leave - // a wide dead band between the two thresholds. Heights measured live. + // ── Fold machine (position, not velocity — owner, scroll round 1) ── + // The dock is an overlay: folding is visual-only and scroll geometry never + // moves, so the thresholds can hug the bottom. Leave it → fold; reach it + // again → re-open. The dead band keeps trackpad drift from chattering. const expandedRef = useRef(expanded); expandedRef.current = expanded; const pendingRef = useRef(!!pending); @@ -733,8 +773,21 @@ function DockOverlay({ requiresRevealRef.current = requiresReveal; const revealedRef = useRef(revealed); revealedRef.current = revealed; - const expandedHRef = useRef(0); - const collapsedHRef = useRef(42); + + // ── 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 + 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 @@ -763,10 +816,9 @@ function DockOverlay({ const onScroll = () => { if (!pendingRef.current) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; - const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240); if (expandedRef.current) { - if (d > delta + 64 && !expandIntentRef.current) setExpanded(false); - } else if (d < 16) { + if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); + } else if (d < EXPAND_DIST) { // Teach checks stay collapsed until the learner opens them (W5): the // at-bottom auto-expand would pop the form over the teaching. if (requiresRevealRef.current && !revealedRef.current) return; @@ -778,10 +830,12 @@ function DockOverlay({ return () => el.removeEventListener("scroll", onScroll); }, [scrollRef, scrollToBottom]); - const recordHeight = (h: number) => { - if (!pendingRef.current) return; - if (expandedRef.current) expandedHRef.current = h; - else collapsedHRef.current = h; + // The settled block height (gradient excluded), offered to the spacer. + // Only ever called with NATURAL heights — never the animated mid-morph + // value (the iso effect's getAnimations guard wraps every call). + const measureBlock = () => { + const b = blockRef.current; + if (b) offer(b.getBoundingClientRect().height); }; const mode = pending ? `question:${pending.toolCallId}` : "chat"; @@ -806,13 +860,15 @@ function DockOverlay({ if (el.getAnimations().length === 0) { const h = el.getBoundingClientRect().height; p.height = h; - recordHeight(h); + measureBlock(); // typing growth → the spacer grows with the box } return; } el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring const h = el.getBoundingClientRect().height; - recordHeight(h); + // Variant change → re-reserve. Growth applies instantly; a drop (the + // fold, or question → composer) is filtered by the offer policy. + measureBlock(); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { duration: 180, @@ -823,9 +879,9 @@ function DockOverlay({ }); return ( -
+
-
+
From 2e20bc629834018c221ed265639da4886703ee94 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:31:46 +0300 Subject: [PATCH 17/18] Scroll round 2 (owner): the scrollbar stays visible + wheel works over the dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two round-1 follow-ups (owner): - The overlay painted over the scroller's scrollbar track. The scroller reserves symmetric gutters (scrollbar-gutter: stable both-edges), so the overlay's right edge is now inset by the measured gutter — half of offsetWidth − clientWidth; zero on macOS overlay scrollbars, the real width on classic platforms. Re-measured on window resize. The headless shell renders classic scrollbars (11px), so the path is live-verified. - Wheel over the dock did nothing: the overlay is a SIBLING of the scroller, so native scroll chaining stops at the overlay (the old in-flow dock chained for free — a real regression, not a preference). A non-passive wheel listener on the dock block forwards the delta to the transcript ONLY when nothing under the cursor can take it: the question area, textarea overflow, and the model menus keep their native scrolling, including their own edge chaining; an unhandled wheel (over a fully-visible box, or at an internal scroller's edge) scrolls the transcript — so wheel-up over the quiz itself now folds the box under the cursor. Verified live: wheel-up over the question box scrolled the transcript (9460→9100) and folded to the bar; wheel-down handled cleanly; the overlay inset matches the measured gutter exactly. --- components/chat.tsx | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/components/chat.tsx b/components/chat.tsx index 93549f9..b6d7f13 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -774,6 +774,47 @@ function DockOverlay({ const revealedRef = useRef(revealed); revealedRef.current = revealed; + // 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. + const [sbInset, setSbInset] = useState(0); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [scrollRef]); + + // 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 + // native scroll chaining stops at the overlay (the old in-flow dock + // chained for free). Internal scrollers — the question area, textarea + // overflow, the model menus — keep native scrolling, including their + // own edge chaining; only when nothing under the cursor can take the + // delta do we forward it to the transcript. + useEffect(() => { + 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); + }; + 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 @@ -879,7 +920,7 @@ function DockOverlay({ }); return ( -
+
From b2a72136e02750c0a9da418eea3490cc0a5b7689 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:33:39 +0300 Subject: [PATCH 18/18] =?UTF-8?q?Scroll=20round=203=20(owner):=20fold=20th?= =?UTF-8?q?reshold=2064=20=E2=86=92=2096px=20(tolerate=20small=20peeks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/chat.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index b6d7f13..444ce6e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -42,10 +42,12 @@ const COLUMN = "mx-auto w-full max-w-none px-6"; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; // The dock overlay's gradient band (h-24) — part of the reserved tail. const GRADIENT_H = 96; -// Fold thresholds (owner, scroll round 1): pixel distance from the bottom — -// position, not velocity. Leave the bottom → the box folds (visual only); -// reach it again → it re-opens. The dead band absorbs trackpad drift. -const COLLAPSE_DIST = 64; +// Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance +// from the bottom — position, not velocity. Leave the bottom → the box +// folds (visual only); reach it again → it re-opens. 96 (was 64, owner +// round 3) tolerates a small peek upward without folding; the dead band +// vs EXPAND_DIST absorbs trackpad drift. +const COLLAPSE_DIST = 96; const EXPAND_DIST = 16; /**