feat: render recursive interactive figures
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user