feat: add presentation evidence stage
This commit is contained in:
@@ -66,12 +66,14 @@ Implementation order:
|
|||||||
[`demo autoplay and replay`](superpowers/specs/2026-07-03-demo-autoplay-replay.md).
|
[`demo autoplay and replay`](superpowers/specs/2026-07-03-demo-autoplay-replay.md).
|
||||||
Implementation:
|
Implementation:
|
||||||
[`demo autoplay and replay plan`](historical/superpowers/plans/2026-07-03-demo-autoplay-replay.md).
|
[`demo autoplay and replay plan`](historical/superpowers/plans/2026-07-03-demo-autoplay-replay.md).
|
||||||
7. Add React presentation mode for the prepared workflow demo. Make the report
|
7. Completed: React presentation mode foundation for the prepared workflow demo.
|
||||||
workflow story primary, demote lifecycle evidence to supporting panels, and
|
Make the report workflow story primary, demote lifecycle evidence to
|
||||||
keep the layout usable on a 720p display. Decision:
|
supporting panels, and keep the layout usable on a 720p display. Decision:
|
||||||
[`React presentation mode before Astro`](adr/0003-react-presentation-mode-before-astro.md).
|
[`React presentation mode before Astro`](adr/0003-react-presentation-mode-before-astro.md).
|
||||||
Design:
|
Design:
|
||||||
[`React presentation mode`](superpowers/specs/2026-07-03-react-presentation-mode-design.md).
|
[`React presentation mode`](superpowers/specs/2026-07-03-react-presentation-mode-design.md).
|
||||||
|
Implementation:
|
||||||
|
[`React presentation mode plan`](superpowers/plans/2026-07-03-react-presentation-mode.md).
|
||||||
8. Add a constrained demo agent that invokes one prepared recipe macro.
|
8. Add a constrained demo agent that invokes one prepared recipe macro.
|
||||||
9. Add a static slide/appendix shell only after presentation mode is clear.
|
9. Add a static slide/appendix shell only after presentation mode is clear.
|
||||||
Astro remains an option, not the default next surface.
|
Astro remains an option, not the default next surface.
|
||||||
|
|||||||
@@ -148,3 +148,15 @@ product CLI setup commands.
|
|||||||
operation, and `Next` applies exactly one operation or recorded event.
|
operation, and `Next` applies exactly one operation or recorded event.
|
||||||
Playback always stops at `issue_review`; approval remains a human action in
|
Playback always stops at `issue_review`; approval remains a human action in
|
||||||
both modes. Replay is visibly labeled and does not create real issues.
|
both modes. Replay is visibly labeled and does not create real issues.
|
||||||
|
|
||||||
|
### Presentation Mode
|
||||||
|
|
||||||
|
The console also exposes `/present`, a replay-first defense route for the
|
||||||
|
prepared `lda_report_workflow` story. It uses the same demo timeline data as
|
||||||
|
the console demo, but renders a 720p-friendly staged view with keyboard beat
|
||||||
|
navigation, operation blocks, graph node spotlight, and evidence drawer.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm --dir web dev
|
||||||
|
# open http://127.0.0.1:5173/present
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
|
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const record: EvidenceRecord = {
|
||||||
|
id: "demo-run-start",
|
||||||
|
operation: "workflow.runs.start",
|
||||||
|
label: "Start run",
|
||||||
|
equivalentCli: "uv run wf run start lda_report_case_study.default",
|
||||||
|
request: { deployment_id: "lda_report_case_study.default" },
|
||||||
|
response: { result: { status: "interrupted" } },
|
||||||
|
durationMs: 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EvidenceDrawer", () => {
|
||||||
|
it("renders current evidence records and can close", () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(<EvidenceDrawer records={[record]} mode="open" close={close} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("complementary", { name: /presentation evidence/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/interrupted/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
|
|
||||||
|
const formatJson = (value: unknown): string => JSON.stringify(value, null, 2);
|
||||||
|
|
||||||
|
type EvidenceDrawerProps = {
|
||||||
|
readonly records: readonly EvidenceRecord[];
|
||||||
|
readonly mode: PresentationState["evidenceMode"];
|
||||||
|
readonly close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EvidenceDrawer = ({ records, mode, close }: EvidenceDrawerProps) => {
|
||||||
|
if (mode === "hidden") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="evidence-drawer" data-mode={mode} aria-label="presentation evidence">
|
||||||
|
<header>
|
||||||
|
<h2>Evidence</h2>
|
||||||
|
<button type="button" onClick={close}>Close</button>
|
||||||
|
</header>
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p>No live evidence captured in this view yet. Replay operation blocks still show recorded evidence.</p>
|
||||||
|
) : (
|
||||||
|
records.map((record) => (
|
||||||
|
<article key={record.id}>
|
||||||
|
<h3>{record.operation}</h3>
|
||||||
|
<p>{record.label}</p>
|
||||||
|
<pre><code>{record.equivalentCli}</code></pre>
|
||||||
|
<pre><code>{formatJson(record.response)}</code></pre>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ describe("PresentationRoute", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Replay/)).toBeInTheDocument();
|
expect(screen.getByText("Replay · running")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows node spotlight when a graph node is selected", async () => {
|
it("shows node spotlight when a graph node is selected", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||||
import { hashForBeat, presentationBeats } from "./beats.js";
|
import { hashForBeat } from "./beats.js";
|
||||||
import { PresentationStage } from "./PresentationStage.js";
|
import { PresentationStage } from "./PresentationStage.js";
|
||||||
import {
|
import {
|
||||||
initialPresentationState,
|
initialPresentationState,
|
||||||
@@ -16,7 +16,7 @@ export const PresentationRoute = () => {
|
|||||||
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
|
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [_evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
|
const [evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
|
||||||
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
||||||
setEvidence((records) => [...records, record]);
|
setEvidence((records) => [...records, record]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -46,20 +46,28 @@ export const PresentationRoute = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (demo.state.phase === "ready") {
|
if (demo.state.phase === "ready" && demo.state.mode !== "replay") {
|
||||||
demo.setMode("replay");
|
demo.setMode("replay");
|
||||||
|
}
|
||||||
|
}, [demo.state.phase, demo.state.mode, demo.setMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demo.state.phase === "ready" && demo.state.mode === "replay") {
|
||||||
demo.start();
|
demo.start();
|
||||||
}
|
}
|
||||||
}, [demo]);
|
}, [demo.state.phase, demo.state.mode, demo.start]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
<PresentationStage
|
<PresentationStage
|
||||||
state={state}
|
state={state}
|
||||||
demo={demo}
|
demo={demo}
|
||||||
|
evidence={evidence}
|
||||||
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
|
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
|
||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
clearNode={() => dispatch({ type: "clear_node" })}
|
clearNode={() => dispatch({ type: "clear_node" })}
|
||||||
|
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||||
|
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import { presentationBeats, type BeatId } from "./beats.js";
|
import { presentationBeats, type BeatId } from "./beats.js";
|
||||||
import { BeatRail } from "./BeatRail.js";
|
import { BeatRail } from "./BeatRail.js";
|
||||||
|
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||||
import { NodeSpotlight } from "./NodeSpotlight.js";
|
import { NodeSpotlight } from "./NodeSpotlight.js";
|
||||||
import { OperationBlock } from "./OperationBlock.js";
|
import { OperationBlock } from "./OperationBlock.js";
|
||||||
import { OperatorChat } from "./OperatorChat.js";
|
import { OperatorChat } from "./OperatorChat.js";
|
||||||
@@ -11,17 +13,23 @@ import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
|||||||
type PresentationStageProps = {
|
type PresentationStageProps = {
|
||||||
readonly state: PresentationState;
|
readonly state: PresentationState;
|
||||||
readonly demo: DemoTimelineController;
|
readonly demo: DemoTimelineController;
|
||||||
|
readonly evidence: readonly EvidenceRecord[];
|
||||||
readonly jump: (beat: BeatId) => void;
|
readonly jump: (beat: BeatId) => void;
|
||||||
readonly selectNode: (nodeId: string) => void;
|
readonly selectNode: (nodeId: string) => void;
|
||||||
readonly clearNode: () => void;
|
readonly clearNode: () => void;
|
||||||
|
readonly openEvidence: () => void;
|
||||||
|
readonly closeOverlay: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PresentationStage = ({
|
export const PresentationStage = ({
|
||||||
state,
|
state,
|
||||||
demo,
|
demo,
|
||||||
|
evidence,
|
||||||
jump,
|
jump,
|
||||||
selectNode,
|
selectNode,
|
||||||
clearNode,
|
clearNode,
|
||||||
|
openEvidence,
|
||||||
|
closeOverlay,
|
||||||
}: PresentationStageProps) => {
|
}: PresentationStageProps) => {
|
||||||
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
||||||
|
|
||||||
@@ -34,9 +42,12 @@ export const PresentationStage = ({
|
|||||||
<div className="presentation-stage" data-beat={state.beat}>
|
<div className="presentation-stage" data-beat={state.beat}>
|
||||||
<OperatorChat state={state} />
|
<OperatorChat state={state} />
|
||||||
<section className="presentation-stage__main">
|
<section className="presentation-stage__main">
|
||||||
<StageCaption eyebrow="lda.chat defense" title={beat.title}>
|
<header className="presentation-stage__header">
|
||||||
<p>{beat.caption}</p>
|
<StageCaption eyebrow="lda.chat defense" title={beat.title}>
|
||||||
</StageCaption>
|
<p>{beat.caption}</p>
|
||||||
|
</StageCaption>
|
||||||
|
<button type="button" onClick={openEvidence}>Evidence</button>
|
||||||
|
</header>
|
||||||
{operationEvent && <OperationBlock event={operationEvent} />}
|
{operationEvent && <OperationBlock event={operationEvent} />}
|
||||||
<WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
|
<WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
|
||||||
{state.selectedNodeId && (
|
{state.selectedNodeId && (
|
||||||
@@ -47,6 +58,7 @@ export const PresentationStage = ({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<BeatRail activeBeat={state.beat} jump={jump} />
|
<BeatRail activeBeat={state.beat} jump={jump} />
|
||||||
|
<EvidenceDrawer records={evidence} mode={state.evidenceMode} close={closeOverlay} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -126,3 +126,44 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.evidence-drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
width: min(36rem, 90vw);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: oklch(0.13 0.025 250);
|
||||||
|
border-left: 1px solid oklch(0.34 0.04 250);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-drawer pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
.presentation-stage {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-caption h1 {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage {
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.presentation-route *,
|
||||||
|
.presentation-route *::before,
|
||||||
|
.presentation-route *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user