style: clarify lifecycle diagrams at presentation distance

This commit is contained in:
lda
2026-07-14 01:57:01 +07:00 Verified
parent c659d3af9b
commit 90b446aea8
9 changed files with 511 additions and 144 deletions
@@ -76,9 +76,7 @@ describe("AuthoringPhaseVisual", () => {
expect(within(result).getByText(/missing edges for outcomes.*ok/i)).toBeInTheDocument();
expect(within(result).getByText(/cannot prove where execution goes next/i)).toBeInTheDocument();
expect(within(result).getByText("Revision 3")).toBeInTheDocument();
expect(within(result).getByRole("note", { name: /prepared fault injection/i })).toHaveTextContent(
"wf draft remove-route lda_report_workflow --revision 2 --step analyze --outcome ok",
);
expect(within(result).queryByRole("note", { name: /prepared fault injection/i })).not.toBeInTheDocument();
});
it("renders route repair as a valid revision with compact prior context", () => {
@@ -173,11 +173,6 @@ const DiagnosticResult = ({ evidence }: { readonly evidence: EvidenceOf<"diagnos
]}
/>
<p className="authoring-result__explanation">{evidence.diagnostic.explanation}</p>
<section className="authoring-result__fault-injection" role="note" aria-label={evidence.faultInjection.label}>
<span>{evidence.faultInjection.label}</span>
<code>{evidence.faultInjection.command}</code>
<p>Valid revision {evidence.faultInjection.fromRevision} became invalid revision {evidence.faultInjection.toRevision}.</p>
</section>
</>
)}
/>
@@ -36,9 +36,7 @@ describe("AuthoringWorkflowDiagram", () => {
it("shows an absent analyze.ok route as the diagnostic headline", () => {
renderDiagram("diagnose");
expect(screen.getByText("Missing route")).toBeInTheDocument();
expect(screen.getByRole("img", { name: /analyze ok route is missing/i })).toBeInTheDocument();
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).not.toBeInTheDocument();
});
it("restores analyze.ok without retaining the missing-route marker", () => {
@@ -46,6 +44,15 @@ describe("AuthoringWorkflowDiagram", () => {
expect(screen.getByRole("img", { name: /analyze ok route restored/i })).toBeInTheDocument();
expect(screen.queryByText("Missing route")).not.toBeInTheDocument();
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).toBeInTheDocument();
});
it("reserves a wider rank for the route-state edge label", () => {
renderDiagram("repair");
const analyze = document.querySelector('[data-id="analyze"]');
const end = document.querySelector('[data-id="__end__"]');
expect(analyze).toHaveStyle({ width: "224px" });
expect(end).toHaveStyle({ width: "224px" });
expect(analyze?.getAttribute("style")).not.toEqual(end?.getAttribute("style"));
});
});
@@ -7,9 +7,12 @@ import {
type Node,
type NodeProps,
type NodeTypes,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { CircleStop, Database, SquareFunction, TriangleAlert } from "lucide-react";
import Dagre from "@dagrejs/dagre";
import { CircleStop, Database, SquareFunction } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ReviewedAuthoringEvidence } from "./reviewed-authoring-evidence.js";
type WorkflowEvidence = Extract<
@@ -49,28 +52,56 @@ const AuthoringFlowNode = ({ id, data }: NodeProps<Node<AuthoringNodeData>>) =>
const nodeTypes: NodeTypes = { authoring: AuthoringFlowNode };
// Stable positions make Draft -> Diagnose -> Repair read as one changing object.
const workflowNodes: Node<AuthoringNodeData>[] = [
const NODE_WIDTH = 224;
const NODE_HEIGHT = 76;
const FIT_VIEW_OPTIONS = { padding: 0.16, minZoom: 0.55, maxZoom: 1.25, duration: 0 } as const;
const workflowNodeDefinitions: Omit<Node<AuthoringNodeData>, "position">[] = [
{
id: "read_documents",
type: "authoring",
position: { x: 0, y: 60 },
style: { width: NODE_WIDTH },
data: { label: "read_documents", role: "source" },
},
{
id: "analyze",
type: "authoring",
position: { x: 310, y: 60 },
style: { width: NODE_WIDTH },
data: { label: "analyze", role: "action" },
},
{
id: "__end__",
type: "authoring",
position: { x: 620, y: 60 },
style: { width: NODE_WIDTH },
data: { label: "END", role: "outcome" },
},
];
const layoutWorkflowNodes = (): Node<AuthoringNodeData>[] => {
const graph = new Dagre.graphlib.Graph();
graph.setGraph({ rankdir: "LR", ranksep: 72, nodesep: 48, marginx: 0, marginy: 0 });
graph.setDefaultEdgeLabel(() => ({}));
for (const node of workflowNodeDefinitions) {
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
graph.setEdge("read_documents", "analyze", { width: 28, height: 22 });
// Reserve the widest label once so all three beats retain identical node positions.
graph.setEdge("analyze", "__end__", { width: 154, height: 24 });
Dagre.layout(graph);
return workflowNodeDefinitions.map((node) => {
const position = graph.node(node.id);
return {
...node,
position: {
x: position.x - NODE_WIDTH / 2,
y: position.y - NODE_HEIGHT / 2,
},
};
});
};
const workflowNodes = layoutWorkflowNodes();
const edgesForMode = (mode: AuthoringWorkflowMode): Edge[] => [
{
id: "read_documents.ok",
@@ -79,18 +110,13 @@ const edgesForMode = (mode: AuthoringWorkflowMode): Edge[] => [
label: "ok",
className: "authoring-workflow-diagram__edge",
},
...(mode === "diagnostic"
? []
: [
{
id: "analyze.ok",
source: "analyze",
target: "__end__",
label: mode === "repair" ? "restored · ok" : "ok",
animated: mode === "repair",
className: `authoring-workflow-diagram__edge${mode === "repair" ? " authoring-workflow-diagram__edge--restored" : ""}`,
},
]),
{
id: "analyze.ok",
source: "analyze",
target: "__end__",
label: mode === "diagnostic" ? "Missing route · ok" : mode === "repair" ? "Route restored · ok" : "ok",
className: `authoring-workflow-diagram__edge${mode === "diagnostic" ? " authoring-workflow-diagram__edge--missing" : ""}${mode === "repair" ? " authoring-workflow-diagram__edge--restored" : ""}`,
},
];
const accessibleLabelForMode = (mode: AuthoringWorkflowMode): string => {
@@ -99,19 +125,42 @@ const accessibleLabelForMode = (mode: AuthoringWorkflowMode): string => {
return "Authoring workflow diagram: valid draft routes";
};
const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps) => (
<div
className="authoring-workflow-diagram"
role="img"
aria-label={accessibleLabelForMode(mode)}
data-workflow-mode={mode}
>
const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [flow, setFlow] = useState<ReactFlowInstance<Node<AuthoringNodeData>, Edge>>();
useEffect(() => {
if (!flow) return undefined;
let frame = window.requestAnimationFrame(() => void flow.fitView(FIT_VIEW_OPTIONS));
// Scene columns resize without remounting React Flow, so fit the settled box again.
const observer = typeof ResizeObserver === "undefined" || !containerRef.current
? undefined
: new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => void flow.fitView(FIT_VIEW_OPTIONS));
});
if (containerRef.current) observer?.observe(containerRef.current);
return () => {
window.cancelAnimationFrame(frame);
observer?.disconnect();
};
}, [flow, mode]);
return (
<div
ref={containerRef}
className="authoring-workflow-diagram"
role="img"
aria-label={accessibleLabelForMode(mode)}
data-workflow-mode={mode}
>
<ReactFlow
nodes={workflowNodes}
edges={edgesForMode(mode)}
nodeTypes={nodeTypes}
onInit={setFlow}
fitView
fitViewOptions={{ padding: 0.16, minZoom: 0.7, maxZoom: 1.25 }}
fitViewOptions={FIT_VIEW_OPTIONS}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -121,24 +170,9 @@ const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps)
preventScrolling={false}
proOptions={{ hideAttribution: true }}
/>
<div className="authoring-workflow-diagram__route-state" aria-hidden="true">
{mode === "diagnostic" ? (
<span className="authoring-workflow-diagram__missing-route">
<TriangleAlert />
<strong>Missing route</strong>
<small>analyze · ok</small>
</span>
) : (
<span
data-authoring-edge-id="analyze.ok"
data-route-state={mode === "repair" ? "restored" : "present"}
>
{mode === "repair" ? "Route restored" : "Complete route"}
</span>
)}
</div>
</div>
);
);
};
/** Renders the same authored workflow while its route state changes between beats. */
export const AuthoringWorkflowDiagram = (props: AuthoringWorkflowDiagramProps) => (
@@ -52,6 +52,12 @@ describe("PreparedAuthoringLifecycleScene", () => {
expect(frame).toHaveTextContent("missing_outcome_edge");
expect(frame).toHaveTextContent("nodes[analyze]");
expect(frame).toHaveTextContent(/missing edges for outcomes.*ok/i);
const setup = within(frame).getByRole("note", { name: /prepared fault injection/i });
expect(setup).toHaveTextContent(/valid revision 2/i);
expect(setup).toHaveTextContent(/remove analyze\.ok/i);
expect(setup).toHaveTextContent(/invalid revision 3/i);
expect(within(screen.getByRole("region", { name: "draft validation diagnostic" }))
.queryByRole("note", { name: /prepared fault injection/i })).not.toBeInTheDocument();
expect(frame).toHaveAttribute("data-authoring-step", "diagnose");
expect(frame).toHaveAttribute("data-recording-phase", "validate");
expect(screen.getByRole("region", { name: "draft validation diagnostic" })).toHaveAttribute(
@@ -127,6 +127,24 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance, discus
</div>
</dl>
</header>
{projection.evidence.kind === "diagnostic" && (
<aside
className="prepared-lifecycle-scene__setup-strip"
role="note"
aria-label={projection.evidence.faultInjection.label}
>
<span>Prepared setup</span>
<strong>
Valid revision {projection.evidence.faultInjection.fromRevision}
{" → remove analyze.ok → "}
invalid revision {projection.evidence.faultInjection.toRevision}
</strong>
<details>
<summary>Exact command</summary>
<code>{projection.evidence.faultInjection.command}</code>
</details>
</aside>
)}
<AuthoringPhaseVisual projection={projection} />
</article>
</div>