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
* 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;
* the wrapped lines — a per-character-CLASS width estimate (the flat
* px/char rate of the first cut overshot the real rendered width, and
* the invisible slack read as "a lot of empty space" beside the text;
* owner follow-up 2026-10-07) plus symmetric padding, floored at a
* minimum so short titles don't collapse into pills. Estimate, NOT
* canvas measurement: the layout path stays deterministic,
* synchronous, and DOM-free (SSR + exact render pins keep working). */
/** 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_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. */
export const LINE_H = 13;
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. */
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);
const widest = Math.max(1, ...lines.map(estimateLineWidth));
return Math.max(NODE_W_MIN, Math.ceil(widest) + 2 * PAD_X);
}
/** 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
// 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";
// the composer/dock shells — but the outlines STAY white (owner
// follow-up: the gray fill is good, white box edges).
const MONO_BOX = "fill-card stroke-white";
const MONO_TEXT = "fill-foreground text-xs";
/** The diagram itself: positioned nodes + bezier edges. Pure-ish render —