# React Presentation Mode Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a first `/present` vertical slice that turns the prepared report workflow replay into a keyboard-driven, 720p-friendly React presentation stage. **Architecture:** Keep the normal console as the product surface and add presentation mode as a separate React route. Presentation mode is a compositor over reusable scene components: a scripted beat controller drives chat, operation blocks, curated graph, node detail, trace/output, and evidence without building a generic layout engine. **Tech Stack:** React 19, Vite, TypeScript, React Router, Motion for React, existing Valibot/demo timeline models, existing console tests with Vitest/Testing Library, Playwright smoke through `pnpx --package @playwright/test`. ## Global Constraints - The first implementation is infrastructure with a provisional script, not the final defense talk. - `/present` defaults to replay and must work without a workflow RPC server. - Live workflow mutations must not be triggered by backward navigation. - The normal console must remain product-like and not inherit cinematic layout choices. - Presentation mode must be readable at `1280x720`. - Use semantic beat state; do not store React components or large layer config objects in beat data. - Keep TSX files lean by extracting focused scene components. - Component extraction from existing console/demo UI is in scope when it prevents duplicate graph, timeline, operation evidence, output, or trace rendering. - No slow typewriter effect; use instant messages with subtle fade/stagger. - Every motion path needs a reduced-motion fallback. --- ## File Structure Create the new presentation area under `web/apps/console/src/presentation/`: ```text presentation/ beats.ts # Beat ids, script metadata, hash helpers beats.test.ts presentation-state.ts # Reducer and keyboard/hash-safe state transitions presentation-state.test.ts PresentationRoute.tsx # Route shell and controller wiring PresentationRoute.test.tsx PresentationStage.tsx # 720p stage compositor BeatRail.tsx StageCaption.tsx OperatorChat.tsx OperationBlock.tsx WorkflowGraphStage.tsx NodeSpotlight.tsx EvidenceDrawer.tsx presentation.css ``` Restructure the app route boundary: ```text app/ App.tsx # BrowserRouter only AppRoutes.tsx # route table ConsoleHome.tsx # current console body moved from App.tsx ``` Modify existing files: ```text web/apps/console/package.json web/apps/console/src/styles/global.css web/apps/console/src/app/App.test.tsx web/README.md docs/current_roadmap.md ``` --- ### Task 1: Route Split And Dependencies **Files:** - Modify: `web/apps/console/package.json` - Create: `web/apps/console/src/app/AppRoutes.tsx` - Create: `web/apps/console/src/app/ConsoleHome.tsx` - Modify: `web/apps/console/src/app/App.tsx` - Modify: `web/apps/console/src/app/App.test.tsx` - Create: `web/apps/console/src/presentation/PresentationRoute.tsx` - Create: `web/apps/console/src/presentation/PresentationRoute.test.tsx` **Interfaces:** - Produces: `AppRoutes(): JSX.Element` - Produces: `ConsoleHome(): JSX.Element` - Produces: `PresentationRoute(): JSX.Element` - Consumes: existing `LdaReportDemoPanel`, `useDemoTimeline`, `LifecycleExplorer`, `SourceInventory` - [ ] **Step 1: Add dependencies** Run: ```powershell pnpm --dir web --filter @lda/console add react-router-dom motion ``` Expected: `web/apps/console/package.json` and `web/pnpm-lock.yaml` update, and install exits 0. Do not hand-edit guessed package versions; let pnpm resolve the current compatible versions and commit the lockfile. - [ ] **Step 2: Write failing route tests** Create `web/apps/console/src/presentation/PresentationRoute.test.tsx`: ```tsx import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { PresentationRoute } from "./PresentationRoute.js"; afterEach(() => cleanup()); describe("PresentationRoute", () => { it("renders the presentation stage entry point", () => { render(); expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument(); expect(screen.getByText(/planner decisions/i)).toBeInTheDocument(); }); }); ``` Modify `web/apps/console/src/app/App.test.tsx` to add: ```tsx import { MemoryRouter } from "react-router-dom"; import { AppRoutes } from "./AppRoutes.js"; ``` Add the test: ```tsx it("routes to presentation mode separately from the console", () => { render( , ); expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument(); expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull(); }); ``` - [ ] **Step 3: Run tests and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx src/app/App.test.tsx ``` Expected: FAIL because `PresentationRoute` and `AppRoutes` do not exist. - [ ] **Step 4: Move current console body into `ConsoleHome`** Create `web/apps/console/src/app/ConsoleHome.tsx` by moving the current body of `App.tsx` into this component. Keep the current connection reducer, source loading, lifecycle explorer, and demo panel behavior unchanged. The exported component signature must be: ```tsx export const ConsoleHome = () => { // existing App implementation body, unchanged except component name }; ``` - [ ] **Step 5: Replace `App.tsx` with router shell** Replace `web/apps/console/src/app/App.tsx` with: ```tsx import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./AppRoutes.js"; export const App = () => ( ); ``` - [ ] **Step 6: Add route table and placeholder presentation route** Create `web/apps/console/src/app/AppRoutes.tsx`: ```tsx import { Navigate, Route, Routes } from "react-router-dom"; import { ConsoleHome } from "./ConsoleHome.js"; import { PresentationRoute } from "../presentation/PresentationRoute.js"; export const AppRoutes = () => ( } /> } /> } /> } /> ); ``` Create `web/apps/console/src/presentation/PresentationRoute.tsx`: ```tsx export const PresentationRoute = () => (

