Plan-graph refinement round 6 (owner): grayed arrows, static glow, glow-sized frame pad

- Arrows into not-yet-visited nodes are grayed out like the nodes
  (stroke-border line + a fill-border copy of the arrowhead); the
  in-chat block stays all-white.
- The yellow glow is static — the pulse is gone (owner).
- Smaller blur: stdDeviation 7 → 5, glow rect margin 10 → 8.
- The glow clipped at the frame on narrow graphs: its visible reach
  (GLOW_MARGIN + 3·GLOW_BLUR = 23 units) far exceeded GRAPH_PAD (8).
  Surfaces that show the glow now pad by GLOW_REACH — the popup's
  viewBox AND its fit box both use POPUP_PAD = GLOW_REACH, so the blur
  never clips; the monochrome in-chat block stays tight at GRAPH_PAD.
This commit is contained in:
amosblomqvist
2026-09-14 07:06:25 +03:00
parent 43ada6ce23
commit 9eb30edb40
2 changed files with 47 additions and 15 deletions
+42 -13
View File
@@ -136,6 +136,14 @@ export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[]
* 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. */
export const GRAPH_PAD = 8; export const GRAPH_PAD = 8;
/** The current node's yellow glow: a fill blurred so it bleeds over the
* node's edges (owner). A Gaussian's visible spread is ≈3σ, so the glow
* reaches GLOW_MARGIN + 3·GLOW_BLUR past the node — any surface that
* shows the glow must pad its viewBox by GLOW_REACH or the blur clips
* at the frame edge (owner: extend the container padding to fit it). */
export const GLOW_MARGIN = 8;
export const GLOW_BLUR = 5;
export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR;
/** Node titles render at text-xs (12.5px) in svg units. The in-chat block /** Node titles render at text-xs (12.5px) in svg units. The in-chat block
* scales up to fill the column but NEVER past the chat prose size * scales up to fill the column but NEVER past the chat prose size
* (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ * (--lh-prose-size, 16.5px) — the width cap that makes that hold. */
@@ -253,11 +261,25 @@ export function PlanDiagram({
line ends AT the notch — the tip beyond it is solid fill. */} line ends AT the notch — the tip beyond it is solid fill. */}
<path d={ARROW_HEAD} className="fill-white" /> <path d={ARROW_HEAD} className="fill-white" />
</marker> </marker>
{/* Same head, grayed — arrows into not-yet-visited nodes are
grayed out like the nodes themselves (owner, round 6). */}
<marker
id={`${markerId}-dim`}
viewBox="0 0 50.2 104"
refX={27.2}
refY={52}
markerWidth={HEAD_W}
markerHeight={HEAD_H}
markerUnits="userSpaceOnUse"
orient="auto"
>
<path d={ARROW_HEAD} className="fill-border" />
</marker>
{/* The current node's highlight: a faint yellow fill BLURRED so it {/* The current node's highlight: a faint yellow fill BLURRED so it
bleeds out over the node's edges (owner). Generous filter region bleeds out over the node's edges (owner). Generous filter region
so the blur never clips at its own bounds. */} so the blur never clips at its own bounds. */}
<filter id={`${markerId}-glow`} x="-60%" y="-60%" width="220%" height="220%"> <filter id={`${markerId}-glow`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation={7} /> <feGaussianBlur stdDeviation={GLOW_BLUR} />
</filter> </filter>
</defs> </defs>
{edges.map((e) => { {edges.map((e) => {
@@ -268,13 +290,16 @@ export function PlanDiagram({
// node border while the line stops at the notch mouth. // node border while the line stops at the notch mouth.
const y2 = (layout.y.get(e.to) ?? 0) - HEAD_TIP_OVERHANG; const y2 = (layout.y.get(e.to) ?? 0) - HEAD_TIP_OVERHANG;
const mid = (y1 + y2) / 2; const mid = (y1 + y2) / 2;
// An arrow into a not-yet-visited node is grayed out like the
// node (owner, round 6); the in-chat block stays all-white.
const dim = !monochrome && statusOf(e.to) === "upcoming";
return ( return (
<path <path
key={`${e.from}->${e.to}`} key={`${e.from}->${e.to}`}
d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`}
className="fill-none stroke-white" className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
strokeWidth={1.5} strokeWidth={1.5}
markerEnd={`url(#${markerId})`} markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
/> />
); );
})} })}
@@ -291,13 +316,13 @@ export function PlanDiagram({
> >
{glow && ( {glow && (
// The current node's highlight: faint yellow, blurred so it // The current node's highlight: faint yellow, blurred so it
// bleeds over the edges (owner, plan-graph round 4). // bleeds over the edges — static, no pulse (owner, round 6).
<rect <rect
x={-10} x={-GLOW_MARGIN}
y={-10} y={-GLOW_MARGIN}
width={NODE_W + 20} width={NODE_W + 2 * GLOW_MARGIN}
height={NODE_H + 20} height={NODE_H + 2 * GLOW_MARGIN}
className="fill-yellow-400/25 animate-pulse" className="fill-yellow-400/25"
filter={`url(#${markerId}-glow)`} filter={`url(#${markerId}-glow)`}
/> />
)} )}
@@ -391,6 +416,9 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
* wide graphs; the fit itself is the floor now. */ * wide graphs; the fit itself is the floor now. */
const ZOOM_MAX = 4; const ZOOM_MAX = 4;
const FIT_PAD = 16; const FIT_PAD = 16;
/** The popup shows the glow, so its frame pads by the glow's reach —
* the in-chat block (monochrome, no glow) stays tight at GRAPH_PAD. */
const POPUP_PAD = GLOW_REACH;
const FIT_ZOOM_MAX = 1.5; const FIT_ZOOM_MAX = 1.5;
interface GraphView { interface GraphView {
@@ -459,10 +487,11 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
}, []); }, []);
const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]); const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]);
// The fit box is the PADDED ink box — the diagram renders with GRAPH_PAD // The fit box is the PADDED ink box — the diagram renders with POPUP_PAD
// so strokes/glow never clip, and the fit must account for it. // (= the glow's reach) so the blurred glow never clips at the frame, and
// the fit must account for that padding.
const box = useMemo( const box = useMemo(
() => (layout ? { w: layout.width + 2 * GRAPH_PAD, h: layout.height + 2 * GRAPH_PAD } : { w: 0, h: 0 }), () => (layout ? { w: layout.width + 2 * POPUP_PAD, h: layout.height + 2 * POPUP_PAD } : { w: 0, h: 0 }),
[layout], [layout],
); );
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
@@ -595,7 +624,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
statusOf={statusOf} statusOf={statusOf}
glowId={statuses?.current ?? null} glowId={statuses?.current ?? null}
markerId={markerId} markerId={markerId}
pad={GRAPH_PAD} pad={POPUP_PAD}
naturalSize naturalSize
/> />
</div> </div>
+5 -2
View File
@@ -139,9 +139,12 @@ describe("plan popup (live derivation)", () => {
assert.match(html, /data-testid="plan-node-b" data-status="current"/); assert.match(html, /data-testid="plan-node-b" data-status="current"/);
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/); assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
// The current node is the glow target: a faint yellow fill, blurred // The current node is the glow target: a faint yellow fill, blurred
// so it bleeds over the edges (owner, plan-graph round 4). // so it bleeds over the edges — static, no pulse (owner, round 6).
assert.match(html, /fill-yellow-400\/25 animate-pulse/); assert.match(html, /fill-yellow-400\/25/);
assert.match(html, /feGaussianBlur/); assert.match(html, /feGaussianBlur/);
assert.doesNotMatch(html, /animate-pulse/);
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
assert.match(html, /marker-end="url\(#.*-dim\)"/);
assert.match(html, /live — statuses derive from your check outcomes/); assert.match(html, /live — statuses derive from your check outcomes/);
}); });