Files
lda-wf/docs/historical/superpowers/plans/2026-07-03-react-presentation-mode.md
T

49 KiB

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/:

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:

app/
  App.tsx                  # BrowserRouter only
  AppRoutes.tsx            # route table
  ConsoleHome.tsx          # current console body moved from App.tsx

Modify existing files:

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:

    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:

    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(<PresentationRoute />);
    
        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:

    import { MemoryRouter } from "react-router-dom";
    import { AppRoutes } from "./AppRoutes.js";
    

    Add the test:

    it("routes to presentation mode separately from the console", () => {
      render(
        <MemoryRouter initialEntries={["/present"]}>
          <AppRoutes />
        </MemoryRouter>,
      );
    
      expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
      expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull();
    });
    
  • Step 3: Run tests and verify red

    Run:

    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:

    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:

    import { BrowserRouter } from "react-router-dom";
    import { AppRoutes } from "./AppRoutes.js";
    
    export const App = () => (
      <BrowserRouter>
        <AppRoutes />
      </BrowserRouter>
    );
    
  • Step 6: Add route table and placeholder presentation route

    Create web/apps/console/src/app/AppRoutes.tsx:

    import { Navigate, Route, Routes } from "react-router-dom";
    import { ConsoleHome } from "./ConsoleHome.js";
    import { PresentationRoute } from "../presentation/PresentationRoute.js";
    
    export const AppRoutes = () => (
      <Routes>
        <Route path="/" element={<ConsoleHome />} />
        <Route path="/console" element={<ConsoleHome />} />
        <Route path="/present" element={<PresentationRoute />} />
        <Route path="*" element={<Navigate to="/" replace />} />
      </Routes>
    );
    

    Create web/apps/console/src/presentation/PresentationRoute.tsx:

    export const PresentationRoute = () => (
      <main className="presentation-route" aria-label="lda.chat presentation">
        <p>Planner decisions are separated from deterministic runtime execution.</p>
      </main>
    );
    
  • Step 7: Run tests and verify green

    Run:

    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

    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:

    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:

    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:

    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:

    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<BeatId>(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:

    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:

    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 (
        <main className="presentation-route" aria-label="lda.chat presentation">
          <p>{beat.caption}</p>
        </main>
      );
    };
    
  • Step 7: Extend route tests for keyboard and hash

    In PresentationRoute.test.tsx, add:

    it("starts from a hash beat and advances with keyboard", async () => {
      window.location.hash = "#interrupt-approval";
      render(<PresentationRoute />);
    
      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:

    pnpm --dir web --filter @lda/console test -- src/presentation
    pnpm --dir web --filter @lda/console typecheck
    

    Expected: PASS and typecheck clean.

  • Step 9: Commit

    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:

    it("renders replay-first chat, beat rail, and stage caption", () => {
      render(<PresentationRoute />);
    
      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:

    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:

    import type { ReactNode } from "react";
    
    type StageCaptionProps = {
      readonly eyebrow: string;
      readonly title: string;
      readonly children: ReactNode;
    };
    
    export const StageCaption = ({ eyebrow, title, children }: StageCaptionProps) => (
      <section className="stage-caption" aria-label={title}>
        <p className="stage-caption__eyebrow">{eyebrow}</p>
        <h1>{title}</h1>
        <div>{children}</div>
      </section>
    );
    

    children is ReactNode so future beats can include short tables, charts, or <a>/<Link> elements without changing the caption API.

  • Step 4: Implement BeatRail

    Create web/apps/console/src/presentation/BeatRail.tsx:

    import { presentationBeats, type BeatId } from "./beats.js";
    
    type BeatRailProps = {
      readonly activeBeat: BeatId;
      readonly jump: (beat: BeatId) => void;
    };
    
    export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
      <nav className="beat-rail" aria-label="presentation beat rail">
        {presentationBeats.map((beat) => (
          <button
            key={beat.id}
            type="button"
            data-active={beat.id === activeBeat}
            onClick={() => jump(beat.id)}
          >
            <span>{beat.lifecycleStep}</span>
            <small>{beat.title}</small>
          </button>
        ))}
      </nav>
    );
    
  • Step 5: Implement OperatorChat

    Create web/apps/console/src/presentation/OperatorChat.tsx:

    import type { PresentationState } from "./presentation-state.js";
    
    type OperatorChatProps = {
      readonly state: PresentationState;
    };
    
    export const OperatorChat = ({ state }: OperatorChatProps) => (
      <aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
        <div className="chat-message chat-message--operator">
          <strong>Operator</strong>
          <p>Prepare the thesis readiness report.</p>
        </div>
        <div className="chat-message chat-message--system">
          <strong>lda.chat</strong>
          <p>Found prepared workflow recipe: <code>lda_report_case_study</code>.</p>
        </div>
        <div className="chat-message chat-message--system">
          <strong>lda.chat</strong>
          <p>Replay mode is active. Live execution is available when connected.</p>
        </div>
      </aside>
    );
    
  • Step 6: Implement PresentationStage

    Create web/apps/console/src/presentation/PresentationStage.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 (
        <div className="presentation-stage" data-beat={state.beat}>
          <OperatorChat state={state} />
          <section className="presentation-stage__main">
            <StageCaption eyebrow="lda.chat defense" title={beat.title}>
              <p>{beat.caption}</p>
            </StageCaption>
            <p className="presentation-stage__mode">
              {demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
            </p>
          </section>
          <BeatRail activeBeat={state.beat} jump={jump} />
        </div>
      );
    };
    
  • Step 7: Wire stage into route

    In PresentationRoute.tsx, import useDemoTimeline, PresentationStage, and local evidence recorder:

    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:

    const [evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
    const recordEvidence = useCallback((record: EvidenceRecord) => {
      setEvidence((records) => [...records, record]);
    }, []);
    const demo = useDemoTimeline(null, recordEvidence);
    

    Replace the <p>{beat.caption}</p> route body with:

    <PresentationStage
      state={state}
      demo={demo}
      jump={(beatId) => 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:

    const [_evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
    
  • Step 8: Add CSS entrypoint

    Create web/apps/console/src/presentation/presentation.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:

    import "./presentation.css";
    
  • Step 9: Run tests and verify green

    Run:

    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

    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:

    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 '<json>'",
      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(<OperationBlock event={event} />);
    
        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:

    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:

    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) => (
      <section className="operation-block" aria-label={`${event.stage} operation`}>
        <header>
          <p>{event.operation ?? event.stage}</p>
          <small>{event.durationMs} ms</small>
        </header>
        {event.equivalentCli && (
          <pre className="operation-block__command"><code>{event.equivalentCli}</code></pre>
        )}
        <div className="operation-block__grid">
          <section>
            <h3>Raw</h3>
            <pre><code>{formatJson(event.rawResponse)}</code></pre>
          </section>
          <section>
            <h3>Interpreted</h3>
            <pre><code>{formatJson(event.interpreted)}</code></pre>
          </section>
        </div>
        <footer>
          <span>{event.resultingIds.deploymentId}</span>
          {event.resultingIds.runId && <span>{event.resultingIds.runId}</span>}
        </footer>
      </section>
    );
    
  • Step 4: Select event for current beat

    In PresentationStage.tsx, derive a visible operation event from the demo timeline:

    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:

    {operationEvent && <OperationBlock event={operationEvent} />}
    

    Import:

    import { OperationBlock } from "./OperationBlock.js";
    
  • Step 5: Update route test to prove operation block can appear

    Add to PresentationRoute.test.tsx:

    it("can advance replay far enough to show a product operation block", async () => {
      render(<PresentationRoute />);
      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:

    .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:

    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:

    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:

    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(<WorkflowGraphStage selectedNodeId={null} selectNode={selectNode} />);
    
        await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
        expect(selectNode).toHaveBeenCalledWith("review_issues");
      });
    });
    

    Add to PresentationRoute.test.tsx:

    it("shows node spotlight when a graph node is selected", async () => {
      render(<PresentationRoute />);
      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:

    import userEvent from "@testing-library/user-event";
    
  • Step 2: Run tests and verify red

    Run:

    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:

    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) => (
      <section className="workflow-graph-stage" aria-label="workflow graph">
        {presentationNodes.map((node) => (
          <button
            key={node.id}
            type="button"
            className="workflow-graph-stage__node"
            data-kind={node.kind}
            data-selected={selectedNodeId === node.id}
            style={{ left: `${node.x}%`, top: `${node.y}%` }}
            onClick={() => selectNode(node.id)}
          >
            {node.label}
          </button>
        ))}
      </section>
    );
    
  • Step 4: Implement node spotlight

    Create web/apps/console/src/presentation/NodeSpotlight.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 (
        <aside className="node-spotlight" role="dialog" aria-label={node.label}>
          <button type="button" onClick={close}>Close</button>
          <p>NodeUse</p>
          <h2>{node.label}</h2>
          <p>{nodeDescription(node.id)}</p>
        </aside>
      );
    };
    
  • Step 5: Wire graph and spotlight into stage

    In PresentationStage.tsx, import:

    import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
    import { NodeSpotlight } from "./NodeSpotlight.js";
    

    Add props:

    readonly selectNode: (nodeId: string) => void;
    readonly clearNode: () => void;
    

    Render graph inside presentation-stage__main:

    <WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
    {state.selectedNodeId && (
      <NodeSpotlight nodeId={state.selectedNodeId} close={clearNode} />
    )}
    

    In PresentationRoute.tsx, pass:

    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:

    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:

    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:

    .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:

    pnpm --dir web --filter @lda/console test -- src/presentation
    pnpm --dir web --filter @lda/console typecheck
    

    Expected: PASS and typecheck clean.

  • Step 9: Commit

    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:

    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(/status/i)).toBeInTheDocument();
      });
    });
    
  • Step 2: Run test and verify red

    Run:

    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:

    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>
      );
    };
    
  • Step 4: Wire evidence into route/stage

    Rename _evidence in PresentationRoute.tsx to evidence.

    Add openEvidence and closeOverlay props to PresentationStage:

    readonly evidence: readonly EvidenceRecord[];
    readonly openEvidence: () => void;
    readonly closeOverlay: () => void;
    

    Pass from route:

    evidence={evidence}
    openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
    closeOverlay={() => dispatch({ type: "close_overlay" })}
    

    In PresentationStage.tsx, import and render:

    import { EvidenceDrawer } from "./EvidenceDrawer.js";
    

    Add a button near the stage header:

    <button type="button" onClick={openEvidence}>Evidence</button>
    

    Render drawer:

    <EvidenceDrawer records={evidence} mode={state.evidenceMode} close={closeOverlay} />
    
  • Step 5: Add evidence CSS and 720p guardrails

    Append:

    .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:

    ### 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:

    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:

    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:

    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:

    Remove-Item -LiteralPath tmp-playwright\presentation-smoke.spec.ts -ErrorAction SilentlyContinue
    Remove-Item -Recurse -Force test-results -ErrorAction SilentlyContinue
    
  • Step 9: Commit

    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.