Planner decisions are separated from deterministic runtime execution.

); ``` - [ ] **Step 7: Run tests and verify green** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx src/app/App.test.tsx pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS and typecheck clean. - [ ] **Step 8: Commit** ```powershell git add web/apps/console/package.json web/pnpm-lock.yaml web/apps/console/src/app web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/PresentationRoute.test.tsx git commit -m "feat: add presentation route shell" ``` --- ### Task 2: Beat Model, Hash Navigation, And Keyboard Control **Files:** - Create: `web/apps/console/src/presentation/beats.ts` - Create: `web/apps/console/src/presentation/beats.test.ts` - Create: `web/apps/console/src/presentation/presentation-state.ts` - Create: `web/apps/console/src/presentation/presentation-state.test.ts` - Modify: `web/apps/console/src/presentation/PresentationRoute.tsx` - Modify: `web/apps/console/src/presentation/PresentationRoute.test.tsx` **Interfaces:** - Produces: `type BeatId` - Produces: `presentationBeats: readonly PresentationBeat[]` - Produces: `beatFromHash(hash: string): BeatId` - Produces: `hashForBeat(beat: BeatId): string` - Produces: `presentationReducer(state, action): PresentationState` - Produces: `usePresentationKeyboard(onNext, onPrevious, onClose): void` inside `PresentationRoute.tsx` - [ ] **Step 1: Write beat helper tests** Create `web/apps/console/src/presentation/beats.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { beatFromHash, hashForBeat, presentationBeats } from "./beats.js"; describe("presentation beats", () => { it("has stable unique beat ids", () => { const ids = presentationBeats.map((beat) => beat.id); expect(new Set(ids).size).toBe(ids.length); expect(ids).toEqual([ "intro", "chat-request", "tool-call-start", "graph-reveal", "interrupt-approval", "resume-output", "trace-evidence", "boundary-wrap", ]); }); it("maps beats to hash fragments and falls back to intro", () => { expect(hashForBeat("interrupt-approval")).toBe("#interrupt-approval"); expect(beatFromHash("#interrupt-approval")).toBe("interrupt-approval"); expect(beatFromHash("#missing")).toBe("intro"); expect(beatFromHash("")).toBe("intro"); }); }); ``` - [ ] **Step 2: Write reducer tests** Create `web/apps/console/src/presentation/presentation-state.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { initialPresentationState, presentationReducer, } from "./presentation-state.js"; describe("presentationReducer", () => { it("advances and rewinds scripted beats without changing playback mode", () => { const advanced = presentationReducer(initialPresentationState, { type: "next" }); const rewound = presentationReducer(advanced, { type: "previous" }); expect(advanced.beat).toBe("chat-request"); expect(advanced.playbackMode).toBe("replay"); expect(rewound.beat).toBe("intro"); }); it("opens node detail without changing the current beat", () => { const state = presentationReducer(initialPresentationState, { type: "select_node", nodeId: "review_issues", }); expect(state.beat).toBe("intro"); expect(state.selectedNodeId).toBe("review_issues"); }); it("closes overlays before rewinding content", () => { const opened = presentationReducer(initialPresentationState, { type: "set_evidence_mode", mode: "open", }); const closed = presentationReducer(opened, { type: "close_overlay" }); expect(closed.evidenceMode).toBe("hidden"); expect(closed.beat).toBe("intro"); }); }); ``` - [ ] **Step 3: Run tests and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/beats.test.ts src/presentation/presentation-state.test.ts ``` Expected: FAIL because modules do not exist. - [ ] **Step 4: Implement beat model** Create `web/apps/console/src/presentation/beats.ts`: ```ts export type BeatId = | "intro" | "chat-request" | "tool-call-start" | "graph-reveal" | "interrupt-approval" | "resume-output" | "trace-evidence" | "boundary-wrap"; export type PresentationBeat = { readonly id: BeatId; readonly title: string; readonly caption: string; readonly lifecycleStep: string; }; export const presentationBeats: readonly PresentationBeat[] = [ { id: "intro", title: "Planner vs runtime", caption: "External planners propose actions; the workflow runtime owns deterministic execution.", lifecycleStep: "Frame", }, { id: "chat-request", title: "Operator request", caption: "The operator asks for a thesis readiness report through a chat-like product surface.", lifecycleStep: "Request", }, { id: "tool-call-start", title: "Product operation", caption: "The assistant invokes a prepared workflow operation instead of inventing ad-hoc script state.", lifecycleStep: "Run", }, { id: "graph-reveal", title: "Workflow graph", caption: "The graph shows reusable structure, not a one-off tool-calling transcript.", lifecycleStep: "Graph", }, { id: "interrupt-approval", title: "Typed interrupt", caption: "Human approval is a typed workflow boundary with explicit resume outcomes.", lifecycleStep: "Interrupt", }, { id: "resume-output", title: "Resume output", caption: "Resuming commits the approved branch and produces report and issue-board output.", lifecycleStep: "Resume", }, { id: "trace-evidence", title: "Trace evidence", caption: "Run records and trace frames make the execution inspectable after the fact.", lifecycleStep: "Trace", }, { id: "boundary-wrap", title: "Boundary", caption: "lda.chat is the workflow substrate that an external or scripted agent can operate.", lifecycleStep: "Boundary", }, ] as const; const beatIds = new Set(presentationBeats.map((beat) => beat.id)); export const beatFromHash = (hash: string): BeatId => { const id = hash.replace(/^#/, "") as BeatId; return beatIds.has(id) ? id : "intro"; }; export const hashForBeat = (beat: BeatId): string => `#${beat}`; ``` - [ ] **Step 5: Implement reducer** Create `web/apps/console/src/presentation/presentation-state.ts`: ```ts import { beatFromHash, presentationBeats, type BeatId } from "./beats.js"; export type PresentationState = { readonly beat: BeatId; readonly selectedNodeId: string | null; readonly chatMode: "full" | "rail" | "hidden"; readonly evidenceMode: "hidden" | "peek" | "open"; readonly playbackMode: "replay" | "live"; }; export type PresentationAction = | { readonly type: "next" } | { readonly type: "previous" } | { readonly type: "jump"; readonly beat: BeatId } | { readonly type: "jump_hash"; readonly hash: string } | { readonly type: "select_node"; readonly nodeId: string } | { readonly type: "clear_node" } | { readonly type: "set_evidence_mode"; readonly mode: PresentationState["evidenceMode"] } | { readonly type: "close_overlay" } | { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] }; export const initialPresentationState: PresentationState = { beat: "intro", selectedNodeId: null, chatMode: "full", evidenceMode: "hidden", playbackMode: "replay", }; const beatIndex = (beat: BeatId): number => presentationBeats.findIndex((candidate) => candidate.id === beat); const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationState => ({ ...state, beat, chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail", evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode, }); export const presentationReducer = ( state: PresentationState, action: PresentationAction, ): PresentationState => { switch (action.type) { case "next": { const next = Math.min(beatIndex(state.beat) + 1, presentationBeats.length - 1); return withDerivedModes(state, presentationBeats[next]?.id ?? state.beat); } case "previous": { const previous = Math.max(beatIndex(state.beat) - 1, 0); return withDerivedModes(state, presentationBeats[previous]?.id ?? state.beat); } case "jump": return withDerivedModes(state, action.beat); case "jump_hash": return withDerivedModes(state, beatFromHash(action.hash)); case "select_node": return { ...state, selectedNodeId: action.nodeId }; case "clear_node": return { ...state, selectedNodeId: null }; case "set_evidence_mode": return { ...state, evidenceMode: action.mode }; case "close_overlay": if (state.evidenceMode !== "hidden") return { ...state, evidenceMode: "hidden" }; if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null }; return state; case "set_playback_mode": return { ...state, playbackMode: action.mode }; } }; ``` - [ ] **Step 6: Wire hash and keyboard in route** Modify `web/apps/console/src/presentation/PresentationRoute.tsx` to use the reducer: ```tsx import { useEffect, useReducer } from "react"; import { hashForBeat, presentationBeats } from "./beats.js"; import { initialPresentationState, presentationReducer, } from "./presentation-state.js"; export const PresentationRoute = () => { const [state, dispatch] = useReducer( presentationReducer, initialPresentationState, (initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }), ); useEffect(() => { const hash = hashForBeat(state.beat); if (window.location.hash !== hash) { window.history.replaceState(null, "", hash); } }, [state.beat]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === " " || event.key === "ArrowRight") { event.preventDefault(); dispatch({ type: "next" }); } else if (event.key === "ArrowLeft") { event.preventDefault(); dispatch({ type: "previous" }); } else if (event.key === "Escape") { dispatch({ type: "close_overlay" }); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]; return (

