feat: stage scene 10 as guided product moment
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user