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:
- **Do** make the workflow graph, operation block, evidence drawer, lifecycle - **Do** make the workflow graph, operation block, presentation evidence
explorer, typed interrupt/resume panel, and trace the main visual surfaces. receipt/inspector, lifecycle explorer, typed interrupt/resume panel, and trace
the main visual surfaces.
- **Do** keep `/console` familiar and product-like: readable tables, standard - **Do** keep `/console` familiar and product-like: readable tables, standard
buttons, clear labels, predictable focus states. buttons, clear labels, predictable focus states.
- **Do** let `/present` be cinematic with staged panels, larger text, and graph - **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 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 prepared workflow, but the main surfaces are the workflow graph, operation
blocks, evidence drawer, lifecycle explorer, typed interrupt/resume panel, and blocks, presentation evidence receipt/inspector, lifecycle explorer, typed
run trace. interrupt/resume panel, and run trace.
Success means a viewer can answer three questions quickly: 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 userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -112,7 +112,7 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument(); 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(); const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation"; window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -122,4 +122,16 @@ describe("PresentationRoute", () => {
await user.click(screen.getByRole("button", { name: /close evidence/i })); await user.click(screen.getByRole("button", { name: /close evidence/i }));
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument(); 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> </label>
)} )}
<div role="tablist" aria-label="Evidence view"> <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 Interpreted
</button> </button>
<button role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}> <button type="button" role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}>
Raw Raw
</button> </button>
</div> </div>
@@ -78,7 +78,7 @@ const renderFigure = (overrides: Partial<React.ComponentProps<typeof Interactive
onFocusPathChange, onFocusPathChange,
...render( ...render(
<InteractiveFigure <InteractiveFigure
catalog={validCatalog} catalog={restOverrides.catalog ?? validCatalog}
focusPath={restOverrides.focusPath ?? []} focusPath={restOverrides.focusPath ?? []}
activeNodeId={restOverrides.activeNodeId ?? null} activeNodeId={restOverrides.activeNodeId ?? null}
onFocusPathChange={onFocusPathChange} onFocusPathChange={onFocusPathChange}
@@ -99,10 +99,26 @@ describe("InteractiveFigure", () => {
}); });
it("renders within a React Flow container for edge support", () => { it("renders within a React Flow container for edge support", () => {
renderFigure({ focusPath: [] }); const { container } = renderFigure({ focusPath: [] });
const rfWrapper = screen.getByTestId("rf__wrapper"); const rfWrapper = screen.getByTestId("rf__wrapper");
expect(rfWrapper).toBeInTheDocument(); expect(rfWrapper).toBeInTheDocument();
expect(rfWrapper.querySelector("[class*='react-flow']")).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 () => { 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 { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js"; import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
@@ -19,6 +19,7 @@ type InteractiveFigureProps = {
readonly activeNodeId: string | null; readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void; readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean; readonly motionDisabled: boolean;
readonly size?: "standard" | "wide";
}; };
type FigureNodeData = { type FigureNodeData = {
@@ -26,6 +27,7 @@ type FigureNodeData = {
readonly label: string; readonly label: string;
readonly summary: string; readonly summary: string;
readonly kind: FigureNodeKind; readonly kind: FigureNodeKind;
readonly orientation: "horizontal" | "vertical";
readonly isActive: boolean; readonly isActive: boolean;
readonly isExpandable: boolean; readonly isExpandable: boolean;
readonly onActivate: (nodeId: string) => void; readonly onActivate: (nodeId: string) => void;
@@ -35,10 +37,12 @@ type FigureNodeData = {
const FigureFlowNode = ({ data }: { data: FigureNodeData }) => { const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
const expandable = data.isExpandable; const expandable = data.isExpandable;
const accessibleName = expandable ? `${data.label}, expand` : data.label; 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 ( return (
<> <>
<Handle type="target" position={Position.Top} id="target" /> <Handle type="target" position={targetPosition} id="target" />
<button <button
type="button" type="button"
className="figure-node" className="figure-node"
@@ -65,7 +69,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>} {expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>} {data.isActive && <span className="figure-node__current-marker">Current</span>}
</button> </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, figure: FigureFlowNode,
}; };
const FitViewOnLayoutChange = ({ layoutVersion }: { layoutVersion: number }) => { const FitViewOnLayoutChange = ({ layoutKey }: { layoutKey: string }) => {
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
useEffect(() => { useEffect(() => {
void fitView({ padding: 0.15, duration: 0 }); void fitView({ padding: 0.15, duration: 0 });
}, [fitView, layoutVersion]); }, [fitView, layoutKey]);
return null; return null;
}; };
@@ -88,24 +92,20 @@ const InteractiveFigureInner = ({
activeNodeId, activeNodeId,
onFocusPathChange, onFocusPathChange,
motionDisabled, motionDisabled,
size = "standard",
}: InteractiveFigureProps) => { }: InteractiveFigureProps) => {
const focus = resolveFigureFocus(catalog, focusPath); const focus = useMemo(
const layout = layoutFigure(focus.figure); () => resolveFigureFocus(catalog, focusPath),
const [focusedNodeId, setFocusedNodeId] = useState<string>( [catalog, focusPath],
activeNodeId ?? focus.figure.nodes[0]?.id ?? "",
); );
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [layoutVersion, setLayoutVersion] = useState(0); const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
useEffect(() => { const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
if (activeNodeId) setFocusedNodeId(activeNodeId); if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
}, [activeNodeId]); focusedNodeIdRef.current = fallbackFocusedNodeId;
}
useEffect(() => {
const firstNode = focus.figure.nodes[0];
if (firstNode) setFocusedNodeId(firstNode.id);
setLayoutVersion((v) => v + 1);
}, [focus.figure.id]);
const handleExpand = useCallback( const handleExpand = useCallback(
(nodeId: string) => { (nodeId: string) => {
@@ -148,17 +148,21 @@ const InteractiveFigureInner = ({
if (direction) { if (direction) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeId, direction); const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
setFocusedNodeId(nextId); focusedNodeIdRef.current = nextId;
const nextNode = containerRef.current?.querySelector( const nextNode = containerRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`, `[data-testid="figure-node-${nextId}"]`,
); );
if (nextNode instanceof HTMLElement) nextNode.focus(); 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( const rfNodes: Node[] = useMemo(
() => () =>
layout.nodes.map((node) => ({ layout.nodes.map((node) => ({
@@ -170,13 +174,14 @@ const InteractiveFigureInner = ({
label: node.label, label: node.label,
summary: node.summary, summary: node.summary,
kind: node.kind, kind: node.kind,
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
isActive: node.id === activeNodeId, isActive: node.id === activeNodeId,
isExpandable: node.childFigureId !== undefined, isExpandable: node.childFigureId !== undefined,
onActivate: setFocusedNodeId, onActivate: handleActivateNode,
onExpand: handleExpand, onExpand: handleExpand,
}, },
})), })),
[layout.nodes, activeNodeId, handleExpand], [layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
); );
const rfEdges: Edge[] = useMemo( const rfEdges: Edge[] = useMemo(
@@ -194,7 +199,7 @@ const InteractiveFigureInner = ({
const handleNodeClick = useCallback( const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => { (_event: React.MouseEvent, node: Node) => {
const data = node.data as FigureNodeData; const data = node.data as FigureNodeData;
setFocusedNodeId(data.nodeId); focusedNodeIdRef.current = data.nodeId;
if (data.isExpandable) handleExpand(data.nodeId); if (data.isExpandable) handleExpand(data.nodeId);
}, },
[handleExpand], [handleExpand],
@@ -207,6 +212,7 @@ const InteractiveFigureInner = ({
aria-label={focus.figure.title} aria-label={focus.figure.title}
data-motion={motionDisabled ? "disabled" : "enabled"} data-motion={motionDisabled ? "disabled" : "enabled"}
data-figure-id={focus.figure.id} data-figure-id={focus.figure.id}
data-figure-size={size}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<FigureBreadcrumbs <FigureBreadcrumbs
@@ -232,7 +238,7 @@ const InteractiveFigureInner = ({
preventScrolling={false} preventScrolling={false}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
> >
<FitViewOnLayoutChange layoutVersion={layoutVersion} /> <FitViewOnLayoutChange layoutKey={focus.figure.id} />
</ReactFlow> </ReactFlow>
</div> </div>
</div> </div>
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveFigureFocus } from "./focus.js"; import { resolveFigureFocus } from "./focus.js";
import { architectureCatalog } from "./architecture-catalog.js"; import { architectureCatalog } from "./architecture-catalog.js";
import { layoutFigure } from "./layout.js";
describe("architectureCatalog", () => { describe("architectureCatalog", () => {
it("contains the conceptual architecture overview", () => { it("contains the conceptual architecture overview", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure; const root = resolveFigureFocus(architectureCatalog, []).figure;
expect(root.layout.kind).toBe("flow");
expect(root.nodes.map((node) => node.label)).toEqual([ expect(root.nodes.map((node) => node.label)).toEqual([
"Client operations", "Client operations",
"Application lifecycle", "Application lifecycle",
@@ -22,6 +24,14 @@ describe("architectureCatalog", () => {
).figure.id).toBe("configured-provider-detail"); ).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", () => { it("gives every factual node an evidence pointer", () => {
for (const figure of architectureCatalog.figures) { for (const figure of architectureCatalog.figures) {
for (const node of figure.nodes) { for (const node of figure.nodes) {
@@ -9,7 +9,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{ {
id: "architecture-overview", id: "architecture-overview",
title: "Architecture", title: "Architecture",
layout: { kind: "layered" }, layout: { kind: "flow" },
nodes: [ nodes: [
{ {
id: "client-operations", id: "client-operations",
@@ -84,7 +84,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{ {
id: "runtime-provider-detail", id: "runtime-provider-detail",
title: "Runtime and providers", title: "Runtime and providers",
layout: { kind: "layered" }, layout: { kind: "flow" },
nodes: [ nodes: [
{ {
id: "workflow-server", id: "workflow-server",
@@ -133,7 +133,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{ {
id: "configured-provider-detail", id: "configured-provider-detail",
title: "Configured providers", title: "Configured providers",
layout: { kind: "layered" }, layout: { kind: "flow" },
nodes: [ nodes: [
{ {
id: "builtin-sources", id: "builtin-sources",
@@ -172,7 +172,7 @@ export const architectureCatalog: FigureCatalogDefinition = defineFigureCatalog(
{ {
id: "node-use-detail", id: "node-use-detail",
title: "NodeUse execution", title: "NodeUse execution",
layout: { kind: "layered" }, layout: { kind: "flow" },
nodes: [ nodes: [
{ {
id: "resolve-bindings", id: "resolve-bindings",
@@ -4,6 +4,7 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.interactive-figure__canvas { .interactive-figure__canvas {
@@ -11,6 +12,24 @@
min-height: 0; 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 { .interactive-figure .react-flow__pane {
pointer-events: none; pointer-events: none;
} }
@@ -50,9 +69,9 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 2px; gap: 2px;
width: 196px; width: 236px;
height: 84px; height: 102px;
padding: 8px 12px; padding: 10px 14px;
border: 2px solid oklch(0.75 0.01 65); border: 2px solid oklch(0.75 0.01 65);
border-radius: 4px; border-radius: 4px;
background: oklch(0.975 0.012 82); background: oklch(0.975 0.012 82);
@@ -81,17 +100,19 @@
} }
.figure-node__label { .figure-node__label {
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
} }
.figure-node__summary { .figure-node__summary {
font-size: 11px; font-size: 12px;
line-height: 1.18;
color: var(--color-editorial-muted, oklch(0.48 0.025 65)); color: var(--color-editorial-muted, oklch(0.48 0.025 65));
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; display: -webkit-box;
white-space: nowrap; -webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%; max-width: 100%;
} }
@@ -15,8 +15,8 @@ export type PositionedFigure = {
readonly edges: readonly FigureEdgeDefinition[]; readonly edges: readonly FigureEdgeDefinition[];
}; };
export const NODE_WIDTH = 196; export const NODE_WIDTH = 236;
export const NODE_HEIGHT = 84; export const NODE_HEIGHT = 102;
const NODESEP = 56; const NODESEP = 56;
const RANKSEP = 88; const RANKSEP = 88;
@@ -86,16 +86,16 @@ describe("presentationReducer", () => {
branchId: "hosted-automation", 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" }); const closed1 = presentationReducer(opened, { type: "close_overlay" });
expect(closed1.evidencePresentationOverride).toBe("hidden"); expect(closed1.selectedNodeId).toBeNull();
expect(closed1.selectedNodeId).toBe("review_issues");
expect(closed1.location.kind).toBe("discussion"); expect(closed1.location.kind).toBe("discussion");
const closed2 = presentationReducer(closed1, { type: "close_overlay" }); const closed2 = presentationReducer(closed1, { type: "close_overlay" });
expect(closed2.selectedNodeId).toBeNull(); expect(closed2.location.kind).toBe("main");
const closed3 = presentationReducer(closed2, { type: "close_overlay" });
expect(closed3.location.kind).toBe("main");
}); });
it("does nothing on next while a discussion branch is open", () => { it("does nothing on next while a discussion branch is open", () => {
@@ -127,7 +127,7 @@ describe("presentationReducer", () => {
presentation: "inspector", presentation: "inspector",
}); });
const closed = presentationReducer(opened, { type: "close_overlay" }); const closed = presentationReducer(opened, { type: "close_overlay" });
expect(closed.evidencePresentationOverride).toBe("hidden"); expect(closed.evidencePresentationOverride).toBeNull();
expect(closed.location).toEqual(initialPresentationState.location); expect(closed.location).toEqual(initialPresentationState.location);
}); });
@@ -204,12 +204,29 @@ describe("presentationReducer", () => {
presentation: "inspector", presentation: "inspector",
}); });
const firstEscape = presentationReducer(withInspector, { type: "close_overlay" }); const firstEscape = presentationReducer(withInspector, { type: "close_overlay" });
expect(firstEscape.evidencePresentationOverride).toBe("hidden"); expect(firstEscape.evidencePresentationOverride).toBeNull();
expect(firstEscape.selectedNodeId).toBe("review_issues"); expect(firstEscape.selectedNodeId).toBe("review_issues");
const secondEscape = presentationReducer(firstEscape, { type: "close_overlay" }); const secondEscape = presentationReducer(firstEscape, { type: "close_overlay" });
expect(secondEscape.selectedNodeId).toBeNull(); 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", () => { it("closes the inspector and recomputes receipt state when the beat changes", () => {
const atReceiptBeat = presentationReducer(initialPresentationState, { const atReceiptBeat = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
@@ -142,6 +142,7 @@ export const presentationReducer = (
...state, ...state,
location: { kind: "discussion", branchId: action.branchId as DiscussionBranchId }, location: { kind: "discussion", branchId: action.branchId as DiscussionBranchId },
discussionReturn: returnLocation, discussionReturn: returnLocation,
evidencePresentationOverride: null,
}; };
} }
case "close_discussion": { case "close_discussion": {
@@ -158,7 +159,9 @@ export const presentationReducer = (
case "set_evidence_presentation": case "set_evidence_presentation":
return { ...state, evidencePresentationOverride: action.presentation }; return { ...state, evidencePresentationOverride: action.presentation };
case "close_overlay": { 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.selectedNodeId !== null) return { ...state, selectedNodeId: null };
if (state.location.kind === "discussion") { if (state.location.kind === "discussion") {
return { return {
@@ -81,6 +81,22 @@
margin: 0.25rem 0 0.5rem; 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 { .scene-body__evidence {
font-size: 0.8rem; font-size: 0.8rem;
color: oklch(0.72 0.03 250); color: oklch(0.72 0.03 250);
@@ -81,6 +81,12 @@ describe("ArchitectureScene", () => {
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime-providers"]); 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", () => { it("renders a directly linked nested provider view", () => {
renderArchitecture({ renderArchitecture({
focusPath: ["runtime-providers", "configured-providers"], focusPath: ["runtime-providers", "configured-providers"],
@@ -20,7 +20,7 @@ export const ArchitectureScene = ({
onFocusPathChange, onFocusPathChange,
motionDisabled, motionDisabled,
}: ArchitectureSceneProps) => ( }: ArchitectureSceneProps) => (
<> <section className="architecture-scene" data-testid="architecture-scene">
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}> <StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p> <p>{beat.caption}</p>
</StageCaption> </StageCaption>
@@ -30,6 +30,7 @@ export const ArchitectureScene = ({
activeNodeId={activeNodeId} activeNodeId={activeNodeId}
onFocusPathChange={onFocusPathChange} onFocusPathChange={onFocusPathChange}
motionDisabled={motionDisabled} motionDisabled={motionDisabled}
size="wide"
/> />
</> </section>
); );