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) ────────────────────
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<string, number>();
const y = 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
* 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<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 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<PlanNodeStatus, string> = {
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 (
<g
@@ -512,7 +539,7 @@ export function PlanDiagram({
>
{/* Rectangular — no corner rounding (owner). */}
<rect
width={NODE_W}
width={w}
height={h}
className={monochrome ? MONO_BOX : BOX[status]}
strokeWidth={STROKE_W}
@@ -526,7 +553,7 @@ export function PlanDiagram({
<rect
x={-GLOW_MARGIN}
y={-GLOW_MARGIN}
width={NODE_W + 2 * GLOW_MARGIN}
width={w + 2 * GLOW_MARGIN}
height={h + 2 * GLOW_MARGIN}
className="fill-yellow-400/25"
filter={`url(#${markerId}-glow)`}
@@ -535,8 +562,9 @@ export function PlanDiagram({
{lines.map((line, i) => (
<text
key={i}
x={12}
x={w / 2}
y={y0 + i * LINE_H}
textAnchor="middle"
className={monochrome ? MONO_TEXT : TEXT[status]}
>
{line}
+39 -13
View File
@@ -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, /<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
// 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"/);