{beat.caption}

); }; ``` - [ ] **Step 7: Extend route tests for keyboard and hash** In `PresentationRoute.test.tsx`, add: ```tsx it("starts from a hash beat and advances with keyboard", async () => { window.location.hash = "#interrupt-approval"; render(); expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument(); }); ``` - [ ] **Step 8: Run tests and verify green** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS and typecheck clean. - [ ] **Step 9: Commit** ```powershell git add web/apps/console/src/presentation git commit -m "feat: add presentation beat controller" ``` --- ### Task 3: Replay-Backed Stage And Scene Components **Files:** - Create: `web/apps/console/src/presentation/PresentationStage.tsx` - Create: `web/apps/console/src/presentation/BeatRail.tsx` - Create: `web/apps/console/src/presentation/StageCaption.tsx` - Create: `web/apps/console/src/presentation/OperatorChat.tsx` - Modify: `web/apps/console/src/presentation/PresentationRoute.tsx` - Modify: `web/apps/console/src/presentation/PresentationRoute.test.tsx` **Interfaces:** - Consumes: `PresentationState`, `presentationBeats` - Consumes: `useDemoTimeline(null, recordEvidence)` for replay data - Produces: `PresentationStage(props): JSX.Element` - Produces: `BeatRail(props): JSX.Element` - Produces: `OperatorChat(props): JSX.Element` - [ ] **Step 1: Write failing stage test** Add to `PresentationRoute.test.tsx`: ```tsx it("renders replay-first chat, beat rail, and stage caption", () => { render(); expect(screen.getByText(/Operator/i)).toBeInTheDocument(); expect(screen.getByText(/Prepare the thesis readiness report/i)).toBeInTheDocument(); expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument(); expect(screen.getByText(/Replay/i)).toBeInTheDocument(); }); ``` - [ ] **Step 2: Run test and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx ``` Expected: FAIL because scene components are missing. - [ ] **Step 3: Implement `StageCaption`** Create `web/apps/console/src/presentation/StageCaption.tsx`: ```tsx import type { ReactNode } from "react"; type StageCaptionProps = { readonly eyebrow: string; readonly title: string; readonly children: ReactNode; }; export const StageCaption = ({ eyebrow, title, children }: StageCaptionProps) => (

{eyebrow}

{title}

{children}
); ``` `children` is `ReactNode` so future beats can include short tables, charts, or ``/`` elements without changing the caption API. - [ ] **Step 4: Implement `BeatRail`** Create `web/apps/console/src/presentation/BeatRail.tsx`: ```tsx import { presentationBeats, type BeatId } from "./beats.js"; type BeatRailProps = { readonly activeBeat: BeatId; readonly jump: (beat: BeatId) => void; }; export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => ( ); ``` - [ ] **Step 5: Implement `OperatorChat`** Create `web/apps/console/src/presentation/OperatorChat.tsx`: ```tsx import type { PresentationState } from "./presentation-state.js"; type OperatorChatProps = { readonly state: PresentationState; }; export const OperatorChat = ({ state }: OperatorChatProps) => ( ); ``` - [ ] **Step 6: Implement `PresentationStage`** Create `web/apps/console/src/presentation/PresentationStage.tsx`: ```tsx import type { DemoTimelineController } from "../demo/useDemoTimeline.js"; import { presentationBeats, type BeatId } from "./beats.js"; import { BeatRail } from "./BeatRail.js"; import { OperatorChat } from "./OperatorChat.js"; import { StageCaption } from "./StageCaption.js"; import type { PresentationState } from "./presentation-state.js"; type PresentationStageProps = { readonly state: PresentationState; readonly demo: DemoTimelineController; readonly jump: (beat: BeatId) => void; }; export const PresentationStage = ({ state, demo, jump }: PresentationStageProps) => { const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]; return (

{beat.caption}

{demo.state.mode === "replay" ? "Replay" : "Live"} ยท {demo.state.phase}

); }; ``` - [ ] **Step 7: Wire stage into route** In `PresentationRoute.tsx`, import `useDemoTimeline`, `PresentationStage`, and local evidence recorder: ```tsx import { useCallback, useEffect, useReducer, useState } from "react"; import type { EvidenceRecord } from "../app/state.js"; import { useDemoTimeline } from "../demo/useDemoTimeline.js"; import { PresentationStage } from "./PresentationStage.js"; ``` Add: ```tsx const [evidence, setEvidence] = useState([]); const recordEvidence = useCallback((record: EvidenceRecord) => { setEvidence((records) => [...records, record]); }, []); const demo = useDemoTimeline(null, recordEvidence); ``` Replace the `

{beat.caption}

` route body with: ```tsx dispatch({ type: "jump", beat: beatId })} /> ``` Keep `evidence` in state even if unused in this task so Task 6 can render it without changing the controller boundary. To avoid an unused variable, pass it later only after Task 6; for this task, name it `_evidence`: ```tsx const [_evidence, setEvidence] = useState([]); ``` - [ ] **Step 8: Add CSS entrypoint** Create `web/apps/console/src/presentation/presentation.css`: ```css .presentation-route { min-height: 100vh; background: oklch(0.12 0.025 250); color: oklch(0.96 0.01 250); } .presentation-stage { min-height: 100vh; display: grid; grid-template-rows: 1fr auto; gap: 1rem; padding: 1.25rem; } .presentation-stage__main { display: grid; align-content: center; gap: 1rem; max-width: 72rem; } .operator-chat { max-width: 48rem; display: grid; gap: 0.75rem; } .operator-chat[data-mode="rail"] { max-width: 22rem; } .chat-message { border: 1px solid oklch(0.34 0.035 250); background: oklch(0.18 0.025 250); padding: 0.85rem 1rem; border-radius: 0.85rem; } .beat-rail { display: flex; gap: 0.5rem; overflow-x: auto; } .beat-rail button { min-width: 8rem; text-align: left; border: 1px solid oklch(0.36 0.04 250); background: oklch(0.18 0.025 250); color: inherit; padding: 0.65rem 0.75rem; } .beat-rail button[data-active="true"] { background: oklch(0.7 0.16 195); color: oklch(0.12 0.025 250); } ``` Import it in `PresentationRoute.tsx`: ```tsx import "./presentation.css"; ``` - [ ] **Step 9: Run tests and verify green** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS and typecheck clean. - [ ] **Step 10: Commit** ```powershell git add web/apps/console/src/presentation git commit -m "feat: render presentation stage shell" ``` --- ### Task 4: Operation Block And Replay Event Interpretation **Files:** - Create: `web/apps/console/src/presentation/OperationBlock.tsx` - Create: `web/apps/console/src/presentation/OperationBlock.test.tsx` - Modify: `web/apps/console/src/presentation/PresentationStage.tsx` - Modify: `web/apps/console/src/presentation/presentation.css` **Interfaces:** - Consumes: `DemoEvent` from `../demo/timeline/models.js` - Produces: `OperationBlock({ event }): JSX.Element` - [ ] **Step 1: Write failing operation block tests** Create `web/apps/console/src/presentation/OperationBlock.test.tsx`: ```tsx import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import type { DemoEvent } from "../demo/timeline/models.js"; import { OperationBlock } from "./OperationBlock.js"; afterEach(() => cleanup()); const event: DemoEvent = { id: "recorded-1-run-start", sequence: 1, stage: "run_start", operation: "workflow.runs.start", reason: "Start the prepared report workflow.", equivalentCli: "uv run wf run start lda_report_case_study.default --input ''", params: { deployment_id: "lda_report_case_study.default" }, rawResponse: { result: { status: "interrupted" } }, interpreted: { status: "interrupted", interrupt: { kind: "issue_review" }, }, durationMs: 88, resultingIds: { deploymentId: "lda_report_case_study.default", runId: "run_demo", }, recordedAt: "2026-07-03T00:00:01.000Z", }; describe("OperationBlock", () => { it("shows command, raw response, and interpreted result", () => { render(); expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument(); expect(screen.getByText(/uv run wf run start/i)).toBeInTheDocument(); expect(screen.getByText(/status/i)).toBeInTheDocument(); expect(screen.getByText(/issue_review/i)).toBeInTheDocument(); expect(screen.getByText(/run_demo/i)).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run test and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/OperationBlock.test.tsx ``` Expected: FAIL because `OperationBlock` does not exist. - [ ] **Step 3: Implement `OperationBlock`** Create `web/apps/console/src/presentation/OperationBlock.tsx`: ```tsx import type { DemoEvent } from "../demo/timeline/models.js"; const formatJson = (value: unknown): string => JSON.stringify(value, null, 2); type OperationBlockProps = { readonly event: DemoEvent; }; export const OperationBlock = ({ event }: OperationBlockProps) => (

