diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 9a68652..6125520 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -44,7 +44,17 @@ import { Hint } from "@/components/ui/hint"; // ─── Layout constants + pure helpers (exported for tests) ──────────────────── -export const NODE_W = 160; +/** Text-first node sizing (owner, plan-hug round 2026-10-07): the box + * hugs its wrapped text instead of a fixed 160px slab. Width comes from + * the wrapped lines — a px-per-character estimate (12px system sans ≈ + * 6.8px/char, slightly generous so nothing overflows) plus symmetric + * padding, floored at a minimum so short titles don't collapse into + * pills. Character-count estimate, NOT canvas measurement: the layout + * path stays deterministic, synchronous, and DOM-free (SSR + exact + * render pins keep working). */ +export const CHAR_W = 6.8; +export const PAD_X = 12; +export const NODE_W_MIN = 96; export const NODE_H = 44; /** Baseline-to-baseline distance of node titles. */ export const LINE_H = 13; @@ -99,7 +109,8 @@ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ fro } /** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style - * layered layout, TB, with our geometry (NODE_W boxes, GAP_X/GAP_Y + * layered layout, TB, with our geometry (text-first sized 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, @@ -111,20 +122,23 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ const x = new Map(); const y = new Map(); const nodeH = new Map(); + const nodeW = 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 }; + if (nodes.length === 0) return { x, y, nodeH, nodeW, 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; + const lines = wrapTitle(n.title); + const h = NODE_H + Math.max(0, lines.length - 2) * LINE_H; nodeH.set(n.id, h); - g.setNode(n.id, { width: NODE_W, height: h }); + nodeW.set(n.id, nodeWidth(lines)); + g.setNode(n.id, { width: nodeW.get(n.id)!, height: h }); } for (const e of edges) { if (ids.has(e.from) && ids.has(e.to)) { @@ -137,8 +151,9 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ for (const n of nodes) { const pos = g.node(n.id); const h = nodeH.get(n.id) ?? NODE_H; + const w = nodeW.get(n.id) ?? NODE_W_MIN; if (pos) { - x.set(n.id, pos.x - NODE_W / 2); + x.set(n.id, pos.x - w / 2); y.set(n.id, pos.y - h / 2); } } @@ -150,6 +165,7 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ x, y, nodeH, + nodeW, edgePts, width: Math.max(g.graph().width ?? 0, 1), height: Math.max(g.graph().height ?? 0, 1), @@ -160,7 +176,7 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ * 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[] { +export function wrapTitle(title: string, maxChars = 28): string[] { const words = title.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return [""]; // Oversized words can never fit a line — chunk them so no content is lost. @@ -182,6 +198,12 @@ export function wrapTitle(title: string, maxChars = 20): string[] { // ─── Geometry caps (owner, plan-graph refinement round 1) ───────────────────── +/** A node's box width for its wrapped lines — the hug itself. Pure. */ +export function nodeWidth(lines: readonly string[]): number { + const widest = Math.max(1, ...lines.map((l) => l.length)); + return Math.max(NODE_W_MIN, Math.ceil(widest * CHAR_W) + 2 * PAD_X); +} + /** 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. */ @@ -352,8 +374,11 @@ const TEXT: Record = { 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 in-chat block speaks the dock language now (owner, plan-hug +// round 2026-10-07): boxes in the lighter background variant like +// the composer/dock shells, not the white-on-transparent mono look. +const MONO_BOX = "fill-card stroke-border"; +const MONO_TEXT = "fill-foreground text-xs"; /** The diagram itself: positioned nodes + bezier edges. Pure-ish render — * every input is data, so SSR render tests pin the whole thing. The root @@ -476,7 +501,7 @@ export function PlanDiagram({ 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 } + ? { x: tx, y: ty, w: layout.nodeW.get(e.to) ?? NODE_W_MIN, h: layout.nodeH.get(e.to) ?? NODE_H } : undefined; // An arrow is white only when BOTH its ends are white (owner, // 2026-09-26): origin AND terminal covered — a gray box's outgoing @@ -500,8 +525,10 @@ export function PlanDiagram({ const status = statusOf(n.id); const glow = n.id === glowId && !monochrome; const lines = wrapTitle(n.title); - // The box grows with its wrapped content; text centers in it. + // The box hugs its wrapped content; text centers in it — + // horizontally too now (text-first sizing round, 2026-10-07). const h = layout.nodeH.get(n.id) ?? NODE_H; + const w = layout.nodeW.get(n.id) ?? NODE_W_MIN; const y0 = (h - lines.length * LINE_H) / 2 + 10; return ( {/* Rectangular — no corner rounding (owner). */} ( {line} diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 88b3807..71eb6f2 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -10,7 +10,8 @@ import { wrapTitle, GAP_X, NODE_H, - NODE_W, + NODE_W_MIN, + nodeWidth, type PopupMessage, } from "../components/plan-block.tsx"; import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts"; @@ -38,8 +39,13 @@ describe("plan block layout (pure)", () => { assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]); assert.equal(depth.get("c"), 2); const layout = blockLayout(CHAIN.nodes, CHAIN.edges); - assert.equal(layout.width, NODE_W); // widest row is the single-node rows + // Text-first sizing (owner, plan-hug round 2026-10-07): these short + // titles hug to the minimum — no more fixed 160px slabs. + const W = nodeWidth(wrapTitle("Alpha node")); + assert.equal(W, NODE_W_MIN); // 10 chars ≈ 92px → floored at the min + assert.equal(layout.width, W); // widest row is the single-node rows assert.equal(layout.height, 3 * NODE_H + 2 * 48); + assert.equal(layout.nodeW.get("a"), W); }); it("centers same-depth nodes over the widest row; tolerates dangling edges", () => { @@ -53,10 +59,11 @@ describe("plan block layout (pure)", () => { { from: "r", to: "y" }, { from: "ghost", to: "r" }, // dangling: ignored defensively ]); - assert.equal(layout.width, 2 * NODE_W + GAP_X); + const W = nodeWidth(wrapTitle("Root")); + assert.equal(layout.width, 2 * W + GAP_X); // Root centered over its two children: (2*W+GAP - W)/2 - assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2); - assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); + assert.equal(layout.x.get("r"), (2 * W + GAP_X - W) / 2); + assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === W + GAP_X); }); it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => { @@ -102,14 +109,25 @@ describe("plan block layout (pure)", () => { }); 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("Chain rule intuition"), ["Chain rule intuition"]); // well under — one line + assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), [ + "Chain rule intuition and", // 24 — appending "fluency" would pass 28 + "fluency", + ]); // Oversized word hard-chunked, nothing lost (owner, round 7). assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [ - "supercalifragilistic", - "expialidocious word", + "supercalifragilisticexpialid", // the chunker cuts AT maxChars + "ocious word", ]); - assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits + assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly 20 — fits + }); + + it("nodeWidth hugs the wrapped text (the box grows WITH its lines)", () => { + // Short titles floor at the min; wide ones size to the widest line. + assert.equal(nodeWidth(["supercalifragilistic"]), 160); // 20 chars ≈ 136 + 24 pad + assert.equal(nodeWidth(wrapTitle("Symmetry and orthogonal complements")), 181); // 23-char line ≈ 157 + 24 + // The hug is symmetric padding, and never below the floor. + assert.ok(nodeWidth(["a"]) === NODE_W_MIN); }); }); @@ -141,10 +159,18 @@ describe("plan block render", () => { // Lines + outlines at the app's standard 1px border weight (owner, // 2026-09-26; was 1.5). assert.match(html, /]*stroke-width="1"/); - assert.match(html, /max-width:225\.28px/); // (160+16) × 1.28 + assert.match(html, /max-width:143\.36px/); // (96+16) × 1.28 — boxes hug now + // Centered text (owner, plan-hug round 2026-10-07): every title line + // anchors to its box's midline — the old x=12 left inset is gone. + assert.match(html, /]*x="48"[^>]*text-anchor="middle"/); // 96/2 + assert.ok(!/x="12"/.test(html), "no left-inset text"); + // The in-chat block speaks the dock language (owner, 2026-10-07): + // lighter-background boxes like the composer shells, not white mono. + assert.match(html, /]*class="fill-card stroke-border"/); // 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"/); + // every side (strokes/glow never clip at the edges). The width + // rides the hug now: 96-min boxes, not the old 160 slabs. + assert.match(html, /viewBox="-8 -8 112 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"/);