feat: choreograph architecture and workflow proof scenes
This commit is contained in:
@@ -16,6 +16,11 @@ const kindLabel: Record<FigureNodeKind, string> = {
|
||||
runtime: "Runtime",
|
||||
boundary: "Boundary",
|
||||
evidence: "Evidence",
|
||||
decision: "Decision",
|
||||
terminal: "Terminal",
|
||||
provider: "Provider",
|
||||
lane: "Lane",
|
||||
loop: "Loop",
|
||||
};
|
||||
|
||||
export const FigureNodeView = ({
|
||||
|
||||
@@ -42,7 +42,18 @@ const validCatalog: FigureCatalogDefinition = {
|
||||
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" },
|
||||
{
|
||||
id: "leaf",
|
||||
label: "Leaf node",
|
||||
summary: "Non-expandable",
|
||||
kind: "artifact",
|
||||
evidence: {
|
||||
label: "Stored fact",
|
||||
title: "Leaf evidence",
|
||||
body: "The graph remains visible while this factual detail is inspected.",
|
||||
facts: [{ label: "Pointer", value: "docs/example.md" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [{ id: "e1", from: "client", to: "runtime", label: "calls" }],
|
||||
},
|
||||
@@ -191,12 +202,20 @@ describe("InteractiveFigure", () => {
|
||||
expect(figure.querySelector(".interactive-figure__canvas")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps root stage figures in presentation mode", () => {
|
||||
it("opens leaf evidence without replacing the figure", () => {
|
||||
renderFigure({ size: "stage" });
|
||||
fireEvent.click(figureNode("leaf"));
|
||||
expect(screen.getByRole("region", { name: /leaf node evidence/i })).toHaveTextContent("Leaf evidence");
|
||||
expect(screen.getByRole("region", { name: /leaf node evidence/i })).toHaveTextContent("docs/example.md");
|
||||
expect(figureNode("leaf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables pan and zoom on the root architecture figure", () => {
|
||||
renderFigure({ focusPath: [], size: "stage" });
|
||||
|
||||
expect(screen.getByRole("group", { name: /architecture/i })).toHaveAttribute(
|
||||
"data-pan-zoom",
|
||||
"disabled",
|
||||
"enabled",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
|
||||
import {
|
||||
Boxes,
|
||||
Cable,
|
||||
CircleStop,
|
||||
Database,
|
||||
FileSearch,
|
||||
GitBranch,
|
||||
Layers3,
|
||||
Network,
|
||||
PauseCircle,
|
||||
Plug,
|
||||
Repeat,
|
||||
Server,
|
||||
Terminal,
|
||||
Users,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
|
||||
import { layoutFigure, type FigureLayoutSize, type PositionedFigure } from "./layout.js";
|
||||
import type {
|
||||
FigureCatalogDefinition,
|
||||
FigureLayoutKind,
|
||||
FigureNodeDefinition,
|
||||
FigureNodeIcon,
|
||||
FigureNodeKind,
|
||||
FigureNodeShape,
|
||||
} from "./model.js";
|
||||
import { layoutFigure, type FigureLayoutSize } from "./layout.js";
|
||||
import { nextFigureNodeId, type FigureDirection } from "./navigation.js";
|
||||
import {
|
||||
popFigureFocus,
|
||||
@@ -27,19 +52,78 @@ type FigureNodeData = {
|
||||
readonly label: string;
|
||||
readonly summary: string;
|
||||
readonly kind: FigureNodeKind;
|
||||
readonly shape: FigureNodeShape;
|
||||
readonly icon: FigureNodeIcon | null;
|
||||
readonly details: FigureNodeDefinition["details"];
|
||||
readonly evidence: FigureNodeDefinition["evidence"];
|
||||
readonly orientation: "horizontal" | "vertical";
|
||||
readonly isActive: boolean;
|
||||
readonly isFocused: boolean;
|
||||
readonly isSelected: boolean;
|
||||
readonly isExpandable: boolean;
|
||||
readonly onActivate: (nodeId: string) => void;
|
||||
readonly onExpand: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
const iconByName: Record<FigureNodeIcon, LucideIcon> = {
|
||||
users: Users,
|
||||
terminal: Terminal,
|
||||
network: Network,
|
||||
server: Server,
|
||||
workflow: Workflow,
|
||||
database: Database,
|
||||
layers: Layers3,
|
||||
branch: GitBranch,
|
||||
repeat: Repeat,
|
||||
pause: PauseCircle,
|
||||
stop: CircleStop,
|
||||
plug: Plug,
|
||||
trace: FileSearch,
|
||||
code: FileSearch,
|
||||
lane: Cable,
|
||||
};
|
||||
|
||||
const iconByKind: Record<FigureNodeKind, FigureNodeIcon> = {
|
||||
actor: "users",
|
||||
operation: "workflow",
|
||||
artifact: "database",
|
||||
runtime: "server",
|
||||
boundary: "network",
|
||||
evidence: "trace",
|
||||
decision: "branch",
|
||||
terminal: "stop",
|
||||
provider: "plug",
|
||||
lane: "lane",
|
||||
loop: "repeat",
|
||||
};
|
||||
|
||||
const shapeByKind: Record<FigureNodeKind, FigureNodeShape> = {
|
||||
actor: "card",
|
||||
operation: "card",
|
||||
artifact: "receipt",
|
||||
runtime: "card",
|
||||
boundary: "boundary",
|
||||
evidence: "receipt",
|
||||
decision: "diamond",
|
||||
terminal: "terminal",
|
||||
provider: "boundary",
|
||||
lane: "sequence",
|
||||
loop: "loop",
|
||||
};
|
||||
|
||||
const horizontalLayouts = new Set<FigureLayoutKind>(["flow", "fan-in", "lanes", "explicit"]);
|
||||
|
||||
const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
|
||||
const expandable = data.isExpandable;
|
||||
const accessibleName = expandable ? `${data.label}, expand` : data.label;
|
||||
const hasEvidence = data.evidence !== undefined || data.details !== undefined;
|
||||
const accessibleName = expandable
|
||||
? `${data.label}, expand`
|
||||
: hasEvidence
|
||||
? `${data.label}, inspect details`
|
||||
: data.label;
|
||||
const targetPosition = data.orientation === "horizontal" ? Position.Left : Position.Top;
|
||||
const sourcePosition = data.orientation === "horizontal" ? Position.Right : Position.Bottom;
|
||||
const Icon = iconByName[data.icon ?? iconByKind[data.kind]];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,7 +132,9 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
|
||||
type="button"
|
||||
className="figure-node"
|
||||
data-figure-node-kind={data.kind}
|
||||
data-figure-shape={data.shape}
|
||||
data-active={data.isActive}
|
||||
data-selected={data.isSelected}
|
||||
data-expandable={expandable}
|
||||
data-testid={`figure-node-${data.nodeId}`}
|
||||
aria-label={accessibleName}
|
||||
@@ -64,9 +150,22 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="figure-node__kind">{data.kind}</span>
|
||||
<span className="figure-node__header">
|
||||
<span className="figure-node__kind">{data.kind}</span>
|
||||
<Icon className="figure-node__icon" size={18} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<strong className="figure-node__label">{data.label}</strong>
|
||||
<span className="figure-node__summary">{data.summary}</span>
|
||||
{data.details && data.details.length > 0 && (
|
||||
<dl className="figure-node__details">
|
||||
{data.details.slice(0, 2).map((detail) => (
|
||||
<div key={`${detail.label}-${detail.value}`}>
|
||||
<dt>{detail.label}</dt>
|
||||
<dd><code>{detail.value}</code></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">▸</span>}
|
||||
{data.isActive && <span className="figure-node__current-marker">Current</span>}
|
||||
</button>
|
||||
@@ -105,12 +204,14 @@ const InteractiveFigureInner = ({
|
||||
() => resolveFigureFocus(catalog, focusPath),
|
||||
[catalog, focusPath],
|
||||
);
|
||||
const layout = useMemo(() => layoutFigure(focus.figure, size), [focus.figure, size]);
|
||||
const layoutSize = size === "stage" && focus.path.length === 0 ? "wide" : size;
|
||||
const layout = useMemo(() => layoutFigure(focus.figure, layoutSize), [focus.figure, layoutSize]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
|
||||
const focusedNodeIdRef = useRef(initialFocusedNodeId);
|
||||
const graphInspectionEnabled = size === "stage" && focus.path.length > 0;
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const graphInspectionEnabled = size === "stage";
|
||||
|
||||
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
useEffect(() => {
|
||||
@@ -125,6 +226,7 @@ const InteractiveFigureInner = ({
|
||||
(nodeId: string) => {
|
||||
const next = pushFigureFocus(catalog, focus, nodeId);
|
||||
if (next.path.length > focus.path.length) {
|
||||
setSelectedNodeId(null);
|
||||
onFocusPathChange(next.path);
|
||||
}
|
||||
},
|
||||
@@ -133,6 +235,7 @@ const InteractiveFigureInner = ({
|
||||
|
||||
const handleBreadcrumbNavigate = useCallback(
|
||||
(path: readonly string[]) => {
|
||||
setSelectedNodeId(null);
|
||||
onFocusPathChange(path);
|
||||
},
|
||||
[onFocusPathChange],
|
||||
@@ -144,6 +247,10 @@ const InteractiveFigureInner = ({
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (selectedNodeId !== null) {
|
||||
setSelectedNodeId(null);
|
||||
return;
|
||||
}
|
||||
const popped = popFigureFocus(catalog, focus);
|
||||
if (popped.path.length < focus.path.length) {
|
||||
onFocusPathChange(popped.path);
|
||||
@@ -171,13 +278,19 @@ const InteractiveFigureInner = ({
|
||||
if (nextNode instanceof HTMLElement) nextNode.focus();
|
||||
}
|
||||
},
|
||||
[catalog, focus, layout, onFocusPathChange],
|
||||
[catalog, focus, layout, onFocusPathChange, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleActivateNode = useCallback((nodeId: string) => {
|
||||
focusedNodeIdRef.current = nodeId;
|
||||
setFocusedNodeId(nodeId);
|
||||
}, []);
|
||||
const node = layout.nodes.find((candidate) => candidate.id === nodeId);
|
||||
setSelectedNodeId(node?.evidence || node?.details ? nodeId : null);
|
||||
}, [layout.nodes]);
|
||||
|
||||
const selectedNode = selectedNodeId === null
|
||||
? undefined
|
||||
: layout.nodes.find((node) => node.id === selectedNodeId);
|
||||
|
||||
const rfNodes: Node[] = useMemo(
|
||||
() =>
|
||||
@@ -190,15 +303,20 @@ const InteractiveFigureInner = ({
|
||||
label: node.label,
|
||||
summary: node.summary,
|
||||
kind: node.kind,
|
||||
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
|
||||
shape: node.shape ?? shapeByKind[node.kind],
|
||||
icon: node.icon ?? null,
|
||||
details: node.details,
|
||||
evidence: node.evidence,
|
||||
orientation: horizontalLayouts.has(layout.definition.layout.kind) ? "horizontal" : "vertical",
|
||||
isActive: node.id === activeNodeId,
|
||||
isFocused: node.id === focusedNodeId,
|
||||
isSelected: node.id === selectedNodeId,
|
||||
isExpandable: node.childFigureId !== undefined,
|
||||
onActivate: handleActivateNode,
|
||||
onExpand: handleExpand,
|
||||
},
|
||||
})),
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, focusedNodeId, handleActivateNode, handleExpand],
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, focusedNodeId, selectedNodeId, handleActivateNode, handleExpand],
|
||||
);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
@@ -208,9 +326,11 @@ const InteractiveFigureInner = ({
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
label: edge.label,
|
||||
type: "default",
|
||||
// Authored maps use orthogonal routing so their deliberate rows and
|
||||
// branches stay readable; Dagre layouts retain softer default curves.
|
||||
type: layout.definition.layout.kind === "explicit" ? "smoothstep" : "default",
|
||||
})),
|
||||
[layout.edges],
|
||||
[layout.definition.layout.kind, layout.edges],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
@@ -233,37 +353,70 @@ const InteractiveFigureInner = ({
|
||||
data-figure-layout={focus.figure.layout.kind}
|
||||
data-figure-focus-level={focus.path.length}
|
||||
data-pan-zoom={graphInspectionEnabled ? "enabled" : "disabled"}
|
||||
data-selected-node={selectedNode?.id ?? ""}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<FigureBreadcrumbs
|
||||
breadcrumbs={focus.breadcrumbs}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
{/* React Flow must measure nodes in its unscaled coordinate space. Keep
|
||||
this canvas responsive instead of reintroducing a CSS scale wrapper. */}
|
||||
<div className="interactive-figure__canvas" ref={containerRef}>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
elementsSelectable={false}
|
||||
minZoom={0.35}
|
||||
maxZoom={2.2}
|
||||
panOnDrag={graphInspectionEnabled}
|
||||
zoomOnScroll={graphInspectionEnabled}
|
||||
zoomOnPinch={graphInspectionEnabled}
|
||||
zoomOnDoubleClick={graphInspectionEnabled}
|
||||
preventScrolling={graphInspectionEnabled}
|
||||
onNodeClick={handleNodeClick}
|
||||
>
|
||||
<FitViewOnLayoutChange layoutKey={focus.figure.id} />
|
||||
</ReactFlow>
|
||||
<div className="interactive-figure__workspace">
|
||||
{/* React Flow must measure nodes in its unscaled coordinate space. Keep
|
||||
this canvas responsive instead of reintroducing a CSS scale wrapper. */}
|
||||
<div className="interactive-figure__canvas" ref={containerRef}>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
elementsSelectable={false}
|
||||
minZoom={0.55}
|
||||
maxZoom={2.2}
|
||||
panOnDrag={graphInspectionEnabled}
|
||||
zoomOnScroll={graphInspectionEnabled}
|
||||
zoomOnPinch={graphInspectionEnabled}
|
||||
zoomOnDoubleClick={graphInspectionEnabled}
|
||||
preventScrolling={graphInspectionEnabled}
|
||||
onNodeClick={handleNodeClick}
|
||||
>
|
||||
<FitViewOnLayoutChange layoutKey={focus.figure.id} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{selectedNode?.evidence && (
|
||||
<aside className="figure-evidence" role="region" aria-label={`${selectedNode.label} evidence`}>
|
||||
<div className="figure-evidence__header">
|
||||
<span className="figure-evidence__label">{selectedNode.evidence.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="figure-evidence__close"
|
||||
onClick={() => setSelectedNodeId(null)}
|
||||
aria-label="Close figure evidence"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<h3>{selectedNode.evidence.title}</h3>
|
||||
<p>{selectedNode.evidence.body}</p>
|
||||
{selectedNode.evidence.facts && selectedNode.evidence.facts.length > 0 && (
|
||||
<dl className="figure-evidence__facts">
|
||||
{selectedNode.evidence.facts.map((fact) => (
|
||||
<div key={`${fact.label}-${fact.value}`}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{selectedNode.evidence.codePointer && (
|
||||
<code className="figure-evidence__pointer">{selectedNode.evidence.codePointer}</code>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,43 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFigureFocus } from "./focus.js";
|
||||
import { architectureCatalog } from "./architecture-catalog.js";
|
||||
import { layoutFigure } from "./layout.js";
|
||||
|
||||
const figure = (id: string) => {
|
||||
const result = architectureCatalog.figures.find((candidate) => candidate.id === id);
|
||||
if (!result) throw new Error(`missing architecture figure: ${id}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
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",
|
||||
"Runtime & providers",
|
||||
it("keeps the thesis architecture spine as the root contract", () => {
|
||||
const root = figure(architectureCatalog.rootFigureId);
|
||||
expect(root.layout.kind).toBe("explicit");
|
||||
if (root.layout.kind !== "explicit") throw new Error("architecture overview must use authored positions");
|
||||
expect(root.layout.positions["node-use"]?.x).toBeGreaterThan(
|
||||
root.layout.positions["core-runtime"]?.x ?? 0,
|
||||
);
|
||||
expect(root.nodes.map((node) => node.label)).toEqual(expect.arrayContaining([
|
||||
"Front door and transport",
|
||||
"Workflow API operations",
|
||||
"WorkflowServer composition",
|
||||
"wf_core execution loop",
|
||||
"Lifecycle records",
|
||||
"Capability inventory",
|
||||
]));
|
||||
});
|
||||
|
||||
it("declares subject-appropriate topologies instead of repeated linear flows", () => {
|
||||
expect(figure("client-surface-detail").layout.kind).toBe("fan-in");
|
||||
expect(figure("workflow-api-detail").layout.kind).toBe("hub");
|
||||
expect(figure("core-runtime-detail").layout.kind).toBe("flow");
|
||||
expect(figure("node-use-detail").layout.kind).toBe("explicit");
|
||||
expect(figure("configured-provider-detail").layout.kind).toBe("fan-in");
|
||||
});
|
||||
|
||||
it("models the supported kernel branches and the provider-neutral boundary", () => {
|
||||
const kernel = figure("core-runtime-detail");
|
||||
expect(kernel.nodes.map((node) => node.label)).toEqual(expect.arrayContaining([
|
||||
"Select ready frame",
|
||||
"Step kind",
|
||||
"Append trace frame",
|
||||
"Route by outcome",
|
||||
]));
|
||||
const stepKinds = figure("step-kind-detail");
|
||||
expect(stepKinds.nodes.map((node) => node.label)).toEqual(expect.arrayContaining([
|
||||
"NodeUse",
|
||||
"Condition",
|
||||
"Foreach",
|
||||
"Join",
|
||||
"Subgraph",
|
||||
"Interrupt",
|
||||
"End",
|
||||
]));
|
||||
const providers = figure("configured-provider-detail");
|
||||
expect(providers.nodes.map((node) => node.label)).toEqual(expect.arrayContaining([
|
||||
"Capability inventory",
|
||||
"Built-in sources",
|
||||
"MCP sources",
|
||||
"Python sources",
|
||||
]));
|
||||
expect(providers.edges.some((edge) => edge.from === "builtin-sources" && edge.to === "capability-inventory")).toBe(true);
|
||||
expect(providers.edges.some((edge) => edge.from === "mcp-sources" && edge.to === "capability-inventory")).toBe(true);
|
||||
expect(providers.edges.some((edge) => edge.from === "python-sources" && edge.to === "capability-inventory")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps NodeUse as an explicit participant sequence with factual evidence", () => {
|
||||
const sequence = figure("node-use-detail");
|
||||
expect(sequence.nodes.map((node) => node.label)).toEqual([
|
||||
"Runtime",
|
||||
"Binding Resolver",
|
||||
"NodeDef Handler",
|
||||
"State Reducers",
|
||||
"Trace Store",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports recursive runtime and provider expansion", () => {
|
||||
expect(resolveFigureFocus(architectureCatalog, ["runtime-providers"]).figure.id)
|
||||
.toBe("runtime-provider-detail");
|
||||
expect(resolveFigureFocus(
|
||||
architectureCatalog,
|
||||
["runtime-providers", "configured-providers"],
|
||||
).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) {
|
||||
if (node.kind === "boundary") continue;
|
||||
expect(node.evidencePointer, `${figure.id}/${node.id}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
expect(sequence.nodes.every((node) => node.evidence !== undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,89 +8,268 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
|
||||
figures: [
|
||||
{
|
||||
id: "architecture-overview",
|
||||
title: "Architecture",
|
||||
layout: { kind: "flow" },
|
||||
title: "Architecture spine",
|
||||
// The overview is an authored architecture map, not an arbitrary DAG.
|
||||
// Explicit positions keep the central spine dominant while preserving
|
||||
// room for the three system boundaries and the NodeUse continuation.
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: {
|
||||
"client-operations": { x: 0, y: 150 },
|
||||
"application-lifecycle": { x: 300, y: 150 },
|
||||
"workflow-server": { x: 600, y: 150 },
|
||||
"lifecycle-records": { x: 900, y: 0 },
|
||||
"runtime-providers": { x: 900, y: 150 },
|
||||
"core-runtime": { x: 900, y: 300 },
|
||||
"node-use": { x: 1200, y: 300 },
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "client-operations",
|
||||
label: "Client operations",
|
||||
summary: "Public lifecycle surface",
|
||||
label: "Front door and transport",
|
||||
summary: "Owner / agent -> CLI -> JSON-RPC",
|
||||
kind: "actor",
|
||||
evidencePointer: "docs/source_architecture.md",
|
||||
icon: "network",
|
||||
evidencePointer: "docs/thesis/system-design-implementation.md#architecture-spine",
|
||||
childFigureId: "client-surface-detail",
|
||||
},
|
||||
{
|
||||
id: "application-lifecycle",
|
||||
label: "Application lifecycle",
|
||||
summary: "WorkflowApi + WorkflowServer",
|
||||
label: "Workflow API operations",
|
||||
summary: "Capabilities, records, deployments, runs",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
childFigureId: "workflow-api-detail",
|
||||
},
|
||||
{
|
||||
id: "workflow-server",
|
||||
label: "WorkflowServer composition",
|
||||
summary: "API + records + inventory + kernel",
|
||||
kind: "runtime",
|
||||
icon: "server",
|
||||
evidencePointer: "src/wf_server/context.py",
|
||||
childFigureId: "runtime-provider-detail",
|
||||
},
|
||||
{
|
||||
id: "core-runtime",
|
||||
label: "wf_core execution loop",
|
||||
summary: "Select, dispatch, trace, route, resume",
|
||||
kind: "loop",
|
||||
icon: "repeat",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
childFigureId: "core-runtime-detail",
|
||||
},
|
||||
{
|
||||
id: "lifecycle-records",
|
||||
label: "Lifecycle records",
|
||||
summary: "Drafts / artifacts / deployments / runs",
|
||||
kind: "artifact",
|
||||
icon: "database",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
evidence: {
|
||||
label: "Durable boundary",
|
||||
title: "Lifecycle records",
|
||||
body: "The application surface persists and inspects each lifecycle record around runtime execution.",
|
||||
facts: [
|
||||
{ label: "Records", value: "Draft, Artifact, Deployment, Run" },
|
||||
{ label: "Runtime output", value: "Status, output, trace" },
|
||||
],
|
||||
codePointer: "src/wf_api/service.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "runtime-providers",
|
||||
label: "Runtime & providers",
|
||||
summary: "CapabilitySource projection",
|
||||
kind: "runtime",
|
||||
evidencePointer: "src/wf_core/runtime/ops/state.py",
|
||||
label: "Capability inventory",
|
||||
summary: "Provider-neutral CapabilitySource map",
|
||||
kind: "boundary",
|
||||
icon: "layers",
|
||||
evidencePointer: "src/wf_platform/sources.py",
|
||||
childFigureId: "runtime-provider-detail",
|
||||
},
|
||||
{
|
||||
id: "node-use",
|
||||
label: "NodeUse",
|
||||
summary: "Typed node execution",
|
||||
label: "NodeUse sequence",
|
||||
summary: "Bindings -> handler -> state -> trace",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
childFigureId: "node-use-detail",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-client-lifecycle", from: "client-operations", to: "application-lifecycle", label: "calls" },
|
||||
{ id: "e-lifecycle-runtime", from: "application-lifecycle", to: "runtime-providers", label: "delegates" },
|
||||
{ id: "e-runtime-node", from: "runtime-providers", to: "node-use", label: "invokes" },
|
||||
{ id: "e-front-door-api", from: "client-operations", to: "application-lifecycle", label: "calls" },
|
||||
{ id: "e-api-server", from: "application-lifecycle", to: "workflow-server", label: "composes" },
|
||||
{ id: "e-server-core", from: "workflow-server", to: "core-runtime", label: "executes" },
|
||||
{ id: "e-server-records", from: "workflow-server", to: "lifecycle-records", label: "persists" },
|
||||
{ id: "e-server-inventory", from: "workflow-server", to: "runtime-providers", label: "projects" },
|
||||
{ id: "e-core-node", from: "core-runtime", to: "node-use", label: "dispatches" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "client-surface-detail",
|
||||
title: "Client surface",
|
||||
layout: { kind: "flow" },
|
||||
layout: { kind: "fan-in" },
|
||||
nodes: [
|
||||
{
|
||||
id: "cli",
|
||||
label: "CLI",
|
||||
summary: "wf command entry",
|
||||
id: "workflow-owner",
|
||||
label: "Workflow owner",
|
||||
summary: "Human author / operator",
|
||||
kind: "actor",
|
||||
evidencePointer: "docs/project_map.md",
|
||||
icon: "users",
|
||||
evidencePointer: "docs/thesis/system-design-implementation.md#introduction",
|
||||
},
|
||||
{
|
||||
id: "json-rpc",
|
||||
label: "JSON-RPC HTTP",
|
||||
summary: "wf_transport_rpc_http",
|
||||
id: "external-agent",
|
||||
label: "External LLM agent",
|
||||
summary: "Plans and operates workflows",
|
||||
kind: "actor",
|
||||
evidencePointer: "src/wf_transport_rpc_http/",
|
||||
icon: "workflow",
|
||||
evidencePointer: "docs/thesis/system-design-implementation.md#introduction",
|
||||
},
|
||||
{
|
||||
id: "web-console",
|
||||
label: "Web console",
|
||||
summary: "React SPA",
|
||||
summary: "React presentation client",
|
||||
kind: "actor",
|
||||
icon: "network",
|
||||
evidencePointer: "web/apps/console",
|
||||
},
|
||||
{
|
||||
id: "cli",
|
||||
label: "wf CLI",
|
||||
summary: "Human and agent command surface",
|
||||
kind: "boundary",
|
||||
icon: "terminal",
|
||||
evidencePointer: "src/wf_cli",
|
||||
},
|
||||
{
|
||||
id: "json-rpc",
|
||||
label: "JSON-RPC / local adapter",
|
||||
summary: "Transport boundary",
|
||||
kind: "boundary",
|
||||
icon: "network",
|
||||
evidencePointer: "src/wf_transport_rpc_http",
|
||||
},
|
||||
{
|
||||
id: "workflow-api",
|
||||
label: "WorkflowApi",
|
||||
summary: "Protocol-neutral application surface",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
evidence: {
|
||||
label: "Public contract",
|
||||
title: "WorkflowApi",
|
||||
body: "CLI, JSON-RPC, and the console converge on one application facade.",
|
||||
facts: [
|
||||
{ label: "Owns", value: "Capabilities and lifecycle operations" },
|
||||
{ label: "Does not own", value: "Transport protocol behavior" },
|
||||
],
|
||||
codePointer: "src/wf_api/service.py",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-cli-api", from: "cli", to: "json-rpc", label: "uses" },
|
||||
{ id: "e-console-api", from: "web-console", to: "json-rpc", label: "uses" },
|
||||
{ id: "e-owner-cli", from: "workflow-owner", to: "cli", label: "operates" },
|
||||
{ id: "e-agent-cli", from: "external-agent", to: "cli", label: "operates" },
|
||||
{ id: "e-console-rpc", from: "web-console", to: "json-rpc", label: "calls" },
|
||||
{ id: "e-cli-rpc", from: "cli", to: "json-rpc", label: "uses" },
|
||||
{ id: "e-rpc-api", from: "json-rpc", to: "workflow-api", label: "adapts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflow-api-detail",
|
||||
title: "Workflow API operations",
|
||||
layout: { kind: "hub" },
|
||||
nodes: [
|
||||
{
|
||||
id: "workflow-api-boundary",
|
||||
label: "WorkflowApi boundary",
|
||||
summary: "Stable application facade",
|
||||
kind: "boundary",
|
||||
shape: "boundary",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "capability-operations",
|
||||
label: "Capabilities",
|
||||
summary: "Discover and call workflow capabilities",
|
||||
kind: "operation",
|
||||
icon: "plug",
|
||||
details: [
|
||||
{ label: "Methods", value: "list / inspect / call" },
|
||||
{ label: "Input", value: "qualified name + payload" },
|
||||
],
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "draft-operations",
|
||||
label: "Drafts",
|
||||
summary: "Validate, compile, patch authoring state",
|
||||
kind: "operation",
|
||||
icon: "layers",
|
||||
details: [{ label: "Methods", value: "validate / compile / patch" }],
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "artifact-operations",
|
||||
label: "Artifacts",
|
||||
summary: "Save immutable workflow versions",
|
||||
kind: "artifact",
|
||||
icon: "database",
|
||||
details: [{ label: "Methods", value: "create / inspect / delete" }],
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "deployment-operations",
|
||||
label: "Deployments",
|
||||
summary: "Bind artifacts to concrete sources",
|
||||
kind: "operation",
|
||||
icon: "network",
|
||||
details: [{ label: "Methods", value: "validate / save / inspect" }],
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "run-operations",
|
||||
label: "Runs",
|
||||
summary: "Start, resume, inspect execution",
|
||||
kind: "runtime",
|
||||
icon: "repeat",
|
||||
details: [{ label: "Methods", value: "start / resume / trace" }],
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "trace-output",
|
||||
label: "Trace and output",
|
||||
summary: "Bounded status / output / trace reads",
|
||||
kind: "evidence",
|
||||
icon: "trace",
|
||||
evidencePointer: "src/wf_api/runs.py",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-capabilities-api", from: "capability-operations", to: "workflow-api-boundary" },
|
||||
{ id: "e-drafts-api", from: "draft-operations", to: "workflow-api-boundary" },
|
||||
{ id: "e-artifacts-api", from: "artifact-operations", to: "workflow-api-boundary" },
|
||||
{ id: "e-deployments-api", from: "deployment-operations", to: "workflow-api-boundary" },
|
||||
{ id: "e-runs-api", from: "run-operations", to: "workflow-api-boundary" },
|
||||
{ id: "e-api-trace", from: "workflow-api-boundary", to: "trace-output", label: "returns" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "runtime-provider-detail",
|
||||
title: "Runtime and providers",
|
||||
layout: { kind: "flow" },
|
||||
title: "WorkflowServer composition",
|
||||
layout: { kind: "hub" },
|
||||
nodes: [
|
||||
{
|
||||
id: "workflow-server",
|
||||
label: "WorkflowServer",
|
||||
summary: "wf_server composition",
|
||||
summary: "Long-lived composition boundary",
|
||||
kind: "runtime",
|
||||
icon: "server",
|
||||
evidencePointer: "src/wf_server/context.py",
|
||||
},
|
||||
{
|
||||
@@ -98,131 +277,378 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
|
||||
label: "WorkflowApi",
|
||||
summary: "Application operations",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "capability-source",
|
||||
label: "CapabilitySource",
|
||||
summary: "Provider-neutral projection",
|
||||
id: "lifecycle-records",
|
||||
label: "Lifecycle records",
|
||||
summary: "Drafts / artifacts / deployments / runs",
|
||||
kind: "artifact",
|
||||
evidencePointer: "docs/source_architecture.md",
|
||||
icon: "database",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
},
|
||||
{
|
||||
id: "core-runtime",
|
||||
label: "wf_core",
|
||||
summary: "Deterministic execution kernel",
|
||||
kind: "loop",
|
||||
icon: "repeat",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
childFigureId: "core-runtime-detail",
|
||||
},
|
||||
{
|
||||
id: "configured-providers",
|
||||
label: "Configured providers",
|
||||
summary: "Source families",
|
||||
kind: "runtime",
|
||||
evidencePointer: "docs/source_architecture.md",
|
||||
label: "Capability inventory",
|
||||
summary: "Provider-neutral CapabilitySource map",
|
||||
kind: "provider",
|
||||
icon: "layers",
|
||||
evidencePointer: "src/wf_platform/sources.py",
|
||||
childFigureId: "configured-provider-detail",
|
||||
},
|
||||
{
|
||||
id: "deterministic-kernel",
|
||||
label: "Deterministic kernel",
|
||||
summary: "Replay-safe execution",
|
||||
kind: "artifact",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
id: "status-output-trace",
|
||||
label: "Status / output / trace",
|
||||
summary: "Inspectable runtime result",
|
||||
kind: "evidence",
|
||||
icon: "trace",
|
||||
evidencePointer: "src/wf_api/runs.py",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-server-api", from: "workflow-server", to: "workflow-api", label: "exposes" },
|
||||
{ id: "e-api-source", from: "workflow-api", to: "capability-source", label: "projects" },
|
||||
{ id: "e-source-providers", from: "capability-source", to: "configured-providers", label: "loads" },
|
||||
{ id: "e-providers-kernel", from: "configured-providers", to: "deterministic-kernel", label: "runs" },
|
||||
{ id: "e-server-records", from: "workflow-server", to: "lifecycle-records", label: "owns" },
|
||||
{ id: "e-server-core", from: "workflow-server", to: "core-runtime", label: "runs" },
|
||||
{ id: "e-server-providers", from: "workflow-server", to: "configured-providers", label: "composes" },
|
||||
{ id: "e-core-output", from: "core-runtime", to: "status-output-trace", label: "produces" },
|
||||
{ id: "e-api-output", from: "workflow-api", to: "status-output-trace", label: "returns" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "configured-provider-detail",
|
||||
title: "Configured providers",
|
||||
layout: { kind: "flow" },
|
||||
title: "Configured provider boundary",
|
||||
layout: { kind: "fan-in" },
|
||||
nodes: [
|
||||
{
|
||||
id: "builtin-sources",
|
||||
label: "Built-in sources",
|
||||
summary: "wf.std / wf.recipes",
|
||||
kind: "runtime",
|
||||
evidencePointer: "src/wf_api/service.py",
|
||||
kind: "provider",
|
||||
icon: "layers",
|
||||
evidencePointer: "src/wf_api/local_sources.py",
|
||||
},
|
||||
{
|
||||
id: "mcp-sources",
|
||||
label: "MCP sources",
|
||||
summary: "wf_sources_mcp",
|
||||
kind: "runtime",
|
||||
evidencePointer: "src/wf_sources_mcp/",
|
||||
kind: "provider",
|
||||
icon: "plug",
|
||||
evidencePointer: "src/wf_sources_mcp",
|
||||
},
|
||||
{
|
||||
id: "python-sources",
|
||||
label: "Python sources",
|
||||
summary: "wf_sources_python",
|
||||
kind: "runtime",
|
||||
evidencePointer: "src/wf_sources_python/",
|
||||
kind: "provider",
|
||||
icon: "code",
|
||||
evidencePointer: "src/wf_sources_python",
|
||||
},
|
||||
{
|
||||
id: "openapi-future",
|
||||
label: "OpenAPI sources",
|
||||
summary: "Future extension",
|
||||
id: "capability-inventory",
|
||||
label: "Capability inventory",
|
||||
summary: "One provider-neutral CapabilitySource map",
|
||||
kind: "boundary",
|
||||
shape: "boundary",
|
||||
icon: "layers",
|
||||
evidencePointer: "src/wf_platform/sources.py",
|
||||
evidence: {
|
||||
label: "Neutral boundary",
|
||||
title: "CapabilitySource inventory",
|
||||
body: "Built-in, MCP, and Python providers project into one workflow-facing source inventory.",
|
||||
facts: [
|
||||
{ label: "Core sees", value: "NodeSpec capabilities" },
|
||||
{ label: "Provider logic", value: "Stays outside wf_core" },
|
||||
],
|
||||
codePointer: "src/wf_server/sources.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source-resolution",
|
||||
label: "Logical source resolution",
|
||||
summary: "Deployment binding selects a concrete source",
|
||||
kind: "operation",
|
||||
icon: "network",
|
||||
evidencePointer: "src/wf_api",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-builtin-mcp", from: "builtin-sources", to: "mcp-sources", label: "adjacent" },
|
||||
{ id: "e-mcp-python", from: "mcp-sources", to: "python-sources", label: "adjacent" },
|
||||
{ id: "e-python-openapi", from: "python-sources", to: "openapi-future", label: "future" },
|
||||
{ id: "e-builtins-inventory", from: "builtin-sources", to: "capability-inventory", label: "projects" },
|
||||
{ id: "e-mcp-inventory", from: "mcp-sources", to: "capability-inventory", label: "projects" },
|
||||
{ id: "e-python-inventory", from: "python-sources", to: "capability-inventory", label: "projects" },
|
||||
{ id: "e-inventory-resolution", from: "capability-inventory", to: "source-resolution", label: "resolves" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "core-runtime-detail",
|
||||
title: "wf_core execution loop",
|
||||
layout: { kind: "flow" },
|
||||
nodes: [
|
||||
{
|
||||
id: "validate-input",
|
||||
label: "Validate workflow input",
|
||||
summary: "Typed input contract",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
shape: "receipt",
|
||||
evidencePointer: "src/wf_core/runtime/preparation.py",
|
||||
},
|
||||
{
|
||||
id: "select-frame",
|
||||
label: "Select ready frame",
|
||||
summary: "Scheduler chooses the next frame",
|
||||
kind: "runtime",
|
||||
icon: "repeat",
|
||||
evidencePointer: "src/wf_core/runtime/scheduler.py",
|
||||
},
|
||||
{
|
||||
id: "dispatch-step-kind",
|
||||
label: "Step kind",
|
||||
summary: "Typed dispatch",
|
||||
kind: "decision",
|
||||
icon: "branch",
|
||||
shape: "diamond",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
childFigureId: "step-kind-detail",
|
||||
},
|
||||
{
|
||||
id: "execute-step",
|
||||
label: "Execute selected step",
|
||||
summary: "Typed handler returns a step result",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
shape: "sequence",
|
||||
evidencePointer: "src/wf_core/models/steps.py",
|
||||
},
|
||||
{
|
||||
id: "append-trace",
|
||||
label: "Append trace frame",
|
||||
summary: "Record step result and lineage",
|
||||
kind: "evidence",
|
||||
icon: "trace",
|
||||
shape: "receipt",
|
||||
evidencePointer: "src/wf_core/runtime/ops/flow.py",
|
||||
},
|
||||
{
|
||||
id: "route-outcome",
|
||||
label: "Route by outcome",
|
||||
summary: "Next edge or terminal path",
|
||||
kind: "operation",
|
||||
icon: "network",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
},
|
||||
{
|
||||
id: "persist-interrupt",
|
||||
label: "Persist interrupt request",
|
||||
summary: "Typed request and resume contract",
|
||||
kind: "evidence",
|
||||
icon: "pause",
|
||||
shape: "receipt",
|
||||
evidencePointer: "src/wf_core/runtime/ops/interrupts.py",
|
||||
},
|
||||
{
|
||||
id: "resume-payload",
|
||||
label: "Resume payload",
|
||||
summary: "Resume outcome returns to routing",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_core/runtime/preparation.py",
|
||||
},
|
||||
{
|
||||
id: "terminal-output",
|
||||
label: "Terminal output",
|
||||
summary: "Status, output, and final trace",
|
||||
kind: "terminal",
|
||||
icon: "stop",
|
||||
shape: "terminal",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-validate-select", from: "validate-input", to: "select-frame", label: "ready" },
|
||||
{ id: "e-select-dispatch", from: "select-frame", to: "dispatch-step-kind", label: "dispatch" },
|
||||
{ id: "e-dispatch-execute", from: "dispatch-step-kind", to: "execute-step", label: "step" },
|
||||
{ id: "e-execute-trace", from: "execute-step", to: "append-trace", label: "result" },
|
||||
{ id: "e-trace-route", from: "append-trace", to: "route-outcome", label: "recorded" },
|
||||
{ id: "e-dispatch-interrupt", from: "dispatch-step-kind", to: "persist-interrupt", label: "interrupt" },
|
||||
{ id: "e-persist-resume", from: "persist-interrupt", to: "resume-payload", label: "resume" },
|
||||
{ id: "e-resume-route", from: "resume-payload", to: "route-outcome", label: "outcome" },
|
||||
{ id: "e-dispatch-end", from: "dispatch-step-kind", to: "terminal-output", label: "end" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "step-kind-detail",
|
||||
title: "Supported step kinds",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: {
|
||||
"node-use": { x: 0, y: 0 },
|
||||
"condition": { x: 300, y: 0 },
|
||||
"foreach": { x: 600, y: 0 },
|
||||
"join": { x: 900, y: 0 },
|
||||
"subgraph": { x: 150, y: 210 },
|
||||
"interrupt": { x: 450, y: 210 },
|
||||
"end": { x: 750, y: 210 },
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "node-use",
|
||||
label: "NodeUse",
|
||||
summary: "Invoke a bound capability",
|
||||
kind: "operation",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_core/models/steps.py",
|
||||
childFigureId: "node-use-detail",
|
||||
},
|
||||
{ id: "condition", label: "Condition", summary: "Route true or false", kind: "decision", icon: "branch", evidencePointer: "src/wf_core/models/steps.py" },
|
||||
{ id: "foreach", label: "Foreach", summary: "Create item frames and barrier", kind: "loop", icon: "repeat", evidencePointer: "src/wf_core/models/steps.py" },
|
||||
{ id: "join", label: "Join", summary: "Close a branch or frame", kind: "operation", icon: "workflow", evidencePointer: "src/wf_core/models/steps.py" },
|
||||
{ id: "subgraph", label: "Subgraph", summary: "Enter a prepared child workflow", kind: "boundary", icon: "layers", evidencePointer: "src/wf_core/models/steps.py" },
|
||||
{ id: "interrupt", label: "Interrupt", summary: "Persist request and wait", kind: "boundary", icon: "pause", evidencePointer: "src/wf_core/models/steps.py", childFigureId: "interrupt-contract-detail" },
|
||||
{ id: "end", label: "End", summary: "Project a terminal outcome", kind: "terminal", icon: "stop", evidencePointer: "src/wf_core/models/steps.py" },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
{
|
||||
id: "interrupt-contract-detail",
|
||||
title: "Typed interrupt contract",
|
||||
layout: { kind: "hub" },
|
||||
nodes: [
|
||||
{
|
||||
id: "interrupt-request",
|
||||
label: "Request schema",
|
||||
summary: "Payload sent to the client",
|
||||
kind: "boundary",
|
||||
icon: "pause",
|
||||
evidencePointer: "src/wf_core/models/steps.py",
|
||||
},
|
||||
{
|
||||
id: "interrupt-resume",
|
||||
label: "Resume schema",
|
||||
summary: "Payload committed back to state",
|
||||
kind: "boundary",
|
||||
icon: "workflow",
|
||||
evidencePointer: "src/wf_core/models/steps.py",
|
||||
},
|
||||
{
|
||||
id: "interrupt-outcome",
|
||||
label: "Resume outcome",
|
||||
summary: "Declared route returns to the loop",
|
||||
kind: "operation",
|
||||
icon: "network",
|
||||
evidencePointer: "src/wf_core/runtime/preparation.py",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-request-resume", from: "interrupt-request", to: "interrupt-resume", label: "validates" },
|
||||
{ id: "e-resume-outcome", from: "interrupt-resume", to: "interrupt-outcome", label: "routes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "node-use-detail",
|
||||
title: "NodeUse execution",
|
||||
layout: { kind: "flow" },
|
||||
title: "NodeUse execution sequence",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: {
|
||||
"runtime-lane": { x: 0, y: 120 },
|
||||
"binding-resolver": { x: 310, y: 120 },
|
||||
"node-def-handler": { x: 620, y: 120 },
|
||||
"state-reducers": { x: 930, y: 120 },
|
||||
"trace-store": { x: 1240, y: 120 },
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "resolve-bindings",
|
||||
label: "Resolve input bindings",
|
||||
summary: "NodeSpec inputs",
|
||||
kind: "operation",
|
||||
evidencePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
id: "runtime-lane",
|
||||
label: "Runtime",
|
||||
summary: "Validate input; select frame",
|
||||
kind: "runtime",
|
||||
shape: "sequence",
|
||||
icon: "server",
|
||||
evidence: {
|
||||
label: "Runtime frame",
|
||||
title: "Workflow Runtime",
|
||||
body: "The runtime selects a ready frame and coordinates the NodeUse branch.",
|
||||
facts: [{ label: "Next", value: "Outcome routing" }],
|
||||
codePointer: "src/wf_core/runtime/step.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invoke-handler",
|
||||
label: "Invoke handler",
|
||||
summary: "Capability call",
|
||||
id: "binding-resolver",
|
||||
label: "Binding Resolver",
|
||||
summary: "Build local node input",
|
||||
kind: "operation",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
shape: "sequence",
|
||||
icon: "network",
|
||||
evidence: {
|
||||
label: "Input",
|
||||
title: "Binding resolution",
|
||||
body: "Input bindings read workflow input, state, context, or literals into the node-local payload.",
|
||||
facts: [{ label: "Result", value: "Local node input" }],
|
||||
codePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "normalize-result",
|
||||
label: "Normalize NodeResult",
|
||||
summary: "Typed output",
|
||||
id: "node-def-handler",
|
||||
label: "NodeDef Handler",
|
||||
summary: "Invoke the capability handler",
|
||||
kind: "operation",
|
||||
evidencePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
shape: "sequence",
|
||||
icon: "plug",
|
||||
evidence: {
|
||||
label: "Handler result",
|
||||
title: "NodeDef invocation",
|
||||
body: "The declared node definition handler returns an outcome and output payload.",
|
||||
facts: [{ label: "Result", value: "Outcome + output payload" }],
|
||||
codePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "apply-reducers",
|
||||
label: "Apply output reducers",
|
||||
summary: "State mutations",
|
||||
kind: "operation",
|
||||
evidencePointer: "src/wf_core/runtime/ops/state.py",
|
||||
id: "state-reducers",
|
||||
label: "State Reducers",
|
||||
summary: "Apply declared output writes",
|
||||
kind: "artifact",
|
||||
shape: "sequence",
|
||||
icon: "database",
|
||||
evidence: {
|
||||
label: "State",
|
||||
title: "Reducer-aware state writes",
|
||||
body: "Successful output bindings merge the node result into workflow state.",
|
||||
facts: [{ label: "Write", value: "Reducer-aware output binding" }],
|
||||
codePointer: "src/wf_core/runtime/ops/state.py",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route-outcome",
|
||||
label: "Route outcome",
|
||||
summary: "Success or failure path",
|
||||
kind: "operation",
|
||||
evidencePointer: "src/wf_core/runtime/step.py",
|
||||
},
|
||||
{
|
||||
id: "record-trace",
|
||||
label: "Record trace",
|
||||
summary: "Inspection evidence",
|
||||
id: "trace-store",
|
||||
label: "Trace Store",
|
||||
summary: "Append frame and route outcome",
|
||||
kind: "evidence",
|
||||
evidencePointer: "src/wf_core/runtime/ops/nodes.py",
|
||||
shape: "sequence",
|
||||
icon: "trace",
|
||||
evidence: {
|
||||
label: "Inspection evidence",
|
||||
title: "Trace frame",
|
||||
body: "The completed step records its outcome, next node, and result before routing continues.",
|
||||
facts: [{ label: "Next", value: "Route by declared outcome" }],
|
||||
codePointer: "src/wf_core/runtime/ops/flow.py",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "e-resolve-invoke", from: "resolve-bindings", to: "invoke-handler", label: "feeds" },
|
||||
{ id: "e-invoke-normalize", from: "invoke-handler", to: "normalize-result", label: "produces" },
|
||||
{ id: "e-normalize-reducers", from: "normalize-result", to: "apply-reducers", label: "reduces" },
|
||||
{ id: "e-reducers-outcome", from: "apply-reducers", to: "route-outcome", label: "routes" },
|
||||
{ id: "e-outcome-trace", from: "route-outcome", to: "record-trace", label: "records" },
|
||||
{ id: "e-runtime-bindings", from: "runtime-lane", to: "binding-resolver", label: "resolve input map" },
|
||||
{ id: "e-bindings-handler", from: "binding-resolver", to: "node-def-handler", label: "local input" },
|
||||
{ id: "e-handler-reducers", from: "node-def-handler", to: "state-reducers", label: "outcome + output" },
|
||||
{ id: "e-reducers-trace", from: "state-reducers", to: "trace-store", label: "append frame" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.interactive-figure__workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="wide"] {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -32,11 +38,7 @@
|
||||
background: color-mix(in oklch, var(--color-editorial-surface, oklch(0.96 0.012 82)) 92%, white);
|
||||
}
|
||||
|
||||
/*
|
||||
Presentation overview figures should not steal normal slide navigation
|
||||
gestures. Focused stage figures opt into React Flow inspection, where the pane
|
||||
needs pointer events for drag-pan and wheel zoom.
|
||||
*/
|
||||
/* Focused stage figures opt into React Flow inspection without scaling a second canvas. */
|
||||
.interactive-figure[data-pan-zoom="disabled"] .react-flow__pane {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -60,8 +62,13 @@
|
||||
|
||||
.interactive-figure .react-flow__edge text {
|
||||
font-family: var(--font-interface, sans-serif);
|
||||
font-size: 11px;
|
||||
fill: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
fill: var(--color-editorial-ink, oklch(0.19 0.015 65));
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-editorial-paper, oklch(0.975 0.012 82));
|
||||
stroke-width: 5px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.interactive-figure .react-flow__controls,
|
||||
@@ -94,7 +101,7 @@
|
||||
font-family: var(--font-interface, sans-serif);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.figure-node:focus-visible {
|
||||
@@ -107,20 +114,32 @@
|
||||
border-color: oklch(0.19 0.015 65);
|
||||
}
|
||||
|
||||
.figure-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.figure-node__kind {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.figure-node__icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.figure-node__label {
|
||||
font-size: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.figure-node__summary {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.18;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
overflow: hidden;
|
||||
@@ -130,15 +149,62 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.figure-node__details {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
margin: 2px 0 0;
|
||||
font-family: var(--font-interface, sans-serif);
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.figure-node__details div {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.figure-node__details dt {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.figure-node__details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.figure-node__details code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"] {
|
||||
--figure-node-width: 256px;
|
||||
--figure-node-height: 112px;
|
||||
--figure-node-height: 150px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0 0 0.45rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-focus-level="0"] {
|
||||
--figure-node-width: 236px;
|
||||
--figure-node-height: 102px;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-focus-level="0"] .figure-node__label {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-focus-level="0"] .figure-node__summary {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"] .figure-breadcrumbs {
|
||||
min-height: 1.6rem;
|
||||
padding: 0.15rem 0 0.35rem;
|
||||
@@ -151,7 +217,10 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-layout="flow"] .interactive-figure__canvas {
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-layout="flow"] .interactive-figure__canvas,
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-layout="fan-in"] .interactive-figure__canvas,
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-layout="lanes"] .interactive-figure__canvas,
|
||||
.interactive-figure[data-figure-size="stage"][data-figure-layout="loop"] .interactive-figure__canvas {
|
||||
min-width: 72rem;
|
||||
}
|
||||
|
||||
@@ -168,14 +237,18 @@
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"] .figure-node__label {
|
||||
font-size: 16px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"] .figure-node__summary {
|
||||
font-size: 12.5px;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.interactive-figure[data-figure-size="stage"] .figure-node__kind {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.figure-node__expand-affordance {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -190,7 +263,7 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Semantic kind colors */
|
||||
/* Semantic kind colors and conventional flow shapes. */
|
||||
.figure-node[data-figure-node-kind="actor"],
|
||||
.figure-node[data-figure-node-kind="operation"] {
|
||||
border-color: var(--color-editorial-ink, oklch(0.19 0.015 65));
|
||||
@@ -204,6 +277,83 @@
|
||||
border-color: var(--color-human, oklch(0.68 0.17 55));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-node-kind="provider"] {
|
||||
border-color: var(--color-source, oklch(0.58 0.12 210));
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-node-kind="decision"] {
|
||||
border-color: var(--color-human, oklch(0.68 0.17 55));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-node-kind="terminal"] {
|
||||
border-color: var(--color-runtime, oklch(0.55 0.14 150));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="diamond"] {
|
||||
position: relative;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
clip-path: polygon(10% 0, 90% 0, 100% 50%, 90% 100%, 10% 100%, 0 50%);
|
||||
padding-inline: 32px;
|
||||
background: var(--color-human, oklch(0.68 0.17 55));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="diamond"]::before {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
content: "";
|
||||
clip-path: inherit;
|
||||
background: var(--color-editorial-paper, oklch(0.975 0.012 82));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="diamond"] .figure-node__label,
|
||||
.figure-node[data-figure-shape="diamond"] .figure-node__summary {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
width: 82%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="diamond"] .figure-node__header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="diamond"] .figure-node__expand-affordance,
|
||||
.figure-node[data-figure-shape="diamond"] .figure-node__current-marker {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="terminal"] {
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="boundary"] {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="receipt"] {
|
||||
border-top-width: 3px;
|
||||
background: color-mix(in oklch, oklch(0.975 0.012 82) 88%, var(--color-editorial-muted, oklch(0.48 0.025 65)));
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="sequence"] {
|
||||
border-top-width: 3px;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
|
||||
.figure-node[data-figure-shape="loop"] {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.figure-node[data-selected="true"] {
|
||||
background: color-mix(in oklch, oklch(0.975 0.012 82) 82%, var(--color-human, oklch(0.68 0.17 55)));
|
||||
}
|
||||
|
||||
.figure-node[data-expandable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -212,6 +362,95 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.figure-evidence {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
z-index: 4;
|
||||
width: min(22rem, calc(100% - 1.5rem));
|
||||
max-height: calc(100% - 1.5rem);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
align-self: stretch;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 55%, transparent);
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in oklch, var(--color-editorial-paper, oklch(0.975 0.012 82)) 94%, white);
|
||||
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
|
||||
font-family: var(--font-interface, sans-serif);
|
||||
}
|
||||
|
||||
.figure-evidence__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.figure-evidence__label {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.figure-evidence__close {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.figure-evidence h3 {
|
||||
margin: 0.7rem 0 0.35rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.figure-evidence p {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.figure-evidence__facts {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 1rem 0;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 35%, transparent);
|
||||
}
|
||||
|
||||
.figure-evidence__facts div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.figure-evidence__facts dt {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.figure-evidence__facts dd {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.figure-evidence__pointer {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.figure-breadcrumbs {
|
||||
display: flex;
|
||||
@@ -250,3 +489,25 @@
|
||||
margin-right: 4px;
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
}
|
||||
|
||||
.interactive-figure[data-motion="disabled"] .figure-node,
|
||||
.interactive-figure[data-motion="disabled"] .figure-evidence,
|
||||
.interactive-figure[data-motion="disabled"] .figure-breadcrumbs__crumb {
|
||||
transition: none;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.interactive-figure .figure-node,
|
||||
.interactive-figure .figure-evidence,
|
||||
.interactive-figure .figure-breadcrumbs__crumb {
|
||||
transition: none;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.figure-evidence {
|
||||
max-height: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ import { describe, expect, it } from "vitest";
|
||||
describe("interactive-figure CSS", () => {
|
||||
it("styles node kinds with semantic colors", () => {
|
||||
expect(css).toContain("data-figure-node-kind");
|
||||
expect(css).toContain("data-figure-shape");
|
||||
expect(css).toContain("font-size: 18px");
|
||||
});
|
||||
|
||||
it("disables figure transitions for reduced motion", () => {
|
||||
expect(css).toContain("prefers-reduced-motion: reduce");
|
||||
expect(css).toContain('data-motion="disabled"');
|
||||
});
|
||||
|
||||
it("avoids pill-shaped cards outside the stage variant", () => {
|
||||
|
||||
@@ -23,6 +23,18 @@ describe("layoutFigure", () => {
|
||||
expect(position(layout, "discover").x).toBeLessThan(position(layout, "repair").x);
|
||||
});
|
||||
|
||||
it("supports named topologies for fan-in, loops, hubs, and sequence lanes", () => {
|
||||
for (const kind of ["spine", "fan-in", "hub", "loop", "lanes"] as const) {
|
||||
const layout = layoutFigure({
|
||||
...flowFigure,
|
||||
id: `topology-${kind}`,
|
||||
layout: { kind },
|
||||
});
|
||||
expect(layout.definition.layout.kind).toBe(kind);
|
||||
expect(layout.nodes).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit authored positions", () => {
|
||||
expect(position(layoutFigure(explicitFigure), "runtime")).toEqual({ x: 420, y: 180 });
|
||||
});
|
||||
@@ -45,7 +57,7 @@ describe("layoutFigure", () => {
|
||||
const standardDelta = position(standard, "repair").x - position(standard, "discover").x;
|
||||
const stageDelta = position(stage, "repair").x - position(stage, "discover").x;
|
||||
|
||||
expect(FIGURE_NODE_DIMENSIONS.stage).toEqual({ width: 256, height: 112 });
|
||||
expect(FIGURE_NODE_DIMENSIONS.stage).toEqual({ width: 256, height: 150 });
|
||||
expect(stageDelta - standardDelta).toBe(
|
||||
FIGURE_NODE_DIMENSIONS.stage.width - FIGURE_NODE_DIMENSIONS.standard.width,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import Dagre from "@dagrejs/dagre";
|
||||
import type {
|
||||
FigureDefinition,
|
||||
FigureEdgeDefinition,
|
||||
FigureLayoutKind,
|
||||
FigureNodeDefinition,
|
||||
} from "./model.js";
|
||||
|
||||
@@ -23,13 +24,29 @@ export const FIGURE_NODE_DIMENSIONS: Record<FigureLayoutSize, {
|
||||
}> = {
|
||||
standard: { width: 236, height: 102 },
|
||||
wide: { width: 236, height: 102 },
|
||||
stage: { width: 256, height: 112 },
|
||||
stage: { width: 256, height: 150 },
|
||||
};
|
||||
|
||||
export const NODE_WIDTH = FIGURE_NODE_DIMENSIONS.standard.width;
|
||||
export const NODE_HEIGHT = FIGURE_NODE_DIMENSIONS.standard.height;
|
||||
const NODESEP = 56;
|
||||
const RANKSEP = 88;
|
||||
const NODESEP = 72;
|
||||
// Presentation graphs need enough room for edge labels without forcing
|
||||
// fitView to shrink six-rank flows below projector-readable type.
|
||||
const RANKSEP = 68;
|
||||
|
||||
const dagreOptions: Record<FigureLayoutKind, {
|
||||
readonly rankdir: "TB" | "LR";
|
||||
readonly ranker: "network-simplex" | "tight-tree" | "longest-path";
|
||||
}> = {
|
||||
layered: { rankdir: "TB", ranker: "network-simplex" },
|
||||
flow: { rankdir: "LR", ranker: "network-simplex" },
|
||||
spine: { rankdir: "TB", ranker: "tight-tree" },
|
||||
"fan-in": { rankdir: "LR", ranker: "network-simplex" },
|
||||
hub: { rankdir: "TB", ranker: "network-simplex" },
|
||||
loop: { rankdir: "TB", ranker: "network-simplex" },
|
||||
lanes: { rankdir: "LR", ranker: "tight-tree" },
|
||||
explicit: { rankdir: "TB", ranker: "network-simplex" },
|
||||
};
|
||||
|
||||
export const layoutFigure = (
|
||||
figure: FigureDefinition,
|
||||
@@ -55,7 +72,7 @@ const layoutDagre = (
|
||||
): PositionedFigure => {
|
||||
const g = new Dagre.graphlib.Graph();
|
||||
g.setGraph({
|
||||
rankdir: figure.layout.kind === "flow" ? "LR" : "TB",
|
||||
...dagreOptions[figure.layout.kind],
|
||||
nodesep: NODESEP,
|
||||
ranksep: RANKSEP,
|
||||
});
|
||||
|
||||
@@ -4,11 +4,65 @@ export type FigureNodeKind =
|
||||
| "artifact"
|
||||
| "runtime"
|
||||
| "boundary"
|
||||
| "evidence";
|
||||
| "evidence"
|
||||
| "decision"
|
||||
| "terminal"
|
||||
| "provider"
|
||||
| "lane"
|
||||
| "loop";
|
||||
|
||||
export type FigureNodeShape =
|
||||
| "card"
|
||||
| "diamond"
|
||||
| "terminal"
|
||||
| "boundary"
|
||||
| "receipt"
|
||||
| "sequence"
|
||||
| "loop"
|
||||
| "merge";
|
||||
|
||||
export type FigureNodeIcon =
|
||||
| "users"
|
||||
| "terminal"
|
||||
| "network"
|
||||
| "server"
|
||||
| "workflow"
|
||||
| "database"
|
||||
| "layers"
|
||||
| "branch"
|
||||
| "repeat"
|
||||
| "pause"
|
||||
| "stop"
|
||||
| "plug"
|
||||
| "trace"
|
||||
| "code"
|
||||
| "lane";
|
||||
|
||||
export type FigureNodeDetail = {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export type FigureNodeEvidence = {
|
||||
readonly label: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly facts?: readonly FigureNodeDetail[];
|
||||
readonly codePointer?: string;
|
||||
};
|
||||
|
||||
export type FigureLayoutKind =
|
||||
| "layered"
|
||||
| "flow"
|
||||
| "spine"
|
||||
| "fan-in"
|
||||
| "hub"
|
||||
| "loop"
|
||||
| "lanes"
|
||||
| "explicit";
|
||||
|
||||
export type FigureLayout =
|
||||
| { readonly kind: "layered" }
|
||||
| { readonly kind: "flow" }
|
||||
| { readonly kind: Exclude<FigureLayoutKind, "explicit"> }
|
||||
| {
|
||||
readonly kind: "explicit";
|
||||
readonly positions: Readonly<Record<string, { readonly x: number; readonly y: number }>>;
|
||||
@@ -19,6 +73,10 @@ export type FigureNodeDefinition = {
|
||||
readonly label: string;
|
||||
readonly summary: string;
|
||||
readonly kind: FigureNodeKind;
|
||||
readonly shape?: FigureNodeShape;
|
||||
readonly icon?: FigureNodeIcon;
|
||||
readonly details?: readonly FigureNodeDetail[];
|
||||
readonly evidence?: FigureNodeEvidence;
|
||||
readonly evidencePointer?: string;
|
||||
readonly childFigureId?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user