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
+12
View File
@@ -173,6 +173,18 @@ prepared `lda_report_workflow` story. It renders a 12-scene, multi-beat
storyboard with stable stage regions, discussion branches, act themes, a chat
dock, and keyboard navigation.
Scenes 8 through 10 use the canonical replay as their only execution evidence.
The handoff expands an interpreted run operation into the center stage, then
keeps one workflow graph mounted while execution reaches the typed interrupt
and approval boundary. Raw protocol payloads remain in the evidence drawer.
The key defense states are directly addressable:
- `/present#scene/workflow-demo/operation`
- `/present#scene/workflow-demo/graph`
- `/present#scene/workflow-demo/interrupt`
- `/present#scene/interrupt-evidence/approval`
```powershell
pnpm --dir web dev
# open http://127.0.0.1:5173/present
@@ -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;
}
}