fix: improve presentation evidence and architecture figure
This commit is contained in:
@@ -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">▸</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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user