feat: choreograph architecture and workflow proof scenes
This commit is contained in:
@@ -415,9 +415,12 @@ Presentation wishlist / defense readiness:
|
||||
prioritized 12-minute Q&A.
|
||||
Completed: [`defense story reweighting`](historical/superpowers/plans/2026-07-13-defense-story-reweighting.md)
|
||||
now provides a typed 42-beat presenter-note catalog, a 750-850-word must-say
|
||||
path, evidence warnings, and a 13-minute timing contract. Remaining follow-ups,
|
||||
in dependency order: [`interactive graphs and Scenes 3-6 choreography`](superpowers/plans/2026-07-13-scenes-3-6-kernel-choreography.md),
|
||||
then [`read-only presenter route`](superpowers/plans/2026-07-13-read-only-presenter-route.md).
|
||||
path, evidence warnings, and a 13-minute timing contract. Completed:
|
||||
[`interactive graphs and Scenes 3-6 choreography`](historical/superpowers/plans/2026-07-13-scenes-3-6-kernel-choreography.md)
|
||||
adds restrained Scene 3-5 choreography, a semantic-zoom architecture map,
|
||||
the readable `wf_core` and NodeUse figures, and a factual prepared-run graph
|
||||
inspector. The next follow-up is
|
||||
[`read-only presenter route`](superpowers/plans/2026-07-13-read-only-presenter-route.md).
|
||||
- Defense Q&A branch set:
|
||||
[`defense Q&A runbook`](runbooks/defense-qna.md) collects answers for
|
||||
"Where is the AI agent?", evaluation validity, security boundaries, demo
|
||||
|
||||
+48
-48
@@ -24,12 +24,12 @@
|
||||
- Modify tests for `PositioningScene`, `BoundaryScene`, `LifecycleScene`, and `ArchitectureScene`
|
||||
- Modify route tests for Scenes 3-6
|
||||
|
||||
- [ ] Assert one primary visual region per beat through `data-visual-role="primary"`.
|
||||
- [ ] Assert reduced-motion behavior and stable accessible headings.
|
||||
- [ ] Assert Scene 6 exposes public surface, kernel loop, state/trace/routing, and provider boundary concepts across its four beats.
|
||||
- [ ] Assert architecture figures declare a topology appropriate to their subject rather than four repeated linear flows.
|
||||
- [ ] Assert the prepared-run graph preserves ten displayed action/boundary/outcome nodes, readable labels, pan/zoom, and a factual node inspector.
|
||||
- [ ] Capture baseline screenshots at `1280x720` and `1024x768` before implementation.
|
||||
- [x] Assert one primary visual region per beat through `data-visual-role="primary"`.
|
||||
- [x] Assert reduced-motion behavior and stable accessible headings.
|
||||
- [x] Assert Scene 6 exposes public surface, kernel loop, state/trace/routing, and provider boundary concepts across its four beats.
|
||||
- [x] Assert architecture figures declare a topology appropriate to their subject rather than four repeated linear flows.
|
||||
- [x] Assert the prepared-run graph preserves ten displayed action/boundary/outcome nodes, readable labels, pan/zoom, and a factual node inspector.
|
||||
- [x] Capture baseline screenshots at `1280x720` and `1024x768` before implementation.
|
||||
|
||||
## Task 2: Add restrained motion to Scene 3 positioning
|
||||
|
||||
@@ -38,28 +38,28 @@
|
||||
- Modify: `presentation.css`
|
||||
- Modify: focused tests
|
||||
|
||||
- [ ] Keep the existing information architecture and add one shared layout transition between the two beats.
|
||||
- [ ] On `landscape`, keep the related-system map as the complete comparison.
|
||||
- [ ] On `lda-position`, enlarge the typed-substrate position and shift the surrounding systems enough to make the focus obvious without hiding the comparison axis.
|
||||
- [ ] Use position and scale before opacity; one clear transform is enough.
|
||||
- [ ] Keep titles, category labels, and evidence readable at both target viewports.
|
||||
- [x] Keep the existing information architecture and add one shared layout transition between the two beats.
|
||||
- [x] On `landscape`, keep the related-system map as the complete comparison.
|
||||
- [x] On `lda-position`, enlarge the typed-substrate position and shift the surrounding systems enough to make the focus obvious without hiding the comparison axis.
|
||||
- [x] Use position and scale before opacity; one clear transform is enough.
|
||||
- [x] Keep titles, category labels, and evidence readable at both target viewports.
|
||||
|
||||
## Task 3: Add restrained motion to Scene 4 planner/runtime ownership
|
||||
|
||||
- [ ] Preserve the current three-pane comparison.
|
||||
- [ ] On `planner`, enlarge the planner pane while retaining runtime destination context.
|
||||
- [ ] On `runtime`, let the runtime pane grow into the released space.
|
||||
- [ ] On `boundary`, restore both sides and enlarge the typed CLI/JSON-RPC seam.
|
||||
- [ ] Animate position and width between states; do not change color theme between beats.
|
||||
- [ ] Keep the spoken claim exact: planner proposes; runtime owns validation, state, execution, trace, and explicit resume.
|
||||
- [x] Preserve the current three-pane comparison.
|
||||
- [x] On `planner`, enlarge the planner pane while retaining runtime destination context.
|
||||
- [x] On `runtime`, let the runtime pane grow into the released space.
|
||||
- [x] On `boundary`, restore both sides and enlarge the typed CLI/JSON-RPC seam.
|
||||
- [x] Animate position and width between states; do not change color theme between beats.
|
||||
- [x] Keep the spoken claim exact: planner proposes; runtime owns validation, state, execution, trace, and explicit resume.
|
||||
|
||||
## Task 4: Add restrained motion to Scene 5 lifecycle vocabulary
|
||||
|
||||
- [ ] Preserve the four-record lifecycle rail and its order.
|
||||
- [ ] For each beat, grow the active record into the available space while the other records compress but remain readable.
|
||||
- [ ] Keep arrows and order visible so the audience does not mistake the records for independent cards.
|
||||
- [ ] Show raw-plan-to-artifact as optional visual evidence without implying Draft is mandatory.
|
||||
- [ ] Avoid replaying the prepared example; Scene 9 owns applied evidence.
|
||||
- [x] Preserve the four-record lifecycle rail and its order.
|
||||
- [x] For each beat, grow the active record into the available space while the other records compress but remain readable.
|
||||
- [x] Keep arrows and order visible so the audience does not mistake the records for independent cards.
|
||||
- [x] Show raw-plan-to-artifact as optional visual evidence without implying Draft is mandatory.
|
||||
- [x] Avoid replaying the prepared example; Scene 9 owns applied evidence.
|
||||
|
||||
## Task 5: Rebuild Scene 6 as an interactive semantic zoom
|
||||
|
||||
@@ -71,19 +71,19 @@
|
||||
- Modify: `web/apps/console/src/presentation/figures/InteractiveFigure.tsx` only if required by an existing figure contract
|
||||
- Modify: associated tests and CSS
|
||||
|
||||
- [ ] Base the root figure on the thesis Architecture Spine, not an invented package map: workflow owner and external agent enter through CLI/JSON-RPC, `WorkflowServer` composes the API, records, inventory, and `wf_core`, then status/output/trace return through the public surface.
|
||||
- [ ] Keep providers as one compact input into capability inventory at the root. Provider-family detail is optional drill-down, not one quarter of the main architecture story.
|
||||
- [ ] Keep click-to-focus and breadcrumb navigation. A node with `childFigureId` zooms into its subsystem; a leaf node opens evidence/details without replacing the whole figure.
|
||||
- [ ] Give the client surface a fan-in topology, with human/external agent clients and the web console converging on CLI or JSON-RPC and then WorkflowApi.
|
||||
- [ ] Give the WorkflowApi zoom a lifecycle-operation surface: capabilities, drafts, artifacts, deployments, and runs surrounding the API boundary. Use compact HTML operation receipts or method groups inside graph nodes when they communicate more than another label box.
|
||||
- [ ] Add a core-runtime child figure based on the thesis flowchart at `system-design-implementation.md:626`: select frame, branch by step kind, trace and route loop, interrupt/resume branch, and terminal output.
|
||||
- [ ] Represent node, condition, foreach, join, subgraph, interrupt, and end with icons or conventional shapes. Explicitly exclude general fork/gather.
|
||||
- [ ] Make `NodeUse` inside the core loop clickable. Its child figure follows the thesis sequence diagram: Runtime, Binding Resolver, NodeDef Handler, State Reducers, and Trace Store. Use participant lanes, message arrows, and small factual state/result panels instead of six identical nodes.
|
||||
- [ ] Allow further click-through from important core nodes where useful: interrupt opens request/resume contract evidence; trace opens a representative trace frame; step dispatch opens the supported-step palette. Do not create drill-down merely because a node exists.
|
||||
- [ ] Group built-in, MCP, and Python providers around the provider-neutral capability boundary; do not connect source families as an arbitrary adjacency chain.
|
||||
- [ ] Use larger labels and summaries, and allow user pan/zoom instead of shrinking the full graph until it fits.
|
||||
- [ ] Qualify determinism in visible copy: core semantics are deterministic for fixed definitions and handler results.
|
||||
- [ ] Keep React Flow as the one interactive graph system. Extend its layout model with named topologies or rank/position hints instead of manually drawing edge coordinates.
|
||||
- [x] Base the root figure on the thesis Architecture Spine, not an invented package map: workflow owner and external agent enter through CLI/JSON-RPC, `WorkflowServer` composes the API, records, inventory, and `wf_core`, then status/output/trace return through the public surface.
|
||||
- [x] Keep providers as one compact input into capability inventory at the root. Provider-family detail is optional drill-down, not one quarter of the main architecture story.
|
||||
- [x] Keep click-to-focus and breadcrumb navigation. A node with `childFigureId` zooms into its subsystem; a leaf node opens evidence/details without replacing the whole figure.
|
||||
- [x] Give the client surface a fan-in topology, with human/external agent clients and the web console converging on CLI or JSON-RPC and then WorkflowApi.
|
||||
- [x] Give the WorkflowApi zoom a lifecycle-operation surface: capabilities, drafts, artifacts, deployments, and runs surrounding the API boundary. Use compact HTML operation receipts or method groups inside graph nodes when they communicate more than another label box.
|
||||
- [x] Add a core-runtime child figure based on the thesis flowchart at `system-design-implementation.md:626`: select frame, branch by step kind, trace and route loop, interrupt/resume branch, and terminal output.
|
||||
- [x] Represent node, condition, foreach, join, subgraph, interrupt, and end with icons or conventional shapes. Explicitly exclude general fork/gather.
|
||||
- [x] Make `NodeUse` inside the core loop clickable. Its child figure follows the thesis sequence diagram: Runtime, Binding Resolver, NodeDef Handler, State Reducers, and Trace Store. Use participant lanes, message arrows, and small factual state/result panels instead of six identical nodes.
|
||||
- [x] Allow further click-through from important core nodes where useful: interrupt opens request/resume contract evidence; trace opens a representative trace frame; step dispatch opens the supported-step palette. Do not create drill-down merely because a node exists.
|
||||
- [x] Group built-in, MCP, and Python providers around the provider-neutral capability boundary; do not connect source families as an arbitrary adjacency chain.
|
||||
- [x] Use larger labels and summaries, and allow user pan/zoom instead of shrinking the full graph until it fits.
|
||||
- [x] Qualify determinism in visible copy: core semantics are deterministic for fixed definitions and handler results.
|
||||
- [x] Keep React Flow as the one interactive graph system. Extend its layout model with named topologies or rank/position hints instead of manually drawing edge coordinates.
|
||||
|
||||
### Scene 6 figure hierarchy
|
||||
|
||||
@@ -115,21 +115,21 @@ The four scripted beats need not visit every child. The audience path should be
|
||||
- Modify: `web/apps/console/src/presentation/styles/demo-workflow.css`
|
||||
- Modify: associated tests
|
||||
|
||||
- [ ] Re-layout the long run graph into a readable two-dimensional flow with the submitted and revision branches separated vertically. Do not shrink ten displayed nodes into one horizontal strip.
|
||||
- [ ] Keep pan, drag, and zoom enabled. Use fit-view as the initial state, not the only readable state.
|
||||
- [ ] Remove selected/current-node treatment from the static graph beat unless a real execution state supports it.
|
||||
- [ ] Replace the generic `NodeSpotlight` paragraph with a reusable node inspector showing node kind, capability or boundary, factual input/output or schema summary, outcomes, and code/evidence pointer.
|
||||
- [ ] Keep the run graph visible behind or beside its inspector so selection retains context.
|
||||
- [ ] Replace “View raw evidence ->” with a precise action label such as “Inspect protocol receipt”. Open the existing evidence inspector at the relevant event rather than a generic drawer.
|
||||
- [ ] Test the interrupt and issue-creation nodes as distinct inspector examples, plus the fallback for ordinary nodes.
|
||||
- [x] Re-layout the long run graph into a readable two-dimensional flow with the submitted and revision branches separated vertically. Do not shrink ten displayed nodes into one horizontal strip.
|
||||
- [x] Keep pan, drag, and zoom enabled. Use fit-view as the initial state, not the only readable state.
|
||||
- [x] Remove selected/current-node treatment from the static graph beat unless a real execution state supports it.
|
||||
- [x] Replace the generic `NodeSpotlight` paragraph with a reusable node inspector showing node kind, capability or boundary, factual input/output or schema summary, outcomes, and code/evidence pointer.
|
||||
- [x] Keep the run graph visible behind or beside its inspector so selection retains context.
|
||||
- [x] Replace “View raw evidence ->” with a precise action label such as “Inspect protocol receipt”. Open the existing evidence inspector at the relevant event rather than a generic drawer.
|
||||
- [x] Test the interrupt and issue-creation nodes as distinct inspector examples, plus the fallback for ordinary nodes.
|
||||
|
||||
## Task 7: Visual verification
|
||||
|
||||
- [ ] Run focused scene and figure tests, full presentation tests, typecheck, and build.
|
||||
- [ ] Capture every Scene 3-6 beat and the Scene 10 graph at both rehearsal viewports.
|
||||
- [ ] Capture architecture root, client surface, core runtime, NodeUse, provider detail, and the open run-node inspector.
|
||||
- [ ] Verify no page scroll, no clipped captions, no detached connectors, and no text below 18px at `1280x720`.
|
||||
- [ ] Run the Impeccable detector and review screenshots manually.
|
||||
- [ ] Update roadmap, archive this plan, and commit.
|
||||
- [x] Run focused scene and figure tests, full presentation tests, typecheck, and build.
|
||||
- [x] Capture every Scene 3-6 beat and the Scene 10 graph at both rehearsal viewports.
|
||||
- [x] Capture architecture root, client surface, core runtime, NodeUse, provider detail, and the open run-node inspector.
|
||||
- [x] Verify no page scroll, no clipped captions, no detached connectors, and no text below 18px at `1280x720`.
|
||||
- [x] Run the Impeccable detector and review screenshots manually.
|
||||
- [x] Update roadmap, archive this plan, and commit.
|
||||
|
||||
**Completion gate:** Scenes 3-5 gain clear motion without redesign churn. Scene 6 remains interactive but every zoom level uses a readable, subject-appropriate diagram. The prepared-run graph is readable without fitting ten nodes into one thin row, and node selection reveals factual details rather than a generic paragraph.
|
||||
@@ -5,10 +5,34 @@ import { NodeSpotlight } from "./NodeSpotlight.js";
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("NodeSpotlight", () => {
|
||||
it("uses a readable fallback for raw interrupt nodes without labels", () => {
|
||||
it("shows the interrupt contract as a factual reusable inspector", () => {
|
||||
render(<NodeSpotlight nodeId="review_issues" close={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Review issues" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Review issues" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Human boundary")).toHaveLength(2);
|
||||
expect(screen.getByText(/issue_review/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/request: issue proposals/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/submitted issues or revision request/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("submitted")).toBeInTheDocument();
|
||||
expect(screen.getByText("cancelled")).toBeInTheDocument();
|
||||
expect(screen.getByText("workflow interrupt: issue_review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("distinguishes an issue-creation action from an ordinary node", () => {
|
||||
const { unmount } = render(<NodeSpotlight nodeId="create_issues" close={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Create issues" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue board source")).toBeInTheDocument();
|
||||
expect(screen.getByText(/submitted issues/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/persisted issue-board entries/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("local.issue_board.create_issues")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<NodeSpotlight nodeId="read_docs" close={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Read documents" })).toBeInTheDocument();
|
||||
expect(screen.getByText("document source")).toBeInTheDocument();
|
||||
expect(screen.getByText("local.lda_docs.read_documents")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,10 @@ type NodeSpotlightProps = {
|
||||
readonly close: () => void;
|
||||
};
|
||||
|
||||
const nodeDescription = (nodeId: string): string => {
|
||||
if (nodeId === "review_issues") {
|
||||
return "Typed interrupt boundary. It exposes request and resume schemas, then waits for a submitted or cancelled outcome.";
|
||||
}
|
||||
if (nodeId === "create_issues") {
|
||||
return "Workflow step that writes selected review items into the local issue-board source.";
|
||||
}
|
||||
return "Prepared report workflow step. The presentation graph is curated, but each node maps back to workflow or run evidence.";
|
||||
const kindLabel = (node: (typeof presentationNodes)[number]): string => {
|
||||
if (node.type === "interrupt") return "Human boundary";
|
||||
if (node.type === "end") return "Outcome";
|
||||
return "Action";
|
||||
};
|
||||
|
||||
export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
|
||||
@@ -52,6 +48,7 @@ export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
|
||||
};
|
||||
|
||||
if (!node) return null;
|
||||
const schemaSummary = "schemaSummary" in node ? node.schemaSummary : null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -60,12 +57,51 @@ export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
data-node-kind={node.type}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<button type="button" ref={closeButtonRef} onClick={close}>Close</button>
|
||||
<p>Workflow node</p>
|
||||
<header className="node-spotlight__header">
|
||||
<div>
|
||||
<p className="node-spotlight__eyebrow">Workflow node</p>
|
||||
<span className="node-spotlight__kind">{kindLabel(node)}</span>
|
||||
</div>
|
||||
<button type="button" ref={closeButtonRef} onClick={close}>Close</button>
|
||||
</header>
|
||||
<h2>{label}</h2>
|
||||
<p>{nodeDescription(node.id)}</p>
|
||||
<p className="node-spotlight__detail">{node.detail}</p>
|
||||
|
||||
<dl className="node-spotlight__facts">
|
||||
<div>
|
||||
<dt>Capability / boundary</dt>
|
||||
<dd>{node.capability}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Input</dt>
|
||||
<dd>{node.inputSummary}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Output</dt>
|
||||
<dd>{node.outputSummary}</dd>
|
||||
</div>
|
||||
{schemaSummary && (
|
||||
<div>
|
||||
<dt>Schema summary</dt>
|
||||
<dd>{schemaSummary}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<section className="node-spotlight__outcomes" aria-labelledby="node-spotlight-outcomes">
|
||||
<h3 id="node-spotlight-outcomes">Outcomes</h3>
|
||||
<ul>
|
||||
{node.outcomes.map((outcome) => <li key={outcome}>{outcome}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer className="node-spotlight__evidence">
|
||||
<span>Code / evidence pointer</span>
|
||||
<code>{node.evidencePointer}</code>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ const event: DemoEvent = {
|
||||
};
|
||||
|
||||
describe("OperationBlock", () => {
|
||||
it("shows command, interpreted summary, and evidence action in expanded mode", () => {
|
||||
it("shows command, interpreted summary, and protocol receipt action in expanded mode", () => {
|
||||
render(<OperationBlock event={event} variant="expanded" openEvidence={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
|
||||
@@ -37,6 +37,8 @@ describe("OperationBlock", () => {
|
||||
expect(screen.getAllByText(/interrupted/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/issue_review/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/run_demo/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: /inspect protocol receipt/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/view raw evidence/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows compact receipt with operation, status, duration, and run id", () => {
|
||||
@@ -53,7 +55,7 @@ describe("OperationBlock", () => {
|
||||
const openEvidence = vi.fn();
|
||||
render(<OperationBlock event={event} variant="expanded" openEvidence={openEvidence} />);
|
||||
|
||||
await userEvent.click(screen.getByText("View raw evidence"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /inspect protocol receipt/i }));
|
||||
expect(openEvidence).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -89,9 +89,10 @@ export const OperationBlock = ({
|
||||
<button
|
||||
type="button"
|
||||
className="operation-block__evidence-action"
|
||||
aria-label="Inspect protocol receipt; view raw evidence"
|
||||
onClick={openEvidence}
|
||||
>
|
||||
View raw evidence
|
||||
Inspect protocol receipt
|
||||
<span aria-hidden="true">-></span>
|
||||
</button>
|
||||
</m.article>
|
||||
|
||||
@@ -39,7 +39,12 @@ const demo: DemoTimelineController = {
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const renderSceneBodyAtMainLocation = (sceneId: MainLocation["sceneId"], beatId: string, openDiscussion = noop) => render(
|
||||
const renderSceneBodyAtMainLocation = (
|
||||
sceneId: MainLocation["sceneId"],
|
||||
beatId: string,
|
||||
openDiscussion = noop,
|
||||
motionDisabled = false,
|
||||
) => render(
|
||||
<SceneBody
|
||||
location={{ kind: "main", sceneId, beatId, focusPath: [] }}
|
||||
demo={demo}
|
||||
@@ -48,7 +53,7 @@ const renderSceneBodyAtMainLocation = (sceneId: MainLocation["sceneId"], beatId:
|
||||
openEvidence={noop}
|
||||
openDiscussion={openDiscussion}
|
||||
onFocusPathChange={noop}
|
||||
motionDisabled={false}
|
||||
motionDisabled={motionDisabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -256,6 +261,42 @@ describe("SceneBody", () => {
|
||||
expect(withinRail.getByText("Source binding")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["positioning", "landscape", "Positioning and Related Systems", "landscape"],
|
||||
["positioning", "lda-position", "Positioning and Related Systems", "lda"],
|
||||
["planner-runtime", "planner", "Planner and Runtime", "planner"],
|
||||
["planner-runtime", "runtime", "Planner and Runtime", "runtime"],
|
||||
["planner-runtime", "boundary", "Planner and Runtime", "boundary"],
|
||||
["lifecycle", "draft", "Workflow Lifecycle", "draft"],
|
||||
["lifecycle", "artifact", "Workflow Lifecycle", "artifact"],
|
||||
["lifecycle", "deployment", "Workflow Lifecycle", "deployment"],
|
||||
["lifecycle", "run", "Workflow Lifecycle", "run"],
|
||||
] as const)("exposes one primary visual contract for %s/%s", (sceneId, beatId, heading, active) => {
|
||||
const { container } = renderSceneBodyAtMainLocation(sceneId, beatId);
|
||||
|
||||
expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
|
||||
const primaries = container.querySelectorAll('[data-visual-role="primary"]');
|
||||
expect(primaries).toHaveLength(1);
|
||||
expect(primaries[0]).toHaveAttribute("data-motion", "enabled");
|
||||
expect(primaries[0]).toHaveAttribute(
|
||||
sceneId === "positioning"
|
||||
? "data-positioning-active-region"
|
||||
: sceneId === "planner-runtime"
|
||||
? "data-boundary-active"
|
||||
: "data-lifecycle-active-stage",
|
||||
active,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks Scene 3-5 primary visuals as motion-disabled without hiding their comparisons", () => {
|
||||
const { container } = renderSceneBodyAtMainLocation("planner-runtime", "runtime", noop, true);
|
||||
|
||||
const primary = container.querySelector('[data-visual-role="primary"]');
|
||||
expect(primary).toHaveAttribute("data-motion", "disabled");
|
||||
expect(within(primary as HTMLElement).getByText("Planner")).toBeInTheDocument();
|
||||
expect(within(primary as HTMLElement).getByText("Runtime")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the lifecycle explanation with the active beat", () => {
|
||||
const { rerender } = render(
|
||||
<SceneBody
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { m } from "motion/react";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||
import type { DemoApprovalActions } from "./demo-approval-actions.js";
|
||||
@@ -88,33 +89,51 @@ const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBe
|
||||
</>
|
||||
);
|
||||
|
||||
const PositioningScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => {
|
||||
const choreographyTransition = (motionDisabled: boolean) => ({
|
||||
duration: motionDisabled ? 0 : 0.34,
|
||||
ease: [0.16, 1, 0.3, 1] as const,
|
||||
});
|
||||
|
||||
type ChoreographyProps = {
|
||||
readonly scene: SceneDefinition;
|
||||
readonly beat: SceneBeatDefinition;
|
||||
readonly motionDisabled: boolean;
|
||||
};
|
||||
|
||||
const PositioningScene = ({ scene, beat, motionDisabled }: ChoreographyProps) => {
|
||||
const highlightLda = beat.id === "lda-position";
|
||||
const transition = choreographyTransition(motionDisabled);
|
||||
return (
|
||||
<>
|
||||
<StageCaption eyebrow={`Act I · ${scene.claimClass}`} title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<div
|
||||
<m.div
|
||||
layout
|
||||
className="scene-body__positioning-map"
|
||||
aria-label="positioning map"
|
||||
data-positioning-active-region={highlightLda ? "lda" : "landscape"}
|
||||
data-visual-role="primary"
|
||||
data-motion={motionDisabled ? "disabled" : "enabled"}
|
||||
transition={transition}
|
||||
>
|
||||
<section className="scene-body__positioning-column" aria-label="direct action patterns">
|
||||
<m.section layout transition={transition} className="scene-body__positioning-column" aria-label="direct action patterns">
|
||||
<p className="scene-body__positioning-label">Direct action</p>
|
||||
<article className="scene-body__positioning-tile">
|
||||
<m.article layout transition={transition} className="scene-body__positioning-tile">
|
||||
<strong>Tool loops</strong>
|
||||
<span>Fast action, no durable lifecycle</span>
|
||||
</article>
|
||||
<article className="scene-body__positioning-tile">
|
||||
</m.article>
|
||||
<m.article layout transition={transition} className="scene-body__positioning-tile">
|
||||
<strong>Generated scripts</strong>
|
||||
<span>Inspectable code, weak deployment records</span>
|
||||
</article>
|
||||
</section>
|
||||
<article
|
||||
</m.article>
|
||||
</m.section>
|
||||
<m.article
|
||||
layout
|
||||
className="scene-body__positioning-substrate"
|
||||
data-positioning-role="substrate"
|
||||
data-positioning-active={highlightLda ? "true" : "false"}
|
||||
transition={transition}
|
||||
>
|
||||
<span className="scene-body__positioning-label">This thesis</span>
|
||||
<strong>lda.chat</strong>
|
||||
@@ -124,23 +143,23 @@ const PositioningScene = ({ scene, beat }: { scene: SceneDefinition; beat: Scene
|
||||
<li>Validation</li>
|
||||
<li>Persisted records</li>
|
||||
</ul>
|
||||
</article>
|
||||
<section className="scene-body__positioning-column" aria-label="adjacent systems">
|
||||
</m.article>
|
||||
<m.section layout transition={transition} className="scene-body__positioning-column" aria-label="adjacent systems">
|
||||
<p className="scene-body__positioning-label">Adjacent systems</p>
|
||||
<article className="scene-body__positioning-tile">
|
||||
<m.article layout transition={transition} className="scene-body__positioning-tile">
|
||||
<strong>Hosted automation</strong>
|
||||
<span>Managed triggers and app integrations</span>
|
||||
</article>
|
||||
<article className="scene-body__positioning-tile">
|
||||
</m.article>
|
||||
<m.article layout transition={transition} className="scene-body__positioning-tile">
|
||||
<strong>Agent graphs</strong>
|
||||
<span>Durable planner loops</span>
|
||||
</article>
|
||||
<article className="scene-body__positioning-tile">
|
||||
</m.article>
|
||||
<m.article layout transition={transition} className="scene-body__positioning-tile">
|
||||
<strong>MCP</strong>
|
||||
<span>Capability protocol boundary</span>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</m.article>
|
||||
</m.section>
|
||||
</m.div>
|
||||
<p className="scene-body__evidence">{scene.evidencePointer}</p>
|
||||
</>
|
||||
);
|
||||
@@ -152,17 +171,28 @@ const boundaryActiveForBeat = (beatId: string): "planner" | "runtime" | "boundar
|
||||
return "planner";
|
||||
};
|
||||
|
||||
const BoundaryScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => {
|
||||
const BoundaryScene = ({ scene, beat, motionDisabled }: ChoreographyProps) => {
|
||||
const active = boundaryActiveForBeat(beat.id);
|
||||
const plannerActive = active === "planner" || active === "boundary";
|
||||
const runtimeActive = active === "runtime" || active === "boundary";
|
||||
const transition = choreographyTransition(motionDisabled);
|
||||
return (
|
||||
<>
|
||||
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<div className="scene-body__boundary" aria-label="planner runtime boundary" data-boundary-active={active}>
|
||||
<section
|
||||
<m.div
|
||||
layout
|
||||
className="scene-body__boundary"
|
||||
aria-label="planner runtime boundary"
|
||||
data-boundary-active={active}
|
||||
data-visual-role="primary"
|
||||
data-motion={motionDisabled ? "disabled" : "enabled"}
|
||||
transition={transition}
|
||||
>
|
||||
<m.section
|
||||
layout
|
||||
transition={transition}
|
||||
className="scene-body__boundary-pane"
|
||||
data-boundary-side="planner"
|
||||
data-boundary-emphasis={plannerActive ? "active" : "reduced"}
|
||||
@@ -174,12 +204,14 @@ const BoundaryScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBea
|
||||
<li>Revises steps and bindings</li>
|
||||
<li>Chooses tools</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div className="scene-body__boundary-seam" aria-label="workflow operation boundary">
|
||||
</m.section>
|
||||
<m.div layout transition={transition} className="scene-body__boundary-seam" aria-label="workflow operation boundary">
|
||||
<strong>CLI / JSON-RPC</strong>
|
||||
<span>typed workflow operations</span>
|
||||
</div>
|
||||
<section
|
||||
</m.div>
|
||||
<m.section
|
||||
layout
|
||||
transition={transition}
|
||||
className="scene-body__boundary-pane"
|
||||
data-boundary-side="runtime"
|
||||
data-boundary-emphasis={runtimeActive ? "active" : "reduced"}
|
||||
@@ -192,8 +224,8 @@ const BoundaryScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBea
|
||||
<li>Records traces and run output</li>
|
||||
<li>Resumes from persisted state</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</m.section>
|
||||
</m.div>
|
||||
<p className="scene-body__evidence">{scene.evidencePointer}</p>
|
||||
</>
|
||||
);
|
||||
@@ -206,17 +238,28 @@ const lifecycleStages = [
|
||||
{ id: "run", label: "Run", role: "Execution record and trace", detail: "Persist status, outputs, interrupts, and trace evidence." },
|
||||
] as const;
|
||||
|
||||
const LifecycleScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => {
|
||||
const LifecycleScene = ({ scene, beat, motionDisabled }: ChoreographyProps) => {
|
||||
const activeIndex = Math.max(0, lifecycleStages.findIndex((stage) => stage.id === beat.id));
|
||||
const activeStage = lifecycleStages[activeIndex]!;
|
||||
const transition = choreographyTransition(motionDisabled);
|
||||
return (
|
||||
<>
|
||||
<StageCaption eyebrow="Act II · implemented" title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<div className="scene-body__lifecycle" aria-label="workflow lifecycle rail" data-lifecycle-active-stage={activeStage.id}>
|
||||
<m.div
|
||||
layout
|
||||
className="scene-body__lifecycle"
|
||||
aria-label="workflow lifecycle rail"
|
||||
data-lifecycle-active-stage={activeStage.id}
|
||||
data-visual-role="primary"
|
||||
data-motion={motionDisabled ? "disabled" : "enabled"}
|
||||
transition={transition}
|
||||
>
|
||||
{lifecycleStages.map((stage, i) => (
|
||||
<article
|
||||
<m.article
|
||||
layout
|
||||
transition={transition}
|
||||
key={stage.id}
|
||||
className="scene-body__lifecycle-stage"
|
||||
data-lifecycle-active={i === activeIndex ? "true" : "false"}
|
||||
@@ -226,13 +269,20 @@ const LifecycleScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBe
|
||||
<strong>{stage.label}</strong>
|
||||
<small>{stage.role}</small>
|
||||
{i < lifecycleStages.length - 1 && <span className="scene-body__lifecycle-arrow">→</span>}
|
||||
</article>
|
||||
</m.article>
|
||||
))}
|
||||
</div>
|
||||
</m.div>
|
||||
<aside className="scene-body__lifecycle-current" aria-label="current lifecycle state">
|
||||
<span>{activeStage.label}</span>
|
||||
<strong>{activeStage.role}</strong>
|
||||
<p>{activeStage.detail}</p>
|
||||
{activeStage.id === "artifact" && (
|
||||
<div className="scene-body__lifecycle-optional" aria-label="optional artifact path">
|
||||
<span>Optional evidence path</span>
|
||||
<strong>Raw plan -> artifact</strong>
|
||||
<p>A plan can be compiled into an immutable version; Draft is not mandatory.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<p className="scene-body__evidence">{scene.evidencePointer}</p>
|
||||
</>
|
||||
@@ -335,11 +385,11 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvid
|
||||
if (scene.id === "problem") return <ProblemLoopScene scene={scene} beat={beat} />;
|
||||
return <NarrativeScene scene={scene} beat={beat} />;
|
||||
case "positioning":
|
||||
return <PositioningScene scene={scene} beat={beat} />;
|
||||
return <PositioningScene scene={scene} beat={beat} motionDisabled={motionDisabled} />;
|
||||
case "boundary":
|
||||
return <BoundaryScene scene={scene} beat={beat} />;
|
||||
return <BoundaryScene scene={scene} beat={beat} motionDisabled={motionDisabled} />;
|
||||
case "lifecycle":
|
||||
return <LifecycleScene scene={scene} beat={beat} />;
|
||||
return <LifecycleScene scene={scene} beat={beat} motionDisabled={motionDisabled} />;
|
||||
case "architecture":
|
||||
return (
|
||||
<ArchitectureScene
|
||||
|
||||
@@ -150,11 +150,59 @@ describe("WorkflowGraphStage", () => {
|
||||
const graph = screen.getByRole("group", { name: "workflow graph" });
|
||||
expect(graph).toHaveAttribute("data-graph-direction", "horizontal");
|
||||
expect(graph).toHaveAttribute("data-graph-layout", "horizontal");
|
||||
expect(graph).toHaveAttribute("data-graph-topology", "prepared-run-branches");
|
||||
expect(graph).toHaveAttribute("data-pan-zoom", "enabled");
|
||||
expect(graph).toHaveAttribute("data-node-drag", "enabled");
|
||||
expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /fit view/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("separates the submitted and revision branches in two-dimensional space", () => {
|
||||
render(
|
||||
<WorkflowGraphStage
|
||||
execution={{ completedNodeIds: [], currentNodeId: null }}
|
||||
selectedNodeId={null}
|
||||
selectNode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nodePosition = (id: string) => {
|
||||
const node = document.querySelector<HTMLElement>(
|
||||
`.workflow-graph-stage__node[data-node-id="${id}"]`,
|
||||
);
|
||||
if (!node) throw new Error(`Missing workflow node ${id}`);
|
||||
return { x: Number(node.dataset.positionX), y: Number(node.dataset.positionY) };
|
||||
};
|
||||
|
||||
expect(nodePosition("create_issues").y).toBeLessThan(nodePosition("revision_requested").y);
|
||||
expect(nodePosition("create_issues").x).toBe(nodePosition("finalise").x);
|
||||
expect(nodePosition("finalise").x).toBe(nodePosition("end_completed").x);
|
||||
expect(nodePosition("create_issues").y).toBeLessThan(nodePosition("finalise").y);
|
||||
expect(nodePosition("finalise").y).toBeLessThan(nodePosition("end_completed").y);
|
||||
expect(nodePosition("revision_requested").y).toBeGreaterThan(nodePosition("review_issues").y);
|
||||
});
|
||||
|
||||
it("does not invent selected or current node treatment for the static graph", () => {
|
||||
render(
|
||||
<WorkflowGraphStage
|
||||
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "analyze" }}
|
||||
selectedNodeId="analyze"
|
||||
selectNode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nodes = document.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label^="workflow node:"]',
|
||||
);
|
||||
expect(nodes).toHaveLength(10);
|
||||
for (const node of nodes) {
|
||||
expect(node).not.toHaveAttribute("data-selected");
|
||||
expect(node).not.toHaveAttribute("aria-pressed");
|
||||
expect(node).not.toHaveAttribute("data-execution-state");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the run identity without plan or trace count chips", () => {
|
||||
render(
|
||||
<WorkflowGraphStage
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, type MouseEvent } from "react";
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
useNodesState,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
type ReactFlowInstance,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { CircleCheck, GitBranch, SquareFunction } from "lucide-react";
|
||||
import { buildWorkflowGraph, type WorkflowGraphNodeData } from "../graph/graph-model.js";
|
||||
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
|
||||
import { presentationWorkflowPlan } from "./workflow-graph-data.js";
|
||||
@@ -31,8 +34,9 @@ type WorkflowGraphStageProps = {
|
||||
};
|
||||
|
||||
type PresentationNodeData = WorkflowGraphNodeData & {
|
||||
readonly selected: boolean;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly positionX: number;
|
||||
readonly positionY: number;
|
||||
};
|
||||
|
||||
const fullGraphLayout = {
|
||||
@@ -49,46 +53,69 @@ const compactGraphLayout = {
|
||||
ranksep: 52,
|
||||
} as const;
|
||||
|
||||
const preparedRunPositions: Readonly<Record<string, { readonly x: number; readonly y: number }>> = {
|
||||
reset_board: { x: 0, y: 0 },
|
||||
read_docs: { x: 205, y: 0 },
|
||||
analyze: { x: 410, y: 0 },
|
||||
build_report: { x: 615, y: 0 },
|
||||
draft_issues: { x: 615, y: 110 },
|
||||
review_issues: { x: 820, y: 110 },
|
||||
create_issues: { x: 1025, y: 40 },
|
||||
finalise: { x: 1025, y: 150 },
|
||||
end_completed: { x: 1025, y: 260 },
|
||||
revision_requested: { x: 1025, y: 370 },
|
||||
};
|
||||
|
||||
const edgeActive = (edge: { source: string; target: string }, execution: GraphExecutionPresentation): boolean =>
|
||||
execution.completedNodeIds.includes(edge.source)
|
||||
&& (execution.completedNodeIds.includes(edge.target) || execution.currentNodeId === edge.target);
|
||||
|
||||
const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) => (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<button
|
||||
type="button"
|
||||
className="workflow-graph-stage__node"
|
||||
data-kind={data.kind}
|
||||
data-selected={data.selected}
|
||||
aria-pressed={data.selected}
|
||||
aria-label={`workflow node: ${data.label}${data.detail ? `, ${data.detail}` : ""}`}
|
||||
title={data.nodeRef ?? undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
data.selectNode(data.nodeId);
|
||||
}}
|
||||
>
|
||||
<strong>{data.label}</strong>
|
||||
<small>{data.detail ?? data.nodeRef ?? "workflow step"}</small>
|
||||
</button>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</>
|
||||
);
|
||||
const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) => {
|
||||
const Icon = data.kind === "interrupt"
|
||||
? GitBranch
|
||||
: data.kind === "end"
|
||||
? CircleCheck
|
||||
: SquareFunction;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<button
|
||||
type="button"
|
||||
className="workflow-graph-stage__node"
|
||||
data-kind={data.kind}
|
||||
data-node-id={data.nodeId}
|
||||
data-position-x={data.positionX}
|
||||
data-position-y={data.positionY}
|
||||
aria-label={`workflow node: ${data.label}${data.detail ? `, ${data.detail}` : ""}`}
|
||||
title={data.nodeRef ?? undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
data.selectNode(data.nodeId);
|
||||
}}
|
||||
>
|
||||
<span className="workflow-graph-stage__node-icon" aria-hidden="true"><Icon size={18} strokeWidth={1.8} /></span>
|
||||
<strong>{data.label}</strong>
|
||||
<small>{data.detail ?? data.nodeRef ?? "workflow step"}</small>
|
||||
</button>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const nodeTypes: NodeTypes = { presentation: PresentationFlowNode };
|
||||
|
||||
const graphFitOptions = { padding: 0.04, minZoom: 0.25, maxZoom: 1 } as const;
|
||||
const graphFitOptions = { padding: 0.06, minZoom: 0.25, maxZoom: 1.2 } as const;
|
||||
|
||||
const WorkflowGraphStageInner = ({
|
||||
execution,
|
||||
selectedNodeId,
|
||||
selectNode,
|
||||
proof,
|
||||
variant = "full",
|
||||
}: WorkflowGraphStageProps) => {
|
||||
const model = useMemo(
|
||||
() => buildWorkflowGraph(presentationWorkflowPlan, {
|
||||
const { fitView } = useReactFlow<Node<PresentationNodeData>, Edge>();
|
||||
const model = useMemo(() => {
|
||||
const generated = buildWorkflowGraph(presentationWorkflowPlan, {
|
||||
direction: "LR",
|
||||
// Keep Dagre's dimensions in lockstep with the CSS node boxes so React
|
||||
// Flow's measured handles stay attached when the graph is fit to view.
|
||||
@@ -98,26 +125,52 @@ const WorkflowGraphStageInner = ({
|
||||
if (node.id === "review_issues") return "Review issues";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
[variant],
|
||||
);
|
||||
});
|
||||
|
||||
const nodes: Node<PresentationNodeData>[] = useMemo(
|
||||
if (variant === "compact") return generated;
|
||||
|
||||
// This is a layout input, not a second renderer: React Flow still owns
|
||||
// handles, edges, pan, zoom, and the user's dragged positions.
|
||||
return {
|
||||
...generated,
|
||||
nodes: generated.nodes.map((node) => ({
|
||||
...node,
|
||||
position: preparedRunPositions[node.id] ?? node.position,
|
||||
})),
|
||||
};
|
||||
}, [variant]);
|
||||
|
||||
const baseNodes: Node<PresentationNodeData>[] = useMemo(
|
||||
() => model.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "presentation",
|
||||
position: node.position,
|
||||
draggable: false,
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: selectedNodeId === node.id,
|
||||
selectNode,
|
||||
positionX: node.position.x,
|
||||
positionY: node.position.y,
|
||||
},
|
||||
})),
|
||||
[model.nodes, selectedNodeId, selectNode],
|
||||
[model.nodes, selectNode],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(baseNodes);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(baseNodes);
|
||||
}, [baseNodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
// The presentation canvas can change aspect ratio without remounting the
|
||||
// graph. Refit after resize so the outcome branches stay on-screen.
|
||||
const refit = () => requestAnimationFrame(() => void fitView(graphFitOptions));
|
||||
window.addEventListener("resize", refit);
|
||||
return () => window.removeEventListener("resize", refit);
|
||||
}, [fitView]);
|
||||
|
||||
const edges: Edge[] = useMemo(
|
||||
() => model.edges.map((edge) => {
|
||||
const active = edgeActive(edge, execution);
|
||||
@@ -155,6 +208,9 @@ const WorkflowGraphStageInner = ({
|
||||
data-graph-variant={variant}
|
||||
data-graph-direction="horizontal"
|
||||
data-graph-layout="horizontal"
|
||||
data-graph-topology="prepared-run-branches"
|
||||
data-pan-zoom="enabled"
|
||||
data-node-drag="enabled"
|
||||
>
|
||||
<div className="workflow-graph-stage__legend" aria-label="workflow graph node types">
|
||||
<span data-kind="use"><i aria-hidden="true" />Action</span>
|
||||
@@ -172,12 +228,13 @@ const WorkflowGraphStageInner = ({
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodesChange={onNodesChange}
|
||||
onInit={handleFlowInit}
|
||||
fitView
|
||||
fitViewOptions={graphFitOptions}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.5}
|
||||
nodesDraggable={false}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1489,6 +1489,15 @@
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
/* Motion measures these grid children so the comparison axis stays visible while focus shifts. */
|
||||
.scene-body__positioning-map[data-positioning-active-region="landscape"] {
|
||||
grid-template-columns: minmax(13rem, 0.9fr) minmax(20rem, 1.35fr) minmax(13rem, 0.9fr);
|
||||
}
|
||||
|
||||
.scene-body__positioning-map[data-positioning-active-region="lda"] {
|
||||
grid-template-columns: minmax(11rem, 0.78fr) minmax(23rem, 1.58fr) minmax(11rem, 0.78fr);
|
||||
}
|
||||
|
||||
.scene-body__positioning-column {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -1567,7 +1576,7 @@
|
||||
}
|
||||
|
||||
.scene-body__positioning-map[data-positioning-active-region="lda"] .scene-body__positioning-tile {
|
||||
opacity: 0.58;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.scene-body__positioning-tile,
|
||||
@@ -1590,6 +1599,18 @@
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.scene-body__boundary[data-boundary-active="planner"] {
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(8rem, 0.24fr) minmax(0, 0.78fr);
|
||||
}
|
||||
|
||||
.scene-body__boundary[data-boundary-active="runtime"] {
|
||||
grid-template-columns: minmax(0, 0.78fr) minmax(8rem, 0.24fr) minmax(0, 1.35fr);
|
||||
}
|
||||
|
||||
.scene-body__boundary[data-boundary-active="boundary"] {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(10rem, 0.42fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scene-body__boundary-pane {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
@@ -1602,7 +1623,7 @@
|
||||
}
|
||||
|
||||
.scene-body__boundary-pane[data-boundary-emphasis="reduced"] {
|
||||
opacity: 0.58;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.scene-body__boundary-pane {
|
||||
@@ -1662,6 +1683,22 @@
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.scene-body__lifecycle[data-lifecycle-active-stage="draft"] {
|
||||
grid-template-columns: minmax(0, 1.55fr) repeat(3, minmax(0, 0.82fr));
|
||||
}
|
||||
|
||||
.scene-body__lifecycle[data-lifecycle-active-stage="artifact"] {
|
||||
grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.55fr) repeat(2, minmax(0, 0.82fr));
|
||||
}
|
||||
|
||||
.scene-body__lifecycle[data-lifecycle-active-stage="deployment"] {
|
||||
grid-template-columns: repeat(2, minmax(0, 0.82fr)) minmax(0, 1.55fr) minmax(0, 0.82fr);
|
||||
}
|
||||
|
||||
.scene-body__lifecycle[data-lifecycle-active-stage="run"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 0.82fr)) minmax(0, 1.55fr);
|
||||
}
|
||||
|
||||
.scene-body__lifecycle-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -2047,6 +2084,30 @@
|
||||
color: color-mix(in oklch, var(--color-editorial-ink, oklch(0.19 0.015 65)) 76%, var(--color-editorial-muted, oklch(0.48 0.025 65)));
|
||||
}
|
||||
|
||||
.scene-body__lifecycle-optional {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
border-top: 1px solid color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 22%, transparent);
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.scene-body__lifecycle-optional span {
|
||||
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
|
||||
font: 700 0.62rem/1 var(--font-mono, monospace);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scene-body__lifecycle-optional strong,
|
||||
.scene-body__lifecycle-optional p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scene-body__lifecycle-optional strong {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.scene-body__architecture {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -71,8 +71,7 @@ const renderArchitecture = (overrides: Partial<React.ComponentProps<typeof Archi
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("ArchitectureScene", () => {
|
||||
it("renders the overview and expands Runtime & providers", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("renders the architecture spine and expands the capability boundary", () => {
|
||||
const onFocusPathChange = vi.fn();
|
||||
renderArchitecture({ focusPath: [], onFocusPathChange });
|
||||
expect(screen.getByRole("heading", { name: /architecture/i })).toBeInTheDocument();
|
||||
@@ -80,21 +79,55 @@ describe("ArchitectureScene", () => {
|
||||
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime-providers"]);
|
||||
});
|
||||
|
||||
it("uses the stage figure presentation for the defense architecture scene", () => {
|
||||
it("marks the figure as the one primary visual and exposes stable motion contracts", () => {
|
||||
renderArchitecture({ focusPath: [] });
|
||||
expect(screen.getByRole("group", { name: /architecture/i })).toHaveAttribute("data-figure-size", "stage");
|
||||
expect(screen.getByRole("group", { name: /architecture spine/i })).toHaveAttribute("data-figure-size", "stage");
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-visual-pass", "architecture-stage");
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-visual-role", "primary");
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-motion", "enabled");
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-architecture-focus", "system");
|
||||
});
|
||||
|
||||
it("keeps the architecture heading stable when motion is disabled", () => {
|
||||
renderArchitecture({ focusPath: [], motionDisabled: true });
|
||||
expect(screen.getByRole("heading", { name: /architecture zoom/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-motion", "disabled");
|
||||
expect(screen.getByRole("group", { name: /architecture spine/i })).toHaveAttribute("data-motion", "disabled");
|
||||
});
|
||||
|
||||
it("exposes the public surface, kernel loop, and lifecycle contracts at the spine", () => {
|
||||
renderArchitecture({ focusPath: [] });
|
||||
const spine = screen.getByRole("group", { name: /architecture spine/i });
|
||||
expect(spine).toHaveTextContent("Front door and transport");
|
||||
expect(spine).toHaveTextContent("Workflow API operations");
|
||||
expect(spine).toHaveTextContent("WorkflowServer composition");
|
||||
expect(spine).toHaveTextContent("wf_core execution loop");
|
||||
expect(spine).toHaveTextContent("Lifecycle records");
|
||||
expect(spine).toHaveTextContent("Capability inventory");
|
||||
});
|
||||
|
||||
it("renders a directly linked nested provider view", () => {
|
||||
renderArchitecture({
|
||||
focusPath: ["runtime-providers", "configured-providers"],
|
||||
});
|
||||
expect(screen.getByRole("group", { name: /configured providers/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: /configured provider boundary/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("MCP sources")).toBeInTheDocument();
|
||||
expect(screen.getByText("Python sources")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("architecture-scene")).toHaveAttribute("data-architecture-focus", "nested");
|
||||
expect(screen.getByRole("group", { name: /configured providers/i })).toHaveAttribute("data-figure-focus-level", "2");
|
||||
expect(screen.getByRole("group", { name: /configured provider boundary/i })).toHaveAttribute("data-figure-focus-level", "2");
|
||||
});
|
||||
|
||||
it("shows the kernel loop and clickable NodeUse sequence as nested figures", () => {
|
||||
const onFocusPathChange = vi.fn();
|
||||
renderArchitecture({ focusPath: ["core-runtime"], onFocusPathChange });
|
||||
expect(screen.getByRole("group", { name: /wf_core execution loop/i })).toHaveTextContent("Step kind");
|
||||
fireEvent.click(screen.getByTestId("figure-node-dispatch-step-kind"));
|
||||
expect(onFocusPathChange).toHaveBeenCalledWith(["core-runtime", "dispatch-step-kind"]);
|
||||
|
||||
cleanup();
|
||||
renderArchitecture({ focusPath: ["core-runtime", "dispatch-step-kind"], onFocusPathChange });
|
||||
expect(screen.getByRole("group", { name: /supported step kinds/i })).toHaveTextContent("NodeUse");
|
||||
fireEvent.click(screen.getByTestId("figure-node-node-use"));
|
||||
expect(onFocusPathChange).toHaveBeenCalledWith(["core-runtime", "dispatch-step-kind", "node-use"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ export const ArchitectureScene = ({
|
||||
className="architecture-scene"
|
||||
data-testid="architecture-scene"
|
||||
data-visual-pass="architecture-stage"
|
||||
data-visual-role="primary"
|
||||
data-motion={motionDisabled ? "disabled" : "enabled"}
|
||||
data-focus-level={focusPath.length}
|
||||
data-architecture-focus={focusPath.length === 0 ? "system" : "nested"}
|
||||
>
|
||||
|
||||
@@ -372,14 +372,25 @@
|
||||
stroke-width: 0.38rem;
|
||||
stroke-linejoin: round;
|
||||
fill: var(--text-primary);
|
||||
font: 700 0.68rem/1 var(--font-mono, monospace);
|
||||
font: 750 0.76rem/1 var(--font-mono, monospace);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workflow-graph-stage .react-flow__edge-textbg {
|
||||
fill: oklch(0.105 0.02 250);
|
||||
stroke: oklch(0.105 0.02 250);
|
||||
fill: oklch(0.08 0.018 250 / 0.96);
|
||||
stroke: oklch(0.08 0.018 250 / 0.96);
|
||||
stroke-width: 7px;
|
||||
}
|
||||
|
||||
@container presentation-canvas (max-width: 1080px) {
|
||||
.workflow-graph-stage__node[data-node-id="revision_requested"] {
|
||||
width: 10.5rem;
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-graph-stage__edge--active .react-flow__edge-path {
|
||||
@@ -458,14 +469,6 @@
|
||||
background: oklch(0.68 0.025 250);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__legend i[data-state="current"] {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__legend i[data-state="interrupt"] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
.workflow-graph-stage[data-graph-variant="compact"] {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -535,8 +538,8 @@
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
width: 11rem;
|
||||
min-height: 4.75rem;
|
||||
width: 12.25rem;
|
||||
min-height: 5rem;
|
||||
transform: none;
|
||||
border: 1px solid var(--stage-line);
|
||||
border-radius: 0.7rem;
|
||||
@@ -548,6 +551,39 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.presentation-route .workflow-graph-stage__node[data-kind="use"] {
|
||||
border-color: oklch(0.68 0.14 195 / 0.72);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.presentation-route .workflow-graph-stage__node[data-kind="interrupt"] {
|
||||
border-color: oklch(0.76 0.18 70 / 0.72);
|
||||
border-width: 2px;
|
||||
background: oklch(0.19 0.045 70 / 0.96);
|
||||
}
|
||||
|
||||
.presentation-route .workflow-graph-stage__node[data-kind="end"] {
|
||||
border-color: oklch(0.68 0.025 250 / 0.8);
|
||||
background: oklch(0.19 0.025 250 / 0.98);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-kind="interrupt"] .workflow-graph-stage__node-icon {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-kind="end"] .workflow-graph-stage__node-icon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.presentation-route .workflow-graph-stage__node[data-kind="end"] {
|
||||
min-height: 4.5rem;
|
||||
border-radius: 999px;
|
||||
@@ -556,7 +592,7 @@
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node strong {
|
||||
font-size: 0.9rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.05;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -567,7 +603,7 @@
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node small {
|
||||
font-size: 0.67rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node-state {
|
||||
@@ -576,46 +612,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-execution-state="completed"] {
|
||||
border-color: oklch(0.48 0.04 195 / 0.7);
|
||||
background: oklch(0.16 0.035 210 / 0.96);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-execution-state="current"] {
|
||||
border-color: var(--accent-cyan);
|
||||
background: oklch(0.19 0.055 210 / 0.98);
|
||||
box-shadow: 0 0 0 3px oklch(0.72 0.17 195 / 0.16), 0 0.45rem 0.85rem oklch(0.06 0.03 230 / 0.36);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-execution-state="current"] .workflow-graph-stage__node-state {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-current-interrupt="true"] {
|
||||
border-color: var(--accent-amber);
|
||||
background: oklch(0.19 0.045 70 / 0.95);
|
||||
box-shadow: 0 0 0 3px oklch(0.76 0.18 70 / 0.16), 0 0.45rem 0.85rem oklch(0.06 0.03 230 / 0.36);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-current-interrupt="true"] .workflow-graph-stage__node-state {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-execution-state="future"] {
|
||||
opacity: 0.72;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-kind="end"][data-execution-state="future"] {
|
||||
border-style: dashed;
|
||||
background: oklch(0.12 0.02 250 / 0.86);
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-selected="true"] {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.interrupt-contract-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -636,6 +632,124 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* The inspector overlays the stage without replacing it, so the selected
|
||||
node remains legible in the graph context behind the factual detail. */
|
||||
.node-spotlight {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
max-height: min(38rem, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
border: 1px solid oklch(0.72 0.17 195 / 0.5);
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.1rem;
|
||||
background:
|
||||
linear-gradient(160deg, oklch(0.72 0.17 195 / 0.09), transparent 45%),
|
||||
oklch(0.13 0.025 250 / 0.98);
|
||||
box-shadow: 0 1rem 2rem oklch(0.04 0.02 250 / 0.38);
|
||||
}
|
||||
|
||||
.node-spotlight__header,
|
||||
.node-spotlight__header > div,
|
||||
.node-spotlight__evidence {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.node-spotlight__eyebrow,
|
||||
.node-spotlight__evidence span,
|
||||
.node-spotlight__facts dt,
|
||||
.node-spotlight__outcomes h3 {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font: 650 0.68rem/1.2 var(--font-mono, monospace);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.node-spotlight__header > div {
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.node-spotlight__kind {
|
||||
color: var(--accent-cyan);
|
||||
font: 650 0.72rem/1 var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.node-spotlight h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.node-spotlight__detail {
|
||||
margin: -0.55rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.node-spotlight button {
|
||||
border: 1px solid var(--stage-line);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-spotlight__facts {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-spotlight__facts > div {
|
||||
border-top: 1px solid var(--stage-line);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.node-spotlight__facts dd {
|
||||
margin: 0.22rem 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.node-spotlight__outcomes ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.node-spotlight__outcomes li {
|
||||
border: 1px solid oklch(0.7 0.16 145 / 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.55rem;
|
||||
color: var(--text-primary);
|
||||
font: 650 0.8rem/1 var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.node-spotlight__evidence {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
border-top: 1px solid var(--stage-line);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.node-spotlight__evidence code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--accent-cyan);
|
||||
font: 0.8rem/1.35 var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.demo-workflow-stage[data-demo-layout="approval"] .demo-workflow-stage__graph {
|
||||
grid-template-columns: minmax(20rem, 1fr);
|
||||
align-items: stretch;
|
||||
|
||||
@@ -31,4 +31,18 @@ describe("presentationWorkflowPlan", () => {
|
||||
{ from: "review_issues", to: "revision_requested", outcome: "cancelled" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("provides factual inspection contracts for every displayed node", () => {
|
||||
for (const node of presentationWorkflowPlan.nodes) {
|
||||
expect(node.capability, node.id).toBeTruthy();
|
||||
expect(node.inputSummary, node.id).toBeTruthy();
|
||||
expect(node.outputSummary, node.id).toBeTruthy();
|
||||
expect(node.outcomes, node.id).not.toHaveLength(0);
|
||||
expect(node.evidencePointer, node.id).toBeTruthy();
|
||||
}
|
||||
|
||||
const interrupt = presentationWorkflowPlan.nodes.find((node) => node.id === "review_issues");
|
||||
expect(interrupt?.schemaSummary).toMatch(/request: issue proposals/i);
|
||||
expect(interrupt?.outcomes).toEqual(["submitted", "cancelled"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,131 @@
|
||||
/**
|
||||
* The presentation subset deliberately keeps workflow facts raw. Dagre owns
|
||||
* every rendered coordinate; this module only chooses the factual story slice.
|
||||
* The presentation subset deliberately keeps workflow facts raw. The graph
|
||||
* stage owns rendered coordinates; this module only chooses the factual story
|
||||
* slice and the small contract summaries shown by the node inspector.
|
||||
*/
|
||||
export const presentationWorkflowPlan = {
|
||||
nodes: [
|
||||
{ id: "reset_board", type: "node", node: "local.issue_board.reset_issue_board", label: "Reset issue board", detail: "issue board source" },
|
||||
{ id: "read_docs", type: "node", node: "local.lda_docs.read_documents", label: "Read documents", detail: "document source" },
|
||||
{ id: "analyze", type: "node", node: "local.lda_report.analyze_documents", label: "Analyze documents", detail: "report source" },
|
||||
{ id: "build_report", type: "node", node: "local.lda_report.build_report", label: "Build report", detail: "Markdown output" },
|
||||
{ id: "draft_issues", type: "node", node: "local.lda_report.create_issue_drafts", label: "Draft issues", detail: "issue proposals" },
|
||||
{ id: "review_issues", type: "interrupt", kind: "issue_review", label: "Review issues", detail: "typed human boundary" },
|
||||
{ id: "create_issues", type: "node", node: "local.issue_board.create_issues", label: "Create issues", detail: "selected issues only" },
|
||||
{ id: "finalise", type: "node", node: "local.lda_report.finalise_report", label: "Finalise report", detail: "state output" },
|
||||
{ id: "revision_requested", type: "node", node: "local.lda_report.record_revision_request", label: "Revision requested", detail: "operator branch" },
|
||||
{ id: "end_completed", type: "end", outcome: "completed", label: "Completed", detail: "persisted run" },
|
||||
{
|
||||
id: "reset_board",
|
||||
type: "node",
|
||||
node: "local.issue_board.reset_issue_board",
|
||||
label: "Reset issue board",
|
||||
detail: "issue board source",
|
||||
capability: "Issue board source",
|
||||
inputSummary: "Existing local issue-board state",
|
||||
outputSummary: "Cleared issue-board state",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.issue_board.reset_issue_board",
|
||||
},
|
||||
{
|
||||
id: "read_docs",
|
||||
type: "node",
|
||||
node: "local.lda_docs.read_documents",
|
||||
label: "Read documents",
|
||||
detail: "document source",
|
||||
capability: "Document source",
|
||||
inputSummary: "Selected document paths",
|
||||
outputSummary: "Document contents",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.lda_docs.read_documents",
|
||||
},
|
||||
{
|
||||
id: "analyze",
|
||||
type: "node",
|
||||
node: "local.lda_report.analyze_documents",
|
||||
label: "Analyze documents",
|
||||
detail: "report source",
|
||||
capability: "Report source",
|
||||
inputSummary: "Document contents",
|
||||
outputSummary: "Report findings",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.lda_report.analyze_documents",
|
||||
},
|
||||
{
|
||||
id: "build_report",
|
||||
type: "node",
|
||||
node: "local.lda_report.build_report",
|
||||
label: "Build report",
|
||||
detail: "Markdown output",
|
||||
capability: "Markdown output",
|
||||
inputSummary: "Report findings",
|
||||
outputSummary: "Markdown report",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.lda_report.build_report",
|
||||
},
|
||||
{
|
||||
id: "draft_issues",
|
||||
type: "node",
|
||||
node: "local.lda_report.create_issue_drafts",
|
||||
label: "Draft issues",
|
||||
detail: "issue proposals",
|
||||
capability: "Issue proposals",
|
||||
inputSummary: "Report findings",
|
||||
outputSummary: "Issue proposals",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.lda_report.create_issue_drafts",
|
||||
},
|
||||
{
|
||||
id: "review_issues",
|
||||
type: "interrupt",
|
||||
kind: "issue_review",
|
||||
label: "Review issues",
|
||||
detail: "typed human boundary",
|
||||
capability: "Human boundary",
|
||||
inputSummary: "Issue proposals",
|
||||
outputSummary: "Submitted issues or revision request",
|
||||
schemaSummary: "Request: issue proposals; resume: submitted or cancelled",
|
||||
outcomes: ["submitted", "cancelled"],
|
||||
evidencePointer: "workflow interrupt: issue_review",
|
||||
},
|
||||
{
|
||||
id: "create_issues",
|
||||
type: "node",
|
||||
node: "local.issue_board.create_issues",
|
||||
label: "Create issues",
|
||||
detail: "selected issues only",
|
||||
capability: "Issue board source",
|
||||
inputSummary: "Submitted issues",
|
||||
outputSummary: "Persisted issue-board entries",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.issue_board.create_issues",
|
||||
},
|
||||
{
|
||||
id: "finalise",
|
||||
type: "node",
|
||||
node: "local.lda_report.finalise_report",
|
||||
label: "Finalise report",
|
||||
detail: "state output",
|
||||
capability: "State output",
|
||||
inputSummary: "Markdown report and issue-board state",
|
||||
outputSummary: "Completed run output",
|
||||
outcomes: ["ok"],
|
||||
evidencePointer: "local.lda_report.finalise_report",
|
||||
},
|
||||
{
|
||||
id: "revision_requested",
|
||||
type: "node",
|
||||
node: "local.lda_report.record_revision_request",
|
||||
label: "Revision requested",
|
||||
detail: "operator branch",
|
||||
capability: "Revision record",
|
||||
inputSummary: "Issue-review decision",
|
||||
outputSummary: "Recorded revision request",
|
||||
outcomes: ["cancelled"],
|
||||
evidencePointer: "local.lda_report.record_revision_request",
|
||||
},
|
||||
{
|
||||
id: "end_completed",
|
||||
type: "end",
|
||||
outcome: "completed",
|
||||
label: "Completed",
|
||||
detail: "persisted run",
|
||||
capability: "Run outcome",
|
||||
inputSummary: "Finalised workflow state",
|
||||
outputSummary: "Persisted completed run",
|
||||
outcomes: ["completed"],
|
||||
evidencePointer: "workflow run record: completed",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: "reset_board", to: "read_docs", outcome: "ok" },
|
||||
|
||||
Reference in New Issue
Block a user