Plan placement (owner): the plan as marginalia — dock band's right flank + top-bar chip

Retires the fixed bottom-left plan window (which sat ON the sidebar and
bled into the session). Two placements replace it, per the owner's
lock-in:

- The plan chip in the top bar beside the phase chip — session-level
  structure beside session state; opens the full-DAG popup; present at
  every width; the access point.
- The plan marginalia: the BARE diagram (no card, no border, no shadow)
  in the dock band's right flank — marginalia on the lesson's page, not a
  floating widget. The band's reserved height (tall while a quiz is
  open) is its canvas; meet-fitted (PlanDiagram's new fill mode) so it
  fits both the flank's width and the band's height; live statuses +
  the yellow current-node glow; click opens the popup. Mounted only
  while a question owns the band — composer mode keeps the reading
  view pristine. Folding the dock fades + blurs it out of existence
  (300ms), returning to the bottom fades it back in. Rendered only when
  the flank holds a real diagram (>= 240px); narrower windows keep the
  chip alone.

Files: chat.tsx (chip, DockOverlay marginalia wrapper + flank measure,
PlanWindow removed), plan-window.tsx (PlanWindow → PlanMarginalia),
plan-block.tsx (PlanDiagram fill prop: h-full w-full meet-fit),
tests/plan-window.test.tsx (rewritten for the marginalia).
This commit is contained in:
amosblomqvist
2026-09-14 11:23:28 +03:00
parent 873c153525
commit 93033fac77
4 changed files with 146 additions and 149 deletions
+80 -7
View File
@@ -23,7 +23,8 @@ import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core"; import type { ChatSettings } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanWindow } from "@/components/plan-window"; import { PlanMarginalia } from "@/components/plan-window";
import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core"; import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock"; import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
@@ -40,6 +41,14 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
*/ */
const COLUMN = "mx-auto w-full max-w-none px-6"; const COLUMN = "mx-auto w-full max-w-none px-6";
const DOCK = "mx-auto w-full max-w-[46rem] px-4"; const DOCK = "mx-auto w-full max-w-[46rem] px-4";
// The DOCK container's fixed width (46rem) and the plan marginalia's
// geometry (owner, plan-placement round): the bare diagram sits in the
// dock band's RIGHT flank — MARGIN_GAP from the DOCK container's edge and
// the same from the column's right edge. Rendered only when the flank
// holds a real diagram (no slivers); otherwise the top-bar chip alone.
const DOCK_W = 46 * 16;
const MARGIN_GAP = 16;
const MARGIN_MIN_W = 240;
// The dock overlay's gradient band (h-24) — part of the reserved tail. // The dock overlay's gradient band (h-24) — part of the reserved tail.
const GRADIENT_H = 96; const GRADIENT_H = 96;
// Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance // Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance
@@ -212,8 +221,12 @@ export function Chat({
} }
}, [chatId, setMessages, sendMessage, status]); }, [chatId, setMessages, sendMessage, status]);
// The live plan popup (wave 4): opened by any transcript plan block. // The live plan popup (wave 4): opened by any transcript plan block, the
// top-bar plan chip, and the dock band's marginalia.
const [planOpen, setPlanOpen] = useState(false); const [planOpen, setPlanOpen] = useState(false);
// The session's live plan (drives the top-bar chip; the marginalia
// derives its own from the same transcript).
const planTree = useMemo(() => latestTree(messages), [messages]);
// ── The plan verdict gate (docs/probe-teach-split.md §4) ── // ── The plan verdict gate (docs/probe-teach-split.md §4) ──
// The plan has been presented (a tree exists) and the session still sits in // The plan has been presented (a tree exists) and the session still sits in
@@ -221,8 +234,8 @@ export function Chat({
// Accept / Decline buttons. Acceptance is the explicit plan→teach seam // Accept / Decline buttons. Acceptance is the explicit plan→teach seam
// (a data-plan-accepted part on the message); decline asks for revision. // (a data-plan-accepted part on the message); decline asks for revision.
const planVerdict = useMemo( const planVerdict = useMemo(
() => phaseAt3(messages) === "plan" && latestTree(messages) !== null, () => phaseAt3(messages) === "plan" && planTree !== null,
[messages], [messages, planTree],
); );
const sendVerdict = useCallback( const sendVerdict = useCallback(
(accepted: boolean) => { (accepted: boolean) => {
@@ -487,6 +500,21 @@ export function Chat({
<header className="flex h-10 items-center gap-3 border-b border-border px-3"> <header className="flex h-10 items-center gap-3 border-b border-border px-3">
<SidebarToggle /> <SidebarToggle />
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">{title}</div> <div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">{title}</div>
{/* The plan chip (owner, plan-placement round): session-level
structure beside the phase chip — access to the full-DAG popup
at every width; the marginalia is the wide-screen glance view. */}
{planTree && (
<button
type="button"
data-testid="plan-chip"
onClick={() => setPlanOpen(true)}
title="Open the full plan"
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
Plan
</button>
)}
<PhaseChip phase={phases.at(-1) ?? "probe"} /> <PhaseChip phase={phases.at(-1) ?? "probe"} />
</header> </header>
@@ -630,6 +658,8 @@ export function Chat({
chatId={chatId} chatId={chatId}
settings={settings} settings={settings}
onSettingsSaved={setSettings} onSettingsSaved={setSettings}
plan={<PlanMarginalia messages={messages} />}
onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal} requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"} phase={phases.at(-1) ?? "probe"}
turns={turns} turns={turns}
@@ -669,8 +699,6 @@ export function Chat({
</div> </div>
</div> </div>
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />} {planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
{/* The plan's ambient presence: live, zoomed on the current node; click → popup. */}
<PlanWindow messages={messages} onOpen={() => setPlanOpen(true)} />
</JobsContext.Provider> </JobsContext.Provider>
</ChatActionsContext.Provider> </ChatActionsContext.Provider>
); );
@@ -710,6 +738,8 @@ function DockOverlay({
settings, settings,
onSettingsSaved, onSettingsSaved,
onReserve, onReserve,
plan,
onOpenPlan,
}: { }: {
pending: ReturnType<typeof findPendingToolCall>; pending: ReturnType<typeof findPendingToolCall>;
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
@@ -727,6 +757,11 @@ function DockOverlay({
/** Reports the dock's settled height (block + gradient) — ChatView's /** Reports the dock's settled height (block + gradient) — ChatView's
* dockReserve state feeds the transcript's spacer from it. */ * dockReserve state feeds the transcript's spacer from it. */
onReserve: (h: number) => void; onReserve: (h: number) => void;
/** The plan marginalia's content (the bare diagram) — rendered only
* while a question owns the band. Null until a plan exists. */
plan?: React.ReactNode;
/** Opens the full-DAG popup (the marginalia + chip share it). */
onOpenPlan: () => void;
}) { }) {
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Teach-phase mount (session reload with a pending check): the dock starts // Teach-phase mount (session reload with a pending check): the dock starts
@@ -773,6 +808,24 @@ function DockOverlay({
const revealedRef = useRef(revealed); const revealedRef = useRef(revealed);
revealedRef.current = revealed; revealedRef.current = revealed;
// The plan marginalia's flank width (owner, plan-placement round): from
// the DOCK container's right edge to the block's right edge — the bare
// diagram's canvas. Re-measured every render (cheap offsetWidth read):
// window resizes flow through sbInset's listener, and the sidebar toggle
// re-renders the column without any event at all. Below MARGIN_MIN_W the
// margin is no margin — the chip alone carries the plan.
const [flankW, setFlankW] = useState(0);
const flankWRef = useRef(0);
useIsoLayoutEffect(() => {
const block = blockRef.current;
if (!block) return;
const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP);
if (Math.abs(next - flankWRef.current) > 1) {
flankWRef.current = next;
setFlankW(next);
}
});
// The overlay must not paint over the scroller's scrollbar (owner, scroll // The overlay must not paint over the scroller's scrollbar (owner, scroll
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
// stable both-edges), so inset the overlay's right edge by the gutter — // stable both-edges), so inset the overlay's right edge by the gutter —
@@ -921,7 +974,7 @@ function DockOverlay({
return ( return (
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}> <div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" /> <div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div ref={blockRef} className="bg-background pb-5"> <div ref={blockRef} className="relative bg-background pb-5">
<div className={DOCK}> <div className={DOCK}>
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg"> <div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
@@ -951,6 +1004,26 @@ function DockOverlay({
onSettingsSaved={onSettingsSaved} onSettingsSaved={onSettingsSaved}
/> />
</div> </div>
{/* The plan marginalia (owner, plan-placement round): the bare
diagram in the band's right flank — boundaless, meet-fitted, part
of the lesson's page rather than a floating widget. Mounted only
while a question owns the band (its reserved height is the canvas);
folding the dock fades + blurs it out of existence, so reading
the transcript in full wipes the margin clean. Click → popup. */}
{pending && plan && flankW >= MARGIN_MIN_W && (
<button
type="button"
data-testid="plan-marginalia"
onClick={onOpenPlan}
title="Open the full plan"
style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }}
className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${
expanded ? "pointer-events-auto opacity-100 blur-0" : "pointer-events-none opacity-0 blur-md"
}`}
>
{plan}
</button>
)}
</div> </div>
</div> </div>
); );
+7 -1
View File
@@ -358,6 +358,7 @@ export function PlanDiagram({
textCapScale, textCapScale,
naturalSize, naturalSize,
monochrome, monochrome,
fill,
}: { }: {
nodes: readonly LayoutNode[]; nodes: readonly LayoutNode[];
edges: ReadonlyArray<{ from: string; to: string }>; edges: ReadonlyArray<{ from: string; to: string }>;
@@ -379,6 +380,11 @@ export function PlanDiagram({
/** The in-chat block's mode: every node white (fill-none, white stroke /** The in-chat block's mode: every node white (fill-none, white stroke
* and text, no glow) — statuses never show in the transcript (owner). */ * and text, no glow) — statuses never show in the transcript (owner). */
monochrome?: boolean; monochrome?: boolean;
/** Meet-fits the diagram inside a fixed box (h-full w-full — the svg's
* default preserveAspectRatio centers + contains the viewBox): the
* dock band's marginalia mode, where the diagram must fit BOTH the
* flank's width and the band's height rather than run width-first. */
fill?: boolean;
}) { }) {
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
// Pad only the full-extent render — the plan window's override viewBox is // Pad only the full-extent render — the plan window's override viewBox is
@@ -391,7 +397,7 @@ export function PlanDiagram({
return ( return (
<svg <svg
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`} viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
className={`block h-auto${naturalSize ? "" : " w-full"}${textCapScale ? " mx-auto" : ""}`} className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
style={ style={
textCapScale textCapScale
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` } ? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
+30 -78
View File
@@ -1,102 +1,54 @@
"use client"; "use client";
import { useId, useMemo } from "react"; import { useId, useMemo } from "react";
import { WaypointsIcon } from "lucide-react";
import type { PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
import { import {
PlanDiagram, PlanDiagram,
blockLayout, GRAPH_PAD,
latestTree, latestTree,
NODE_H, TEXT_CAP_SCALE,
NODE_W,
type PopupMessage, type PopupMessage,
} from "@/components/plan-block"; } from "@/components/plan-block";
/** /**
* THE PLAN WINDOW (docs/probe-teach-split.md §6): the plan's persistent, * THE PLAN MARGINALIA: the plan's ambient presence in the session view —
* ambient presence — the successor of the injected plan blocks. Always on * the successor of the old fixed corner card (the plan window, retired
* once a tree exists, it shows a ZOOMED-IN window on the CURRENT node and * with the dock-band placement round). NO container: the bare diagram,
* moves by itself as the lesson advances (auto-pan: the viewBox re-centers * meet-fitted into the dock band's right flank — the wrapper (DockOverlay)
* on the latest tagged check — no animation machinery, the window just * positions and fades it — so it reads as marginalia on the lesson's
* follows the derived position). Click opens the full-DAG popup. * page, not a floating widget. Live by construction: statuses derive from
* the transcript on every render (derivePlanStatuses — calls, not
* results), so the glow moves the moment a tagged check streams in.
* *
* Live by construction: statuses derive from the transcript on every * Visibile only while a question owns the dock band (the reserved tail's
* render (derivePlanStatuses — calls, not results), so the window moves * height is the diagram's canvas — quiz open, tall band, full plan);
* the moment a tagged check call streams in or a record adoption lands. * folding the dock fades + blurs the whole thing out of existence. The
* Hidden on narrow viewports (the dock owns that corner's collapse * top-bar plan chip carries access at every width; the popup is the study
* pattern); the learner's visual polish pass (colors, motion) comes later. * view. The viewBox pad clears the current node's blurred yellow halo
* (GLOW_REACH) at the extent edges.
*/ */
/** Window dimensions in SVG units — 2:1, ~2¼ node widths: the current const MARGIN_PAD = GRAPH_PAD + 16;
* node plus its immediate neighborhood. Aspect-matched to the card. */
const VIEW_W = 360;
const VIEW_H = 180;
/** export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) {
* The windowed viewBox, centered on the current node — pure (exported for
* tests). Falls back to the FIRST node (authoring order — the lesson's
* start) while no check has been tagged. A layout smaller than the window
* centers itself; a larger one clamps so the window never strays into
* empty space beyond an edge node.
*/
export function planWindowViewBox(tree: PlanTree, current: string | null): string {
const layout = blockLayout(tree.nodes, tree.edges);
const focusId =
current && layout.x.has(current)
? current
: (tree.nodes[0]?.id ?? null);
const cx =
focusId !== null
? (layout.x.get(focusId) ?? 0) + NODE_W / 2
: Math.max(layout.width, 1) / 2;
const cy =
focusId !== null
? (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);
const x = clamp(cx, layout.width, VIEW_W) - VIEW_W / 2;
const y = clamp(cy, layout.height, VIEW_H) - VIEW_H / 2;
return `${x} ${y} ${VIEW_W} ${VIEW_H}`;
}
export function PlanWindow({ messages, onOpen }: { messages: readonly PopupMessage[]; onOpen: () => void }) {
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
const tree = useMemo(() => latestTree(messages), [messages]); const tree = useMemo(() => latestTree(messages), [messages]);
const statuses = useMemo( const statuses = useMemo(
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null), () => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
[tree, messages], [tree, messages],
); );
if (!tree || tree.nodes.length === 0) return null; // no plan yet: no window if (!tree || tree.nodes.length === 0) return null; // no plan yet: nothing in the margin
const statusOf = (id: string) => (statuses ? derivedNodeStatus(statuses, id) : "upcoming"); const statusOf = (id: string) => (statuses ? derivedNodeStatus(statuses, id) : "upcoming");
const currentTitle = statuses?.current
? (tree.nodes.find((n) => n.id === statuses.current)?.title ?? null)
: null;
return ( return (
<button <PlanDiagram
type="button" nodes={tree.nodes}
data-testid="plan-window" edges={tree.edges}
onClick={onOpen} statusOf={statusOf}
title="Open the full plan" glowId={statuses?.current ?? null}
className="fixed bottom-3 left-3 z-30 hidden w-80 flex-col gap-1 rounded-xl border bg-card/95 p-2 text-left shadow-lg backdrop-blur sm:flex" markerId={markerId}
> pad={MARGIN_PAD}
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> textCapScale={TEXT_CAP_SCALE}
<WaypointsIcon className="size-3.5 text-primary" /> fill
The plan />
<span className="ml-auto min-w-0 truncate font-normal normal-case" title={currentTitle ?? undefined}>
{currentTitle ?? "starting"}
</span>
</div>
<PlanDiagram
nodes={tree.nodes}
edges={tree.edges}
statusOf={statusOf}
glowId={statuses?.current ?? null}
markerId={markerId}
viewBoxOverride={planWindowViewBox(tree, statuses?.current ?? null)}
/>
</button>
); );
} }
+29 -63
View File
@@ -1,13 +1,13 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { planWindowViewBox, PlanWindow } from "../components/plan-window.tsx"; import { PlanMarginalia } from "../components/plan-window.tsx";
import { blockLayout, NODE_H, NODE_W, type PopupMessage } from "../components/plan-block.tsx"; import { blockLayout, type PopupMessage } from "../components/plan-block.tsx";
import type { PlanTree } from "../src/core/contracts.ts"; import type { PlanTree } from "../src/core/contracts.ts";
// The plan window: the ambient live view — a windowed viewBox centered on // The plan marginalia: the bare diagram in the dock band's right flank —
// the current node (auto-pan by derivation, not animation), full plan // full plan (padded viewBox, meet-fit), statuses + glow live by derivation,
// hidden until a tree exists, click → popup. // nothing until a tree exists. Positioning and fade live in DockOverlay.
const CHAIN: PlanTree = { const CHAIN: PlanTree = {
nodes: [ nodes: [
@@ -44,68 +44,34 @@ function transcript(tree: PlanTree, taggedNodeId?: string): PopupMessage[] {
]; ];
} }
describe("planWindowViewBox (pure)", () => { describe("PlanMarginalia render", () => {
it("centers the window on the current node", () => { it("renders nothing until a tree exists", () => {
const layout = blockLayout(CHAIN.nodes, CHAIN.edges); assert.equal(renderToStaticMarkup(<PlanMarginalia messages={[]} />), "");
const cy = layout.y.get("b")! + NODE_H / 2; // chain: all x = 0
const vb = planWindowViewBox(CHAIN, "b").split(" ").map(Number);
assert.equal(vb[3], 180); // 2:1 window
assert.equal(vb[1]! + 90, cy, "vertical center = node b's center");
// layout narrower than the window → centered on the (single-column) layout
assert.equal(vb[0]! + 180, layout.width / 2);
}); });
it("falls back to the first node while no check has been tagged — clamped at the layout's top edge", () => { it("renders the FULL plan (padded full-extent viewBox), not a windowed crop", () => {
const html = renderToStaticMarkup(<PlanMarginalia messages={transcript(CHAIN)} />);
// The viewBox covers the whole layout plus the glow-clearing pad —
// never the retired window's 2:1 crop.
const layout = blockLayout(CHAIN.nodes, CHAIN.edges); const layout = blockLayout(CHAIN.nodes, CHAIN.edges);
const vb = planWindowViewBox(CHAIN, null).split(" ").map(Number); const pad = 24; // MARGIN_PAD: GRAPH_PAD + 16 (glow halo clearance)
// Focusing row 0 would center half-off the layout; the clamp pins the const vb = `viewBox="${-pad} ${-pad} ${layout.width + 2 * pad} ${layout.height + 2 * pad}"`;
// window to the top edge instead — node a stays fully inside it. assert.ok(html.includes(vb), `expected ${vb} in the svg`);
assert.equal(vb[1], 0); assert.ok(!/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html), "the windowed viewBox is retired");
const aTop = layout.y.get("a")!; // Meet-fit mode: the svg fills the band box (h-full w-full), letting
assert.ok(aTop >= vb[1] && aTop + NODE_H <= vb[1] + 180, "the first node is inside the window"); // preserveAspectRatio contain the diagram in the flank.
assert.ok(html.includes('class="block h-full w-full'), "fill mode classes");
}); });
it("clamps along wide rows — the window never strays past an edge node", () => { it("glows the current node and dims the not-yet-visited (live statuses)", () => {
const wide: PlanTree = { const html = renderToStaticMarkup(<PlanMarginalia messages={transcript(CHAIN, "b")} />);
nodes: [ // The yellow blurred halo marks the tagged check's node…
{ id: "r1", title: "R1" }, assert.ok(html.includes("fill-yellow-400/25"), "glow on the current node");
{ id: "r2", title: "R2" }, assert.ok(html.includes('data-testid="plan-node-b" data-status="current"'), "b is current");
{ id: "r3", title: "R3" }, // …and arrows into upcoming nodes carry the dimmed head.
{ id: "t", title: "T" }, assert.ok(
], html.includes("url(#:r0:-dim)") || /url\(#\S*-dim\)/.test(html),
edges: [ "dimmed arrowhead into the upcoming node",
{ from: "r1", to: "t" }, );
{ from: "r2", to: "t" },
{ from: "r3", to: "t" },
],
version: 1,
};
const layout = blockLayout(wide.nodes, wide.edges);
assert.ok(layout.width > 360, "fixture: a row wider than the window");
// Rightmost root: window's right edge must not exceed the layout width.
const vb = planWindowViewBox(wide, "r3").split(" ").map(Number);
assert.ok(vb[0]! + 360 <= layout.width + 0.001, "clamped at the right edge");
// Leftmost root: window's left edge must not go negative.
const vb2 = planWindowViewBox(wide, "r1").split(" ").map(Number);
assert.ok(vb2[0]! >= -0.001, "clamped at the left edge");
}); });
}); });
describe("PlanWindow render", () => {
it("hidden while no plan exists", () => {
assert.equal(renderToStaticMarkup(<PlanWindow messages={[]} onOpen={() => {}} />), "");
});
it("with a plan: renders windowed, glowing current from the latest tagged call; click target present", () => {
const html = renderToStaticMarkup(<PlanWindow messages={transcript(CHAIN, "b")} onOpen={() => {}} />);
assert.ok(html.includes('data-testid="plan-window"'));
assert.ok(html.includes('data-status="current"'));
assert.ok(html.includes('data-status="upcoming"'));
// the viewBox is windowed (not the full layout extent)
assert.ok(/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html));
// no current yet → first node is the fallback focus, header says starting
const html2 = renderToStaticMarkup(<PlanWindow messages={transcript(CHAIN)} onOpen={() => {}} />);
assert.ok(html2.includes("starting"));
assert.ok(!html2.includes('data-status="current"'), "nothing current before the first tagged check");
});
});