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:
+80
-7
@@ -23,7 +23,8 @@ import { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings } from "@/src/core";
|
||||
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 { QuestionDock } from "@/components/question-dock";
|
||||
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 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.
|
||||
const GRADIENT_H = 96;
|
||||
// Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance
|
||||
@@ -212,8 +221,12 @@ export function Chat({
|
||||
}
|
||||
}, [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);
|
||||
// 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 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
|
||||
// (a data-plan-accepted part on the message); decline asks for revision.
|
||||
const planVerdict = useMemo(
|
||||
() => phaseAt3(messages) === "plan" && latestTree(messages) !== null,
|
||||
[messages],
|
||||
() => phaseAt3(messages) === "plan" && planTree !== null,
|
||||
[messages, planTree],
|
||||
);
|
||||
const sendVerdict = useCallback(
|
||||
(accepted: boolean) => {
|
||||
@@ -487,6 +500,21 @@ export function Chat({
|
||||
<header className="flex h-10 items-center gap-3 border-b border-border px-3">
|
||||
<SidebarToggle />
|
||||
<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"} />
|
||||
</header>
|
||||
|
||||
@@ -630,6 +658,8 @@ export function Chat({
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
plan={<PlanMarginalia messages={messages} />}
|
||||
onOpenPlan={() => setPlanOpen(true)}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
@@ -669,8 +699,6 @@ export function Chat({
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
@@ -710,6 +738,8 @@ function DockOverlay({
|
||||
settings,
|
||||
onSettingsSaved,
|
||||
onReserve,
|
||||
plan,
|
||||
onOpenPlan,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** 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
|
||||
* dockReserve state feeds the transcript's spacer from it. */
|
||||
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();
|
||||
// Teach-phase mount (session reload with a pending check): the dock starts
|
||||
@@ -773,6 +808,24 @@ function DockOverlay({
|
||||
const revealedRef = useRef(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
|
||||
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
||||
// stable both-edges), so inset the overlay's right edge by the gutter —
|
||||
@@ -921,7 +974,7 @@ function DockOverlay({
|
||||
return (
|
||||
<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 ref={blockRef} className="bg-background pb-5">
|
||||
<div ref={blockRef} className="relative bg-background pb-5">
|
||||
<div className={DOCK}>
|
||||
<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"}>
|
||||
@@ -951,6 +1004,26 @@ function DockOverlay({
|
||||
onSettingsSaved={onSettingsSaved}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -358,6 +358,7 @@ export function PlanDiagram({
|
||||
textCapScale,
|
||||
naturalSize,
|
||||
monochrome,
|
||||
fill,
|
||||
}: {
|
||||
nodes: readonly LayoutNode[];
|
||||
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
|
||||
* and text, no glow) — statuses never show in the transcript (owner). */
|
||||
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]);
|
||||
// Pad only the full-extent render — the plan window's override viewBox is
|
||||
@@ -391,7 +397,7 @@ export function PlanDiagram({
|
||||
return (
|
||||
<svg
|
||||
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={
|
||||
textCapScale
|
||||
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
||||
|
||||
+30
-78
@@ -1,102 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { PlanTree } from "@/src/core";
|
||||
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
||||
import {
|
||||
PlanDiagram,
|
||||
blockLayout,
|
||||
GRAPH_PAD,
|
||||
latestTree,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
TEXT_CAP_SCALE,
|
||||
type PopupMessage,
|
||||
} from "@/components/plan-block";
|
||||
|
||||
/**
|
||||
* THE PLAN WINDOW (docs/probe-teach-split.md §6): the plan's persistent,
|
||||
* ambient presence — the successor of the injected plan blocks. Always on
|
||||
* once a tree exists, it shows a ZOOMED-IN window on the CURRENT node and
|
||||
* moves by itself as the lesson advances (auto-pan: the viewBox re-centers
|
||||
* on the latest tagged check — no animation machinery, the window just
|
||||
* follows the derived position). Click opens the full-DAG popup.
|
||||
* THE PLAN MARGINALIA: the plan's ambient presence in the session view —
|
||||
* the successor of the old fixed corner card (the plan window, retired
|
||||
* with the dock-band placement round). NO container: the bare diagram,
|
||||
* meet-fitted into the dock band's right flank — the wrapper (DockOverlay)
|
||||
* positions and fades it — so it reads as marginalia on the lesson's
|
||||
* 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
|
||||
* render (derivePlanStatuses — calls, not results), so the window moves
|
||||
* the moment a tagged check call streams in or a record adoption lands.
|
||||
* Hidden on narrow viewports (the dock owns that corner's collapse
|
||||
* pattern); the learner's visual polish pass (colors, motion) comes later.
|
||||
* Visibile only while a question owns the dock band (the reserved tail's
|
||||
* height is the diagram's canvas — quiz open, tall band, full plan);
|
||||
* folding the dock fades + blurs the whole thing out of existence. The
|
||||
* top-bar plan chip carries access at every width; the popup is the study
|
||||
* 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
|
||||
* node plus its immediate neighborhood. Aspect-matched to the card. */
|
||||
const VIEW_W = 360;
|
||||
const VIEW_H = 180;
|
||||
const MARGIN_PAD = GRAPH_PAD + 16;
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) {
|
||||
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
||||
const tree = useMemo(() => latestTree(messages), [messages]);
|
||||
const statuses = useMemo(
|
||||
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
|
||||
[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 currentTitle = statuses?.current
|
||||
? (tree.nodes.find((n) => n.id === statuses.current)?.title ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="plan-window"
|
||||
onClick={onOpen}
|
||||
title="Open the full plan"
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<WaypointsIcon className="size-3.5 text-primary" />
|
||||
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>
|
||||
<PlanDiagram
|
||||
nodes={tree.nodes}
|
||||
edges={tree.edges}
|
||||
statusOf={statusOf}
|
||||
glowId={statuses?.current ?? null}
|
||||
markerId={markerId}
|
||||
pad={MARGIN_PAD}
|
||||
textCapScale={TEXT_CAP_SCALE}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-63
@@ -1,13 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { planWindowViewBox, PlanWindow } from "../components/plan-window.tsx";
|
||||
import { blockLayout, NODE_H, NODE_W, type PopupMessage } from "../components/plan-block.tsx";
|
||||
import { PlanMarginalia } from "../components/plan-window.tsx";
|
||||
import { blockLayout, type PopupMessage } from "../components/plan-block.tsx";
|
||||
import type { PlanTree } from "../src/core/contracts.ts";
|
||||
|
||||
// The plan window: the ambient live view — a windowed viewBox centered on
|
||||
// the current node (auto-pan by derivation, not animation), full plan
|
||||
// hidden until a tree exists, click → popup.
|
||||
// The plan marginalia: the bare diagram in the dock band's right flank —
|
||||
// full plan (padded viewBox, meet-fit), statuses + glow live by derivation,
|
||||
// nothing until a tree exists. Positioning and fade live in DockOverlay.
|
||||
|
||||
const CHAIN: PlanTree = {
|
||||
nodes: [
|
||||
@@ -44,68 +44,34 @@ function transcript(tree: PlanTree, taggedNodeId?: string): PopupMessage[] {
|
||||
];
|
||||
}
|
||||
|
||||
describe("planWindowViewBox (pure)", () => {
|
||||
it("centers the window on the current node", () => {
|
||||
const layout = blockLayout(CHAIN.nodes, CHAIN.edges);
|
||||
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);
|
||||
describe("PlanMarginalia render", () => {
|
||||
it("renders nothing until a tree exists", () => {
|
||||
assert.equal(renderToStaticMarkup(<PlanMarginalia messages={[]} />), "");
|
||||
});
|
||||
|
||||
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 vb = planWindowViewBox(CHAIN, null).split(" ").map(Number);
|
||||
// Focusing row 0 would center half-off the layout; the clamp pins the
|
||||
// window to the top edge instead — node a stays fully inside it.
|
||||
assert.equal(vb[1], 0);
|
||||
const aTop = layout.y.get("a")!;
|
||||
assert.ok(aTop >= vb[1] && aTop + NODE_H <= vb[1] + 180, "the first node is inside the window");
|
||||
const pad = 24; // MARGIN_PAD: GRAPH_PAD + 16 (glow halo clearance)
|
||||
const vb = `viewBox="${-pad} ${-pad} ${layout.width + 2 * pad} ${layout.height + 2 * pad}"`;
|
||||
assert.ok(html.includes(vb), `expected ${vb} in the svg`);
|
||||
assert.ok(!/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html), "the windowed viewBox is retired");
|
||||
// Meet-fit mode: the svg fills the band box (h-full w-full), letting
|
||||
// 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", () => {
|
||||
const wide: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "r1", title: "R1" },
|
||||
{ id: "r2", title: "R2" },
|
||||
{ id: "r3", title: "R3" },
|
||||
{ id: "t", title: "T" },
|
||||
],
|
||||
edges: [
|
||||
{ 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");
|
||||
it("glows the current node and dims the not-yet-visited (live statuses)", () => {
|
||||
const html = renderToStaticMarkup(<PlanMarginalia messages={transcript(CHAIN, "b")} />);
|
||||
// The yellow blurred halo marks the tagged check's node…
|
||||
assert.ok(html.includes("fill-yellow-400/25"), "glow on the current node");
|
||||
assert.ok(html.includes('data-testid="plan-node-b" data-status="current"'), "b is current");
|
||||
// …and arrows into upcoming nodes carry the dimmed head.
|
||||
assert.ok(
|
||||
html.includes("url(#:r0:-dim)") || /url\(#\S*-dim\)/.test(html),
|
||||
"dimmed arrowhead into the upcoming node",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user