feat: stage scene 10 as guided product moment

This commit is contained in:
lda
2026-07-09 15:22:55 +07:00 Verified
parent 9a6fab1e7f
commit b382638961
5 changed files with 260 additions and 69 deletions
@@ -23,6 +23,7 @@ const demoController = (
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
...overrides,
});
@@ -37,6 +37,7 @@ const demo: DemoTimelineController = {
submitSelectedIssues: noopAsync,
cancelReview: noopAsync,
restart: noop,
primeReplayToStage: noop,
};
const requireSceneBeat = (sceneId: string, beatId: string) => {
@@ -98,17 +99,13 @@ describe("DemoWorkflowScene", () => {
expect(contract).toHaveTextContent("run_recorded_lda_report");
});
it("carries the same graph and contract into Scene 10 approval", () => {
it("carries the contract into Scene 10 approval via guided product moment", () => {
renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("workflow graph")).toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "approval");
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("makes the Scene 10 approval contract the primary visual", () => {
@@ -119,23 +116,22 @@ describe("DemoWorkflowScene", () => {
expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true");
expect(screen.getByLabelText("typed interrupt contract")).toHaveTextContent("Operator decision");
expect(screen.getByRole("group", { name: /issue review resume/i })).toHaveTextContent("submitted / cancelled");
expect(screen.getByLabelText("workflow graph")).toBeInTheDocument();
});
it("marks trace beat as evidence layout", () => {
it("marks trace beat as evidence layout via guided product moment", () => {
renderBeat("trace", "interrupt-evidence");
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "evidence");
expect(screen.getByLabelText("workflow.runs.trace operation")).toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "trace");
});
it("renders resume and trace operations as expanded evidence summaries", () => {
it("renders resume and trace via guided product moment", () => {
const { unmount } = renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "resume");
unmount();
renderBeat("trace", "interrupt-evidence");
expect(screen.getByLabelText("workflow.runs.trace operation")).toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "trace");
});
it("passes run proof into full graph beats", () => {
@@ -145,15 +141,14 @@ describe("DemoWorkflowScene", () => {
unmount();
renderBeat("output", "interrupt-evidence");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("JSON-RPC evidence");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "output");
});
it("marks outcome-panel layouts so CSS can clear the receipt row", () => {
it("marks outcome-panel layouts via guided product moment", () => {
renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "operation");
expect(screen.getByLabelText("demo outcome proof")).toBeInTheDocument();
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "resume");
});
it("shows the continuity rail across Scene 9 operation, graph, and interrupt beats", () => {
@@ -176,24 +171,23 @@ describe("DemoWorkflowScene", () => {
interrupt.unmount();
renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "compact");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "approval");
});
it("keeps full graph mode for graph and output beats", () => {
it("keeps full graph mode for graph beats and guided product moment for output", () => {
const graph = renderBeat("graph");
expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "full");
graph.unmount();
renderBeat("output", "interrupt-evidence");
expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "full");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "output");
});
it("keeps approval beat contract, compact graph, and outcome proof present", () => {
it("keeps approval beat contract via guided product moment", () => {
renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true");
expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "compact");
expect(screen.queryByLabelText("demo outcome proof")).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "approval");
});
it("wires approval actions into the Scene 10 schema approval surface", () => {
@@ -232,16 +226,16 @@ describe("DemoWorkflowScene", () => {
expect(screen.queryByText("Resume schema")).not.toBeInTheDocument();
});
it("adds outcome proof to resume, output, and trace beats", () => {
it("adds guided product moment for resume, output, and trace beats", () => {
const resume = renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("Same persisted run");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "resume");
resume.unmount();
const output = renderBeat("output", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("Report markdown");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "output");
output.unmount();
renderBeat("trace", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("Trace frames");
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "trace");
});
});
@@ -9,6 +9,7 @@ import {
import type { DemoApprovalActions } from "./demo-approval-actions.js";
import { DemoContinuityRail } from "./DemoContinuityRail.js";
import { DemoOutcomePanel } from "./DemoOutcomePanel.js";
import { GuidedProductMoment } from "./GuidedProductMoment.js";
import { InterruptContractPreview } from "./InterruptContractPreview.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
@@ -87,6 +88,8 @@ export const DemoWorkflowScene = ({
? "preview"
: null;
const isGuidedScene10 = scene.id === "interrupt-evidence";
return (
<>
<StageCaption eyebrow="Live system walkthrough" title={scene.title}>
@@ -96,55 +99,68 @@ export const DemoWorkflowScene = ({
<DemoContinuityRail lens={lens} />
<div className="demo-workflow-stage" data-beat={beat.id} data-demo-layout={layout} aria-label="demo workflow stage">
{showExpandedOperation && currentEvent && (
<OperationBlock
event={currentEvent}
variant="expanded"
{isGuidedScene10 ? (
<GuidedProductMoment
beat={beat}
demo={demo}
contract={contract}
operation={currentOperation}
approvalActions={approvalActions}
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}
proof={runProof}
variant={graphVariant}
/>
{contractMode && contract && (
<InterruptContractPreview
contract={contract}
mode={contractMode}
hero={layout === "approval"}
approvalActions={approvalActions}
) : (
<>
{showExpandedOperation && currentEvent && (
<OperationBlock
event={currentEvent}
variant="expanded"
openEvidence={openEvidence}
/>
)}
</div>
)}
{showOutcomePanel && (
<DemoOutcomePanel
beatId={beat.id}
lens={lens}
operation={currentOperation}
contract={contract}
/>
)}
{showReceipt && runStart && (
<OperationBlock
event={runStart}
variant="receipt"
openEvidence={openEvidence}
/>
)}
{showExpandedOperation && !currentEvent && (
<div className="demo-workflow-stage__pending" role="status">
Replay operation is not available yet.
</div>
{showGraph && (
<div className="demo-workflow-stage__graph">
<WorkflowGraphStage
execution={execution}
selectedNodeId={selectedNodeId}
selectNode={selectNode}
proof={runProof}
variant={graphVariant}
/>
{contractMode && contract && (
<InterruptContractPreview
contract={contract}
mode={contractMode}
hero={layout === "approval"}
approvalActions={approvalActions}
/>
)}
</div>
)}
{showOutcomePanel && (
<DemoOutcomePanel
beatId={beat.id}
lens={lens}
operation={currentOperation}
contract={contract}
/>
)}
{showExpandedOperation && !currentEvent && (
<div className="demo-workflow-stage__pending" role="status">
Replay operation is not available yet.
</div>
)}
</>
)}
</div>
@@ -0,0 +1,96 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { projectInterruptContract, projectOperationPresentation } from "./demo-workflow-model.js";
import { GuidedProductMoment } from "./GuidedProductMoment.js";
import { findBeat } from "./storyboard.js";
afterEach(() => cleanup());
const recording = loadCanonicalDemoRecording();
const runStart = recording.events.find((event) => event.stage === "run_start")!;
const runResume = recording.events.find((event) => event.stage === "run_resume")!;
const contract = projectInterruptContract(runStart, runResume);
const resumeOperation = projectOperationPresentation(runResume);
const demo = {
state: { mode: "replay", phase: "review", events: recording.events, appliedCount: 3, autoplay: false, error: null },
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: null,
canStart: true,
setMode: vi.fn(),
start: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
next: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
} as unknown as DemoTimelineController;
describe("GuidedProductMoment", () => {
it("makes approval the primary product decision", () => {
render(
<GuidedProductMoment
beat={findBeat("interrupt-evidence", "approval")!}
demo={demo}
contract={contract}
operation={null}
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "approval");
expect(screen.getByText(/Run is paused/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
});
it("makes resume operation proof primary on resume beat", () => {
render(
<GuidedProductMoment
beat={findBeat("interrupt-evidence", "resume")!}
demo={demo}
contract={contract}
operation={resumeOperation}
openEvidence={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveAttribute("data-moment", "resume");
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
});
it("marks the primary surface for visual hierarchy", () => {
render(
<GuidedProductMoment
beat={findBeat("interrupt-evidence", "approval")!}
demo={demo}
contract={contract}
operation={null}
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: /current product moment/i })).toHaveClass("guided-product-moment");
expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true");
});
});
@@ -0,0 +1,84 @@
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { DemoApprovalActions } from "./demo-approval-actions.js";
import type {
InterruptContractPresentation,
OperationPresentation,
} from "./demo-workflow-model.js";
import { DemoOutcomePanel } from "./DemoOutcomePanel.js";
import { InterruptContractPreview } from "./InterruptContractPreview.js";
import { OperationBlock } from "./OperationBlock.js";
import type { SceneBeatDefinition } from "./storyboard.js";
import { demoBeatLensForBeat } from "./demo-workflow-model.js";
export type GuidedProductMomentProps = {
readonly beat: SceneBeatDefinition;
readonly demo: DemoTimelineController;
readonly contract: InterruptContractPresentation | null;
readonly operation: OperationPresentation | null;
readonly approvalActions?: DemoApprovalActions | undefined;
readonly openEvidence: () => void;
};
const momentForBeat = (beatId: string): "approval" | "resume" | "output" | "trace" =>
beatId === "resume" || beatId === "output" || beatId === "trace" ? beatId : "approval";
const statusCopy = (
moment: ReturnType<typeof momentForBeat>,
approvalActions?: DemoApprovalActions,
): string => {
if (moment !== "approval") return "Same persisted run; inspect the proof below.";
if (approvalActions?.state === "submitted") return "Submitted. Same run resumed.";
if (approvalActions?.state === "cancelled") {
return "Cancelled in presentation replay. No resume evidence is shown.";
}
return "Run is paused. Submit resumes this same run.";
};
export const GuidedProductMoment = ({
beat,
demo,
contract,
operation,
approvalActions,
openEvidence,
}: GuidedProductMomentProps) => {
const moment = momentForBeat(beat.id);
const lens = demoBeatLensForBeat(beat.id);
const runResume = demo.state.events.find((event) => event.stage === "run_resume");
return (
<section className="guided-product-moment" aria-label="current product moment" data-moment={moment}>
<header className="guided-product-moment__header">
<span>{lens.eyebrow}</span>
<strong>{lens.headline}</strong>
<p>{statusCopy(moment, approvalActions)}</p>
</header>
<div className="guided-product-moment__primary">
{moment === "approval" && contract ? (
<InterruptContractPreview
contract={contract}
mode="approval"
hero
approvalActions={approvalActions}
/>
) : null}
{moment === "resume" && runResume ? (
<OperationBlock
event={runResume}
variant="expanded"
openEvidence={openEvidence}
/>
) : null}
{(moment === "output" || moment === "trace") ? (
<DemoOutcomePanel
beatId={beat.id}
lens={lens}
operation={operation}
contract={contract}
/>
) : null}
</div>
</section>
);
};