plan: the hug calibrated — measured rates, 16px symmetric, white outlines

The first cut's flat 6.8px/char table was calibrated for a generic 12px
sans; the plan titles actually render in Source Sans 3 at 11px — much
narrower — so every box carried ~10px of invisible slack per side (the
owner's "a lot of empty space left and right of the text"). The class
rates are now least-squares-fitted against getComputedTextLength
measurements of the live plan titles (narrow 3.1, wide 9.2, caps+digits
6.0, default 5.4): residuals land within ±4px per line, and PAD_X stays
16 — equal to the vertical (44−11)/2. Smoke-verified: per-side padding
now reads 14.4–19.1px across all 12 nodes (was 22.4–28.6, 10/12 over
25). And per the owner's follow-up: the in-transcript boxes keep their
WHITE outlines (gray card fill good, box edges stay white).

1428 tests, tsc, lint, build green.
This commit is contained in:
amosblomqvist
2026-10-07 09:40:33 +03:00
parent fa61251cfb
commit f979e35f57
2 changed files with 56 additions and 19 deletions
+41 -12
View File
@@ -46,16 +46,44 @@ import { Hint } from "@/components/ui/hint";
/** Text-first node sizing (owner, plan-hug round 2026-10-07): the box /** 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 * hugs its wrapped text instead of a fixed 160px slab. Width comes from
* the wrapped lines — a px-per-character estimate (12px system sans ≈ * the wrapped lines — a per-character-CLASS width estimate (the flat
* 6.8px/char, slightly generous so nothing overflows) plus symmetric * px/char rate of the first cut overshot the real rendered width, and
* padding, floored at a minimum so short titles don't collapse into * the invisible slack read as "a lot of empty space" beside the text;
* pills. Character-count estimate, NOT canvas measurement: the layout * owner follow-up 2026-10-07) plus symmetric padding, floored at a
* path stays deterministic, synchronous, and DOM-free (SSR + exact * minimum so short titles don't collapse into pills. Estimate, NOT
* render pins keep working). */ * canvas measurement: the layout path stays deterministic,
export const CHAR_W = 6.8; * synchronous, and DOM-free (SSR + exact render pins keep working). */
export const PAD_X = 12;
/** Symmetric horizontal padding — EQUALS the vertical padding the
* single-line text gets in NODE_H (owner: "the horizontal padding
* should be the same as the vertical one"): (44 − 11) / 2 ≈ 16 (the
* prose font renders text-xs at 11px). */
export const PAD_X = 16;
export const NODE_W_MIN = 96; export const NODE_W_MIN = 96;
export const NODE_H = 44; export const NODE_H = 44;
// Width classes for the PROSE font (Source Sans 3 as shipped, text-xs
// rendering at 11px): narrow stems and punctuation, wide m/w, caps +
// digits, everything else. Rates least-squares-fitted against
// getComputedTextLength() measurements of the live plan titles
// (2026-10-07 smoke) — residuals land within ±4px per LINE, so
// per-side padding reads as PAD_X ±2. A different reading font
// (Appearance override) drifts the estimate slightly; accepted.
const NARROW_CHARS = new Set(" ijltfr.,:;'!|()[]-\\");
const WIDE_CHARS = new Set("mwMW@");
const CAP_DIGIT = /[A-Z0-9]/;
/** Estimated rendered width (px) of one line at text-xs. Pure. */
export function estimateLineWidth(line: string): number {
let w = 0;
for (const ch of line) {
if (NARROW_CHARS.has(ch)) w += 3.1;
else if (WIDE_CHARS.has(ch)) w += 9.2;
else if (CAP_DIGIT.test(ch)) w += 6.0;
else w += 5.4;
}
return w;
}
/** Baseline-to-baseline distance of node titles. */ /** Baseline-to-baseline distance of node titles. */
export const LINE_H = 13; export const LINE_H = 13;
export const GAP_X = 24; export const GAP_X = 24;
@@ -200,8 +228,8 @@ export function wrapTitle(title: string, maxChars = 28): string[] {
/** A node's box width for its wrapped lines — the hug itself. Pure. */ /** A node's box width for its wrapped lines — the hug itself. Pure. */
export function nodeWidth(lines: readonly string[]): number { export function nodeWidth(lines: readonly string[]): number {
const widest = Math.max(1, ...lines.map((l) => l.length)); const widest = Math.max(1, ...lines.map(estimateLineWidth));
return Math.max(NODE_W_MIN, Math.ceil(widest * CHAR_W) + 2 * PAD_X); return Math.max(NODE_W_MIN, Math.ceil(widest) + 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
@@ -376,8 +404,9 @@ const TEXT: Record<PlanNodeStatus, string> = {
}; };
// The in-chat block speaks the dock language now (owner, plan-hug // The in-chat block speaks the dock language now (owner, plan-hug
// round 2026-10-07): boxes in the lighter background variant like // round 2026-10-07): boxes in the lighter background variant like
// the composer/dock shells, not the white-on-transparent mono look. // the composer/dock shells — but the outlines STAY white (owner
const MONO_BOX = "fill-card stroke-border"; // follow-up: the gray fill is good, white box edges).
const MONO_BOX = "fill-card stroke-white";
const MONO_TEXT = "fill-foreground text-xs"; 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 —
+15 -7
View File
@@ -12,6 +12,7 @@ import {
NODE_H, NODE_H,
NODE_W_MIN, NODE_W_MIN,
nodeWidth, nodeWidth,
estimateLineWidth,
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";
@@ -42,8 +43,10 @@ describe("plan block layout (pure)", () => {
// Text-first sizing (owner, plan-hug round 2026-10-07): these short // Text-first sizing (owner, plan-hug round 2026-10-07): these short
// titles hug to the minimum — no more fixed 160px slabs. // titles hug to the minimum — no more fixed 160px slabs.
const W = nodeWidth(wrapTitle("Alpha node")); const W = nodeWidth(wrapTitle("Alpha node"));
assert.equal(W, NODE_W_MIN); // 10 chars ≈ 92px → floored at the min assert.equal(W, NODE_W_MIN); // est ≈ 50 → floored at the min
assert.equal(layout.width, W); // widest row is the single-node rows // All three short titles floor — the extent is the floor itself.
assert.equal(nodeWidth(wrapTitle("Gamma node")), NODE_W_MIN); // est ≈ 59.9
assert.equal(layout.width, NODE_W_MIN);
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); assert.equal(layout.nodeW.get("a"), W);
}); });
@@ -124,10 +127,14 @@ describe("plan block layout (pure)", () => {
it("nodeWidth hugs the wrapped text (the box grows WITH its lines)", () => { 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. // Short titles floor at the min; wide ones size to the widest line.
assert.equal(nodeWidth(["supercalifragilistic"]), 160); // 20 chars ≈ 136 + 24 pad // Per-char-CLASS estimate (owner follow-up: the flat px/char rate
assert.equal(nodeWidth(wrapTitle("Symmetry and orthogonal complements")), 181); // 23-char line ≈ 157 + 24 // overshot the rendered width — invisible slack beside the text).
// The hug is symmetric padding, and never below the floor. assert.equal(nodeWidth(["supercalifragilistic"]), 117); // est ≈ 85 + 2×16
assert.equal(nodeWidth(wrapTitle("Symmetry and orthogonal complements")), 149);
// The hug is symmetric padding — 16 a side, the SAME as the vertical
// (44−12)/2 — and never below the floor.
assert.ok(nodeWidth(["a"]) === NODE_W_MIN); assert.ok(nodeWidth(["a"]) === NODE_W_MIN);
assert.ok(Math.abs(estimateLineWidth("The normal-form ladder") - 106.3) < 0.5);
}); });
}); });
@@ -165,8 +172,9 @@ describe("plan block render", () => {
assert.match(html, /<text[^>]*x="48"[^>]*text-anchor="middle"/); // 96/2 assert.match(html, /<text[^>]*x="48"[^>]*text-anchor="middle"/); // 96/2
assert.ok(!/x="12"/.test(html), "no left-inset text"); assert.ok(!/x="12"/.test(html), "no left-inset text");
// The in-chat block speaks the dock language (owner, 2026-10-07): // The in-chat block speaks the dock language (owner, 2026-10-07):
// lighter-background boxes like the composer shells, not white mono. // lighter-background boxes like the composer shells — with WHITE
assert.match(html, /<rect[^>]*class="fill-card stroke-border"/); // outlines (owner follow-up: gray fill good, box edges stay white).
assert.match(html, /<rect[^>]*class="fill-card stroke-white"/);
// 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). The width // every side (strokes/glow never clip at the edges). The width
// rides the hug now: 96-min boxes, not the old 160 slabs. // rides the hug now: 96-min boxes, not the old 160 slabs.