feat: render recursive interactive figures

This commit is contained in:
lda
2026-07-05 17:49:50 +07:00 Verified
parent 957077709f
commit b44ef12ca4
6 changed files with 542 additions and 0 deletions
@@ -0,0 +1,28 @@
import type { FigureBreadcrumb } from "./focus.js";
type FigureBreadcrumbsProps = {
readonly breadcrumbs: readonly FigureBreadcrumb[];
readonly onNavigate: (path: readonly string[]) => void;
};
export const FigureBreadcrumbs = ({
breadcrumbs,
onNavigate,
}: FigureBreadcrumbsProps) => (
<nav className="figure-breadcrumbs" aria-label="Figure navigation">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<button
key={`${crumb.path.join("/")}-${index}`}
type="button"
className="figure-breadcrumbs__crumb"
aria-current={isLast ? "page" : undefined}
onClick={() => onNavigate(crumb.path)}
>
{crumb.label}
</button>
);
})}
</nav>
);
@@ -0,0 +1,64 @@
import type { FigureNodeDefinition } from "./model.js";
type FigureNodeViewProps = {
readonly node: FigureNodeDefinition;
readonly isActive: boolean;
readonly focusedNodeId: string;
readonly onActivate: (nodeId: string) => void;
readonly onExpand: (nodeId: string) => void;
readonly onFocus: (nodeId: string) => void;
};
const kindLabel: Record<string, string> = {
actor: "Actor",
operation: "Operation",
artifact: "Artifact",
runtime: "Runtime",
boundary: "Boundary",
evidence: "Evidence",
};
export const FigureNodeView = ({
node,
isActive,
focusedNodeId,
onActivate,
onExpand,
onFocus,
}: FigureNodeViewProps) => {
const expandable = node.childFigureId !== undefined;
const accessibleName = expandable
? `${node.label}, expand`
: node.label;
return (
<button
type="button"
className="figure-node"
data-figure-node-kind={node.kind}
data-active={isActive}
data-expandable={expandable}
data-testid={`figure-node-${node.id}`}
role="button"
aria-label={accessibleName}
tabIndex={focusedNodeId === node.id ? 0 : -1}
onClick={() => {
onActivate(node.id);
if (expandable) onExpand(node.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && expandable) {
event.preventDefault();
onExpand(node.id);
}
}}
onFocus={() => onFocus(node.id)}
>
<span className="figure-node__kind">{kindLabel[node.kind] ?? node.kind}</span>
<strong className="figure-node__label">{node.label}</strong>
<span className="figure-node__summary">{node.summary}</span>
{expandable && <span className="figure-node__expand-affance" aria-hidden="true"></span>}
{isActive && <span className="figure-node__current-marker">Current</span>}
</button>
);
};
@@ -0,0 +1,118 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FigureCatalogDefinition } from "./model.js";
import { InteractiveFigure } from "./InteractiveFigure.js";
const validCatalog: FigureCatalogDefinition = {
rootFigureId: "architecture-overview",
figures: [
{
id: "architecture-overview",
title: "Architecture",
layout: { kind: "layered" },
nodes: [
{ id: "client", label: "Client operations", summary: "CLI callers", kind: "actor" },
{ id: "runtime", label: "Runtime & providers", summary: "WorkflowServer", kind: "runtime", childFigureId: "runtime-detail" },
{ id: "leaf", label: "Leaf node", summary: "Non-expandable", kind: "artifact" },
],
edges: [{ id: "e1", from: "client", to: "runtime" }],
},
{
id: "runtime-detail",
title: "Runtime detail",
layout: { kind: "layered" },
nodes: [
{ id: "providers", label: "Configured providers", summary: "Built-in and external", kind: "runtime", childFigureId: "provider-detail" },
{ id: "leaf2", label: "Leaf detail", summary: "Static detail", kind: "artifact" },
],
edges: [{ id: "e2", from: "providers", to: "leaf2" }],
},
{
id: "provider-detail",
title: "Provider detail",
layout: { kind: "layered" },
nodes: [
{ id: "mcp", label: "MCP providers", summary: "Model Context Protocol", kind: "runtime" },
{ id: "python", label: "Python provider", summary: "Trusted in-process", kind: "runtime" },
],
edges: [{ id: "e3", from: "mcp", to: "python" }],
},
],
};
const renderFigure = (overrides: Partial<React.ComponentProps<typeof InteractiveFigure>> = {}) => {
const onFocusPathChange = overrides.onFocusPathChange ?? vi.fn();
const { onFocusPathChange: _, ...restOverrides } = overrides;
return {
onFocusPathChange,
...render(
<InteractiveFigure
catalog={validCatalog}
focusPath={restOverrides.focusPath ?? []}
activeNodeId={restOverrides.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange}
motionDisabled={restOverrides.motionDisabled ?? false}
/>,
),
};
};
afterEach(() => cleanup());
describe("InteractiveFigure", () => {
it("renders conceptual labels and hides evidence pointers by default", () => {
renderFigure({ focusPath: [] });
expect(screen.getByRole("button", { name: /runtime & providers.*expand/i })).toBeInTheDocument();
expect(screen.queryByText(/docs\/source_architecture\.md/i)).not.toBeInTheDocument();
});
it("expands a child figure by click and Enter", async () => {
const user = userEvent.setup();
const { onFocusPathChange } = renderFigure({ focusPath: [], onFocusPathChange: vi.fn() });
await user.click(screen.getByRole("button", { name: /runtime & providers/i }));
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime"]);
screen.getByRole("button", { name: /runtime & providers/i }).focus();
await user.keyboard("{Enter}");
expect(onFocusPathChange).toHaveBeenLastCalledWith(["runtime"]);
});
it("pops one focus level with Escape and breadcrumb activation", async () => {
const user = userEvent.setup();
const { onFocusPathChange } = renderFigure({
focusPath: ["runtime", "providers"],
onFocusPathChange: vi.fn(),
});
screen.getByRole("button", { name: /configured providers/i }).focus();
await user.keyboard("{Escape}");
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime"]);
await user.click(screen.getByRole("button", { name: /architecture/i }));
expect(onFocusPathChange).toHaveBeenLastCalledWith([]);
});
it("uses arrow keys inside the figure without bubbling presentation navigation", async () => {
const user = userEvent.setup();
const outerKeyDown = vi.fn();
render(
<div onKeyDown={outerKeyDown}>
<InteractiveFigure
catalog={validCatalog}
focusPath={[]}
activeNodeId="client"
onFocusPathChange={vi.fn()}
motionDisabled={false}
/>
</div>,
);
screen.getByRole("button", { name: /client operations/i }).focus();
await user.keyboard("{ArrowDown}");
expect(outerKeyDown).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /runtime & providers/i })).toHaveFocus();
});
it("retains all information when motion is disabled", () => {
renderFigure({ focusPath: ["runtime"], motionDisabled: true });
expect(screen.getByRole("group", { name: /runtime detail/i })).toHaveAttribute("data-motion", "disabled");
expect(screen.getAllByRole("button").length).toBeGreaterThan(1);
});
});
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { FigureCatalogDefinition } from "./model.js";
import { layoutFigure, type PositionedFigure } from "./layout.js";
import { nextFigureNodeId, type FigureDirection } from "./navigation.js";
import {
popFigureFocus,
pushFigureFocus,
resolveFigureFocus,
type FigureFocus,
} from "./focus.js";
import { FigureBreadcrumbs } from "./FigureBreadcrumbs.js";
import { FigureNodeView } from "./FigureNodeView.js";
import "./interactive-figure.css";
type InteractiveFigureProps = {
readonly catalog: FigureCatalogDefinition;
readonly focusPath: readonly string[];
readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean;
};
export const InteractiveFigure = ({
catalog,
focusPath,
activeNodeId,
onFocusPathChange,
motionDisabled,
}: InteractiveFigureProps) => {
const focus = resolveFigureFocus(catalog, focusPath);
const layout = layoutFigure(focus.figure);
const [focusedNodeId, setFocusedNodeId] = useState<string>(
activeNodeId ?? focus.figure.nodes[0]?.id ?? "",
);
const groupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeNodeId) setFocusedNodeId(activeNodeId);
}, [activeNodeId]);
const handleExpand = useCallback(
(nodeId: string) => {
const next = pushFigureFocus(catalog, focus, nodeId);
if (next.path.length > focus.path.length) {
onFocusPathChange(next.path);
}
},
[catalog, focus, onFocusPathChange],
);
const handleBreadcrumbNavigate = useCallback(
(path: readonly string[]) => {
onFocusPathChange(path);
},
[onFocusPathChange],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const key = event.key;
if (key === "Escape") {
event.preventDefault();
event.stopPropagation();
const popped = popFigureFocus(catalog, focus);
if (popped.path.length < focus.path.length) {
onFocusPathChange(popped.path);
}
return;
}
const directionMap: Record<string, FigureDirection> = {
ArrowUp: "ArrowUp",
ArrowDown: "ArrowDown",
ArrowLeft: "ArrowLeft",
ArrowRight: "ArrowRight",
};
const direction = directionMap[key];
if (direction) {
event.preventDefault();
event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeId, direction);
setFocusedNodeId(nextId);
const nextEl = groupRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`,
) as HTMLElement | null;
nextEl?.focus();
}
},
[catalog, focus, focusedNodeId, layout, onFocusPathChange],
);
return (
<div
className="interactive-figure"
role="group"
aria-label={focus.figure.title}
data-motion={motionDisabled ? "disabled" : "enabled"}
onKeyDown={handleKeyDown}
>
<FigureBreadcrumbs
breadcrumbs={focus.breadcrumbs}
onNavigate={handleBreadcrumbNavigate}
/>
<div className="interactive-figure__canvas" ref={groupRef}>
{layout.nodes.map((node) => (
<FigureNodeView
key={node.id}
node={node}
isActive={node.id === activeNodeId}
focusedNodeId={focusedNodeId}
onActivate={() => setFocusedNodeId(node.id)}
onExpand={handleExpand}
onFocus={setFocusedNodeId}
/>
))}
{layout.edges.map((edge) => (
<svg
key={edge.id}
className="interactive-figure__edge"
aria-hidden="true"
>
<line
x1={getNodeCenter(layout, edge.from)?.x ?? 0}
y1={getNodeCenter(layout, edge.from)?.y ?? 0}
x2={getNodeCenter(layout, edge.to)?.x ?? 0}
y2={getNodeCenter(layout, edge.to)?.y ?? 0}
stroke="currentColor"
strokeWidth={1.5}
markerEnd="url(#figure-arrow)"
/>
</svg>
))}
<svg className="interactive-figure__edge-defs" aria-hidden="true">
<defs>
<marker id="figure-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" />
</marker>
</defs>
</svg>
</div>
</div>
);
};
const NODE_WIDTH = 196;
const NODE_HEIGHT = 84;
const getNodeCenter = (
layout: PositionedFigure,
nodeId: string,
): { x: number; y: number } | undefined => {
const node = layout.nodes.find((n) => n.id === nodeId);
if (!node) return undefined;
return {
x: node.position.x + NODE_WIDTH / 2,
y: node.position.y + NODE_HEIGHT / 2,
};
};
@@ -0,0 +1,153 @@
.interactive-figure {
position: relative;
width: 100%;
height: 100%;
}
.interactive-figure__canvas {
position: relative;
width: 100%;
height: calc(100% - 32px);
}
.interactive-figure__edge {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
}
.interactive-figure__edge-defs {
position: absolute;
width: 0;
height: 0;
}
.figure-node {
position: absolute;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 196px;
height: 84px;
padding: 8px 12px;
border: 2px solid oklch(0.75 0.01 65);
border-radius: 4px;
background: oklch(0.975 0.012 82);
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
font-family: var(--font-interface, sans-serif);
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease;
}
.figure-node:focus-visible {
outline: 3px solid oklch(0.53 0.17 250);
outline-offset: 2px;
}
.figure-node[data-active="true"] {
border-width: 3px;
border-color: oklch(0.19 0.015 65);
}
.figure-node__kind {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
}
.figure-node__label {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.figure-node__summary {
font-size: 11px;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.figure-node__expand-affance {
position: absolute;
top: 8px;
right: 8px;
font-size: 14px;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
}
.figure-node__current-marker {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Semantic kind colors */
.figure-node[data-figure-node-kind="actor"],
.figure-node[data-figure-node-kind="operation"] {
border-color: var(--color-intent, oklch(0.53 0.17 250));
}
.figure-node[data-figure-node-kind="runtime"] {
border-color: var(--color-runtime, oklch(0.55 0.14 150));
}
.figure-node[data-figure-node-kind="boundary"] {
border-color: var(--color-human, oklch(0.68 0.17 55));
}
.figure-node[data-expandable="true"] {
cursor: pointer;
}
.figure-node[data-expandable="false"] {
cursor: default;
}
/* Breadcrumbs */
.figure-breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
font-size: 12px;
font-family: var(--font-interface, sans-serif);
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
}
.figure-breadcrumbs__crumb {
background: none;
border: none;
padding: 2px 4px;
color: inherit;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.figure-breadcrumbs__crumb:focus-visible {
outline: 2px solid oklch(0.53 0.17 250);
outline-offset: 1px;
}
.figure-breadcrumbs__crumb[aria-current="page"] {
text-decoration: none;
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
font-weight: 600;
}
.figure-breadcrumbs__crumb + .figure-breadcrumbs__crumb::before {
content: "";
margin-right: 4px;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
}
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const css = readFileSync(
join(import.meta.dirname, "interactive-figure.css"),
"utf8",
);
import { describe, expect, it } from "vitest";
describe("interactive-figure CSS", () => {
it("styles node kinds with semantic colors", () => {
expect(css).toContain("data-figure-node-kind");
});
it("does not use uniform rounded cards or gradients", () => {
expect(css).not.toContain("border-radius: 9999px");
expect(css).not.toContain("linear-gradient");
});
});