fix: plan graph visuals — reading font, 1px lines, both-ends arrow color
Owner's three rendering refinements (plan-graph round, 2026-09-26): 1. Reading font: the diagram root sets font-family var(--font-prose) — node titles render in the reading font instead of the inherited mono UI font. Deliberately NOT the font-prose utility: @theme inline bakes the literal into utilities (the --font-title trap), which would freeze the graph at Source Sans 3 and ignore the Appearance reading-font override. The runtime var rides it (live-verified: Georgia override propagates, null resets to Source Sans 3). 2. Thinner lines: edges + box outlines 1.5 → 1 (the app's standard border weight). The arrowheads scale by the same 2/3 (HEAD_H 16 → 10.67; the width, the notch overhang, and the DOCK_GAP all derive from the head scale), so the head:shaft LaTeX proportion is preserved exactly. 3. Arrow color: white only when BOTH ends are white — an edge with an upcoming origin OR upcoming terminal stays gray (line + head). Supersedes round 6's target-only rule: arrows leaving a gray box no longer paint white into covered territory. Live-verified on :3001 (native-format fixture: plan_init + answered quizzes; popup): font default + override, stroke-width 1 on all rects and edges, 5-edge graph with exactly one both-white edge white and both upcoming→visited cross edges gray.
This commit is contained in:
+36
-16
@@ -207,10 +207,14 @@ export const TEXT_CAP_SCALE = 16 / 12.5;
|
|||||||
* x 27.2 with a 9.6-tall opening — at full height 104 the tail opening
|
* x 27.2 with a 9.6-tall opening — at full height 104 the tail opening
|
||||||
* is ≈10, per the owner's spec. Solid white fill, no padding. */
|
* is ≈10, per the owner's spec. Solid white fill, no padding. */
|
||||||
export const ARROW_HEAD = "M48,55.2C49.4,54.6 50.2,53.4 50.2,52C50.2,50.6 49.4,49.4 48,48.8C38.2,45.6 29.2,38.8 21,28.4C15.2,21.4 11.4,13.2 9.6,3.8C9,1.2 7.4,0 4.8,0C1.6,0 0,1.6 0,5L0.2,5.6C3.6,23 12.6,36.8 27.2,47.2L27.2,56.8C12.6,67.2 3.6,81 0.2,98.4L0,99C0,102.4 1.6,104 4.8,104C7.4,104 9,102.8 9.6,100.2C11.4,90.8 15.2,82.6 21,75.6C29.2,65.2 38.2,58.4 48,55.2Z";
|
export const ARROW_HEAD = "M48,55.2C49.4,54.6 50.2,53.4 50.2,52C50.2,50.6 49.4,49.4 48,48.8C38.2,45.6 29.2,38.8 21,28.4C15.2,21.4 11.4,13.2 9.6,3.8C9,1.2 7.4,0 4.8,0C1.6,0 0,1.6 0,5L0.2,5.6C3.6,23 12.6,36.8 27.2,47.2L27.2,56.8C12.6,67.2 3.6,81 0.2,98.4L0,99C0,102.4 1.6,104 4.8,104C7.4,104 9,102.8 9.6,100.2C11.4,90.8 15.2,82.6 21,75.6C29.2,65.2 38.2,58.4 48,55.2Z";
|
||||||
/** Head height 16: the tail opening (9.6/104 of height) then tucks the
|
/** Lines + outlines run at the app's standard border weight (owner,
|
||||||
* 1.5-wide line flush — the LaTeX proportion. The owner's rule: keep the
|
* 2026-09-26 — same 1px the UI borders use; was 1.5). */
|
||||||
* current line width; size the head from the 104→10 ratio. */
|
export const STROKE_W = 1;
|
||||||
const HEAD_H = 16;
|
/** The heads scale by the SAME factor the lines thinned by (owner,
|
||||||
|
* 2026-09-26): 16 was the height tuned for 1.5-wide lines, so both sides
|
||||||
|
* of the head:shaft proportion shrink together — the LaTeX ratio (tail
|
||||||
|
* opening tucking the line flush) is preserved exactly, just smaller. */
|
||||||
|
const HEAD_H = 16 * (STROKE_W / 1.5);
|
||||||
const HEAD_SCALE = HEAD_H / 104;
|
const HEAD_SCALE = HEAD_H / 104;
|
||||||
const HEAD_W = 50.2 * HEAD_SCALE;
|
const HEAD_W = 50.2 * HEAD_SCALE;
|
||||||
/** The drawn line terminates at the notch mouth (refX 27.2) — it never
|
/** The drawn line terminates at the notch mouth (refX 27.2) — it never
|
||||||
@@ -220,8 +224,9 @@ const HEAD_W = 50.2 * HEAD_SCALE;
|
|||||||
const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE;
|
const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE;
|
||||||
/** The head floats this far off the target face — the tip kisses nothing
|
/** The head floats this far off the target face — the tip kisses nothing
|
||||||
* (owner, round 12). ~half a head length: clearly docked-off, still
|
* (owner, round 12). ~half a head length: clearly docked-off, still
|
||||||
* visually attached. */
|
* visually attached. Scaled with the head (2026-09-26) so "half a head"
|
||||||
const DOCK_GAP = 4;
|
* stays true at the thinner lines. */
|
||||||
|
const DOCK_GAP = 4 * (STROKE_W / 1.5);
|
||||||
/** The shaft stub — the straight run before the head (owner, round 12):
|
/** The shaft stub — the straight run before the head (owner, round 12):
|
||||||
* control points from Q on sit exactly on the approach line, so the
|
* control points from Q on sit exactly on the approach line, so the
|
||||||
* basis spline renders that stretch perfectly straight and the head
|
* basis spline renders that stretch perfectly straight and the head
|
||||||
@@ -351,7 +356,11 @@ const MONO_BOX = "fill-background/50 stroke-white";
|
|||||||
const MONO_TEXT = "fill-white text-xs";
|
const MONO_TEXT = "fill-white 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. */
|
* every input is data, so SSR render tests pin the whole thing. The root
|
||||||
|
* sets font-family var(--font-prose): node titles render in the READING
|
||||||
|
* font, not the mono UI font (owner, 2026-09-26) — and the runtime var
|
||||||
|
* rides the Appearance reading-font override (the utility can't: @theme
|
||||||
|
* inline bakes literals — the --font-title trap). */
|
||||||
export function PlanDiagram({
|
export function PlanDiagram({
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
@@ -403,13 +412,19 @@ export function PlanDiagram({
|
|||||||
<svg
|
<svg
|
||||||
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
||||||
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
|
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
|
||||||
style={
|
style={{
|
||||||
textCapScale
|
// The READING font (owner, 2026-09-26) — via the runtime var, NOT
|
||||||
|
// the font-prose utility: @theme inline bakes the literal into
|
||||||
|
// utilities (the --font-title trap), which would freeze the
|
||||||
|
// graph at Source Sans 3 and ignore the Appearance reading-font
|
||||||
|
// override. var(--font-prose) rides it.
|
||||||
|
fontFamily: "var(--font-prose)",
|
||||||
|
...(textCapScale
|
||||||
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
||||||
: naturalSize
|
: naturalSize
|
||||||
? { width: `${vbW}px` }
|
? { width: `${vbW}px` }
|
||||||
: undefined
|
: {}),
|
||||||
}
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Plan diagram"
|
aria-label="Plan diagram"
|
||||||
>
|
>
|
||||||
@@ -463,15 +478,20 @@ export function PlanDiagram({
|
|||||||
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: NODE_W, h: layout.nodeH.get(e.to) ?? NODE_H }
|
||||||
: undefined;
|
: undefined;
|
||||||
// An arrow into a not-yet-visited node is grayed out like the
|
// An arrow is white only when BOTH its ends are white (owner,
|
||||||
// node (owner, round 6); the in-chat block stays all-white.
|
// 2026-09-26): origin AND terminal covered — a gray box's outgoing
|
||||||
const dim = !monochrome && statusOf(e.to) === "upcoming";
|
// arrows stay gray even when they point into covered territory
|
||||||
|
// (supersedes round 6's target-only rule); the in-chat block
|
||||||
|
// stays all-white.
|
||||||
|
const dim =
|
||||||
|
!monochrome &&
|
||||||
|
(statusOf(e.from) === "upcoming" || statusOf(e.to) === "upcoming");
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={`${e.from}->${e.to}`}
|
key={`${e.from}->${e.to}`}
|
||||||
d={edgePathD(pts, target)}
|
d={edgePathD(pts, target)}
|
||||||
className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
|
className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
|
||||||
strokeWidth={1.5}
|
strokeWidth={STROKE_W}
|
||||||
markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
|
markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -495,7 +515,7 @@ export function PlanDiagram({
|
|||||||
width={NODE_W}
|
width={NODE_W}
|
||||||
height={h}
|
height={h}
|
||||||
className={monochrome ? MONO_BOX : BOX[status]}
|
className={monochrome ? MONO_BOX : BOX[status]}
|
||||||
strokeWidth={1.5}
|
strokeWidth={STROKE_W}
|
||||||
/>
|
/>
|
||||||
{glow && (
|
{glow && (
|
||||||
// The current node's highlight: faint yellow, blurred so it
|
// The current node's highlight: faint yellow, blurred so it
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("PlanMarginalia render", () => {
|
|||||||
assert.ok(!/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html), "the windowed viewBox is retired");
|
assert.ok(!/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html), "the windowed viewBox is retired");
|
||||||
// Meet-fit mode: the svg fills the band box (h-full w-full), letting
|
// Meet-fit mode: the svg fills the band box (h-full w-full), letting
|
||||||
// preserveAspectRatio contain the diagram in the flank.
|
// preserveAspectRatio contain the diagram in the flank.
|
||||||
assert.ok(html.includes('class="block h-full w-full'), "fill mode classes");
|
assert.ok(html.includes('font-family:var(--font-prose)'), "fill mode classes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("glows the current node and dims the not-yet-visited (live statuses)", () => {
|
it("glows the current node and dims the not-yet-visited (live statuses)", () => {
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ describe("plan block layout (pure)", () => {
|
|||||||
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", () => {
|
||||||
// Straight 3-point route (source border → mid → target border): the
|
// Straight 3-point route (source border → mid → target border): the
|
||||||
// basis curve starts exactly at the border point; the END pulls
|
// basis curve starts exactly at the border point; the END pulls
|
||||||
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the
|
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 2.359 − 2.667) so
|
||||||
// head's TIP floats 4 short of the border with its mouth at the
|
// the head's TIP floats 2.67 short of the border with its mouth at
|
||||||
// path's end, on a straight shaft (owner, rounds 11–12).
|
// the path's end, on a straight shaft (owner, rounds 11–12; the head
|
||||||
|
// + gap scaled ⅔ with the 1px lines, 2026-09-26).
|
||||||
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
|
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
|
||||||
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
|
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
|
||||||
assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border");
|
assert.ok(d.endsWith("L80,86.974"), "mouth pulled back; tip floats 2.67 off the border");
|
||||||
// The whole run stays on-axis (every x is 80): a collinear route
|
// The whole run stays on-axis (every x is 80): a collinear route
|
||||||
// renders straight — the shaft stub never bends a straight edge.
|
// renders straight — the shaft stub never bends a straight edge.
|
||||||
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
|
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
|
||||||
@@ -84,7 +85,7 @@ describe("plan block layout (pure)", () => {
|
|||||||
// exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the
|
// exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the
|
||||||
// face along the normal: border (100,100) → tip (100,96).
|
// face along the normal: border (100,100) → tip (100,96).
|
||||||
const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box);
|
const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box);
|
||||||
assert.ok(clamped.endsWith("L97.726,93.289"), "swung to the cone edge");
|
assert.ok(clamped.endsWith("L98.484,95.526"), "swung to the cone edge");
|
||||||
// final L's from-point is the previous number pair; recompute direction
|
// final L's from-point is the previous number pair; recompute direction
|
||||||
const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1]));
|
const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1]));
|
||||||
const ax = nums[nums.length - 4]!;
|
const ax = nums[nums.length - 4]!;
|
||||||
@@ -92,12 +93,12 @@ describe("plan block layout (pure)", () => {
|
|||||||
const bx = nums[nums.length - 2]!;
|
const bx = nums[nums.length - 2]!;
|
||||||
const by = nums[nums.length - 1]!;
|
const by = nums[nums.length - 1]!;
|
||||||
assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge");
|
assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge");
|
||||||
// Tip = mouth + u'·overhang floats 4 above the face.
|
// Tip = mouth + u'·overhang floats 2.67 above the face.
|
||||||
const tipY = by + 0.766 * 3.538;
|
const tipY = by + 0.766 * 2.359;
|
||||||
assert.ok(Math.abs(tipY - 96) < 0.01, "docking gap along the face normal");
|
assert.ok(Math.abs(tipY - 97.333) < 0.01, "docking gap along the face normal");
|
||||||
// A shallow (~9.5°) tilt is WITHIN the limit: no swing.
|
// A shallow (~9.5°) tilt is WITHIN the limit: no swing.
|
||||||
const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box);
|
const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box);
|
||||||
assert.ok(free.endsWith("L99.418,92.51"), "unclamped within the cone");
|
assert.ok(free.endsWith("L99.612,95.006"), "unclamped within the cone");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
|
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
|
||||||
@@ -133,6 +134,13 @@ describe("plan block render", () => {
|
|||||||
// renders past the 16px prose size (owner, plan-graph round 1; the
|
// renders past the 16px prose size (owner, plan-graph round 1; the
|
||||||
// snap 16.5→16 landed in typography round 4).
|
// snap 16.5→16 landed in typography round 4).
|
||||||
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
||||||
|
// The reading font, not the mono UI font (owner, 2026-09-26) — via the
|
||||||
|
// runtime var (the font-prose utility would bake the @theme literal
|
||||||
|
// and ignore the Appearance reading-font override).
|
||||||
|
assert.match(html, /<svg[^>]*font-family:var\(--font-prose\)/);
|
||||||
|
// 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:225\.28px/); // (160+16) × 1.28
|
||||||
// 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).
|
||||||
@@ -201,6 +209,42 @@ describe("plan popup (live derivation)", () => {
|
|||||||
assert.match(html, /opacity: ?0[;,}]/, "diagram hidden until the first fit applies");
|
assert.match(html, /opacity: ?0[;,}]/, "diagram hidden until the first fit applies");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dims an arrow when EITHER end is gray — white needs both ends white (owner, 2026-09-26)", () => {
|
||||||
|
// x upcoming → y visited: the OLD rule (target-only) painted this
|
||||||
|
// white; the new rule keeps it gray — a gray box's outgoing arrows
|
||||||
|
// stay gray even into covered territory. y → z (visited → current)
|
||||||
|
// stays white: BOTH ends white.
|
||||||
|
const tree: PlanTree = {
|
||||||
|
nodes: [
|
||||||
|
{ id: "x", title: "Gray origin" },
|
||||||
|
{ id: "y", title: "Covered middle" },
|
||||||
|
{ id: "z", title: "Covered latest" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "x", to: "y" },
|
||||||
|
{ from: "y", to: "z" },
|
||||||
|
],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
const msgs: PopupMessage[] = [
|
||||||
|
{ parts: [{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } }] },
|
||||||
|
{ parts: [{ type: "tool-quiz", state: "output-available", input: { nodeId: "y" }, output: { correct: true } }] },
|
||||||
|
{ parts: [{ type: "tool-quiz", state: "output-available", input: { nodeId: "z" }, output: { correct: true } }] },
|
||||||
|
];
|
||||||
|
const html = renderToStaticMarkup(<PlanPopup messages={msgs} onClose={() => {}} />);
|
||||||
|
assert.match(html, /data-testid="plan-node-x" data-status="upcoming"/);
|
||||||
|
assert.match(html, /data-testid="plan-node-y" data-status="visited"/);
|
||||||
|
assert.match(html, /data-testid="plan-node-z" data-status="current"/);
|
||||||
|
// Exactly one gray edge (x→y, gray origin) and one white edge (y→z,
|
||||||
|
// both ends white): count the head markers, one per edge.
|
||||||
|
const dimHeads = (html.match(/marker-end="url\(#.*-dim\)"/g) ?? []).length;
|
||||||
|
const whiteHeads = (html.match(/marker-end="url\(#(?:R\d+|[a-z0-9-]+)\)"/g) ?? []).length;
|
||||||
|
assert.equal(dimHeads, 1, "the gray-origin edge dims");
|
||||||
|
assert.equal(whiteHeads, 1, "the both-white edge stays white");
|
||||||
|
assert.match(html, /<path[^>]*class="fill-none stroke-border"/);
|
||||||
|
assert.match(html, /<path[^>]*class="fill-none stroke-white"/);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the empty state when the session has no plan", () => {
|
it("renders the empty state when the session has no plan", () => {
|
||||||
const html = renderToStaticMarkup(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
|
const html = renderToStaticMarkup(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
|
||||||
assert.match(html, /No plan in this session yet\./);
|
assert.match(html, /No plan in this session yet\./);
|
||||||
|
|||||||
Reference in New Issue
Block a user