feat: add presentation evidence stage

This commit is contained in:
lda
2026-07-03 05:33:59 +07:00 Verified
parent 045f25ae72
commit a148338199
8 changed files with 148 additions and 11 deletions
+5 -3
View File
@@ -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.
+12
View File
@@ -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;
}
}