plan: the hug — text-first boxes, centered titles, dock-colored transcript

The plan skin drops its fixed 160px slabs: every box now sizes to its
wrapped text (px-per-char estimate + symmetric 12px padding, floored at
96px), the wrap limit rises 20→28 chars — the early-wrap fix, boxes
grow so lines stop breaking with room to spare — and every title line
anchors to its box midline (textAnchor middle, was a x=12 left inset).
The in-transcript snapshot block speaks the dock language now: boxes
fill-card stroke-border like the composer shells, text fill-foreground,
retiring the white-on-transparent mono look. The popup and the margin
diagram keep their status colors. Mermaid proper is untouched (owner:
it's fine as is).

Character-count estimation, not canvas measurement: the layout path
stays deterministic, synchronous, DOM-free — SSR and exact render pins
keep working. Verified live on the Relational-databases plan (12 nodes,
widths 174–215 tracking the longest line, no overflow, glow intact).

1428 tests, tsc, lint, build green.
This commit is contained in:
amosblomqvist
2026-10-07 09:25:14 +03:00
parent 8aa1e23cba
commit fa61251cfb
2 changed files with 81 additions and 27 deletions
+42 -14
View File
@@ -44,7 +44,17 @@ import { Hint } from "@/components/ui/hint";
// ─── Layout constants + pure helpers (exported for tests) ──────────────────── // ─── 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; export const NODE_H = 44;
/** Baseline-to-baseline distance of node titles. */ /** Baseline-to-baseline distance of node titles. */
export const LINE_H = 13; 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 /** 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). * separations, zero margin — the caller adds GRAPH_PAD/POPUP_PAD).
* Node sizes are fed in (boxes grow with their wrapped content, round * Node sizes are fed in (boxes grow with their wrapped content, round
* 7); dagre returns node CENTERS, we hand back top-lefts like before, * 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<string, number>(); const x = new Map<string, number>();
const y = new Map<string, number>(); const y = new Map<string, number>();
const nodeH = new Map<string, number>(); const nodeH = new Map<string, number>();
const nodeW = new Map<string, number>();
/** Dagre's per-edge waypoint routes — each starts/ends at the node /** Dagre's per-edge waypoint routes — each starts/ends at the node
* border intersection on whatever SIDE the edge leaves/enters * border intersection on whatever SIDE the edge leaves/enters
* (round 11: the mermaid line drawing, no more fixed bottom-center → * (round 11: the mermaid line drawing, no more fixed bottom-center →
* top-center). Keyed `${from}->${to}`. */ * top-center). Keyed `${from}->${to}`. */
const edgePts = new Map<string, Array<{ x: number; y: number }>>(); const edgePts = new Map<string, Array<{ x: number; y: number }>>();
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 ids = new Set(nodes.map((n) => n.id));
const routed: Array<{ from: string; to: string }> = []; const routed: Array<{ from: string; to: string }> = [];
const g = new graphlib.Graph({ compound: false, multigraph: true }); const g = new graphlib.Graph({ compound: false, multigraph: true });
g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 }); g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 });
for (const n of nodes) { 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); 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) { for (const e of edges) {
if (ids.has(e.from) && ids.has(e.to)) { 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) { for (const n of nodes) {
const pos = g.node(n.id); const pos = g.node(n.id);
const h = nodeH.get(n.id) ?? NODE_H; const h = nodeH.get(n.id) ?? NODE_H;
const w = nodeW.get(n.id) ?? NODE_W_MIN;
if (pos) { 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); y.set(n.id, pos.y - h / 2);
} }
} }
@@ -150,6 +165,7 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{
x, x,
y, y,
nodeH, nodeH,
nodeW,
edgePts, edgePts,
width: Math.max(g.graph().width ?? 0, 1), width: Math.max(g.graph().width ?? 0, 1),
height: Math.max(g.graph().height ?? 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 * 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 * each node). A single oversized word is hard-chunked across lines at
* maxChars. Pure. */ * 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); const words = title.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [""]; if (words.length === 0) return [""];
// Oversized words can never fit a line — chunk them so no content is lost. // 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) ───────────────────── // ─── 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 /** SVG-unit padding around the layout extent: strokes are centered on the
* geometry (half bleeds outside the extent) and the glow halo reaches −4 — * geometry (half bleeds outside the extent) and the glow halo reaches −4 —
* without this pad the outermost node borders clip at the svg edge. */ * without this pad the outermost node borders clip at the svg edge. */
@@ -352,8 +374,11 @@ const TEXT: Record<PlanNodeStatus, string> = {
current: "fill-white text-xs font-medium", current: "fill-white text-xs font-medium",
upcoming: "fill-muted-foreground/80 text-xs", upcoming: "fill-muted-foreground/80 text-xs",
}; };
const MONO_BOX = "fill-background/50 stroke-white"; // The in-chat block speaks the dock language now (owner, plan-hug
const MONO_TEXT = "fill-white text-xs"; // 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 — /** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
* every input is data, so SSR render tests pin the whole thing. The root * 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 ty = layout.y.get(e.to);
const target = const target =
tx !== undefined && ty !== undefined 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; : undefined;
// An arrow is white only when BOTH its ends are white (owner, // An arrow is white only when BOTH its ends are white (owner,
// 2026-09-26): origin AND terminal covered — a gray box's outgoing // 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 status = statusOf(n.id);
const glow = n.id === glowId && !monochrome; const glow = n.id === glowId && !monochrome;
const lines = wrapTitle(n.title); 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 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; const y0 = (h - lines.length * LINE_H) / 2 + 10;
return ( return (
<g <g
@@ -512,7 +539,7 @@ export function PlanDiagram({
> >
{/* Rectangular — no corner rounding (owner). */} {/* Rectangular — no corner rounding (owner). */}
<rect <rect
width={NODE_W} width={w}
height={h} height={h}
className={monochrome ? MONO_BOX : BOX[status]} className={monochrome ? MONO_BOX : BOX[status]}
strokeWidth={STROKE_W} strokeWidth={STROKE_W}
@@ -526,7 +553,7 @@ export function PlanDiagram({
<rect <rect
x={-GLOW_MARGIN} x={-GLOW_MARGIN}
y={-GLOW_MARGIN} y={-GLOW_MARGIN}
width={NODE_W + 2 * GLOW_MARGIN} width={w + 2 * GLOW_MARGIN}
height={h + 2 * GLOW_MARGIN} height={h + 2 * GLOW_MARGIN}
className="fill-yellow-400/25" className="fill-yellow-400/25"
filter={`url(#${markerId}-glow)`} filter={`url(#${markerId}-glow)`}
@@ -535,8 +562,9 @@ export function PlanDiagram({
{lines.map((line, i) => ( {lines.map((line, i) => (
<text <text
key={i} key={i}
x={12} x={w / 2}
y={y0 + i * LINE_H} y={y0 + i * LINE_H}
textAnchor="middle"
className={monochrome ? MONO_TEXT : TEXT[status]} className={monochrome ? MONO_TEXT : TEXT[status]}
> >
{line} {line}
+39 -13
View File
@@ -10,7 +10,8 @@ import {
wrapTitle, wrapTitle,
GAP_X, GAP_X,
NODE_H, NODE_H,
NODE_W, NODE_W_MIN,
nodeWidth,
type PopupMessage, type PopupMessage,
} from "../components/plan-block.tsx"; } from "../components/plan-block.tsx";
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts"; 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.deepEqual(order.map((n) => n.id), ["a", "b", "c"]);
assert.equal(depth.get("c"), 2); assert.equal(depth.get("c"), 2);
const layout = blockLayout(CHAIN.nodes, CHAIN.edges); 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.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", () => { 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: "r", to: "y" },
{ from: "ghost", to: "r" }, // dangling: ignored defensively { 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 // 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.equal(layout.x.get("r"), (2 * W + GAP_X - W) / 2);
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); 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", () => { 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)", () => { 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"), ["Chain rule intuition"]); // well under — one line
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]); 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). // Oversized word hard-chunked, nothing lost (owner, round 7).
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [ assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
"supercalifragilistic", "supercalifragilisticexpialid", // the chunker cuts AT maxChars
"expialidocious word", "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, // Lines + outlines at the app's standard 1px border weight (owner,
// 2026-09-26; was 1.5). // 2026-09-26; was 1.5).
assert.match(html, /<rect[^>]*stroke-width="1"/); assert.match(html, /<rect[^>]*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, /<text[^>]*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, /<rect[^>]*class="fill-card stroke-border"/);
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on // Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
// every side (strokes/glow never clip at the edges). // every side (strokes/glow never clip at the edges). The width
assert.match(html, /viewBox="-8 -8 176 152"/); // 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. // Snapshot statuses render as carried.
assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);