fix: improve presentation evidence and architecture figure

This commit is contained in:
lda
2026-07-08 01:41:59 +07:00 Verified
parent 0c2b23ffd3
commit eb40c951d0
15 changed files with 169 additions and 60 deletions
@@ -78,7 +78,7 @@ const renderFigure = (overrides: Partial<React.ComponentProps<typeof Interactive
onFocusPathChange,
...render(
<InteractiveFigure
catalog={validCatalog}
catalog={restOverrides.catalog ?? validCatalog}
focusPath={restOverrides.focusPath ?? []}
activeNodeId={restOverrides.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange}
@@ -99,10 +99,26 @@ describe("InteractiveFigure", () => {
});
it("renders within a React Flow container for edge support", () => {
renderFigure({ focusPath: [] });
const { container } = renderFigure({ focusPath: [] });
const rfWrapper = screen.getByTestId("rf__wrapper");
expect(rfWrapper).toBeInTheDocument();
expect(rfWrapper.querySelector("[class*='react-flow']")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-top")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-bottom")).toBeInTheDocument();
});
it("uses left and right handles for flow figures", () => {
const flowCatalog: FigureCatalogDefinition = {
...validCatalog,
figures: validCatalog.figures.map((figure) =>
figure.id === validCatalog.rootFigureId
? { ...figure, layout: { kind: "flow" as const } }
: figure,
),
};
const { container } = renderFigure({ catalog: flowCatalog });
expect(container.querySelector(".react-flow__handle-left")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-right")).toBeInTheDocument();
});
it("expands a child figure by click and Enter", async () => {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
@@ -19,6 +19,7 @@ type InteractiveFigureProps = {
readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean;
readonly size?: "standard" | "wide";
};
type FigureNodeData = {
@@ -26,6 +27,7 @@ type FigureNodeData = {
readonly label: string;
readonly summary: string;
readonly kind: FigureNodeKind;
readonly orientation: "horizontal" | "vertical";
readonly isActive: boolean;
readonly isExpandable: boolean;
readonly onActivate: (nodeId: string) => void;
@@ -35,10 +37,12 @@ type FigureNodeData = {
const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
const expandable = data.isExpandable;
const accessibleName = expandable ? `${data.label}, expand` : data.label;
const targetPosition = data.orientation === "horizontal" ? Position.Left : Position.Top;
const sourcePosition = data.orientation === "horizontal" ? Position.Right : Position.Bottom;
return (
<>
<Handle type="target" position={Position.Top} id="target" />
<Handle type="target" position={targetPosition} id="target" />
<button
type="button"
className="figure-node"
@@ -65,7 +69,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>}
</button>
<Handle type="source" position={Position.Bottom} id="source" />
<Handle type="source" position={sourcePosition} id="source" />
</>
);
};
@@ -74,11 +78,11 @@ const nodeTypes: NodeTypes = {
figure: FigureFlowNode,
};
const FitViewOnLayoutChange = ({ layoutVersion }: { layoutVersion: number }) => {
const FitViewOnLayoutChange = ({ layoutKey }: { layoutKey: string }) => {
const { fitView } = useReactFlow();
useEffect(() => {
void fitView({ padding: 0.15, duration: 0 });
}, [fitView, layoutVersion]);
}, [fitView, layoutKey]);
return null;
};
@@ -88,24 +92,20 @@ const InteractiveFigureInner = ({
activeNodeId,
onFocusPathChange,
motionDisabled,
size = "standard",
}: InteractiveFigureProps) => {
const focus = resolveFigureFocus(catalog, focusPath);
const layout = layoutFigure(focus.figure);
const [focusedNodeId, setFocusedNodeId] = useState<string>(
activeNodeId ?? focus.figure.nodes[0]?.id ?? "",
const focus = useMemo(
() => resolveFigureFocus(catalog, focusPath),
[catalog, focusPath],
);
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
const containerRef = useRef<HTMLDivElement>(null);
const [layoutVersion, setLayoutVersion] = useState(0);
const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
useEffect(() => {
if (activeNodeId) setFocusedNodeId(activeNodeId);
}, [activeNodeId]);
useEffect(() => {
const firstNode = focus.figure.nodes[0];
if (firstNode) setFocusedNodeId(firstNode.id);
setLayoutVersion((v) => v + 1);
}, [focus.figure.id]);
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
focusedNodeIdRef.current = fallbackFocusedNodeId;
}
const handleExpand = useCallback(
(nodeId: string) => {
@@ -148,17 +148,21 @@ const InteractiveFigureInner = ({
if (direction) {
event.preventDefault();
event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeId, direction);
setFocusedNodeId(nextId);
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
focusedNodeIdRef.current = nextId;
const nextNode = containerRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`,
);
if (nextNode instanceof HTMLElement) nextNode.focus();
}
},
[catalog, focus, focusedNodeId, layout, onFocusPathChange],
[catalog, focus, layout, onFocusPathChange],
);
const handleActivateNode = useCallback((nodeId: string) => {
focusedNodeIdRef.current = nodeId;
}, []);
const rfNodes: Node[] = useMemo(
() =>
layout.nodes.map((node) => ({
@@ -170,13 +174,14 @@ const InteractiveFigureInner = ({
label: node.label,
summary: node.summary,
kind: node.kind,
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
isActive: node.id === activeNodeId,
isExpandable: node.childFigureId !== undefined,
onActivate: setFocusedNodeId,
onActivate: handleActivateNode,
onExpand: handleExpand,
},
})),
[layout.nodes, activeNodeId, handleExpand],
[layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
);
const rfEdges: Edge[] = useMemo(
@@ -194,7 +199,7 @@ const InteractiveFigureInner = ({
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
const data = node.data as FigureNodeData;
setFocusedNodeId(data.nodeId);
focusedNodeIdRef.current = data.nodeId;
if (data.isExpandable) handleExpand(data.nodeId);
},
[handleExpand],
@@ -207,6 +212,7 @@ const InteractiveFigureInner = ({
aria-label={focus.figure.title}
data-motion={motionDisabled ? "disabled" : "enabled"}
data-figure-id={focus.figure.id}
data-figure-size={size}
onKeyDown={handleKeyDown}
>
<FigureBreadcrumbs
@@ -232,7 +238,7 @@ const InteractiveFigureInner = ({
preventScrolling={false}
onNodeClick={handleNodeClick}
>
<FitViewOnLayoutChange layoutVersion={layoutVersion} />
<FitViewOnLayoutChange layoutKey={focus.figure.id} />
</ReactFlow>
</div>
</div>
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import { resolveFigureFocus } from "./focus.js";
import { architectureCatalog } from "./architecture-catalog.js";
import { layoutFigure } from "./layout.js";
describe("architectureCatalog", () => {
it("contains the conceptual architecture overview", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure;
expect(root.layout.kind).toBe("flow");
expect(root.nodes.map((node) => node.label)).toEqual([
"Client operations",
"Application lifecycle",
@@ -22,6 +24,14 @@ describe("architectureCatalog", () => {
).figure.id).toBe("configured-provider-detail");
});
it("lays out the architecture path horizontally for presentation", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure;
const layout = layoutFigure(root);
const client = layout.nodes.find((node) => node.id === "client-operations");
const nodeUse = layout.nodes.find((node) => node.id === "node-use");
expect(client?.position.x).toBeLessThan(nodeUse?.position.x ?? 0);
});
it("gives every factual node an evidence pointer", () => {
for (const figure of architectureCatalog.figures) {
for (const node of figure.nodes) {
@@ -9,7 +9,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "architecture-overview",
title: "Architecture",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "client-operations",
@@ -84,7 +84,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "runtime-provider-detail",
title: "Runtime and providers",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "workflow-server",
@@ -133,7 +133,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "configured-provider-detail",
title: "Configured providers",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "builtin-sources",
@@ -172,7 +172,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "node-use-detail",
title: "NodeUse execution",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "resolve-bindings",
@@ -4,6 +4,7 @@
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.interactive-figure__canvas {
@@ -11,6 +12,24 @@
min-height: 0;
}
.interactive-figure[data-figure-size="wide"] {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.35rem;
scrollbar-gutter: stable;
}
.interactive-figure[data-figure-size="wide"] .interactive-figure__canvas {
min-width: 1280px;
min-height: 420px;
}
.interactive-figure[data-figure-size="wide"] .react-flow {
border: 1px solid color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 45%, transparent);
border-radius: 0.75rem;
background: color-mix(in oklch, var(--color-editorial-surface, oklch(0.96 0.012 82)) 92%, white);
}
.interactive-figure .react-flow__pane {
pointer-events: none;
}
@@ -50,9 +69,9 @@
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 196px;
height: 84px;
padding: 8px 12px;
width: 236px;
height: 102px;
padding: 10px 14px;
border: 2px solid oklch(0.75 0.01 65);
border-radius: 4px;
background: oklch(0.975 0.012 82);
@@ -81,17 +100,19 @@
}
.figure-node__label {
font-size: 14px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.figure-node__summary {
font-size: 11px;
font-size: 12px;
line-height: 1.18;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%;
}
@@ -15,8 +15,8 @@ export type PositionedFigure = {
readonly edges: readonly FigureEdgeDefinition[];
};
export const NODE_WIDTH = 196;
export const NODE_HEIGHT = 84;
export const NODE_WIDTH = 236;
export const NODE_HEIGHT = 102;
const NODESEP = 56;
const RANKSEP = 88;