fix: improve presentation evidence and architecture figure

This commit is contained in:
lda
2026-07-08 01:41:59 +07:00 Verified
parent 0c2b23ffd3
commit eb40c951d0
15 changed files with 169 additions and 60 deletions
+3 -2
View File
@@ -245,8 +245,9 @@ legible without hiding raw evidence.
### Do:
- **Do** make the workflow graph, operation block, evidence drawer, lifecycle
explorer, typed interrupt/resume panel, and trace the main visual surfaces.
- **Do** make the workflow graph, operation block, presentation evidence
receipt/inspector, lifecycle explorer, typed interrupt/resume panel, and trace
the main visual surfaces.
- **Do** keep `/console` familiar and product-like: readable tables, standard
buttons, clear labels, predictable focus states.
- **Do** let `/present` be cinematic with staged panels, larger text, and graph
+2 -2
View File
@@ -32,8 +32,8 @@ the resulting runs remain auditable through traces and evidence records.
Chat is a framing device, not the core product. It may introduce or narrate a
prepared workflow, but the main surfaces are the workflow graph, operation
blocks, evidence drawer, lifecycle explorer, typed interrupt/resume panel, and
run trace.
blocks, presentation evidence receipt/inspector, lifecycle explorer, typed
interrupt/resume panel, and run trace.
Success means a viewer can answer three questions quickly:
@@ -1,4 +1,4 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -112,7 +112,7 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument();
});
it("closes the inspector when navigation moves to another beat", async () => {
it("closes the inspector from the explicit close action", async () => {
const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -122,4 +122,16 @@ describe("PresentationRoute", () => {
await user.click(screen.getByRole("button", { name: /close evidence/i }));
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument();
});
it("returns to the receipt after closing the inspector on a receipt beat", async () => {
const user = userEvent.setup();
window.location.hash = "#scene/interrupt-evidence/trace";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
await user.click(await screen.findByRole("button", { name: /inspect evidence/i }));
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /close evidence/i }));
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
});
});
@@ -91,10 +91,10 @@ export const EvidenceInspector = ({
</label>
)}
<div role="tablist" aria-label="Evidence view">
<button role="tab" aria-selected={view === "interpreted"} onClick={() => setView("interpreted")}>
<button type="button" role="tab" aria-selected={view === "interpreted"} onClick={() => setView("interpreted")}>
Interpreted
</button>
<button role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}>
<button type="button" role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}>
Raw
</button>
</div>
@@ -78,7 +78,7 @@ const renderFigure = (overrides: Partial<React.ComponentProps<typeof Interactive
onFocusPathChange,
...render(
<InteractiveFigure
catalog={validCatalog}
catalog={restOverrides.catalog ?? validCatalog}
focusPath={restOverrides.focusPath ?? []}
activeNodeId={restOverrides.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange}
@@ -99,10 +99,26 @@ describe("InteractiveFigure", () => {
});
it("renders within a React Flow container for edge support", () => {
renderFigure({ focusPath: [] });
const { container } = renderFigure({ focusPath: [] });
const rfWrapper = screen.getByTestId("rf__wrapper");
expect(rfWrapper).toBeInTheDocument();
expect(rfWrapper.querySelector("[class*='react-flow']")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-top")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-bottom")).toBeInTheDocument();
});
it("uses left and right handles for flow figures", () => {
const flowCatalog: FigureCatalogDefinition = {
...validCatalog,
figures: validCatalog.figures.map((figure) =>
figure.id === validCatalog.rootFigureId
? { ...figure, layout: { kind: "flow" as const } }
: figure,
),
};
const { container } = renderFigure({ catalog: flowCatalog });
expect(container.querySelector(".react-flow__handle-left")).toBeInTheDocument();
expect(container.querySelector(".react-flow__handle-right")).toBeInTheDocument();
});
it("expands a child figure by click and Enter", async () => {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
@@ -19,6 +19,7 @@ type InteractiveFigureProps = {
readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean;
readonly size?: "standard" | "wide";
};
type FigureNodeData = {
@@ -26,6 +27,7 @@ type FigureNodeData = {
readonly label: string;
readonly summary: string;
readonly kind: FigureNodeKind;
readonly orientation: "horizontal" | "vertical";
readonly isActive: boolean;
readonly isExpandable: boolean;
readonly onActivate: (nodeId: string) => void;
@@ -35,10 +37,12 @@ type FigureNodeData = {
const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
const expandable = data.isExpandable;
const accessibleName = expandable ? `${data.label}, expand` : data.label;
const targetPosition = data.orientation === "horizontal" ? Position.Left : Position.Top;
const sourcePosition = data.orientation === "horizontal" ? Position.Right : Position.Bottom;
return (
<>
<Handle type="target" position={Position.Top} id="target" />
<Handle type="target" position={targetPosition} id="target" />
<button
type="button"
className="figure-node"
@@ -65,7 +69,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>}
</button>
<Handle type="source" position={Position.Bottom} id="source" />
<Handle type="source" position={sourcePosition} id="source" />
</>
);
};
@@ -74,11 +78,11 @@ const nodeTypes: NodeTypes = {
figure: FigureFlowNode,
};
const FitViewOnLayoutChange = ({ layoutVersion }: { layoutVersion: number }) => {
const FitViewOnLayoutChange = ({ layoutKey }: { layoutKey: string }) => {
const { fitView } = useReactFlow();
useEffect(() => {
void fitView({ padding: 0.15, duration: 0 });
}, [fitView, layoutVersion]);
}, [fitView, layoutKey]);
return null;
};
@@ -88,24 +92,20 @@ const InteractiveFigureInner = ({
activeNodeId,
onFocusPathChange,
motionDisabled,
size = "standard",
}: InteractiveFigureProps) => {
const focus = resolveFigureFocus(catalog, focusPath);
const layout = layoutFigure(focus.figure);
const [focusedNodeId, setFocusedNodeId] = useState<string>(
activeNodeId ?? focus.figure.nodes[0]?.id ?? "",
const focus = useMemo(
() => resolveFigureFocus(catalog, focusPath),
[catalog, focusPath],
);
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
const containerRef = useRef<HTMLDivElement>(null);
const [layoutVersion, setLayoutVersion] = useState(0);
const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
useEffect(() => {
if (activeNodeId) setFocusedNodeId(activeNodeId);
}, [activeNodeId]);
useEffect(() => {
const firstNode = focus.figure.nodes[0];
if (firstNode) setFocusedNodeId(firstNode.id);
setLayoutVersion((v) => v + 1);
}, [focus.figure.id]);
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
focusedNodeIdRef.current = fallbackFocusedNodeId;
}
const handleExpand = useCallback(
(nodeId: string) => {
@@ -148,17 +148,21 @@ const InteractiveFigureInner = ({
if (direction) {
event.preventDefault();
event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeId, direction);
setFocusedNodeId(nextId);
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
focusedNodeIdRef.current = nextId;
const nextNode = containerRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`,
);
if (nextNode instanceof HTMLElement) nextNode.focus();
}
},
[catalog, focus, focusedNodeId, layout, onFocusPathChange],
[catalog, focus, layout, onFocusPathChange],
);
const handleActivateNode = useCallback((nodeId: string) => {
focusedNodeIdRef.current = nodeId;
}, []);
const rfNodes: Node[] = useMemo(
() =>
layout.nodes.map((node) => ({
@@ -170,13 +174,14 @@ const InteractiveFigureInner = ({
label: node.label,
summary: node.summary,
kind: node.kind,
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
isActive: node.id === activeNodeId,
isExpandable: node.childFigureId !== undefined,
onActivate: setFocusedNodeId,
onActivate: handleActivateNode,
onExpand: handleExpand,
},
})),
[layout.nodes, activeNodeId, handleExpand],
[layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
);
const rfEdges: Edge[] = useMemo(
@@ -194,7 +199,7 @@ const InteractiveFigureInner = ({
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
const data = node.data as FigureNodeData;
setFocusedNodeId(data.nodeId);
focusedNodeIdRef.current = data.nodeId;
if (data.isExpandable) handleExpand(data.nodeId);
},
[handleExpand],
@@ -207,6 +212,7 @@ const InteractiveFigureInner = ({
aria-label={focus.figure.title}
data-motion={motionDisabled ? "disabled" : "enabled"}
data-figure-id={focus.figure.id}
data-figure-size={size}
onKeyDown={handleKeyDown}
>
<FigureBreadcrumbs
@@ -232,7 +238,7 @@ const InteractiveFigureInner = ({
preventScrolling={false}
onNodeClick={handleNodeClick}
>
<FitViewOnLayoutChange layoutVersion={layoutVersion} />
<FitViewOnLayoutChange layoutKey={focus.figure.id} />
</ReactFlow>
</div>
</div>
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import { resolveFigureFocus } from "./focus.js";
import { architectureCatalog } from "./architecture-catalog.js";
import { layoutFigure } from "./layout.js";
describe("architectureCatalog", () => {
it("contains the conceptual architecture overview", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure;
expect(root.layout.kind).toBe("flow");
expect(root.nodes.map((node) => node.label)).toEqual([
"Client operations",
"Application lifecycle",
@@ -22,6 +24,14 @@ describe("architectureCatalog", () => {
).figure.id).toBe("configured-provider-detail");
});
it("lays out the architecture path horizontally for presentation", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure;
const layout = layoutFigure(root);
const client = layout.nodes.find((node) => node.id === "client-operations");
const nodeUse = layout.nodes.find((node) => node.id === "node-use");
expect(client?.position.x).toBeLessThan(nodeUse?.position.x ?? 0);
});
it("gives every factual node an evidence pointer", () => {
for (const figure of architectureCatalog.figures) {
for (const node of figure.nodes) {
@@ -9,7 +9,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "architecture-overview",
title: "Architecture",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "client-operations",
@@ -84,7 +84,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "runtime-provider-detail",
title: "Runtime and providers",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "workflow-server",
@@ -133,7 +133,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "configured-provider-detail",
title: "Configured providers",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "builtin-sources",
@@ -172,7 +172,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{
id: "node-use-detail",
title: "NodeUse execution",
layout: { kind: "layered" },
layout: { kind: "flow" },
nodes: [
{
id: "resolve-bindings",
@@ -4,6 +4,7 @@
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.interactive-figure__canvas {
@@ -11,6 +12,24 @@
min-height: 0;
}
.interactive-figure[data-figure-size="wide"] {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.35rem;
scrollbar-gutter: stable;
}
.interactive-figure[data-figure-size="wide"] .interactive-figure__canvas {
min-width: 1280px;
min-height: 420px;
}
.interactive-figure[data-figure-size="wide"] .react-flow {
border: 1px solid color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 45%, transparent);
border-radius: 0.75rem;
background: color-mix(in oklch, var(--color-editorial-surface, oklch(0.96 0.012 82)) 92%, white);
}
.interactive-figure .react-flow__pane {
pointer-events: none;
}
@@ -50,9 +69,9 @@
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 196px;
height: 84px;
padding: 8px 12px;
width: 236px;
height: 102px;
padding: 10px 14px;
border: 2px solid oklch(0.75 0.01 65);
border-radius: 4px;
background: oklch(0.975 0.012 82);
@@ -81,17 +100,19 @@
}
.figure-node__label {
font-size: 14px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.figure-node__summary {
font-size: 11px;
font-size: 12px;
line-height: 1.18;
color: var(--color-editorial-muted, oklch(0.48 0.025 65));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%;
}
@@ -15,8 +15,8 @@ export type PositionedFigure = {
readonly edges: readonly FigureEdgeDefinition[];
};
export const NODE_WIDTH = 196;
export const NODE_HEIGHT = 84;
export const NODE_WIDTH = 236;
export const NODE_HEIGHT = 102;
const NODESEP = 56;
const RANKSEP = 88;
@@ -86,16 +86,16 @@ describe("presentationReducer", () => {
branchId: "hosted-automation",
});
expect(opened.evidencePresentationOverride).toBeNull();
expect(opened.selectedNodeId).toBe("review_issues");
expect(opened.location.kind).toBe("discussion");
const closed1 = presentationReducer(opened, { type: "close_overlay" });
expect(closed1.evidencePresentationOverride).toBe("hidden");
expect(closed1.selectedNodeId).toBe("review_issues");
expect(closed1.selectedNodeId).toBeNull();
expect(closed1.location.kind).toBe("discussion");
const closed2 = presentationReducer(closed1, { type: "close_overlay" });
expect(closed2.selectedNodeId).toBeNull();
const closed3 = presentationReducer(closed2, { type: "close_overlay" });
expect(closed3.location.kind).toBe("main");
expect(closed2.location.kind).toBe("main");
});
it("does nothing on next while a discussion branch is open", () => {
@@ -127,7 +127,7 @@ describe("presentationReducer", () => {
presentation: "inspector",
});
const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidencePresentationOverride).toBe("hidden");
expect(closed.evidencePresentationOverride).toBeNull();
expect(closed.location).toEqual(initialPresentationState.location);
});
@@ -204,12 +204,29 @@ describe("presentationReducer", () => {
presentation: "inspector",
});
const firstEscape = presentationReducer(withInspector, { type: "close_overlay" });
expect(firstEscape.evidencePresentationOverride).toBe("hidden");
expect(firstEscape.evidencePresentationOverride).toBeNull();
expect(firstEscape.selectedNodeId).toBe("review_issues");
const secondEscape = presentationReducer(firstEscape, { type: "close_overlay" });
expect(secondEscape.selectedNodeId).toBeNull();
});
it("returns to the beat receipt after closing an explicit inspector", () => {
const atReceiptBeat = presentationReducer(initialPresentationState, {
type: "jump",
location: { kind: "main", sceneId: "interrupt-evidence", beatId: "trace", focusPath: [] },
});
expect(compositionForState(atReceiptBeat).evidencePresentation).toBe("receipt");
const opened = presentationReducer(atReceiptBeat, {
type: "set_evidence_presentation",
presentation: "inspector",
});
expect(compositionForState(opened).evidencePresentation).toBe("inspector");
const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidencePresentationOverride).toBeNull();
expect(compositionForState(closed).evidencePresentation).toBe("receipt");
});
it("closes the inspector and recomputes receipt state when the beat changes", () => {
const atReceiptBeat = presentationReducer(initialPresentationState, {
type: "jump",
@@ -142,6 +142,7 @@ export const presentationReducer = (
...state,
location: { kind: "discussion", branchId: action.branchId as DiscussionBranchId },
discussionReturn: returnLocation,
evidencePresentationOverride: null,
};
}
case "close_discussion": {
@@ -158,7 +159,9 @@ export const presentationReducer = (
case "set_evidence_presentation":
return { ...state, evidencePresentationOverride: action.presentation };
case "close_overlay": {
if (state.evidencePresentationOverride === "inspector") return { ...state, evidencePresentationOverride: "hidden" };
// The inspector is transient. Closing it should reveal the current beat's
// default evidence presentation again, which may be a receipt.
if (state.evidencePresentationOverride === "inspector") return { ...state, evidencePresentationOverride: null };
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
if (state.location.kind === "discussion") {
return {
@@ -81,6 +81,22 @@
margin: 0.25rem 0 0.5rem;
}
.architecture-scene {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.65rem;
}
.architecture-scene .stage-caption h1 {
margin-bottom: 0.25rem;
}
.architecture-scene .stage-caption p {
margin: 0;
}
.scene-body__evidence {
font-size: 0.8rem;
color: oklch(0.72 0.03 250);
@@ -81,6 +81,12 @@ describe("ArchitectureScene", () => {
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime-providers"]);
});
it("uses the wide figure presentation for the defense architecture scene", () => {
renderArchitecture({ focusPath: [] });
expect(screen.getByRole("group", { name: /architecture/i })).toHaveAttribute("data-figure-size", "wide");
expect(screen.getByTestId("architecture-scene")).toBeInTheDocument();
});
it("renders a directly linked nested provider view", () => {
renderArchitecture({
focusPath: ["runtime-providers", "configured-providers"],
@@ -20,7 +20,7 @@ export const ArchitectureScene = ({
onFocusPathChange,
motionDisabled,
}: ArchitectureSceneProps) => (
<>
<section className="architecture-scene" data-testid="architecture-scene">
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
@@ -30,6 +30,7 @@ export const ArchitectureScene = ({
activeNodeId={activeNodeId}
onFocusPathChange={onFocusPathChange}
motionDisabled={motionDisabled}
size="wide"
/>
</>
</section>
);