feat: add workflow demo presentation stage

This commit is contained in:
lda
2026-07-05 06:29:52 +07:00 Verified
parent c95d71fe5d
commit a8402a4d1d
19 changed files with 1944 additions and 233 deletions
@@ -0,0 +1,116 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
import { findBeat, findScene } from "./storyboard.js";
afterEach(() => cleanup());
const noop = () => {};
const noopAsync = async () => {};
const recording = loadCanonicalDemoRecording();
const demo: DemoTimelineController = {
state: {
mode: "replay",
phase: "review",
events: recording.events,
appliedCount: recording.events.length,
autoplay: false,
error: null,
},
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: recording.recordingId,
canStart: true,
setMode: noop,
start: noop,
pause: noop,
play: noop,
next: noopAsync,
submitSelectedIssues: noopAsync,
cancelReview: noopAsync,
restart: noop,
};
const requireSceneBeat = (sceneId: string, beatId: string) => {
const scene = findScene(sceneId);
const beat = findBeat(sceneId, beatId);
if (!scene || !beat) throw new Error(`missing storyboard fixture ${sceneId}/${beatId}`);
return { scene, beat };
};
const renderBeat = (
beatId: string,
sceneId = "workflow-demo",
openEvidence = vi.fn(),
) => {
const { scene, beat } = requireSceneBeat(sceneId, beatId);
const rendered = render(
<DemoWorkflowScene
scene={scene}
beat={beat}
demo={demo}
selectedNodeId={null}
selectNode={noop}
openEvidence={openEvidence}
/>,
);
return { ...rendered, openEvidence };
};
describe("DemoWorkflowScene", () => {
it("gives the start operation the stage and opens raw evidence on demand", async () => {
const { openEvidence } = renderBeat("operation");
expect(screen.getByLabelText("workflow.runs.start operation")).toBeInTheDocument();
expect(screen.queryByLabelText("workflow graph")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /view raw evidence/i }));
expect(openEvidence).toHaveBeenCalledOnce();
});
it("keeps the run receipt visible when the graph takes over", () => {
renderBeat("graph");
expect(screen.getByLabelText("workflow.runs.start execution receipt")).toBeInTheDocument();
expect(screen.getByText("run_recorded_lda_report")).toBeInTheDocument();
expect(screen.getByLabelText("workflow graph")).toBeInTheDocument();
});
it("renders the canonical typed interrupt contract", () => {
renderBeat("interrupt");
const contract = screen.getByLabelText("typed interrupt contract");
expect(contract).toHaveTextContent("issue_review");
expect(contract).toHaveTextContent("submitted / cancelled");
expect(contract).toHaveTextContent('"type": "object"');
expect(contract).toHaveTextContent("run_recorded_lda_report");
});
it("carries the same graph and contract into Scene 10 approval", () => {
renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("workflow graph")).toBeInTheDocument();
expect(screen.getByLabelText("typed interrupt contract")).toHaveTextContent(
"run_recorded_lda_report",
);
expect(screen.getByRole("button", { name: /issue review/i })).toHaveAttribute(
"data-execution-state",
"current",
);
});
it("renders resume and trace operations as expanded evidence summaries", () => {
const { unmount } = renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
unmount();
renderBeat("trace", "interrupt-evidence");
expect(screen.getByLabelText("workflow.runs.trace operation")).toBeInTheDocument();
});
});
@@ -0,0 +1,105 @@
import type { DemoEvent } from "../demo/timeline/models.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import {
graphExecutionForBeat,
projectInterruptContract,
} from "./demo-workflow-model.js";
import { InterruptContractPreview } from "./InterruptContractPreview.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
import { StageCaption } from "./StageCaption.js";
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
type DemoWorkflowSceneProps = {
readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition;
readonly demo: DemoTimelineController;
readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void;
readonly openEvidence: () => void;
};
const operationStageByBeat: Readonly<Record<string, DemoEvent["stage"] | undefined>> = {
operation: "run_start",
resume: "run_resume",
trace: "trace_read",
};
const findEvent = (
demo: DemoTimelineController,
stage: DemoEvent["stage"],
): DemoEvent | null => demo.state.events.find((event) => event.stage === stage) ?? null;
export const DemoWorkflowScene = ({
scene,
beat,
demo,
selectedNodeId,
selectNode,
openEvidence,
}: DemoWorkflowSceneProps) => {
const runStart = findEvent(demo, "run_start");
const currentStage = operationStageByBeat[beat.id];
const currentEvent = currentStage ? findEvent(demo, currentStage) : null;
const contract = runStart ? projectInterruptContract(runStart) : null;
const execution = graphExecutionForBeat(beat.id);
const showExpandedOperation = beat.id === "operation" || beat.id === "resume" || beat.id === "trace";
const showGraph = beat.id === "graph" || beat.id === "interrupt" || beat.id === "approval" || beat.id === "output";
const showReceipt = showGraph;
const contractMode = beat.id === "approval"
? "approval"
: beat.id === "interrupt"
? "preview"
: null;
return (
<>
<StageCaption eyebrow="Live system walkthrough" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<div className="demo-workflow-stage" data-beat={beat.id}>
{showExpandedOperation && currentEvent && (
<OperationBlock
event={currentEvent}
variant="expanded"
openEvidence={openEvidence}
/>
)}
{showReceipt && runStart && (
<OperationBlock
event={runStart}
variant="receipt"
openEvidence={openEvidence}
/>
)}
{showGraph && (
<div className="demo-workflow-stage__graph">
<WorkflowGraphStage
execution={execution}
selectedNodeId={selectedNodeId}
selectNode={selectNode}
/>
{contractMode && contract && (
<InterruptContractPreview contract={contract} mode={contractMode} />
)}
</div>
)}
{showExpandedOperation && !currentEvent && (
<div className="demo-workflow-stage__pending" role="status">
Replay operation is not available yet.
</div>
)}
</div>
{selectedNodeId && (
<NodeSpotlight nodeId={selectedNodeId} close={() => selectNode("")} />
)}
</>
);
};
@@ -0,0 +1,41 @@
import { m } from "motion/react";
import type { InterruptContractPresentation } from "./demo-workflow-model.js";
import { formatJson } from "./format.js";
type InterruptContractPreviewProps = {
readonly contract: InterruptContractPresentation;
readonly mode: "preview" | "approval";
};
export const InterruptContractPreview = ({
contract,
mode,
}: InterruptContractPreviewProps) => (
<m.aside
className="interrupt-contract-preview"
data-mode={mode}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
aria-label="typed interrupt contract"
>
<header>
<span>{mode === "approval" ? "Operator decision" : "Execution paused"}</span>
<strong>{contract.kind}</strong>
</header>
<dl>
<div>
<dt>Persisted run</dt>
<dd><code>{contract.runId ?? "unavailable"}</code></dd>
</div>
<div>
<dt>Resume outcomes</dt>
<dd>{contract.outcomes.join(" / ")}</dd>
</div>
</dl>
<div className="interrupt-contract-preview__schema">
<span>Resume schema</span>
<pre><code>{formatJson(contract.resumeSchema)}</code></pre>
</div>
</m.aside>
);
@@ -1,5 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DemoEvent } from "../demo/timeline/models.js";
import { OperationBlock } from "./OperationBlock.js";
@@ -27,13 +28,42 @@ const event: DemoEvent = {
};
describe("OperationBlock", () => {
it("shows command, raw response, and interpreted result", () => {
render(<OperationBlock event={event} />);
it("shows command, interpreted summary, and evidence action in expanded mode", () => {
render(<OperationBlock event={event} variant="expanded" openEvidence={vi.fn()} />);
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
expect(screen.getByText(/uv run wf run start/i)).toBeInTheDocument();
expect(screen.getByText("Raw")).toBeInTheDocument();
expect(screen.getByText("Interpreted")).toBeInTheDocument();
expect(screen.getByText("Workflow operation")).toBeInTheDocument();
expect(screen.getAllByText(/interrupted/i).length).toBeGreaterThan(0);
expect(screen.getByText(/issue_review/i)).toBeInTheDocument();
expect(screen.getAllByText(/run_demo/i).length).toBeGreaterThan(0);
});
it("shows compact receipt with operation, status, duration, and run id", () => {
render(<OperationBlock event={event} variant="receipt" openEvidence={vi.fn()} />);
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
expect(screen.getByText(/interrupted/i)).toBeInTheDocument();
expect(screen.getByText(/88 ms/i)).toBeInTheDocument();
expect(screen.getByText(/run_demo/i)).toBeInTheDocument();
expect(screen.queryByText("Workflow operation")).not.toBeInTheDocument();
});
it("invokes onOpenEvidence when the evidence action is clicked", async () => {
const openEvidence = vi.fn();
render(<OperationBlock event={event} variant="expanded" openEvidence={openEvidence} />);
await userEvent.click(screen.getByText("View raw evidence"));
expect(openEvidence).toHaveBeenCalledOnce();
});
it("renders deployment and run ids as unavailable when missing", () => {
const eventWithoutIds: DemoEvent = {
...event,
resultingIds: { deploymentId: null, runId: null },
};
render(<OperationBlock event={eventWithoutIds} variant="expanded" openEvidence={vi.fn()} />);
expect(screen.getAllByText(/unavailable/i).length).toBeGreaterThan(0);
});
});
@@ -1,32 +1,97 @@
import { m } from "motion/react";
import type { DemoEvent } from "../demo/timeline/models.js";
import { formatJson } from "./format.js";
import { projectOperationPresentation } from "./demo-workflow-model.js";
export type OperationVariant = "expanded" | "receipt";
type OperationBlockProps = {
readonly event: DemoEvent;
readonly variant: OperationVariant;
readonly openEvidence: () => void;
};
export const OperationBlock = ({ event }: OperationBlockProps) => (
<section className="operation-block" aria-label={`${event.stage} operation`}>
<header>
<p>{event.operation ?? event.stage}</p>
<small>{event.durationMs} ms</small>
</header>
{event.equivalentCli && (
<pre className="operation-block__command"><code>{event.equivalentCli}</code></pre>
)}
<div className="operation-block__grid">
<section>
<h3>Raw</h3>
<pre><code>{formatJson(event.rawResponse)}</code></pre>
</section>
<section>
<h3>Interpreted</h3>
<pre><code>{formatJson(event.interpreted)}</code></pre>
</section>
</div>
<footer>
<span>Deployment: {event.resultingIds.deploymentId}</span>
{event.resultingIds.runId && <span>Run: {event.resultingIds.runId}</span>}
</footer>
</section>
);
export const OperationBlock = ({
event,
variant,
openEvidence,
}: OperationBlockProps) => {
const operation = projectOperationPresentation(event);
if (variant === "receipt") {
return (
<m.div
layout
layoutId="workflow-start-operation"
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
className="operation-receipt"
aria-label={`${operation.operation} execution receipt`}
>
<div className="operation-receipt__identity">
<span className="operation-receipt__pulse" aria-hidden="true" />
<strong>{operation.operation}</strong>
<span className="operation-status" data-status={operation.status}>
{operation.status}
</span>
</div>
<code>{operation.runId ?? "run unavailable"}</code>
<small>{operation.durationMs} ms</small>
</m.div>
);
}
return (
<m.article
layout
layoutId="workflow-start-operation"
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
className="operation-block operation-block--expanded"
aria-label={`${operation.operation} operation`}
>
<header className="operation-block__header">
<div>
<span className="operation-block__kicker">Workflow operation</span>
<strong>{operation.operation}</strong>
</div>
<div className="operation-block__status-group">
<span className="operation-status" data-status={operation.status}>
{operation.status}
</span>
<small>{operation.durationMs} ms</small>
</div>
</header>
{operation.command && (
<div className="operation-command">
<span aria-hidden="true">$</span>
<code>{operation.command}</code>
</div>
)}
<dl className="operation-summary">
<div>
<dt>Deployment</dt>
<dd><code>{operation.deploymentId ?? "unavailable"}</code></dd>
</div>
<div>
<dt>Run</dt>
<dd><code>{operation.runId ?? "unavailable"}</code></dd>
</div>
<div>
<dt>Boundary</dt>
<dd data-emphasis={operation.interruptKind ? "interrupt" : undefined}>
{operation.interruptKind ?? "none"}
</dd>
</div>
</dl>
<button
type="button"
className="operation-block__evidence-action"
onClick={openEvidence}
>
View raw evidence
<span aria-hidden="true">-&gt;</span>
</button>
</m.article>
);
};
@@ -1,3 +1,4 @@
import { m } from "motion/react";
import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js";
import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js";
import type { PresentationState } from "./presentation-state.js";
@@ -29,23 +30,36 @@ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage>
const renderPart = (
part: AgentMessagePart,
index: number,
key: string,
onApprove?: () => void,
onDeny?: () => void,
) => {
switch (part.type) {
case "text":
return <p key={index}>{part.text}</p>;
return <p key={key}>{part.text}</p>;
case "tool-call":
if (part.call.name === "startPreparedReportRun") {
return (
<m.div
key={key}
layout
layoutId="workflow-start-operation"
className="chat-tool-part chat-tool-part--handoff"
>
<span>Workflow operation</span>
<code>{part.call.name}</code>
</m.div>
);
}
return (
<div key={index} className="chat-tool-part">
<div key={key} className="chat-tool-part">
<span>Tool call</span>
<code>{part.call.name}</code>
</div>
);
case "tool-result":
return (
<div key={index} className="chat-tool-part chat-tool-part--result">
<div key={key} className="chat-tool-part chat-tool-part--result">
<span>Tool result</span>
<code>{part.result.name}</code>
<small>{part.result.status}</small>
@@ -53,14 +67,14 @@ const renderPart = (
);
case "presentation-action":
return (
<div key={index} className="chat-tool-part chat-tool-part--presentation">
<div key={key} className="chat-tool-part chat-tool-part--presentation">
<span>Presentation action</span>
<code>{part.action.type}</code>
</div>
);
case "approval-request":
return (
<div key={index} className="chat-tool-part chat-tool-part--approval">
<div key={key} className="chat-tool-part chat-tool-part--approval">
<span>Approval required</span>
<code>{part.name}</code>
<p>{part.prompt}</p>
@@ -71,21 +85,38 @@ const renderPart = (
</div>
);
case "error":
return <p key={index} className="chat-error">{part.message}</p>;
return <p key={key} className="chat-error">{part.message}</p>;
default:
return null;
}
};
const partKey = (messageId: string, part: AgentMessagePart): string => {
switch (part.type) {
case "text":
return `${messageId}-text-${part.text}`;
case "tool-call":
return `${messageId}-call-${part.call.id}`;
case "tool-result":
return `${messageId}-result-${part.result.callId}`;
case "presentation-action":
return `${messageId}-action-${JSON.stringify(part.action)}`;
case "approval-request":
return `${messageId}-approval-${part.callId}`;
case "error":
return `${messageId}-error-${part.message}`;
}
};
export const OperatorChat = ({ state, messages, onApprove, onDeny }: OperatorChatProps) => {
const visibleMessages = messages && messages.length > 0 ? messages : fallbackMessages(state);
const composition = compositionForState(state);
return (
<aside className="operator-chat" data-mode={composition.chatMode} aria-label="scripted operator chat">
<aside className="operator-chat" data-mode={composition.chatMode} data-chat-theme={composition.chatTheme} aria-label="scripted operator chat">
{visibleMessages.map((message) => (
<div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}>
<strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong>
{message.parts.map((part, index) => renderPart(part, index, onApprove, onDeny))}
{message.parts.map((part) => renderPart(part, partKey(message.id, part), onApprove, onDeny))}
</div>
))}
</aside>
@@ -16,6 +16,7 @@ import { hashForLocation } from "./storyboard-navigation.js";
import { findScene } from "./storyboard.js";
import type { PresentationLocation } from "./storyboard.js";
import "./presentation.css";
import "./styles/demo-workflow.css";
const projectRecordingToEvidence = (
recording: import("../demo/timeline/models.js").DemoRecording,
@@ -1,3 +1,4 @@
import { domAnimation, LayoutGroup, LazyMotion } from "motion/react";
import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import { SceneBody } from "./SceneBody.js";
@@ -45,54 +46,63 @@ export const PresentationStage = ({
const composition = compositionForState(state);
const isMainScene = state.location.kind === "main";
const activeSceneView = isMainScene
? findScene(state.location.sceneId)?.view ?? "unknown"
: "discussion";
return (
<div
className="presentation-stage"
data-stage-theme={composition.stageTheme}
data-chat-theme={composition.chatTheme}
data-chat-mode={composition.chatMode}
data-evidence-mode={composition.evidenceMode}
>
<aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
</aside>
<section className="presentation-stage__primary" aria-label="primary presentation region">
{state.location.kind === "discussion" ? (
<DiscussionPanel branchId={state.location.branchId} onClose={closeDiscussion} />
) : (
<>
<SceneBody
location={state.location}
demo={demo}
selectedNodeId={state.selectedNodeId}
selectNode={selectNode}
/>
<button
type="button"
className="presentation-stage__discussion-toggle"
onClick={toggleDiscussionIndex}
>
Discussion topics
</button>
</>
)}
{state.discussionIndexOpen && (
<DiscussionIndex
onSelect={(branchId) => {
toggleDiscussionIndex();
openDiscussion(branchId);
}}
/>
)}
<p className="presentation-stage__mode">
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
</p>
</section>
<aside className="presentation-stage__evidence" aria-label="evidence region">
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
</aside>
<SceneRail location={state.location} jump={jump} />
</div>
<LazyMotion features={domAnimation}>
<LayoutGroup id="presentation-stage">
<div
className="presentation-stage"
data-stage-theme={composition.stageTheme}
data-chat-theme={composition.chatTheme}
data-chat-mode={composition.chatMode}
data-evidence-mode={composition.evidenceMode}
data-scene-view={activeSceneView}
>
<aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
</aside>
<section className="presentation-stage__primary" aria-label="primary presentation region">
{state.location.kind === "discussion" ? (
<DiscussionPanel branchId={state.location.branchId} onClose={closeDiscussion} />
) : (
<>
<SceneBody
location={state.location}
demo={demo}
selectedNodeId={state.selectedNodeId}
selectNode={selectNode}
openEvidence={openEvidence}
/>
<button
type="button"
className="presentation-stage__discussion-toggle"
onClick={toggleDiscussionIndex}
>
Discussion topics
</button>
</>
)}
{state.discussionIndexOpen && (
<DiscussionIndex
onSelect={(branchId) => {
toggleDiscussionIndex();
openDiscussion(branchId);
}}
/>
)}
<p className="presentation-stage__mode">
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
</p>
</section>
<aside className="presentation-stage__evidence" aria-label="evidence region">
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
</aside>
<SceneRail location={state.location} jump={jump} />
</div>
</LayoutGroup>
</LazyMotion>
);
};
@@ -1,9 +1,38 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { SceneBody } from "./SceneBody.js";
import type { PresentationLocation } from "./storyboard.js";
const noop = () => {};
const noopAsync = async () => {};
const recording = loadCanonicalDemoRecording();
const demo: DemoTimelineController = {
state: {
mode: "replay",
phase: "review",
events: recording.events,
appliedCount: recording.events.length,
autoplay: false,
error: null,
},
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: recording.recordingId,
canStart: true,
setMode: noop,
start: noop,
pause: noop,
play: noop,
next: noopAsync,
submitSelectedIssues: noopAsync,
cancelReview: noopAsync,
restart: noop,
};
afterEach(() => cleanup());
@@ -13,9 +42,10 @@ describe("SceneBody", () => {
render(
<SceneBody
location={location}
demo={null as never}
demo={demo}
selectedNodeId={null}
selectNode={noop}
openEvidence={noop}
/>,
);
expect(screen.getByRole("heading", { name: /Positioning and Related Systems/i })).toBeInTheDocument();
@@ -27,9 +57,10 @@ describe("SceneBody", () => {
render(
<SceneBody
location={location}
demo={null as never}
demo={demo}
selectedNodeId={null}
selectNode={noop}
openEvidence={noop}
/>,
);
expect(screen.getByLabelText(/workflow graph/i)).toBeInTheDocument();
@@ -1,23 +1,14 @@
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { findBeat, findScene, type PresentationLocation, type SceneDefinition, type SceneBeatDefinition } from "./storyboard.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
import { StageCaption } from "./StageCaption.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
type SceneBodyProps = {
readonly location: PresentationLocation;
readonly demo: DemoTimelineController;
readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void;
};
const operationStageByBeat: Readonly<Record<string, string | undefined>> = {
operation: "run_start",
interrupt: "interrupt",
approval: "interrupt",
resume: "run_resume",
trace: "trace_read",
readonly openEvidence: () => void;
};
const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
@@ -206,41 +197,11 @@ const AgentHandoffScene = ({ scene, beat }: { scene: SceneDefinition; beat: Scen
</>
);
const DemoWorkflowScene = ({
scene,
beat,
demo,
selectedNodeId,
selectNode,
}: {
scene: SceneDefinition;
beat: SceneBeatDefinition;
demo: DemoTimelineController;
selectedNodeId: string | null;
selectNode: (nodeId: string) => void;
}) => {
const operationStage = operationStageByBeat[beat.id] ?? null;
const operationEvent = operationStage
? demo.state.events.find((event) => event.stage === operationStage) ?? null
: null;
return (
<>
<StageCaption eyebrow="Demo" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
{operationEvent && <OperationBlock event={operationEvent} />}
<WorkflowGraphStage selectedNodeId={selectedNodeId} selectNode={selectNode} />
{selectedNodeId && <NodeSpotlight nodeId={selectedNodeId} close={() => selectNode("")} />}
</>
);
};
const assertNever = (value: never): never => {
throw new Error(`Unexpected view: ${value}`);
};
export const SceneBody = ({ location, demo, selectedNodeId, selectNode }: SceneBodyProps) => {
export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvidence }: SceneBodyProps) => {
const sceneId = location.kind === "main" ? location.sceneId : "positioning";
const beatId = location.kind === "main" ? location.beatId : "landscape";
const scene = findScene(sceneId) ?? findScene("thesis")!;
@@ -269,6 +230,7 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode }: SceneB
demo={demo}
selectedNodeId={selectedNodeId}
selectNode={selectNode}
openEvidence={openEvidence}
/>
);
case "evaluation":
@@ -8,9 +8,54 @@ afterEach(() => cleanup());
describe("WorkflowGraphStage", () => {
it("renders curated workflow nodes and allows node selection", async () => {
const selectNode = vi.fn();
render(<WorkflowGraphStage selectedNodeId={null} selectNode={selectNode} />);
render(
<WorkflowGraphStage
execution={{ completedNodeIds: [], currentNodeId: null }}
selectedNodeId={null}
selectNode={selectNode}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(selectNode).toHaveBeenCalledWith("review_issues");
});
it("distinguishes completed, current interrupt, and future nodes semantically", () => {
render(
<WorkflowGraphStage
execution={{
completedNodeIds: ["read_docs", "build_report"],
currentNodeId: "review_issues",
}}
selectedNodeId={null}
selectNode={vi.fn()}
/>,
);
const readDocs = screen.getByRole("button", { name: /read documents/i });
const buildReport = screen.getByRole("button", { name: /build report/i });
const reviewIssues = screen.getByRole("button", { name: /issue review/i });
const createIssues = screen.getByRole("button", { name: /create issues/i });
expect(readDocs).toHaveAttribute("data-execution-state", "completed");
expect(buildReport).toHaveAttribute("data-execution-state", "completed");
expect(reviewIssues).toHaveAttribute("data-execution-state", "current");
expect(reviewIssues).toHaveAttribute("data-current-interrupt", "true");
expect(reviewIssues).toHaveTextContent("Current interrupt");
expect(createIssues).toHaveAttribute("data-execution-state", "future");
});
it("renders connectors between nodes", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "build_report" }}
selectedNodeId={null}
selectNode={vi.fn()}
/>,
);
const connectors = screen.getAllByTestId("workflow-connector");
expect(connectors).toHaveLength(4);
expect(connectors.filter((connector) => connector.dataset.active === "true")).toHaveLength(1);
});
});
@@ -1,39 +1,131 @@
import { m } from "motion/react";
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
export type PresentationNode = {
readonly id: string;
readonly label: string;
readonly detail: string;
readonly kind: "node" | "interrupt" | "end";
readonly x: number;
readonly y: number;
};
export const presentationNodes: readonly PresentationNode[] = [
{ id: "read_docs", label: "Read docs", kind: "node", x: 8, y: 45 },
{ id: "build_report", label: "Build report", kind: "node", x: 30, y: 30 },
{ id: "review_issues", label: "Issue review", kind: "interrupt", x: 52, y: 45 },
{ id: "create_issues", label: "Create issues", kind: "node", x: 74, y: 30 },
{ id: "end_completed", label: "Completed", kind: "end", x: 92, y: 45 },
export const presentationNodes: ReadonlyArray<PresentationNode> = [
{ id: "read_docs", label: "Read documents", detail: "5 selected", kind: "node", x: 9, y: 57 },
{ id: "build_report", label: "Build report", detail: "Markdown", kind: "node", x: 30, y: 35 },
{ id: "review_issues", label: "Issue review", detail: "Typed interrupt", kind: "interrupt", x: 52, y: 57 },
{ id: "create_issues", label: "Create issues", detail: "Selected only", kind: "node", x: 74, y: 35 },
{ id: "end_completed", label: "Completed", detail: "Persisted run", kind: "end", x: 92, y: 57 },
];
type PresentationEdge = readonly [from: string, to: string];
const presentationEdges: ReadonlyArray<PresentationEdge> = [
["read_docs", "build_report"],
["build_report", "review_issues"],
["review_issues", "create_issues"],
["create_issues", "end_completed"],
];
type NodeExecutionState = "completed" | "current" | "future";
type WorkflowGraphStageProps = {
readonly execution: GraphExecutionPresentation;
readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void;
};
export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraphStageProps) => (
<section className="workflow-graph-stage" aria-label="workflow graph">
{presentationNodes.map((node) => (
<button
key={node.id}
type="button"
className="workflow-graph-stage__node"
data-kind={node.kind}
data-selected={selectedNodeId === node.id}
aria-pressed={selectedNodeId === node.id}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
onClick={() => selectNode(node.id)}
>
{node.label}
</button>
))}
</section>
const executionStateForNode = (
nodeId: string,
execution: GraphExecutionPresentation,
): NodeExecutionState => {
if (execution.currentNodeId === nodeId) return "current";
if (execution.completedNodeIds.includes(nodeId)) return "completed";
return "future";
};
const requireNode = (nodeId: string): PresentationNode => {
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
if (!node) throw new Error(`presentation edge references unknown node ${nodeId}`);
return node;
};
export const WorkflowGraphStage = ({
execution,
selectedNodeId,
selectNode,
}: WorkflowGraphStageProps) => (
<div className="workflow-graph-stage" role="group" aria-label="workflow graph">
<div className="workflow-graph-stage__legend" aria-hidden="true">
<span><i data-state="completed" />Completed</span>
<span><i data-state="current" />Current</span>
<span><i data-state="interrupt" />Human boundary</span>
</div>
<svg className="workflow-graph-stage__connectors" aria-hidden="true">
<defs>
<marker id="workflow-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" />
</marker>
<marker id="workflow-arrow-active" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" />
</marker>
</defs>
{presentationEdges.map(([fromId, toId]) => {
const from = requireNode(fromId);
const to = requireNode(toId);
const active = execution.completedNodeIds.includes(from.id);
return (
<line
key={`${fromId}-${toId}`}
data-testid="workflow-connector"
data-active={active}
x1={`${from.x}%`}
y1={`${from.y}%`}
x2={`${to.x}%`}
y2={`${to.y}%`}
markerEnd={active ? "url(#workflow-arrow-active)" : "url(#workflow-arrow)"}
/>
);
})}
</svg>
{presentationNodes.map((node, index) => {
const executionState = executionStateForNode(node.id, execution);
const currentInterrupt = executionState === "current" && node.kind === "interrupt";
const stateLabel = currentInterrupt
? "Current interrupt"
: executionState === "current"
? "Current"
: executionState === "completed"
? "Completed"
: "Queued";
return (
<m.div
key={node.id}
className="workflow-graph-stage__node-slot"
style={{ left: `${node.x}%`, top: `${node.y}%` }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: index * 0.025 }}
>
<button
type="button"
className="workflow-graph-stage__node"
data-kind={node.kind}
data-execution-state={executionState}
data-current-interrupt={currentInterrupt}
data-selected={selectedNodeId === node.id}
aria-pressed={selectedNodeId === node.id}
aria-label={`${node.label}, ${stateLabel}`}
onClick={() => selectNode(node.id)}
>
<span className="workflow-graph-stage__node-state">{stateLabel}</span>
<strong>{node.label}</strong>
<small>{node.detail}</small>
</button>
</m.div>
);
})}
</div>
);
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { DemoEvent } from "../demo/timeline/models.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import {
graphExecutionForBeat,
projectInterruptContract,
projectOperationPresentation,
} from "./demo-workflow-model.js";
const requireEvent = (stage: DemoEvent["stage"]): DemoEvent => {
const event = loadCanonicalDemoRecording().events.find((candidate) => candidate.stage === stage);
if (!event) throw new Error(`canonical recording is missing ${stage}`);
return event;
};
describe("demo workflow presentation model", () => {
it("projects the canonical run without inventing display facts", () => {
const event = requireEvent("run_start");
expect(projectOperationPresentation(event)).toEqual({
operation: "workflow.runs.start",
status: "interrupted",
durationMs: 88,
command: "uv run wf run start lda_report_case_study.default --input '<json>'",
deploymentId: "lda_report_case_study.default",
runId: "run_recorded_lda_report",
interruptKind: "issue_review",
});
});
it("projects the canonical typed interrupt contract", () => {
expect(projectInterruptContract(requireEvent("run_start"))).toEqual({
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
runId: "run_recorded_lda_report",
});
});
it("degrades malformed interpreted data to bounded fallbacks", () => {
const sparse: DemoEvent = {
...requireEvent("run_start"),
operation: null,
equivalentCli: null,
interpreted: "malformed",
rawResponse: null,
};
expect(projectOperationPresentation(sparse)).toMatchObject({
operation: "run_start",
status: "unknown",
command: null,
interruptKind: null,
});
expect(projectInterruptContract(sparse)).toBeNull();
});
it("maps storyboard beats to one continuous execution", () => {
expect(graphExecutionForBeat("graph")).toEqual({
completedNodeIds: ["read_docs"],
currentNodeId: "build_report",
});
expect(graphExecutionForBeat("interrupt")).toEqual({
completedNodeIds: ["read_docs", "build_report"],
currentNodeId: "review_issues",
});
expect(graphExecutionForBeat("approval")).toEqual({
completedNodeIds: ["read_docs", "build_report"],
currentNodeId: "review_issues",
});
});
});
@@ -0,0 +1,114 @@
import * as v from "valibot";
import type { DemoEvent } from "../demo/timeline/models.js";
const InterruptProjectionSchema = v.looseObject({
kind: v.string(),
outcomes: v.optional(v.array(v.string())),
resume_schema: v.optional(v.unknown()),
});
const RunInterpretationSchema = v.looseObject({
status: v.optional(v.string()),
interrupt: v.optional(v.nullable(InterruptProjectionSchema)),
});
const RawRunResponseSchema = v.looseObject({
result: v.optional(v.looseObject({
status: v.optional(v.string()),
})),
});
export type OperationPresentation = {
readonly operation: string;
readonly status: string;
readonly durationMs: number;
readonly command: string | null;
readonly deploymentId: string | null;
readonly runId: string | null;
readonly interruptKind: string | null;
};
export type InterruptContractPresentation = {
readonly kind: string;
readonly outcomes: ReadonlyArray<string>;
readonly resumeSchema: unknown;
readonly runId: string | null;
};
export type GraphExecutionPresentation = {
readonly completedNodeIds: ReadonlyArray<string>;
readonly currentNodeId: string | null;
};
const decodeInterpretation = (event: DemoEvent) => {
const decoded = v.safeParse(RunInterpretationSchema, event.interpreted);
return decoded.success ? decoded.output : null;
};
const decodeRawResponse = (event: DemoEvent) => {
const decoded = v.safeParse(RawRunResponseSchema, event.rawResponse);
return decoded.success ? decoded.output : null;
};
/**
* Produces bounded display data from replay evidence. A malformed optional
* detail must not crash the defense route, but it also must not be invented.
*/
export const projectOperationPresentation = (
event: DemoEvent,
): OperationPresentation => {
const interpretation = decodeInterpretation(event);
const rawResponse = decodeRawResponse(event);
return {
operation: event.operation ?? event.stage,
status: interpretation?.status ?? rawResponse?.result?.status ?? "unknown",
durationMs: event.durationMs,
command: event.equivalentCli,
deploymentId: event.resultingIds.deploymentId,
runId: event.resultingIds.runId,
interruptKind: interpretation?.interrupt?.kind ?? null,
};
};
export const projectInterruptContract = (
event: DemoEvent,
): InterruptContractPresentation | null => {
const interrupt = decodeInterpretation(event)?.interrupt;
if (!interrupt || !interrupt.outcomes || interrupt.resume_schema === undefined) return null;
return {
kind: interrupt.kind,
outcomes: interrupt.outcomes,
resumeSchema: interrupt.resume_schema,
runId: event.resultingIds.runId,
};
};
export const graphExecutionForBeat = (
beatId: string,
): GraphExecutionPresentation => {
switch (beatId) {
case "operation":
return { completedNodeIds: [], currentNodeId: "read_docs" };
case "graph":
return { completedNodeIds: ["read_docs"], currentNodeId: "build_report" };
case "interrupt":
case "approval":
return {
completedNodeIds: ["read_docs", "build_report"],
currentNodeId: "review_issues",
};
case "resume":
return {
completedNodeIds: ["read_docs", "build_report", "review_issues"],
currentNodeId: "create_issues",
};
case "output":
case "trace":
return {
completedNodeIds: ["read_docs", "build_report", "review_issues", "create_issues"],
currentNodeId: "end_completed",
};
default:
return { completedNodeIds: [], currentNodeId: null };
}
};
@@ -1,19 +1,30 @@
:root {
--chat-width: 18rem;
--evidence-width: 0rem;
}
body {
margin: 0;
padding: 0;
}
.presentation-route {
height: 100dvh;
min-height: 0;
overflow: hidden;
background: oklch(0.12 0.025 250);
color: oklch(0.96 0.01 250);
background: var(--stage-canvas, oklch(0.12 0.025 250));
color: var(--text-primary, oklch(0.96 0.01 250));
/* Presentation tokens scoped to presentation route */
--stage-canvas: oklch(0.12 0.025 250);
--stage-surface: oklch(0.16 0.025 250);
--stage-inset: oklch(0.14 0.025 250);
--stage-line: oklch(0.36 0.04 250);
--text-primary: oklch(0.96 0.01 250);
--text-secondary: oklch(0.78 0.035 250);
--text-muted: oklch(0.65 0.03 250);
--accent-cyan: oklch(0.72 0.17 195);
--accent-amber: oklch(0.76 0.18 70);
--success: oklch(0.7 0.16 145);
--failure: oklch(0.65 0.2 25);
--chat-width: 18rem;
--evidence-width: 0rem;
/* Paper theme tokens */
--paper-bg: oklch(0.97 0.005 80);
--paper-text: oklch(0.15 0.01 80);
--paper-muted: oklch(0.5 0.03 80);
--paper-accent: oklch(0.45 0.14 80);
--paper-border: oklch(0.75 0.04 80);
--paper-surface: oklch(0.92 0.01 80);
}
.presentation-stage {
@@ -28,22 +39,26 @@ body {
/* Stage themes */
.presentation-stage[data-stage-theme="paper"] {
background: oklch(0.97 0.005 80);
color: oklch(0.15 0.01 80);
background: var(--paper-bg);
color: var(--paper-text);
}
.presentation-stage[data-stage-theme="night"] {
background: oklch(0.12 0.025 250);
color: oklch(0.96 0.01 250);
background: var(--stage-canvas);
color: var(--text-primary);
}
/* Paper theme child-element overrides */
.presentation-stage[data-stage-theme="paper"] .stage-caption__eyebrow {
color: oklch(0.45 0.04 80);
color: var(--paper-muted);
}
.presentation-stage[data-stage-theme="paper"] .stage-caption h1 {
color: var(--paper-text);
}
.presentation-stage[data-stage-theme="paper"] .scene-body__evidence {
color: oklch(0.5 0.03 80);
color: var(--paper-muted);
}
.presentation-stage[data-stage-theme="paper"] .scene-rail__scene {
@@ -168,16 +183,37 @@ body {
}
/* Chat themes */
.presentation-stage[data-chat-theme="light"] .presentation-stage__chat {
.presentation-stage[data-chat-theme="light"] .presentation-stage__chat,
.operator-chat[data-chat-theme="light"] {
background: oklch(0.96 0.005 80);
color: oklch(0.18 0.01 80);
}
.presentation-stage[data-chat-theme="dark"] .presentation-stage__chat {
.presentation-stage[data-chat-theme="dark"] .presentation-stage__chat,
.operator-chat[data-chat-theme="dark"] {
background: oklch(0.14 0.025 250);
color: oklch(0.92 0.01 250);
}
/* Light chat message overrides */
.presentation-stage[data-chat-theme="light"] .chat-message,
.operator-chat[data-chat-theme="light"] .chat-message {
border-color: oklch(0.75 0.04 80);
background: oklch(0.92 0.01 80);
color: oklch(0.18 0.01 80);
}
/* Light chat tool-part overrides */
.presentation-stage[data-chat-theme="light"] .chat-tool-part,
.operator-chat[data-chat-theme="light"] .chat-tool-part {
border-color: oklch(0.75 0.04 80);
}
.presentation-stage[data-chat-theme="light"] .chat-tool-part span,
.operator-chat[data-chat-theme="light"] .chat-tool-part span {
color: oklch(0.45 0.03 80);
}
/* Chat modes */
.presentation-stage[data-chat-mode="hidden"] {
grid-template:
@@ -331,8 +367,8 @@ body {
}
.operation-block {
border: 1px solid oklch(0.36 0.04 250);
background: oklch(0.16 0.025 250);
border: 1px solid var(--stage-line);
background: var(--stage-surface);
padding: 0.75rem;
border-radius: 0.65rem;
flex-shrink: 0;
@@ -343,10 +379,33 @@ body {
display: flex;
justify-content: space-between;
gap: 0.75rem;
color: oklch(0.78 0.035 250);
color: var(--text-secondary);
font-size: 0.8rem;
}
.operation-block__name {
font-weight: 600;
margin: 0;
}
.operation-block__status {
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.operation-block__status[data-status="interrupted"] {
background: oklch(0.76 0.18 70 / 0.2);
color: var(--accent-amber);
}
.operation-block__status[data-status="completed"] {
background: oklch(0.7 0.16 145 / 0.2);
color: var(--success);
}
.operation-block__command,
.operation-block pre {
white-space: pre-wrap;
@@ -354,44 +413,166 @@ body {
font-size: 0.8rem;
}
.operation-block__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
.operation-block__command {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--stage-inset);
border-radius: 0.4rem;
border: 1px solid var(--stage-line);
}
@media (max-width: 800px) {
.operation-block__grid {
grid-template-columns: 1fr;
}
.operation-block__interpreted {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--stage-inset);
border-radius: 0.4rem;
border: 1px solid var(--stage-line);
}
.operation-block__interpreted-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0 0 0.25rem;
}
.operation-block__interpreted-status,
.operation-block__interpreted-interrupt,
.operation-block__interpreted-ids {
margin: 0.15rem 0;
font-size: 0.8rem;
color: var(--text-primary);
}
.operation-block__interpreted-interrupt {
color: var(--accent-amber);
}
.operation-block__evidence-action {
display: inline-block;
margin: 0.5rem 0;
padding: 0.3rem 0.6rem;
border: 1px solid var(--stage-line);
background: var(--stage-inset);
color: var(--text-secondary);
border-radius: 0.3rem;
font-size: 0.75rem;
cursor: pointer;
}
.operation-block__evidence-action:hover {
background: var(--stage-surface);
color: var(--text-primary);
}
.operation-block--receipt {
padding: 0.5rem 0.75rem;
}
.operation-block--receipt header {
gap: 0.5rem;
}
.operation-block__run-id {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.workflow-graph-stage {
position: relative;
min-height: 12rem;
flex: 1;
border: 1px solid oklch(0.32 0.04 250);
background: oklch(0.14 0.025 250);
border: 1px solid var(--stage-line);
background: var(--stage-canvas);
overflow: hidden;
}
.workflow-graph-stage__connectors {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.workflow-graph-stage__connector {
stroke: var(--stage-line);
stroke-width: 2;
stroke-dasharray: 4 4;
}
.workflow-graph-stage__connector--active {
stroke: var(--accent-cyan);
stroke-dasharray: none;
}
.workflow-graph-stage__arrowhead {
fill: var(--stage-line);
}
.workflow-graph-stage__arrowhead--active {
fill: var(--accent-cyan);
}
.workflow-graph-stage__node {
position: absolute;
transform: translate(-50%, -50%);
border: 1px solid oklch(0.44 0.055 250);
background: oklch(0.2 0.03 250);
color: inherit;
border: 1px solid var(--stage-line);
background: var(--stage-surface);
color: var(--text-primary);
padding: 0.5rem 0.7rem;
border-radius: 0.6rem;
font-size: 0.8rem;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.workflow-graph-stage__node--completed {
background: oklch(0.18 0.02 250);
border-color: oklch(0.44 0.04 250);
}
.workflow-graph-stage__node--current {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px oklch(0.72 0.17 195 / 0.3);
}
.workflow-graph-stage__node--interrupt {
border-color: var(--accent-amber);
box-shadow: 0 0 0 2px oklch(0.76 0.18 70 / 0.3);
}
.workflow-graph-stage__node--future {
opacity: 0.6;
}
.workflow-graph-stage__node-label {
display: block;
}
.workflow-graph-stage__node-state {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.workflow-graph-stage__node--current .workflow-graph-stage__node-state {
color: var(--accent-cyan);
}
.workflow-graph-stage__node--interrupt .workflow-graph-stage__node-state {
color: var(--accent-amber);
}
.workflow-graph-stage__node[data-kind="interrupt"] {
border-color: oklch(0.76 0.18 70);
border-color: var(--accent-amber);
}
.workflow-graph-stage__node[data-selected="true"] {
outline: 3px solid oklch(0.72 0.17 195);
outline: 3px solid var(--accent-cyan);
}
.node-spotlight {
@@ -937,3 +1118,61 @@ body {
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Demo workflow scene */
.demo-workflow-scene__graph-area {
flex: 1;
display: flex;
min-height: 0;
}
.demo-workflow-scene__interrupt-preview {
padding: 0.65rem 0.75rem;
border: 1px solid var(--accent-amber);
background: oklch(0.76 0.18 70 / 0.1);
border-radius: 0.5rem;
flex-shrink: 0;
}
.demo-workflow-scene__interrupt-preview h3 {
margin: 0 0 0.35rem;
font-size: 0.85rem;
color: var(--accent-amber);
}
.demo-workflow-scene__interrupt-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.75rem;
margin: 0;
font-size: 0.8rem;
}
.demo-workflow-scene__interrupt-details dt {
color: var(--text-muted);
}
.demo-workflow-scene__interrupt-details dd {
margin: 0;
color: var(--text-primary);
}
.demo-workflow-scene__approval-context {
padding: 0.65rem 0.75rem;
border: 1px solid var(--accent-cyan);
background: oklch(0.72 0.17 195 / 0.1);
border-radius: 0.5rem;
flex-shrink: 0;
}
.demo-workflow-scene__approval-context h3 {
margin: 0 0 0.35rem;
font-size: 0.85rem;
color: var(--accent-cyan);
}
.demo-workflow-scene__approval-prompt {
margin: 0 0 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
@@ -0,0 +1,738 @@
/* The demo scenes use one continuous stage; global console card styles must not leak in. */
.presentation-route .presentation-stage__primary {
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
}
.presentation-stage[data-scene-view="demo"] .presentation-stage__primary {
position: relative;
padding: 1rem 1.25rem 0.7rem;
background:
radial-gradient(circle at 76% 18%, oklch(0.7 0.13 195 / 0.08), transparent 30rem),
linear-gradient(145deg, oklch(0.13 0.025 250), oklch(0.105 0.02 250));
color: var(--text-primary);
}
.presentation-route {
position: fixed;
z-index: 100;
inset: 0;
width: 100vw;
}
.presentation-stage[data-scene-view="demo"] .stage-caption {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(15rem, 36%);
column-gap: 2rem;
align-items: end;
flex: 0 0 auto;
margin: 0;
border: 0;
border-radius: 0;
padding: 0 0 0.85rem;
background: transparent;
box-shadow: none;
}
.presentation-stage[data-scene-view="demo"] .stage-caption__eyebrow {
grid-column: 1;
margin: 0 0 0.2rem;
color: var(--accent-cyan);
font: 650 0.68rem/1 var(--font-mono, monospace);
letter-spacing: 0.14em;
}
.presentation-stage[data-scene-view="demo"] .stage-caption h1 {
grid-column: 1;
margin: 0;
max-width: 15ch;
color: var(--text-primary);
font-size: clamp(1.8rem, 3vw, 2.75rem);
font-weight: 680;
line-height: 0.98;
letter-spacing: -0.035em;
}
.presentation-stage[data-scene-view="demo"] .stage-caption > div {
grid-column: 2;
grid-row: 1 / span 2;
align-self: end;
border-left: 1px solid oklch(0.72 0.17 195 / 0.45);
padding-left: 1rem;
color: var(--text-secondary);
font-size: 0.86rem;
line-height: 1.45;
}
.presentation-stage[data-scene-view="demo"] .stage-caption > div p {
margin: 0;
}
.demo-workflow-stage {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.demo-workflow-stage__pending {
display: grid;
flex: 1;
place-items: center;
border: 1px dashed var(--stage-line);
color: var(--text-muted);
font-family: var(--font-mono, monospace);
}
/* Operation handoff: a single large receipt, not a stack of dashboard cards. */
.presentation-route .operation-block--expanded {
position: relative;
display: grid;
grid-template-rows: auto auto 1fr auto;
align-self: stretch;
width: 100%;
margin: 0;
border: 1px solid oklch(0.72 0.17 195 / 0.42);
border-radius: 1rem;
padding: clamp(1.1rem, 2.5vw, 2rem);
overflow: hidden;
background:
linear-gradient(90deg, oklch(0.72 0.17 195 / 0.07), transparent 42%),
oklch(0.145 0.025 250 / 0.92);
box-shadow: 0 1.5rem 4rem oklch(0.04 0.02 250 / 0.35);
}
.presentation-route .operation-block--expanded::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--accent-cyan);
content: "";
}
.operation-block__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
.operation-block__header strong {
display: block;
margin-top: 0.3rem;
color: var(--text-primary);
font: 650 clamp(1.35rem, 2.5vw, 2.15rem)/1.1 var(--font-mono, monospace);
letter-spacing: -0.04em;
}
.operation-block__kicker {
color: var(--accent-cyan);
font: 650 0.68rem/1 var(--font-mono, monospace);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.operation-block__status-group {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--text-muted);
font-family: var(--font-mono, monospace);
}
.operation-status {
display: inline-flex;
align-items: center;
min-height: 1.65rem;
border: 1px solid var(--stage-line);
border-radius: 999px;
padding: 0.25rem 0.65rem;
font: 650 0.68rem/1 var(--font-mono, monospace);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.operation-status[data-status="interrupted"] {
border-color: oklch(0.76 0.18 70 / 0.55);
background: oklch(0.76 0.18 70 / 0.1);
color: var(--accent-amber);
}
.operation-status[data-status="completed"] {
border-color: oklch(0.7 0.16 145 / 0.55);
background: oklch(0.7 0.16 145 / 0.1);
color: var(--success);
}
.operation-command {
display: flex;
align-items: flex-start;
gap: 0.7rem;
margin: clamp(1rem, 3vh, 1.8rem) 0;
border: 1px solid oklch(0.4 0.04 250 / 0.75);
border-radius: 0.7rem;
padding: 0.9rem 1rem;
background: oklch(0.09 0.02 250 / 0.8);
color: oklch(0.88 0.03 195);
font-family: var(--font-mono, monospace);
font-size: clamp(0.76rem, 1.3vw, 0.92rem);
line-height: 1.55;
}
.operation-command > span {
color: var(--accent-cyan);
user-select: none;
}
.operation-command code {
overflow-wrap: anywhere;
}
.operation-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-self: end;
margin: 0;
border-block: 1px solid var(--stage-line);
}
.operation-summary > div {
min-width: 0;
padding: 0.9rem 1rem;
}
.operation-summary > div + div {
border-left: 1px solid var(--stage-line);
}
.operation-summary dt {
margin-bottom: 0.35rem;
color: var(--text-muted);
font: 650 0.64rem/1 var(--font-mono, monospace);
letter-spacing: 0.11em;
text-transform: uppercase;
}
.operation-summary dd {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 0.86rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.operation-summary dd[data-emphasis="interrupt"] {
color: var(--accent-amber);
}
.operation-block__evidence-action {
justify-self: start;
margin-top: 1rem;
border: 0;
border-bottom: 1px solid oklch(0.72 0.17 195 / 0.5);
border-radius: 0;
padding: 0.3rem 0;
background: transparent;
color: var(--accent-cyan);
font: 650 0.75rem/1 var(--font-mono, monospace);
cursor: pointer;
}
.operation-block__evidence-action span {
display: inline-block;
margin-left: 0.5rem;
transition: transform 180ms ease;
}
.operation-block__evidence-action:hover span,
.operation-block__evidence-action:focus-visible span {
transform: translateX(0.25rem);
}
/* Graph beats keep the operation as a narrow provenance receipt. */
.operation-receipt {
position: absolute;
z-index: 5;
top: 0;
left: 0;
right: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 1rem;
min-height: 2.6rem;
border: 1px solid var(--stage-line);
border-radius: 0.55rem;
padding: 0.4rem 0.7rem;
background: oklch(0.12 0.022 250 / 0.9);
backdrop-filter: blur(12px);
color: var(--text-secondary);
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
}
.operation-receipt__identity {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.operation-receipt__identity strong {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
}
.operation-receipt__pulse {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 0 0.25rem oklch(0.72 0.17 195 / 0.12);
}
.operation-receipt .operation-status {
min-height: 1.35rem;
padding: 0.15rem 0.45rem;
font-size: 0.58rem;
}
.demo-workflow-stage__graph {
display: grid;
grid-template-columns: minmax(0, 1fr);
flex: 1;
min-width: 0;
min-height: 0;
padding-top: 3.15rem;
}
.demo-workflow-stage__graph:has(.interrupt-contract-preview) {
grid-template-columns: minmax(0, 1fr) minmax(17rem, 29%);
gap: 0.9rem;
}
.presentation-route .workflow-graph-stage {
position: relative;
min-width: 0;
min-height: 0;
border: 1px solid oklch(0.4 0.04 250 / 0.7);
border-radius: 0.85rem;
overflow: hidden;
background:
linear-gradient(oklch(0.35 0.03 250 / 0.12) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.35 0.03 250 / 0.12) 1px, transparent 1px),
radial-gradient(circle at 50% 50%, oklch(0.72 0.17 195 / 0.06), transparent 60%),
oklch(0.105 0.02 250);
background-size: 1.75rem 1.75rem, 1.75rem 1.75rem, auto, auto;
}
.workflow-graph-stage__legend {
position: absolute;
z-index: 4;
top: 0.7rem;
right: 0.8rem;
display: flex;
gap: 0.9rem;
color: var(--text-muted);
font: 600 0.58rem/1 var(--font-mono, monospace);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.workflow-graph-stage__legend span {
display: flex;
align-items: center;
gap: 0.3rem;
}
.workflow-graph-stage__legend i {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--stage-line);
}
.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__connectors {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.workflow-graph-stage__connectors line {
stroke: oklch(0.48 0.035 250 / 0.55);
stroke-width: 1.4;
stroke-dasharray: 4 6;
}
.workflow-graph-stage__connectors line[data-active="true"] {
stroke: var(--accent-cyan);
stroke-width: 2;
stroke-dasharray: none;
}
#workflow-arrow polygon {
fill: oklch(0.48 0.035 250);
}
#workflow-arrow-active polygon {
fill: var(--accent-cyan);
}
.workflow-graph-stage__node-slot {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
}
.presentation-route .workflow-graph-stage__node {
position: relative;
display: grid;
gap: 0.25rem;
width: clamp(7.2rem, 11vw, 9.5rem);
min-height: 4.35rem;
transform: none;
border: 1px solid var(--stage-line);
border-radius: 0.7rem;
padding: 0.65rem 0.75rem;
background: oklch(0.15 0.025 250 / 0.96);
color: var(--text-primary);
text-align: left;
box-shadow: 0 0.65rem 1.5rem oklch(0.04 0.02 250 / 0.3);
cursor: pointer;
}
.workflow-graph-stage__node strong {
font-size: 0.82rem;
line-height: 1.05;
}
.workflow-graph-stage__node small {
color: var(--text-muted);
font-size: 0.67rem;
}
.workflow-graph-stage__node-state {
color: var(--text-muted);
font: 650 0.55rem/1 var(--font-mono, monospace);
letter-spacing: 0.09em;
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.14), 0 1rem 2.5rem oklch(0.06 0.03 230 / 0.5);
}
.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.14), 0 1rem 2.5rem oklch(0.06 0.03 230 / 0.5);
}
.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.48;
box-shadow: none;
}
.workflow-graph-stage__node[data-selected="true"] {
outline: 2px solid var(--accent-cyan);
outline-offset: 3px;
}
.interrupt-contract-preview {
position: relative;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
min-height: 0;
border: 1px solid oklch(0.76 0.18 70 / 0.48);
border-radius: 0.85rem;
padding: 1rem;
overflow: hidden;
background:
linear-gradient(180deg, oklch(0.76 0.18 70 / 0.08), transparent 42%),
oklch(0.135 0.025 250);
}
.interrupt-contract-preview[data-mode="approval"] {
box-shadow: inset 0 3px 0 var(--accent-amber);
}
.interrupt-contract-preview__eyebrow {
margin: 0 0 0.25rem;
color: var(--accent-amber);
font: 650 0.62rem/1 var(--font-mono, monospace);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.interrupt-contract-preview h2 {
margin: 0 0 0.8rem;
color: var(--text-primary);
font-size: 1.2rem;
line-height: 1.05;
}
.interrupt-contract-preview > header {
display: grid;
gap: 0.25rem;
margin-bottom: 0.9rem;
}
.interrupt-contract-preview > header span {
color: var(--accent-amber);
font: 650 0.6rem/1 var(--font-mono, monospace);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.interrupt-contract-preview > header strong {
color: var(--text-primary);
font: 650 1.15rem/1.1 var(--font-mono, monospace);
}
.interrupt-contract-preview dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin: 0 0 0.8rem;
font-size: 0.72rem;
}
.interrupt-contract-preview dl > div {
min-width: 0;
}
.interrupt-contract-preview dt {
margin-bottom: 0.25rem;
color: var(--text-muted);
}
.interrupt-contract-preview dd {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-family: var(--font-mono, monospace);
text-overflow: ellipsis;
white-space: nowrap;
}
.interrupt-contract-preview__schema > span {
color: var(--text-muted);
font: 650 0.6rem/1 var(--font-mono, monospace);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.interrupt-contract-preview__schema {
min-height: 0;
border-top: 1px solid var(--stage-line);
padding-top: 0.7rem;
}
.interrupt-contract-preview__schema strong {
color: var(--text-muted);
font: 650 0.6rem/1 var(--font-mono, monospace);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.interrupt-contract-preview__schema pre {
max-height: 11rem;
margin: 0.45rem 0 0;
overflow: auto;
color: oklch(0.85 0.04 195);
font: 0.63rem/1.45 var(--font-mono, monospace);
white-space: pre-wrap;
}
/* Chat remains an app surface while the workflow stage stays cinematic. */
.presentation-stage[data-chat-theme="light"] .presentation-stage__chat {
border-right: 1px solid oklch(0.82 0.015 80);
background: oklch(0.975 0.004 80);
}
.presentation-stage[data-chat-theme="light"] .operator-chat {
gap: 1.05rem;
padding: 1rem 0.85rem;
background: transparent;
}
.presentation-stage[data-chat-theme="light"] .chat-message {
border: 0;
border-radius: 0;
padding: 0;
background: transparent;
box-shadow: none;
color: oklch(0.22 0.015 80);
font-size: 0.78rem;
line-height: 1.45;
}
.presentation-stage[data-chat-theme="light"] .chat-message > strong {
display: block;
margin-bottom: 0.35rem;
color: oklch(0.42 0.025 80);
font: 650 0.6rem/1 var(--font-mono, monospace);
letter-spacing: 0.09em;
text-transform: uppercase;
}
.presentation-stage[data-chat-theme="light"] .chat-message--operator {
margin-left: 1rem;
border-radius: 0.85rem;
padding: 0.65rem 0.75rem;
background: oklch(0.925 0.012 80);
}
.presentation-stage[data-chat-theme="light"] .chat-message p {
margin: 0.2rem 0;
}
.presentation-stage[data-chat-theme="light"] .chat-tool-part {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.3rem;
margin-top: 0.5rem;
border: 1px solid oklch(0.82 0.015 80);
border-radius: 0.55rem;
padding: 0.55rem 0.65rem;
background: oklch(0.95 0.006 80);
}
.presentation-stage[data-chat-theme="light"] .chat-tool-part span {
grid-column: 1 / -1;
color: oklch(0.48 0.04 195);
font: 650 0.56rem/1 var(--font-mono, monospace);
letter-spacing: 0.09em;
}
.presentation-stage[data-chat-theme="light"] .chat-tool-part code {
overflow: hidden;
color: oklch(0.26 0.025 250);
font-size: 0.68rem;
text-overflow: ellipsis;
}
.presentation-stage[data-chat-theme="light"] .chat-tool-part--handoff {
border-color: oklch(0.56 0.13 195 / 0.55);
background: oklch(0.94 0.025 195);
}
.presentation-route .presentation-stage__discussion-toggle {
position: absolute;
right: 1.25rem;
bottom: 0.7rem;
z-index: 8;
border: 0;
padding: 0.25rem 0;
background: transparent;
color: var(--text-muted);
font: 600 0.62rem/1 var(--font-mono, monospace);
cursor: pointer;
}
.presentation-route .presentation-stage__mode {
position: absolute;
bottom: 0.7rem;
left: 1.25rem;
z-index: 8;
margin: 0;
color: var(--text-muted);
font: 600 0.6rem/1 var(--font-mono, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.presentation-route .scene-rail {
gap: 0.25rem;
min-height: 2.7rem;
border-top: 1px solid oklch(0.36 0.04 250 / 0.7);
padding: 0.35rem 0.45rem;
background: oklch(0.095 0.02 250);
}
.presentation-route .scene-rail__scene {
min-width: 2.5rem;
min-height: 1.9rem;
border: 0;
border-radius: 0.35rem;
padding: 0.25rem 0.35rem;
background: transparent;
color: oklch(0.62 0.025 250);
}
.presentation-route .scene-rail__scene[data-active="true"] {
background: oklch(0.72 0.17 195 / 0.13);
color: var(--text-primary);
}
.presentation-route .scene-rail__title {
display: none;
}
@media (max-width: 1050px) {
.demo-workflow-stage__graph:has(.interrupt-contract-preview) {
grid-template-columns: minmax(0, 1fr) 15rem;
}
.presentation-route .workflow-graph-stage__node {
width: 7rem;
padding-inline: 0.55rem;
}
}
@media (max-height: 760px) {
.presentation-stage[data-scene-view="demo"] .presentation-stage__primary {
gap: 0.55rem;
padding-top: 0.8rem;
}
.presentation-stage[data-scene-view="demo"] .stage-caption {
padding-bottom: 0.55rem;
}
.presentation-stage[data-scene-view="demo"] .stage-caption h1 {
font-size: clamp(1.65rem, 3vw, 2.2rem);
}
.operation-block__header strong {
font-size: clamp(1.2rem, 2.2vw, 1.75rem);
}
.operation-command {
margin: 0.8rem 0;
}
.interrupt-contract-preview__schema pre {
max-height: 8rem;
}
}