Plan-graph refinement round 4 (owner): the simple white look + no entrance fly-in
Look (rectangular, white language — owner spec): - Boxes: no corner rounding (rx dropped everywhere). - Visited: fill-none, white stroke, white text. Upcoming: today's gray (fill-card, border stroke, muted text) — kept per owner. Current: white stroke/text + a faint yellow highlight — a blurred yellow fill (feGaussianBlur) that bleeds out over the node's edges, still breathing. - Edges + interim arrowhead: white (marker swaps to the owner's SVG when it lands: right-pointing, solid fill, tight viewBox, tip at right edge). - The IN-CHAT plan block renders monochrome — every node white, no status language, no glow; the popup/plan-window show the progression. Popup entrance fix: the transition from round 3 animated the first fit (graph flew in from the top-left zoom:1 state). The transition now stays off until the first fit has been applied — the popup paints settled.
This commit is contained in:
+56
-18
@@ -143,16 +143,24 @@ export const TEXT_CAP_SCALE = 16.5 / 12.5;
|
|||||||
|
|
||||||
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
||||||
|
|
||||||
|
// The simple look (owner, plan-graph round 4): rectangular boxes, white
|
||||||
|
// outlines/arrows/text; no fill on visited, today's gray look on upcoming,
|
||||||
|
// and the current node carries a faint yellow glow (a blurred yellow fill
|
||||||
|
// bleeding over the edges). The IN-CHAT block is monochrome white instead
|
||||||
|
// — no status language; the popup/window show the progression.
|
||||||
const BOX: Record<PlanNodeStatus, string> = {
|
const BOX: Record<PlanNodeStatus, string> = {
|
||||||
visited: "fill-phase-teach/10 stroke-phase-teach/50",
|
visited: "fill-none stroke-white",
|
||||||
current: "fill-primary/10 stroke-primary",
|
current: "fill-none stroke-white",
|
||||||
|
// The grayed-out not-yet-visited look stays as-is (owner).
|
||||||
upcoming: "fill-card stroke-border",
|
upcoming: "fill-card stroke-border",
|
||||||
};
|
};
|
||||||
const TEXT: Record<PlanNodeStatus, string> = {
|
const TEXT: Record<PlanNodeStatus, string> = {
|
||||||
visited: "fill-muted-foreground text-xs",
|
visited: "fill-white text-xs",
|
||||||
current: "fill-foreground text-xs font-medium",
|
current: "fill-white text-xs font-medium",
|
||||||
upcoming: "fill-muted-foreground/80 text-xs",
|
upcoming: "fill-muted-foreground/80 text-xs",
|
||||||
};
|
};
|
||||||
|
const MONO_BOX = "fill-none stroke-white";
|
||||||
|
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. */
|
||||||
@@ -166,6 +174,7 @@ export function PlanDiagram({
|
|||||||
pad: padUnits,
|
pad: padUnits,
|
||||||
textCapScale,
|
textCapScale,
|
||||||
naturalSize,
|
naturalSize,
|
||||||
|
monochrome,
|
||||||
}: {
|
}: {
|
||||||
nodes: readonly LayoutNode[];
|
nodes: readonly LayoutNode[];
|
||||||
edges: ReadonlyArray<{ from: string; to: string }>;
|
edges: ReadonlyArray<{ from: string; to: string }>;
|
||||||
@@ -184,6 +193,9 @@ export function PlanDiagram({
|
|||||||
* the popup's transform math depends on it; w-full inside the popup's
|
* the popup's transform math depends on it; w-full inside the popup's
|
||||||
* shrink-to-fit wrapper resolved to the popup width and broke fit. */
|
* shrink-to-fit wrapper resolved to the popup width and broke fit. */
|
||||||
naturalSize?: boolean;
|
naturalSize?: boolean;
|
||||||
|
/** The in-chat block's mode: every node white (fill-none, white stroke
|
||||||
|
* and text, no glow) — statuses never show in the transcript (owner). */
|
||||||
|
monochrome?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
||||||
// Pad only the full-extent render — the plan window's override viewBox is
|
// Pad only the full-extent render — the plan window's override viewBox is
|
||||||
@@ -217,8 +229,16 @@ export function PlanDiagram({
|
|||||||
markerHeight="6"
|
markerHeight="6"
|
||||||
orient="auto-start-reverse"
|
orient="auto-start-reverse"
|
||||||
>
|
>
|
||||||
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
|
{/* Interim head — swapped for the owner's arrowhead SVG when it
|
||||||
|
lands (right-pointing, tight viewBox, tip at the right edge). */}
|
||||||
|
<path d="M0,1 L7,4 L0,7 z" className="fill-white" />
|
||||||
</marker>
|
</marker>
|
||||||
|
{/* The current node's highlight: a faint yellow fill BLURRED so it
|
||||||
|
bleeds out over the node's edges (owner). Generous filter region
|
||||||
|
so the blur never clips at its own bounds. */}
|
||||||
|
<filter id={`${markerId}-glow`} x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation={7} />
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
{edges.map((e) => {
|
{edges.map((e) => {
|
||||||
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
|
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
|
||||||
@@ -230,7 +250,7 @@ export function PlanDiagram({
|
|||||||
<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-muted-foreground/40"
|
className="fill-none stroke-white"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
markerEnd={`url(#${markerId})`}
|
markerEnd={`url(#${markerId})`}
|
||||||
/>
|
/>
|
||||||
@@ -238,7 +258,7 @@ export function PlanDiagram({
|
|||||||
})}
|
})}
|
||||||
{nodes.map((n) => {
|
{nodes.map((n) => {
|
||||||
const status = statusOf(n.id);
|
const status = statusOf(n.id);
|
||||||
const glow = n.id === glowId;
|
const glow = n.id === glowId && !monochrome;
|
||||||
const lines = wrapTitle(n.title);
|
const lines = wrapTitle(n.title);
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -248,20 +268,31 @@ export function PlanDiagram({
|
|||||||
data-status={status}
|
data-status={status}
|
||||||
>
|
>
|
||||||
{glow && (
|
{glow && (
|
||||||
// The glow target: a soft primary halo breathing around the box.
|
// The current node's highlight: faint yellow, blurred so it
|
||||||
|
// bleeds over the edges (owner, plan-graph round 4).
|
||||||
<rect
|
<rect
|
||||||
x={-4}
|
x={-10}
|
||||||
y={-4}
|
y={-10}
|
||||||
width={NODE_W + 8}
|
width={NODE_W + 20}
|
||||||
height={NODE_H + 8}
|
height={NODE_H + 20}
|
||||||
rx={12}
|
className="fill-yellow-400/25 animate-pulse"
|
||||||
className="fill-none stroke-primary/70 animate-pulse"
|
filter={`url(#${markerId}-glow)`}
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<rect width={NODE_W} height={NODE_H} rx={10} className={BOX[status]} strokeWidth={1.5} />
|
{/* Rectangular — no corner rounding (owner). */}
|
||||||
|
<rect
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
className={monochrome ? MONO_BOX : BOX[status]}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, i) => (
|
||||||
<text key={i} x={12} y={lines.length === 1 ? 27 : 18 + i * 13} className={TEXT[status]}>
|
<text
|
||||||
|
key={i}
|
||||||
|
x={12}
|
||||||
|
y={lines.length === 1 ? 27 : 18 + i * 13}
|
||||||
|
className={monochrome ? MONO_TEXT : TEXT[status]}
|
||||||
|
>
|
||||||
{line}
|
{line}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
@@ -303,6 +334,7 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () =>
|
|||||||
markerId={markerId}
|
markerId={markerId}
|
||||||
pad={GRAPH_PAD}
|
pad={GRAPH_PAD}
|
||||||
textCapScale={TEXT_CAP_SCALE}
|
textCapScale={TEXT_CAP_SCALE}
|
||||||
|
monochrome
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -416,6 +448,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||||
const userAdjustedRef = useRef(false);
|
const userAdjustedRef = useRef(false);
|
||||||
const atFloor = view.zoom <= fitZoom + 1e-9;
|
const atFloor = view.zoom <= fitZoom + 1e-9;
|
||||||
|
// The transition stays off until the FIRST fit has been applied — the
|
||||||
|
// popup paints at the fit directly instead of flying in from the initial
|
||||||
|
// top-left zoom:1 state (owner, plan-graph round 4).
|
||||||
|
const [settled, setSettled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (atFloor) userAdjustedRef.current = false;
|
if (atFloor) userAdjustedRef.current = false;
|
||||||
@@ -423,6 +459,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userAdjustedRef.current || !size.w || !size.h) return;
|
if (userAdjustedRef.current || !size.w || !size.h) return;
|
||||||
setView(fitGraphView(box, size, fitZoom));
|
setView(fitGraphView(box, size, fitZoom));
|
||||||
|
setSettled(true);
|
||||||
}, [fitZoom, size, box]);
|
}, [fitZoom, size, box]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -526,7 +563,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
className="absolute top-0 left-0 origin-top-left"
|
className="absolute top-0 left-0 origin-top-left"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
||||||
transition: panning ? "none" : "transform 180ms ease-out",
|
transition:
|
||||||
|
panning || !settled ? "none" : "transform 180ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlanDiagram
|
<PlanDiagram
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ describe("plan popup (live derivation)", () => {
|
|||||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||||
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.
|
// The current node is the glow target: a faint yellow fill, blurred
|
||||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
// so it bleeds over the edges (owner, plan-graph round 4).
|
||||||
|
assert.match(html, /fill-yellow-400\/25 animate-pulse/);
|
||||||
|
assert.match(html, /feGaussianBlur/);
|
||||||
assert.match(html, /live — statuses derive from your check outcomes/);
|
assert.match(html, /live — statuses derive from your check outcomes/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user