{event.operation ?? event.stage}

{event.durationMs} ms
{event.equivalentCli && (
{event.equivalentCli}
)}

Raw

{formatJson(event.rawResponse)}

Interpreted

{formatJson(event.interpreted)}
{event.resultingIds.deploymentId} {event.resultingIds.runId && {event.resultingIds.runId}}
); ``` - [ ] **Step 4: Select event for current beat** In `PresentationStage.tsx`, derive a visible operation event from the demo timeline: ```tsx const operationEvent = demo.state.events.find((event) => event.stage === "run_start") ?? demo.state.events.find((event) => event.operation !== null) ?? null; ``` Render it after `StageCaption` when present: ```tsx {operationEvent && } ``` Import: ```tsx import { OperationBlock } from "./OperationBlock.js"; ``` - [ ] **Step 5: Update route test to prove operation block can appear** Add to `PresentationRoute.test.tsx`: ```tsx it("can advance replay far enough to show a product operation block", async () => { render(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); expect(await screen.findByText(/workflow.runs.start/i)).toBeInTheDocument(); }); ``` If the replay controller does not auto-start yet, this test will fail until Task 5 starts replay on mount. Keep the test here to pin the intended behavior. - [ ] **Step 6: Add operation CSS** Append to `presentation.css`: ```css .operation-block { border: 1px solid oklch(0.36 0.04 250); background: oklch(0.16 0.025 250); padding: 1rem; border-radius: 0.85rem; } .operation-block header, .operation-block footer { display: flex; justify-content: space-between; gap: 1rem; color: oklch(0.78 0.035 250); } .operation-block__command, .operation-block pre { white-space: pre-wrap; overflow-wrap: anywhere; } .operation-block__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } @media (max-width: 800px) { .operation-block__grid { grid-template-columns: 1fr; } } ``` - [ ] **Step 7: Run tests and verify green or document dependency on Task 5** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/OperationBlock.test.tsx src/presentation/PresentationRoute.test.tsx ``` Expected: `OperationBlock.test.tsx` PASS. If `PresentationRoute.test.tsx` fails because replay is not started on mount, leave the test failing and complete Task 5 immediately. - [ ] **Step 8: Commit if all tests green; otherwise defer commit until Task 5** If green: ```powershell git add web/apps/console/src/presentation git commit -m "feat: show presentation operation blocks" ``` --- ### Task 5: Replay Startup, Graph Stage, And Node Spotlight **Files:** - Create: `web/apps/console/src/presentation/WorkflowGraphStage.tsx` - Create: `web/apps/console/src/presentation/NodeSpotlight.tsx` - Create: `web/apps/console/src/presentation/WorkflowGraphStage.test.tsx` - Modify: `web/apps/console/src/presentation/PresentationRoute.tsx` - Modify: `web/apps/console/src/presentation/PresentationStage.tsx` - Modify: `web/apps/console/src/presentation/presentation.css` **Interfaces:** - Produces: `presentationNodes` - Produces: `WorkflowGraphStage({ selectedNodeId, selectNode })` - Produces: `NodeSpotlight({ nodeId, close })` - [ ] **Step 1: Write graph/spotlight tests** Create `web/apps/console/src/presentation/WorkflowGraphStage.test.tsx`: ```tsx import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WorkflowGraphStage } from "./WorkflowGraphStage.js"; afterEach(() => cleanup()); describe("WorkflowGraphStage", () => { it("renders curated workflow nodes and allows node selection", async () => { const selectNode = vi.fn(); render(); await userEvent.click(screen.getByRole("button", { name: /issue review/i })); expect(selectNode).toHaveBeenCalledWith("review_issues"); }); }); ``` Add to `PresentationRoute.test.tsx`: ```tsx it("shows node spotlight when a graph node is selected", async () => { render(); await userEvent.click(screen.getByRole("button", { name: /issue review/i })); expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument(); expect(screen.getByText(/NodeUse/i)).toBeInTheDocument(); }); ``` Add missing import: ```tsx import userEvent from "@testing-library/user-event"; ``` - [ ] **Step 2: Run tests and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/WorkflowGraphStage.test.tsx src/presentation/PresentationRoute.test.tsx ``` Expected: FAIL because graph components do not exist. - [ ] **Step 3: Implement curated graph** Create `web/apps/console/src/presentation/WorkflowGraphStage.tsx`: ```tsx export type PresentationNode = { readonly id: string; readonly label: 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 }, ]; type WorkflowGraphStageProps = { readonly selectedNodeId: string | null; readonly selectNode: (nodeId: string) => void; }; export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraphStageProps) => (
{presentationNodes.map((node) => ( ))}
); ``` - [ ] **Step 4: Implement node spotlight** Create `web/apps/console/src/presentation/NodeSpotlight.tsx`: ```tsx import { presentationNodes } from "./WorkflowGraphStage.js"; type NodeSpotlightProps = { readonly nodeId: string; readonly close: () => void; }; const nodeDescription = (nodeId: string): string => { if (nodeId === "review_issues") { return "NodeUse of a typed interrupt boundary. It exposes request and resume schemas and waits for a submitted or cancelled outcome."; } if (nodeId === "create_issues") { return "NodeUse that writes selected review items into the local issue-board source."; } return "NodeUse in the prepared report workflow. The presentation graph is curated, but every node maps back to real workflow/run evidence."; }; export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => { const node = presentationNodes.find((candidate) => candidate.id === nodeId); if (!node) return null; return ( ); }; ``` - [ ] **Step 5: Wire graph and spotlight into stage** In `PresentationStage.tsx`, import: ```tsx import { WorkflowGraphStage } from "./WorkflowGraphStage.js"; import { NodeSpotlight } from "./NodeSpotlight.js"; ``` Add props: ```ts readonly selectNode: (nodeId: string) => void; readonly clearNode: () => void; ``` Render graph inside `presentation-stage__main`: ```tsx {state.selectedNodeId && ( )} ``` In `PresentationRoute.tsx`, pass: ```tsx selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })} clearNode={() => dispatch({ type: "clear_node" })} ``` - [ ] **Step 6: Start replay automatically on presentation route mount** In `PresentationRoute.tsx`, add: ```tsx useEffect(() => { if (demo.state.phase === "ready") { demo.setMode("replay"); demo.start(); } }, [demo]); ``` If this causes an effect loop because `demo` is not stable, narrow the dependency to: ```tsx useEffect(() => { if (demo.state.phase === "ready") { demo.setMode("replay"); demo.start(); } }, [demo.state.phase, demo.setMode, demo.start]); ``` - [ ] **Step 7: Add graph CSS** Append to `presentation.css`: ```css .workflow-graph-stage { position: relative; min-height: 16rem; border: 1px solid oklch(0.32 0.04 250); background: oklch(0.14 0.025 250); overflow: hidden; } .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; padding: 0.7rem 0.9rem; border-radius: 0.8rem; } .workflow-graph-stage__node[data-kind="interrupt"] { border-color: oklch(0.76 0.18 70); } .workflow-graph-stage__node[data-selected="true"] { outline: 3px solid oklch(0.72 0.17 195); } .node-spotlight { position: fixed; right: 1.25rem; top: 1.25rem; width: min(28rem, calc(100vw - 2.5rem)); border: 1px solid oklch(0.42 0.05 250); background: oklch(0.17 0.025 250); color: inherit; padding: 1rem; z-index: 20; } ``` - [ ] **Step 8: Run tests and verify green** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS and typecheck clean. - [ ] **Step 9: Commit** ```powershell git add web/apps/console/src/presentation git commit -m "feat: add presentation graph spotlight" ``` --- ### Task 6: Evidence Drawer, 720p Browser Smoke, And Docs **Files:** - Create: `web/apps/console/src/presentation/EvidenceDrawer.tsx` - Create: `web/apps/console/src/presentation/EvidenceDrawer.test.tsx` - Modify: `web/apps/console/src/presentation/PresentationRoute.tsx` - Modify: `web/apps/console/src/presentation/PresentationStage.tsx` - Modify: `web/apps/console/src/presentation/presentation.css` - Modify: `web/README.md` - Modify: `docs/current_roadmap.md` **Interfaces:** - Consumes: local `EvidenceRecord[]` from `PresentationRoute` - Produces: `EvidenceDrawer({ records, mode, close }): JSX.Element` - [ ] **Step 1: Write failing evidence drawer test** Create `web/apps/console/src/presentation/EvidenceDrawer.test.tsx`: ```tsx 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(); expect(screen.getByRole("complementary", { name: /presentation evidence/i })).toBeInTheDocument(); expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument(); expect(screen.getByText(/status/i)).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run test and verify red** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/presentation/EvidenceDrawer.test.tsx ``` Expected: FAIL because `EvidenceDrawer` does not exist. - [ ] **Step 3: Implement `EvidenceDrawer`** Create `web/apps/console/src/presentation/EvidenceDrawer.tsx`: ```tsx 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 ( ); }; ``` - [ ] **Step 4: Wire evidence into route/stage** Rename `_evidence` in `PresentationRoute.tsx` to `evidence`. Add `openEvidence` and `closeOverlay` props to `PresentationStage`: ```ts readonly evidence: readonly EvidenceRecord[]; readonly openEvidence: () => void; readonly closeOverlay: () => void; ``` Pass from route: ```tsx evidence={evidence} openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })} closeOverlay={() => dispatch({ type: "close_overlay" })} ``` In `PresentationStage.tsx`, import and render: ```tsx import { EvidenceDrawer } from "./EvidenceDrawer.js"; ``` Add a button near the stage header: ```tsx ``` Render drawer: ```tsx ``` - [ ] **Step 5: Add evidence CSS and 720p guardrails** Append: ```css .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; } } ``` - [ ] **Step 6: Update docs** In `web/README.md`, add a short section: ```md ### 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 ``` ``` In `docs/current_roadmap.md`, mark the presentation route foundation as completed only after the browser smoke passes: ```md 7. Completed: React presentation mode foundation for the prepared workflow demo... ``` - [ ] **Step 7: Run full web verification** Run: ```powershell pnpm --dir web test pnpm --dir web typecheck pnpm --dir web build ``` Expected: - console, RPC, and server tests pass - typecheck clean - build clean - [ ] **Step 8: Run Playwright smoke** Create a temporary spec at `tmp-playwright/presentation-smoke.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test("presentation route is keyboard navigable at 720p", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("http://127.0.0.1:5173/present"); await expect(page.getByRole("main", { name: /lda.chat presentation/i })).toBeVisible(); await expect(page.getByText(/Replay/i)).toBeVisible(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await expect(page.getByText(/workflow.runs.start/i)).toBeVisible(); await page.getByRole("button", { name: /issue review/i }).click(); await expect(page.getByRole("dialog", { name: /issue review/i })).toBeVisible(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: /^Evidence$/ }).click(); await expect(page.getByRole("complementary", { name: /presentation evidence/i })).toBeVisible(); }); ``` With `pnpm --dir web dev` already running, run: ```powershell pnpx --package @playwright/test playwright test tmp-playwright/presentation-smoke.spec.ts --browser=chromium --reporter=line ``` Expected: PASS. Remove the temporary spec and generated screenshots/test-results after the smoke: ```powershell Remove-Item -LiteralPath tmp-playwright\presentation-smoke.spec.ts -ErrorAction SilentlyContinue Remove-Item -Recurse -Force test-results -ErrorAction SilentlyContinue ``` - [ ] **Step 9: Commit** ```powershell git add web/apps/console/src/presentation web/README.md docs/current_roadmap.md git commit -m "feat: add presentation evidence stage" ``` --- ## Final Review Checklist - [ ] Search the plan, spec, and presentation source for placeholder markers and stale Astro-first wording before final commit. - [ ] `pnpm --dir web test` passes. - [ ] `pnpm --dir web typecheck` passes. - [ ] `pnpm --dir web build` passes. - [ ] Playwright smoke passes at `1280x720`. - [ ] `/` and `/console` still show the normal console. - [ ] `/present` defaults to replay and can be driven by keyboard. - [ ] `#interrupt-approval` deep link lands on the interrupt beat. - [ ] Clicking a graph node opens `NodeSpotlight`; `Esc` closes it. - [ ] Back navigation rewinds beat state and does not trigger workflow mutation calls. ## Self-Review Notes Spec coverage: - Route, replay default, hash navigation, keyboard controls: Tasks 1-2. - Provisional script and beat editing: Task 2. - Component extraction and lean TSX files: Tasks 1, 3, 5, 6. - Operation block: Task 4. - Curated graph and NodeUse detail: Task 5. - Evidence drawer and 720p smoke: Task 6. - Console remains product-like: Task 1 route split and final checklist. Known deferred work: - Final defense copy and polished visual identity. - Real constrained demo agent macro. - Static slide/appendix shell. - Advanced motion choreography beyond basic CSS and Motion dependency setup.