diff --git a/components/chat.tsx b/components/chat.tsx index 71495e6..444ce6e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -40,6 +40,15 @@ 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, 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; /** * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat @@ -264,6 +273,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 +474,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 +617,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 +680,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 +712,7 @@ function DockOverlay({ chatId, settings, onSettingsSaved, + onReserve, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -685,6 +727,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 +763,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 +775,62 @@ function DockOverlay({ requiresRevealRef.current = requiresReveal; const revealedRef = useRef(revealed); revealedRef.current = revealed; - const expandedHRef = useRef(0); - const collapsedHRef = useRef(42); + + // 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 + // 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 +859,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 +873,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 +903,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 +922,9 @@ function DockOverlay({ }); return ( -
+
-
+
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/components/plan-block.tsx b/components/plan-block.tsx index 77db98b..e696ea1 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -3,6 +3,15 @@ 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"; +// 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"; @@ -12,17 +21,19 @@ 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 * 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). */ @@ -31,6 +42,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; @@ -40,7 +53,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)); @@ -79,68 +94,256 @@ 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(); - layers.forEach((l, d) => { - 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)); - }); - }); - const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y; - return { x, y, width: maxWidth, height }; + const nodeH = new Map(); + /** 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) { + 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 }); + routed.push(e); + } + } + // (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); + } + } + 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), + }; } -/** 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; } +// ─── 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; +/** 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. */ +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; +/** 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; +/** 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 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]!; + 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; + 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 + // 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); + } + return edgeLine(pts) ?? ""; +} + // ─── 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", + // 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", }; 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-background/50 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. */ @@ -151,6 +354,10 @@ export function PlanDiagram({ glowId, markerId, viewBoxOverride, + pad: padUnits, + textCapScale, + naturalSize, + monochrome, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; @@ -159,48 +366,112 @@ 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; + /** 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; + /** 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 + // 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 ( - + {/* 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. */} + + {/* 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) => { - 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; - 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; + // 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={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} - className="fill-none stroke-muted-foreground/40" + d={edgePathD(pts, target)} + className={dim ? "fill-none stroke-border" : "fill-none stroke-white"} strokeWidth={1.5} - markerEnd={`url(#${markerId})`} + markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`} /> ); })} {nodes.map((n) => { const status = statusOf(n.id); - const glow = n.id === glowId; + 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 ( + {/* Rectangular — no corner rounding (owner). */} + {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 — 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. )} - {lines.map((line, i) => ( - + {line} ))} @@ -254,7 +539,18 @@ 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). */} + ); } @@ -281,10 +577,16 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null { return null; } -/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */ -const ZOOM_MAX = 2; -const ZOOM_FLOOR = 0.15; +/** 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 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; +/** 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 { @@ -295,10 +597,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 { @@ -354,12 +655,22 @@ 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 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 * POPUP_PAD, h: layout.height + 2 * POPUP_PAD } : { w: 0, h: 0 }), + [layout], + ); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); 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; @@ -367,6 +678,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); + // 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(() => { @@ -386,12 +703,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); }; @@ -409,6 +731,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) => { @@ -462,7 +785,11 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa <>
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/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 6969412..f708a1b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "cmdk": "^1.1.1", "d3-force": "^3.0.0", "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", + "dagre-d3-es": "7.0.14", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 92a271b..f48ce7d 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,10 +59,55 @@ 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("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; 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,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"); + // 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)", () => { 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 }); }); @@ -82,11 +128,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"/); @@ -135,8 +184,13 @@ describe("plan popup (live derivation)", () => { 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 — 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/); }); 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, /▾/);