# Editorial Canvas And Interactive Figure 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:** Replace the presentation's theme-switching dashboard shell with one scalable editorial canvas and prove a reusable recursive Interactive Figure through Scene 6's architecture explanation.
**Architecture:** Keep `/present`, canonical replay evidence, storyboard navigation, and reducer ownership, but remove obsolete whole-stage theme state and persistent audience chrome. Add a deep `figures/` module whose small interface accepts a declarative catalog, Focus Path, active node, and change callback while hiding catalog validation, recursive resolution, Dagre layout, React Flow integration, keyboard navigation, breadcrumbs, and evidence metadata. Scene 6 becomes the tracer-bullet consumer; chat replacement, Guided Run Beat Gates, Schema Form Surface, presenter companion, and Scene 10 product-graph migration remain separate plans.
**Tech Stack:** React 19, TypeScript 6, Tailwind CSS 4.3.2 through `@tailwindcss/vite` 4.3.2, Newsreader Variable 5.2.10, Source Sans 3, IBM Plex Mono, React Flow 12.11.1, Dagre 3.0.0, Motion 12.42.2, Vitest 4.1.9, Testing Library, Playwright CLI.
## Global Constraints
- Treat [`2026-07-04-defense-presentation-storyboard-design.md`](../specs/2026-07-04-defense-presentation-storyboard-design.md) as the live presentation contract.
- Preserve `/present`, `/console`, canonical replay data, discussion deep links, and existing workflow RPC behavior.
- Do not implement chat replacement, Prompt Macros, Beat Gates, Approval Session, Schema Form Surface, `/presenter`, or remote transport in this slice.
- Use one warm Editorial Canvas. Do not retain whole-stage paper/night switching or dark-mode return transitions.
- Product surfaces may retain native styling, but presentation code must not fork product components.
- Design at exactly `1280x720`; other viewports scale and letterbox the complete canvas instead of reflowing scenes.
- Use only Reveal, Expand, and Reframe motion. Unchanged nodes must remain mounted and must not replay entrance animation.
- Use blue for planner/client intent, green for runtime execution, and orange for human intervention; labels and shapes must preserve meaning without color.
- Each beat shows one primary visual, one short claim, and at most three supporting labels.
- Interactive figures use conceptual labels first; package and symbol details appear after expansion.
- Every factual Figure Node carries an evidence pointer. Motivational nodes may omit one.
- Tailwind is additive. Disable Preflight so existing `/console` CSS is not reset.
- Do not introduce `any`, unsafe assertions, a new Effect service, a second presentation state store, or compatibility wrappers for removed internal UI contracts.
- Update tests before implementation, verify the red state, then implement the smallest passing behavior.
- Scope browser review to 100 percent zoom at `1280x720` and verify `/console` after presentation changes.
---
### Task 1: Add The Editorial Canvas Foundation
**Files:**
- Modify: `web/apps/console/package.json`
- Modify: `web/pnpm-lock.yaml`
- Modify: `web/apps/console/vite.config.ts`
- Modify: `web/apps/console/src/main.tsx`
- Modify: `web/apps/console/src/fontsource.d.ts`
- Create: `web/apps/console/src/presentation/canvas-fit.ts`
- Create: `web/apps/console/src/presentation/canvas-fit.test.ts`
- Create: `web/apps/console/src/presentation/PresentationCanvas.tsx`
- Create: `web/apps/console/src/presentation/PresentationCanvas.test.tsx`
- Create: `web/apps/console/src/presentation/styles/editorial.css`
- Create: `web/apps/console/src/presentation/styles/editorial.test.ts`
- Modify: `web/apps/console/src/presentation/PresentationRoute.tsx`
**Interfaces:**
- Produces: `PRESENTATION_WIDTH`, `PRESENTATION_HEIGHT`, `ViewportSize`, `CanvasFit`, and `fitPresentationCanvas(viewport)` from `canvas-fit.ts`.
- Produces: `PresentationCanvas({ children })`, the only module that reads viewport dimensions and scales the fixed canvas.
- Preserves: the existing `PresentationRoute` state owner and children; later tasks render inside the canvas without knowing viewport dimensions.
- [ ] **Step 1: Write failing canvas-fit tests**
Create `canvas-fit.test.ts` with exact proportional-fit expectations:
```ts
import { describe, expect, it } from "vitest";
import { fitPresentationCanvas } from "./canvas-fit.js";
describe("fitPresentationCanvas", () => {
it.each([
[{ width: 1280, height: 720 }, { scale: 1, offsetX: 0, offsetY: 0 }],
[{ width: 1920, height: 1080 }, { scale: 1.5, offsetX: 0, offsetY: 0 }],
[{ width: 1024, height: 768 }, { scale: 0.8, offsetX: 0, offsetY: 96 }],
[{ width: 1600, height: 900 }, { scale: 1.25, offsetX: 0, offsetY: 0 }],
])("fits %o without reflow", (viewport, expected) => {
expect(fitPresentationCanvas(viewport)).toEqual(expected);
});
it("returns a bounded zero fit for an unavailable viewport", () => {
expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({
scale: 0,
offsetX: 0,
offsetY: 0,
});
});
});
```
- [ ] **Step 2: Run the pure tests and verify the red state**
Run:
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/canvas-fit.test.ts
```
Expected: FAIL because `canvas-fit.ts` does not exist.
- [ ] **Step 3: Implement the pure canvas fit**
Create `canvas-fit.ts`:
```ts
export const PRESENTATION_WIDTH = 1280;
export const PRESENTATION_HEIGHT = 720;
export type ViewportSize = {
readonly width: number;
readonly height: number;
};
export type CanvasFit = {
readonly scale: number;
readonly offsetX: number;
readonly offsetY: number;
};
export const fitPresentationCanvas = (viewport: ViewportSize): CanvasFit => {
if (viewport.width <= 0 || viewport.height <= 0) {
return { scale: 0, offsetX: 0, offsetY: 0 };
}
const scale = Math.min(
viewport.width / PRESENTATION_WIDTH,
viewport.height / PRESENTATION_HEIGHT,
);
return {
scale,
offsetX: (viewport.width - PRESENTATION_WIDTH * scale) / 2,
offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2,
};
};
```
- [ ] **Step 4: Write failing `PresentationCanvas` tests**
Create `PresentationCanvas.test.tsx`. Stub `window.innerWidth` and `innerHeight`, restore them after each test, and verify initial and resize behavior:
```tsx
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { PRESENTATION_HEIGHT, PRESENTATION_WIDTH } from "./canvas-fit.js";
import { PresentationCanvas } from "./PresentationCanvas.js";
const setViewport = (width: number, height: number) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
};
afterEach(() => cleanup());
describe("PresentationCanvas", () => {
it("renders one fixed 1280x720 audience canvas", () => {
setViewport(1280, 720);
render(Scene
);
const canvas = screen.getByTestId("presentation-canvas");
expect(canvas).toHaveStyle({
width: `${PRESENTATION_WIDTH}px`,
height: `${PRESENTATION_HEIGHT}px`,
transform: "scale(1)",
left: "0px",
top: "0px",
});
});
it("recomputes letterboxing after viewport resize", () => {
setViewport(1280, 720);
render(Scene
);
setViewport(1024, 768);
act(() => window.dispatchEvent(new Event("resize")));
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
transform: "scale(0.8)",
left: "0px",
top: "96px",
});
});
});
```
- [ ] **Step 5: Install additive styling dependencies**
Run from the repository root:
```powershell
pnpm --dir web --filter @lda/console add --save-exact @fontsource-variable/newsreader@5.2.10
pnpm --dir web --filter @lda/console add --save-dev --save-exact tailwindcss@4.3.2 @tailwindcss/vite@4.3.2
```
Expected: `package.json` and `pnpm-lock.yaml` update; existing package versions remain unchanged.
- [ ] **Step 6: Configure Tailwind without global Preflight**
In `vite.config.ts`, add the official Vite plugin after React:
```ts
import tailwindcss from "@tailwindcss/vite";
plugins: [react(), tailwindcss()],
```
Create `styles/editorial.css` using explicit Tailwind imports that omit `preflight.css`:
```css
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@theme {
--font-editorial: "Newsreader Variable", Georgia, serif;
--font-interface: "Source Sans 3", sans-serif;
--font-evidence: "IBM Plex Mono", monospace;
--color-editorial-paper: oklch(0.975 0.012 82);
--color-editorial-ink: oklch(0.19 0.015 65);
--color-editorial-muted: oklch(0.48 0.025 65);
--color-intent: oklch(0.53 0.17 250);
--color-runtime: oklch(0.55 0.14 150);
--color-human: oklch(0.68 0.17 55);
}
.presentation-viewport {
position: fixed;
inset: 0;
overflow: hidden;
background: oklch(0.13 0.01 65);
}
.presentation-canvas {
position: absolute;
transform-origin: top left;
overflow: hidden;
background: var(--color-editorial-paper);
color: var(--color-editorial-ink);
font-family: var(--font-interface);
}
```
Create `styles/editorial.test.ts` to pin additive integration and prevent a later full Tailwind import from resetting `/console`:
```ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const css = readFileSync(
fileURLToPath(new URL("./editorial.css", import.meta.url)),
"utf8",
);
describe("editorial Tailwind integration", () => {
it("loads theme and utilities without Preflight", () => {
expect(css).toContain('tailwindcss/theme.css');
expect(css).toContain('tailwindcss/utilities.css');
expect(css).not.toContain('tailwindcss/preflight.css');
expect(css).not.toContain('@import "tailwindcss"');
});
});
```
Import `@fontsource-variable/newsreader/wght.css` and `styles/editorial.css` from `main.tsx`. Add the exact module declaration to `fontsource.d.ts`:
```ts
declare module "@fontsource-variable/newsreader/wght.css";
```
- [ ] **Step 7: Implement and mount `PresentationCanvas`**
Implement `PresentationCanvas.tsx` with one window resize subscription. Add a short comment that the fixed canvas deliberately prevents slide reflow:
```tsx
import { useEffect, useState, type ReactNode } from "react";
import {
fitPresentationCanvas,
PRESENTATION_HEIGHT,
PRESENTATION_WIDTH,
type ViewportSize,
} from "./canvas-fit.js";
type PresentationCanvasProps = { readonly children: ReactNode };
const readViewport = (): ViewportSize => ({
width: window.innerWidth,
height: window.innerHeight,
});
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
const [viewport, setViewport] = useState(readViewport);
useEffect(() => {
const resize = () => setViewport(readViewport());
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
const fit = fitPresentationCanvas(viewport);
return (
);
};
```
Wrap the existing `PresentationRoute` content in `PresentationCanvas`; do not move reducer or replay ownership into the canvas module.
- [ ] **Step 8: Run focused and regression checks**
Run:
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/canvas-fit.test.ts src/presentation/PresentationCanvas.test.tsx src/presentation/styles/editorial.test.ts src/app/App.test.tsx
pnpm --dir web --filter @lda/console typecheck
pnpm --dir web --filter @lda/console build
```
Expected: all tests PASS, typecheck exits zero, and Vite builds Tailwind utilities without resetting `/console`.
- [ ] **Step 9: Commit the editorial canvas foundation**
```powershell
git add web/apps/console/package.json web/pnpm-lock.yaml web/apps/console/vite.config.ts web/apps/console/src/main.tsx web/apps/console/src/fontsource.d.ts web/apps/console/src/presentation/canvas-fit.ts web/apps/console/src/presentation/canvas-fit.test.ts web/apps/console/src/presentation/PresentationCanvas.tsx web/apps/console/src/presentation/PresentationCanvas.test.tsx web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/styles/editorial.css web/apps/console/src/presentation/styles/editorial.test.ts
git commit -m "feat: add fixed editorial presentation canvas"
```
---
### Task 2: Define And Validate Recursive Figure Catalogs
**Files:**
- Create: `web/apps/console/src/presentation/figures/model.ts`
- Create: `web/apps/console/src/presentation/figures/catalog.ts`
- Create: `web/apps/console/src/presentation/figures/test-fixtures.ts`
- Create: `web/apps/console/src/presentation/figures/catalog.test.ts`
- Create: `web/apps/console/src/presentation/figures/focus.ts`
- Create: `web/apps/console/src/presentation/figures/focus.test.ts`
**Interfaces:**
- Produces: `FigureNodeKind`, `FigureLayout`, `FigureNodeDefinition`, `FigureEdgeDefinition`, `FigureDefinition`, `FigureCatalogDefinition`, `FigureCatalogIssue`, and `defineFigureCatalog(catalog)`.
- Produces: `FigureFocus`, `resolveFigureFocus(catalog, path)`, `pushFigureFocus(catalog, focus, nodeId)`, and `popFigureFocus(catalog, focus)`.
- Invariant: all figure ids and per-figure node ids are unique; edge endpoints and child figure ids exist; child references are acyclic; invalid runtime Focus Paths resolve to the catalog root.
- [ ] **Step 1: Define complete shared figure fixtures**
Create `test-fixtures.ts` with typed, reusable values for all figure tests. The
valid catalog must contain this exact recursive shape:
```text
architecture-overview
runtime --child--> runtime-detail
runtime-detail
providers --child--> provider-detail
provider-detail
python-provider
```
Export complete `FigureCatalogDefinition` values named `validCatalog`,
`duplicateFigureCatalog`, `duplicateNodeCatalog`, `unknownRootCatalog`,
`unknownEdgeCatalog`, `unknownChildCatalog`, and `cyclicCatalog`. Also export
complete `FigureDefinition` values named `layeredFigure`, `flowFigure`,
`explicitFigure`, and `explicitFigureMissingPosition`. Once Task 3 introduces
that type, add complete `PositionedFigure` values named `navigationLayout` and
`tiedNavigationLayout`. Do not use `Partial`, `as`, non-null assertions, or
mutation to construct invalid fixtures. Reuse named typed node, edge, and figure
constants through object spread so every fixture remains a valid TypeScript
value even when it is invalid by catalog policy.
The valid focus labels must be:
- root figure title: `Architecture`;
- root node `runtime`: `Runtime & providers`;
- runtime-detail node `providers`: `Configured providers`;
- a non-expandable runtime-detail node with id `leaf`.
- [ ] **Step 2: Write failing catalog validation tests**
Create a small nested catalog fixture and test:
```ts
import { describe, expect, it } from "vitest";
import { defineFigureCatalog } from "./catalog.js";
import {
cyclicCatalog,
duplicateFigureCatalog,
duplicateNodeCatalog,
unknownChildCatalog,
unknownEdgeCatalog,
unknownRootCatalog,
validCatalog,
} from "./test-fixtures.js";
describe("defineFigureCatalog", () => {
it("accepts a valid recursive catalog", () => {
expect(() => defineFigureCatalog(validCatalog)).not.toThrow();
});
it.each([
["duplicate figure", duplicateFigureCatalog, "duplicate_figure"],
["duplicate node", duplicateNodeCatalog, "duplicate_node"],
["unknown root figure", unknownRootCatalog, "unknown_root_figure"],
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
["recursive child cycle", cyclicCatalog, "child_cycle"],
])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(code);
});
});
```
The fixtures must be complete `FigureCatalogDefinition` values, not partial
objects hidden behind assertions.
- [ ] **Step 3: Write failing recursive-focus tests**
Cover root resolution, two-level expansion, breadcrumbs, invalid-path fallback, non-expandable nodes, and pop behavior:
```ts
import { validCatalog } from "./test-fixtures.js";
describe("figure focus", () => {
it("resolves a two-level Focus Path with breadcrumbs", () => {
const focus = resolveFigureFocus(validCatalog, ["runtime", "providers"]);
expect(focus.figure.id).toBe("provider-detail");
expect(focus.path).toEqual(["runtime", "providers"]);
expect(focus.breadcrumbs.map((item) => item.label)).toEqual([
"Architecture",
"Runtime & providers",
"Configured providers",
]);
});
it("fails closed to the root for an invalid Focus Path", () => {
expect(resolveFigureFocus(validCatalog, ["missing"]).path).toEqual([]);
expect(resolveFigureFocus(validCatalog, ["runtime", "missing"]).figure.id)
.toBe("architecture-overview");
});
it("pushes only expandable nodes and pops one level", () => {
const root = resolveFigureFocus(validCatalog, []);
const runtime = pushFigureFocus(validCatalog, root, "runtime");
expect(runtime.path).toEqual(["runtime"]);
expect(pushFigureFocus(validCatalog, runtime, "leaf")).toEqual(runtime);
expect(popFigureFocus(validCatalog, runtime).path).toEqual([]);
});
});
```
- [ ] **Step 4: Run tests and verify the red state**
Run:
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures/catalog.test.ts src/presentation/figures/focus.test.ts
```
Expected: FAIL because the figure modules do not exist.
- [ ] **Step 5: Define the figure model**
Use these exact public types in `model.ts`:
```ts
export type FigureNodeKind =
| "actor"
| "operation"
| "artifact"
| "runtime"
| "boundary"
| "evidence";
export type FigureLayout =
| { readonly kind: "layered" }
| { readonly kind: "flow" }
| {
readonly kind: "explicit";
readonly positions: Readonly>;
};
export type FigureNodeDefinition = {
readonly id: string;
readonly label: string;
readonly summary: string;
readonly kind: FigureNodeKind;
readonly evidencePointer?: string;
readonly childFigureId?: string;
};
export type FigureEdgeDefinition = {
readonly id: string;
readonly from: string;
readonly to: string;
readonly label?: string;
};
export type FigureDefinition = {
readonly id: string;
readonly title: string;
readonly layout: FigureLayout;
readonly nodes: readonly FigureNodeDefinition[];
readonly edges: readonly FigureEdgeDefinition[];
};
export type FigureCatalogDefinition = {
readonly rootFigureId: string;
readonly figures: readonly FigureDefinition[];
};
```
Define explicit issue codes in `catalog.ts` and throw one aggregated `Error` from `defineFigureCatalog` so authored catalog failures identify every invalid reference in one run. Add a docstring explaining that static authored data is validated once at module load; user or server payloads are not accepted through this interface.
- [ ] **Step 6: Implement recursive focus resolution**
Use this public result shape in `focus.ts`:
```ts
export type FigureBreadcrumb = {
readonly label: string;
readonly path: readonly string[];
};
export type FigureFocus = {
readonly figure: FigureDefinition;
readonly path: readonly string[];
readonly breadcrumbs: readonly FigureBreadcrumb[];
};
```
`resolveFigureFocus` must walk node `childFigureId` references from the root. If any path segment is missing or non-expandable, return the root focus with an empty path. `pushFigureFocus` and `popFigureFocus` must call the resolver rather than duplicating traversal logic.
- [ ] **Step 7: Run focused tests and typecheck**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures/catalog.test.ts src/presentation/figures/focus.test.ts
pnpm --dir web --filter @lda/console typecheck
```
Expected: all catalog and focus tests PASS with zero TypeScript errors.
- [ ] **Step 8: Commit the recursive figure model**
```powershell
git add web/apps/console/src/presentation/figures/model.ts web/apps/console/src/presentation/figures/catalog.ts web/apps/console/src/presentation/figures/test-fixtures.ts web/apps/console/src/presentation/figures/catalog.test.ts web/apps/console/src/presentation/figures/focus.ts web/apps/console/src/presentation/figures/focus.test.ts
git commit -m "feat: define recursive presentation figures"
```
---
### Task 3: Add Deterministic Figure Layout And Spatial Navigation
**Files:**
- Modify: `web/apps/console/src/presentation/figures/test-fixtures.ts`
- Create: `web/apps/console/src/presentation/figures/layout.ts`
- Create: `web/apps/console/src/presentation/figures/layout.test.ts`
- Create: `web/apps/console/src/presentation/figures/navigation.ts`
- Create: `web/apps/console/src/presentation/figures/navigation.test.ts`
**Interfaces:**
- Consumes: `FigureDefinition` from Task 2 and the existing Dagre dependency.
- Produces: `PositionedFigure`, `PositionedFigureNode`, and `layoutFigure(figure)`.
- Produces: `FigureDirection` and `nextFigureNodeId(figure, currentNodeId, direction)`.
- Invariant: layout is deterministic, does not mutate authored definitions, and supports only `layered`, `flow`, and `explicit` modes.
- [ ] **Step 1: Write failing layout tests**
Test relational geometry instead of brittle exact Dagre coordinates:
```ts
describe("layoutFigure", () => {
it("places layered edges from top to bottom", () => {
const layout = layoutFigure(layeredFigure);
expect(position(layout, "client").y).toBeLessThan(position(layout, "runtime").y);
});
it("places flow edges from left to right", () => {
const layout = layoutFigure(flowFigure);
expect(position(layout, "discover").x).toBeLessThan(position(layout, "repair").x);
});
it("preserves explicit authored positions", () => {
expect(position(layoutFigure(explicitFigure), "runtime")).toEqual({ x: 420, y: 180 });
});
it("rejects an explicit layout missing a node position", () => {
expect(() => layoutFigure(explicitFigureMissingPosition))
.toThrow("missing_explicit_position:runtime");
});
it("is deterministic and does not mutate the definition", () => {
const before = structuredClone(layeredFigure);
expect(layoutFigure(layeredFigure)).toEqual(layoutFigure(layeredFigure));
expect(layeredFigure).toEqual(before);
});
});
```
- [ ] **Step 2: Write failing directional-navigation tests**
Cover every direction, no candidate, unknown current node, and deterministic tie-breaking by node id:
```ts
describe("nextFigureNodeId", () => {
it.each([
["ArrowRight", "left", "right"],
["ArrowLeft", "right", "left"],
["ArrowDown", "top", "bottom"],
["ArrowUp", "bottom", "top"],
] as const)("moves %s spatially", (direction, start, expected) => {
expect(nextFigureNodeId(navigationLayout, start, direction)).toBe(expected);
});
it("keeps focus when no node exists in that direction", () => {
expect(nextFigureNodeId(navigationLayout, "left", "ArrowLeft")).toBe("left");
});
it("keeps an unknown current node unchanged", () => {
expect(nextFigureNodeId(navigationLayout, "missing", "ArrowRight"))
.toBe("missing");
});
it("breaks equally distant candidates by node id", () => {
expect(nextFigureNodeId(tiedNavigationLayout, "start", "ArrowRight"))
.toBe("alpha");
});
});
```
- [ ] **Step 3: Run tests and verify the red state**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures/layout.test.ts src/presentation/figures/navigation.test.ts
```
Expected: FAIL because layout and navigation modules do not exist.
- [ ] **Step 4: Implement layout with existing Dagre**
Create a new Dagre graph for each call. Sort nodes and edges by id before adding them. Use `rankdir: "TB"` for `layered`, `rankdir: "LR"` for `flow`, `nodesep: 56`, `ranksep: 88`, node width `196`, and node height `84`. For explicit layout, reject a missing authored node position with `Error("missing_explicit_position:")`.
Return:
```ts
export type PositionedFigureNode = FigureNodeDefinition & {
readonly position: { readonly x: number; readonly y: number };
};
export type PositionedFigure = {
readonly definition: FigureDefinition;
readonly nodes: readonly PositionedFigureNode[];
readonly edges: readonly FigureEdgeDefinition[];
};
```
- [ ] **Step 5: Implement spatial keyboard navigation**
For each candidate in the requested half-plane, rank by primary-axis distance, then perpendicular distance, then node id. Unknown current ids return the input id unchanged. Export only:
```ts
export type FigureDirection = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
export const nextFigureNodeId = (
figure: PositionedFigure,
currentNodeId: string,
direction: FigureDirection,
): string => {
const current = figure.nodes.find((node) => node.id === currentNodeId);
if (!current) return currentNodeId;
const horizontal = direction === "ArrowLeft" || direction === "ArrowRight";
const sign = direction === "ArrowLeft" || direction === "ArrowUp" ? -1 : 1;
const candidates = figure.nodes
.filter((node) => node.id !== currentNodeId)
.map((node) => {
const dx = node.position.x - current.position.x;
const dy = node.position.y - current.position.y;
return {
id: node.id,
primary: horizontal ? dx * sign : dy * sign,
secondary: Math.abs(horizontal ? dy : dx),
};
})
.filter((candidate) => candidate.primary > 0)
.sort((left, right) =>
left.primary - right.primary ||
left.secondary - right.secondary ||
left.id.localeCompare(right.id),
);
return candidates[0]?.id ?? currentNodeId;
};
```
- [ ] **Step 6: Run focused tests and typecheck**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures/layout.test.ts src/presentation/figures/navigation.test.ts
pnpm --dir web --filter @lda/console typecheck
```
Expected: all tests PASS and typecheck exits zero.
- [ ] **Step 7: Commit figure layout and navigation**
```powershell
git add web/apps/console/src/presentation/figures/test-fixtures.ts web/apps/console/src/presentation/figures/layout.ts web/apps/console/src/presentation/figures/layout.test.ts web/apps/console/src/presentation/figures/navigation.ts web/apps/console/src/presentation/figures/navigation.test.ts
git commit -m "feat: lay out and navigate presentation figures"
```
---
### Task 4: Render Accessible Recursive Interactive Figures
**Files:**
- Create: `web/apps/console/src/presentation/figures/FigureNodeView.tsx`
- Create: `web/apps/console/src/presentation/figures/FigureBreadcrumbs.tsx`
- Create: `web/apps/console/src/presentation/figures/InteractiveFigure.tsx`
- Create: `web/apps/console/src/presentation/figures/InteractiveFigure.test.tsx`
- Create: `web/apps/console/src/presentation/figures/interactive-figure.css`
**Interfaces:**
- Consumes: catalog, focus, layout, and navigation modules from Tasks 2 and 3; existing React Flow and Motion dependencies.
- Produces one deep module interface:
```ts
type InteractiveFigureProps = {
readonly catalog: FigureCatalogDefinition;
readonly focusPath: readonly string[];
readonly activeNodeId: string | null;
readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean;
};
```
- Invariant: callers do not know React Flow nodes, Dagre positions, breadcrumb derivation, or keyboard-navigation details.
- [ ] **Step 1: Write failing renderer interaction tests**
Use the standard `ResizeObserver` and `DOMRect` test fakes already used by `WorkflowGraph.test.tsx`. Cover all interface behavior:
```tsx
describe("InteractiveFigure", () => {
it("renders conceptual labels and hides evidence pointers by default", () => {
renderFigure({ focusPath: [] });
expect(screen.getByRole("button", { name: /runtime & providers.*expand/i })).toBeInTheDocument();
expect(screen.queryByText(/docs\/source_architecture\.md/i)).not.toBeInTheDocument();
});
it("expands a child figure by click and Enter", async () => {
const onFocusPathChange = vi.fn();
renderFigure({ focusPath: [], onFocusPathChange });
await userEvent.click(screen.getByRole("button", { name: /runtime & providers/i }));
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime"]);
screen.getByRole("button", { name: /runtime & providers/i }).focus();
await userEvent.keyboard("{Enter}");
expect(onFocusPathChange).toHaveBeenLastCalledWith(["runtime"]);
});
it("pops one focus level with Escape and breadcrumb activation", async () => {
const onFocusPathChange = vi.fn();
renderFigure({ focusPath: ["runtime", "providers"], onFocusPathChange });
await userEvent.keyboard("{Escape}");
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime"]);
await userEvent.click(screen.getByRole("button", { name: /architecture/i }));
expect(onFocusPathChange).toHaveBeenLastCalledWith([]);
});
it("uses arrow keys inside the figure without bubbling presentation navigation", async () => {
const outerKeyDown = vi.fn();
render({figureElement()}
);
screen.getByRole("button", { name: /client operations/i }).focus();
await userEvent.keyboard("{ArrowDown}");
expect(outerKeyDown).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /runtime & providers/i })).toHaveFocus();
});
it("retains all information when motion is disabled", () => {
renderFigure({ focusPath: ["runtime"], motionDisabled: true });
expect(screen.getByRole("group", { name: /runtime & providers/i })).toHaveAttribute("data-motion", "disabled");
expect(screen.getAllByRole("button").length).toBeGreaterThan(1);
});
});
```
- [ ] **Step 2: Run the renderer tests and verify the red state**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures/InteractiveFigure.test.tsx
```
Expected: FAIL because `InteractiveFigure.tsx` does not exist.
- [ ] **Step 3: Implement semantic figure nodes and breadcrumbs**
`FigureNodeView` must render one keyboard-focusable button with:
- `data-figure-node-kind` for semantic styling;
- `data-active` for beat focus;
- an accessible name ending in `expand` only when `childFigureId` exists;
- conceptual label and at most one short summary;
- a visible expansion affordance that is not color-only.
`FigureBreadcrumbs` receives the resolved breadcrumbs and calls `onFocusPathChange(crumb.path)`; mark the final crumb with `aria-current="page"`.
- [ ] **Step 4: Implement the deep Interactive Figure module**
Inside `InteractiveFigure`:
1. call `resolveFigureFocus(catalog, focusPath)`;
2. call `layoutFigure(focus.figure)`;
3. convert positioned nodes and edges to React Flow internals privately;
4. keep one roving focused-node id local to the module;
5. stop propagation for figure arrow keys and `Escape`;
6. use `pushFigureFocus` and `popFigureFocus` for all path changes;
7. use a brief opacity Reveal only when the active child figure id changes;
8. keep the same React Flow instance mounted while only `activeNodeId` changes;
9. set `nodesDraggable={false}`, `nodesConnectable={false}`, `panOnDrag={false}`, `zoomOnScroll={false}`, `fitView`, and `proOptions={{ hideAttribution: true }}`.
Do not expose React Flow `Node`, `Edge`, or node-type objects from the module.
- [ ] **Step 5: Add semantic styling**
In `interactive-figure.css`, style directly on the warm canvas:
- actors/operations use the intent blue family;
- runtime nodes use green;
- boundaries use orange;
- artifacts/evidence remain ink-neutral;
- current nodes use border thickness and a textual `Current` marker, not glow;
- inactive nodes remain fully readable;
- avoid uniform rounded cards, gradients, pills, blur, and generic stagger;
- focus-visible outlines meet WCAG contrast;
- breadcrumbs remain compact and readable at the back of a room.
- [ ] **Step 6: Run renderer, model, and accessibility checks**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures
pnpm --dir web --filter @lda/console typecheck
pnpx react-doctor@latest --verbose --scope changed
```
Expected: figure tests PASS, typecheck exits zero, and React Doctor reports no new error-severity findings. Fix warnings introduced in `figures/` before committing.
- [ ] **Step 7: Commit the Interactive Figure renderer**
```powershell
git add web/apps/console/src/presentation/figures
git commit -m "feat: render recursive interactive figures"
```
---
### Task 5: Make Focus Path Canonical Presentation State
**Files:**
- Modify: `web/apps/console/src/presentation/storyboard.ts`
- Modify: `web/apps/console/src/presentation/storyboard.test.ts`
- Modify: `web/apps/console/src/presentation/storyboard-navigation.ts`
- Modify: `web/apps/console/src/presentation/storyboard-navigation.test.ts`
- Modify: `web/apps/console/src/presentation/presentation-state.ts`
- Modify: `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`
- Modify: `web/apps/console/src/presentation/SceneBody.test.tsx`
- Modify: `web/apps/console/src/presentation/SceneRail.tsx` only if it still exists before Task 7
**Interfaces:**
- Produces: required `focusPath` on `MainLocation` and optional `figure` metadata on `SceneBeatDefinition`.
- Produces: `PresentationAction` variant `{ type: "set_focus_path"; path: readonly string[] }`.
- Canonical hash format: `#scene///focus//`; the `/focus/...` suffix is omitted for an empty path.
- Invariant: next/previous applies the destination beat's canonical Focus Path; manual focus updates only the current location; discussion return preserves the exact Focus Path.
- [ ] **Step 1: Extend failing navigation tests**
Add these cases before changing types:
```ts
it("round-trips a recursive Focus Path", () => {
const location: MainLocation = {
kind: "main",
sceneId: "architecture",
beatId: "runtime",
focusPath: ["runtime-providers", "configured-providers"],
};
expect(hashForLocation(location)).toBe(
"#scene/architecture/runtime/focus/runtime-providers/configured-providers",
);
expect(locationFromHash(hashForLocation(location))).toEqual(location);
});
it("decodes escaped focus segments and rejects malformed encoding", () => {
expect(locationFromHash("#scene/architecture/runtime/focus/runtime%20providers"))
.toMatchObject({ focusPath: ["runtime providers"] });
expect(locationFromHash("#scene/architecture/runtime/focus/%ZZ"))
.toEqual(defaultMainLocation);
});
```
Update all existing `MainLocation` fixtures to include `focusPath: []`.
- [ ] **Step 2: Extend failing reducer tests**
Add tests proving manual focus, canonical reset, and discussion return:
```ts
it("sets focus without changing scene or beat", () => {
const focused = presentationReducer(architectureState, {
type: "set_focus_path",
path: ["runtime-providers"],
});
expect(focused.location).toEqual({
...architectureState.location,
focusPath: ["runtime-providers"],
});
});
it("next applies the destination beat canonical Focus Path", () => {
const next = presentationReducer(manuallyFocusedClientBeat, { type: "next" });
expect(next.location).toEqual({
kind: "main",
sceneId: "architecture",
beatId: "api",
focusPath: [],
});
});
it("discussion return restores the exact Focus Path", () => {
const opened = presentationReducer(deepRuntimeState, {
type: "open_discussion",
branchId: "provider-security",
});
expect(presentationReducer(opened, { type: "close_discussion" }).location)
.toEqual(deepRuntimeState.location);
});
```
- [ ] **Step 3: Run navigation and reducer tests to verify red state**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/storyboard-navigation.test.ts src/presentation/presentation-state.test.ts
```
Expected: compile/test failure because `MainLocation` lacks `focusPath` and the action is unknown.
- [ ] **Step 4: Extend storyboard and location types**
Add:
```ts
export type FigureBeatDefinition = {
readonly catalogId: string;
readonly focusPath: readonly string[];
readonly activeNodeId: string | null;
};
export type SceneBeatDefinition = {
// existing fields retained until Task 7 removes theme fields
readonly figure: FigureBeatDefinition | null;
};
export type MainLocation = {
readonly kind: "main";
readonly sceneId: MainSceneId;
readonly beatId: string;
readonly focusPath: readonly string[];
};
```
Extend `sceneBeat` options with `figure`, defaulting to `null`. Set `defaultMainLocation.focusPath` to the first beat's canonical path or `[]`.
- [ ] **Step 5: Implement canonical focus hashes**
Change `hashForLocation`, `locationFromHash`, and `flattenMainLocations` together. Parse scene, beat, and optional focus segments separately; do not keep the current greedy beat regex. A hash without `/focus` remains a real external URL contract and resolves to an empty path. A malformed encoded segment returns `defaultMainLocation`.
When flattening beat locations, use `beat.figure?.focusPath ?? []`; this is what resets manual exploration when navigation advances.
- [ ] **Step 6: Add `set_focus_path` reducer behavior**
Only update focus for a main location:
```ts
case "set_focus_path":
if (state.location.kind !== "main") return state;
return {
...state,
location: { ...state.location, focusPath: action.path },
};
```
Keep discussion return as the complete `MainLocation`, including focus path.
- [ ] **Step 7: Update all typed location call sites**
Use:
```powershell
rg -n 'kind: "main"' web/apps/console/src
```
Update every real `MainLocation` in production and tests with `focusPath`. Do not make `focusPath` optional and do not add a compatibility normalizer.
- [ ] **Step 8: Run all presentation state/navigation tests**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/storyboard.test.ts src/presentation/storyboard-navigation.test.ts src/presentation/presentation-state.test.ts src/presentation/PresentationRoute.test.tsx src/presentation/SceneBody.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Expected: all tests PASS and no location construction errors remain.
- [ ] **Step 9: Commit canonical Focus Path state**
```powershell
git add web/apps/console/src/presentation/storyboard.ts web/apps/console/src/presentation/storyboard.test.ts web/apps/console/src/presentation/storyboard-navigation.ts web/apps/console/src/presentation/storyboard-navigation.test.ts web/apps/console/src/presentation/presentation-state.ts web/apps/console/src/presentation/presentation-state.test.ts web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/PresentationRoute.test.tsx web/apps/console/src/presentation/SceneBody.test.tsx web/apps/console/src/presentation/SceneRail.tsx
git commit -m "feat: persist presentation figure focus"
```
---
### Task 6: Replace Scene 6 With The Architecture Figure
**Files:**
- Create: `web/apps/console/src/presentation/figures/architecture-catalog.ts`
- Create: `web/apps/console/src/presentation/figures/architecture-catalog.test.ts`
- Create: `web/apps/console/src/presentation/scenes/ArchitectureScene.tsx`
- Create: `web/apps/console/src/presentation/scenes/ArchitectureScene.test.tsx`
- Modify: `web/apps/console/src/presentation/SceneBody.tsx`
- Modify: `web/apps/console/src/presentation/SceneBody.test.tsx`
- Modify: `web/apps/console/src/presentation/storyboard.ts`
- Modify: `web/apps/console/src/presentation/storyboard.test.ts`
**Interfaces:**
- Produces: `ARCHITECTURE_CATALOG_ID = "system-architecture"` and validated `architectureCatalog`.
- Produces: `ArchitectureScene({ scene, beat, focusPath, onFocusPathChange, motionDisabled })`.
- Consumes: Interactive Figure interface only; `ArchitectureScene` must not import React Flow or Dagre.
- [ ] **Step 1: Write failing catalog content tests**
Test the factual contract, recursion, and evidence completeness:
```ts
describe("architectureCatalog", () => {
it("contains the conceptual architecture overview", () => {
const root = resolveFigureFocus(architectureCatalog, []).figure;
expect(root.nodes.map((node) => node.label)).toEqual([
"Client operations",
"Application lifecycle",
"Runtime & providers",
"NodeUse",
]);
});
it("supports recursive runtime and provider expansion", () => {
expect(resolveFigureFocus(architectureCatalog, ["runtime-providers"]).figure.id)
.toBe("runtime-provider-detail");
expect(resolveFigureFocus(
architectureCatalog,
["runtime-providers", "configured-providers"],
).figure.id).toBe("configured-provider-detail");
});
it("gives every factual node an evidence pointer", () => {
for (const figure of architectureCatalog.figures) {
for (const node of figure.nodes) {
expect(node.evidencePointer, `${figure.id}/${node.id}`).toBeTruthy();
}
}
});
});
```
- [ ] **Step 2: Author the architecture catalog from repository evidence**
Create five figures with these exact responsibilities:
1. `architecture-overview`: Client operations โ Application lifecycle โ Runtime & providers โ NodeUse.
2. `client-surface-detail`: CLI, JSON-RPC HTTP, and web console as callers of the same public lifecycle operations.
3. `runtime-provider-detail`: `WorkflowServer` composition, `WorkflowApi`, provider-neutral `CapabilitySource` projection, configured providers, and deterministic kernel.
4. `configured-provider-detail`: built-in `wf.std`/`wf.recipes`, stateful MCP, trusted in-process Python, and explicitly future OpenAPI.
5. `node-use-detail`: resolve input bindings, invoke handler, normalize `NodeResult`, apply output reducers, route outcome, and record trace.
Use conceptual labels in `label`, package/symbol names in `summary`, and evidence pointers to:
- `docs/source_architecture.md`
- `docs/project_map.md`
- `src/wf_core/runtime/ops/nodes.py`
- `src/wf_core/runtime/ops/state.py`
- `src/wf_core/runtime/step.py`
- `src/wf_api/service.py`
- `src/wf_server/server.py` or the actual server-composition entry point found during implementation
- `src/wf_transport_rpc_http/`
- `src/wf_sources_mcp/`
- `src/wf_sources_python/`
Mark OpenAPI as future in both label and summary; do not imply it is implemented.
- [ ] **Step 3: Write failing Scene 6 interaction tests**
```tsx
describe("ArchitectureScene", () => {
it("renders the overview and expands Runtime & providers", async () => {
const onFocusPathChange = vi.fn();
renderArchitecture({ focusPath: [], onFocusPathChange });
expect(screen.getByRole("heading", { name: /architecture/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /runtime & providers.*expand/i }));
expect(onFocusPathChange).toHaveBeenCalledWith(["runtime-providers"]);
});
it("renders a directly linked nested provider view", () => {
renderArchitecture({
focusPath: ["runtime-providers", "configured-providers"],
});
expect(screen.getByRole("group", { name: /configured providers/i })).toBeInTheDocument();
expect(screen.getByText(/MCP/i)).toBeInTheDocument();
expect(screen.getByText(/Python/i)).toBeInTheDocument();
});
});
```
- [ ] **Step 4: Implement the thin architecture scene adapter**
`ArchitectureScene` renders `StageCaption` plus exactly one `InteractiveFigure`. It passes `beat.figure?.activeNodeId`, the location Focus Path, reducer callback, and motion state. It does not duplicate catalog traversal, layout, breadcrumbs, or evidence rendering.
- [ ] **Step 5: Give architecture beats canonical figure state**
Set all Scene 6 beats to `catalogId: ARCHITECTURE_CATALOG_ID`:
- `client`: root path `[]`, active node `clients`;
- `api`: root path `[]`, active node `application-lifecycle`;
- `runtime`: path `["runtime-providers"]`, active node `configured-providers`;
- `node-use`: path `["node-use"]`, active node `invoke-handler`.
Update `SceneBody` to delegate the `architecture` branch to `ArchitectureScene`; remove the old `architectureLayers` constant and nested `` implementation completely.
- [ ] **Step 6: Run Scene 6 and figure regression tests**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/figures src/presentation/scenes/ArchitectureScene.test.tsx src/presentation/SceneBody.test.tsx src/presentation/storyboard.test.ts
pnpm --dir web --filter @lda/console typecheck
```
Expected: all tests PASS; Scene 6 uses only the deep Interactive Figure interface.
- [ ] **Step 7: Commit the Scene 6 tracer bullet**
```powershell
git add web/apps/console/src/presentation/figures/architecture-catalog.ts web/apps/console/src/presentation/figures/architecture-catalog.test.ts web/apps/console/src/presentation/scenes/ArchitectureScene.tsx web/apps/console/src/presentation/scenes/ArchitectureScene.test.tsx web/apps/console/src/presentation/SceneBody.tsx web/apps/console/src/presentation/SceneBody.test.tsx web/apps/console/src/presentation/storyboard.ts web/apps/console/src/presentation/storyboard.test.ts
git commit -m "feat: make architecture figure expandable"
```
---
### Task 7: Remove Obsolete Theme And Audience Chrome Internals
**Files:**
- Modify: `web/apps/console/src/presentation/storyboard.ts`
- Modify: `web/apps/console/src/presentation/storyboard.test.ts`
- Modify: `web/apps/console/src/presentation/presentation-state.ts`
- Modify: `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`
- Modify: `web/apps/console/src/presentation/PresentationStage.tsx`
- Modify: `web/apps/console/src/presentation/OperatorChat.tsx`
- Modify: `web/apps/console/src/presentation/presentation.css`
- Modify: `web/apps/console/src/presentation/styles/demo-workflow.css`
- Create: `web/apps/console/src/presentation/SceneProgress.tsx`
- Create: `web/apps/console/src/presentation/SceneProgress.test.tsx`
- Delete: `web/apps/console/src/presentation/SceneRail.tsx`
- Delete: `web/apps/console/src/presentation/ChatDock.tsx`
- Delete: `web/apps/console/src/presentation/DiscussionIndex.tsx`
- Delete: `web/apps/console/src/presentation/PresenterControls.tsx`
- Delete: `web/apps/console/src/presentation/PresenterControls.test.tsx`
**Interfaces:**
- Preserves: scene/beat navigation, direct discussion hashes, evidence overlays, replay operation, and keyboard next/previous/Escape.
- Removes: `StageTheme`, `ChatTheme`, `stageTheme`, `chatTheme`, all theme override state/actions, `controlsOpen`, `discussionIndexOpen`, `toggle_controls`, `toggle_discussion_index`, corner agent trigger, audience scene rail, mode label, and detached chat dock.
- Produces: one warm stage identity; dark terminal/product insets remain local to their content.
- [ ] **Step 1: Change tests to the target audience contract**
In `PresentationRoute.test.tsx`, replace the old rail/mode/button expectations with:
```tsx
it("shows only quiet audience progress chrome", () => {
render(
);
expect(screen.getByLabelText(/scene progress/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Replay ยท/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /run prepared agent/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /discussion topics/i })).not.toBeInTheDocument();
});
it("keeps the editorial canvas identity across architecture beats", async () => {
window.location.hash = "#scene/architecture/client";
render(
);
const stage = screen.getByLabelText(/primary presentation region/i);
expect(stage).not.toHaveAttribute("data-stage-theme");
await userEvent.keyboard("{ArrowRight}");
expect(stage).not.toHaveAttribute("data-stage-theme");
});
```
Create `SceneProgress.test.tsx` before implementation:
```tsx
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { SceneProgress } from "./SceneProgress.js";
afterEach(() => cleanup());
describe("SceneProgress", () => {
it("shows quiet scene and beat position without navigation buttons", () => {
render(
);
expect(screen.getByLabelText(/scene progress/i)).toHaveTextContent("6 / 12");
expect(screen.getByLabelText(/scene progress/i)).toHaveTextContent("3 / 4");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
```
In `presentation-state.test.ts`, delete theme override tests and assert the initial state no longer has those keys. Keep close-overlay tests for selected workflow nodes, evidence, and discussion return.
- [ ] **Step 2: Run tests and verify target failures**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation/PresentationRoute.test.tsx src/presentation/presentation-state.test.ts src/presentation/SceneProgress.test.tsx
```
Expected: FAIL because old theme state and audience chrome still render and
`SceneProgress.tsx` does not exist.
- [ ] **Step 3: Remove obsolete state and storyboard fields**
Remove stage/chat theme types and fields from the storyboard. Keep `ChatMode` until the later chat-replacement plan because scene composition still controls whether chat is hidden, full, rail, or dock.
Remove these reducer fields and actions without compatibility aliases:
- `stageThemeOverride`
- `chatThemeOverride`
- `chatModeOverride`
- `controlsOpen`
- `discussionIndexOpen`
- `set_stage_theme`
- `set_chat_theme`
- `set_chat_mode`
- `toggle_controls`
- `toggle_discussion_index`
Reduce `compositionForState` to derived `chatMode` and `evidenceMode` only.
- [ ] **Step 4: Remove obsolete audience components and triggers**
Delete the five files listed above. In `PresentationRoute`:
- remove `P` handling;
- remove presenter override callbacks;
- remove `ChatDock` and `PresenterControls` rendering;
- remove the detached `Run prepared agent` button;
- keep replay initialization because later demo scenes still consume it.
In `PresentationStage`:
- remove `SceneRail` and `DiscussionIndex`;
- remove the audience discussion button and mode label;
- render `SceneProgress`, whose interface is `{ readonly location: PresentationLocation }`, with current scene number and beat position only;
- keep `DiscussionPanel` for direct discussion hashes and keep `EvidenceDrawer`.
- [ ] **Step 5: Replace global theme CSS with editorial ownership**
In `presentation.css`:
- remove `data-stage-theme` and `data-chat-theme` selector families;
- remove rail, dock, presenter-control, discussion-index, mode-label, and detached-agent-button rules;
- make the stage transparent over `PresentationCanvas` and keep primary content warm;
- set major claims to `var(--font-editorial)` and body text to `var(--font-interface)`;
- retain scene-specific styles not yet migrated, but remove dark whole-stage backgrounds.
In `demo-workflow.css`, scope dark styling to the actual `.demo-workflow-stage`, terminal, graph, or evidence product inset. It must not set `.presentation-stage__primary` to dark.
Update `OperatorChat` to one light treatment and remove `data-chat-theme`; the later AI Elements plan will replace its markup.
- [ ] **Step 6: Run presentation and console regressions**
```powershell
pnpm --dir web --filter @lda/console exec vitest run src/presentation src/app/App.test.tsx
pnpm --dir web --filter @lda/console typecheck
pnpm --dir web --filter @lda/console build
```
Expected: presentation tests PASS, `/console` app tests remain unchanged, typecheck exits zero, and build succeeds.
- [ ] **Step 7: Commit removal of obsolete presentation internals**
```powershell
git add -A -- web/apps/console/src/presentation
git commit -m "refactor: simplify presentation audience surface"
```
---
### Task 8: Build The Visual Review Gate And Close Documentation
**Files:**
- Create: `docs/runbooks/presentation-visual-review.md`
- Modify: `web/README.md`
- Modify: `docs/current_roadmap.md`
- Modify: `docs/superpowers/specs/2026-07-04-defense-presentation-storyboard-design.md` only if implementation discoveries change the live contract
- Move after completion: `docs/superpowers/plans/2026-07-05-editorial-canvas-interactive-figure.md` to `docs/historical/superpowers/plans/2026-07-05-editorial-canvas-interactive-figure.md`
**Interfaces:**
- Produces: exact repeatable review URLs and Playwright commands for root, one-level, and two-level Scene 6 focus.
- Records: the first redesign slice as completed while leaving chat, schema forms, Guided Run, Scene 10, and presenter companion as future slices.
- [ ] **Step 1: Run the complete automated suite**
```powershell
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
pnpx react-doctor@latest --verbose --scope changed
git diff --check
```
Expected: 0 test failures, 0 type errors, successful production builds, no new React Doctor error-severity findings, and no whitespace errors. The existing bundle-size warning is not a failure.
- [ ] **Step 2: Create the visual-review runbook**
Document these exact review states:
```text
http://127.0.0.1:5173/present#scene/architecture/client
http://127.0.0.1:5173/present#scene/architecture/runtime/focus/runtime-providers
http://127.0.0.1:5173/present#scene/architecture/runtime/focus/runtime-providers/configured-providers
http://127.0.0.1:5173/console
```
Include Playwright CLI commands to open a named session, resize to `1280 720`, capture each state, inspect screenshots, and close the session. State explicitly that screenshots require human approval and are not pixel-diff tests.
- [ ] **Step 3: Run the `1280x720` browser contract smoke**
Verify all of the following:
1. the canvas is exactly `1280x720` at a matching viewport;
2. `1024x768` scales to `0.8` with vertical letterboxing and no reflow;
3. the warm Editorial Canvas remains unchanged across Scene 1 and Scene 6;
4. no full scene rail, replay label, discussion button, corner agent button, or presenter controls appear;
5. Scene 6 root has one primary figure and no competing card grid;
6. clicking Runtime & providers expands in place with a breadcrumb;
7. clicking Configured providers expands a second level;
8. `Escape` pops exactly one level;
9. `Tab`, arrows, and `Enter` work without advancing the presentation while figure focus is active;
10. deep links reproduce both focus depths after reload;
11. every visible node remains readable without relying on color;
12. no vertical or horizontal scrollbar appears;
13. `/console` retains connection, lifecycle, graph, and execution layouts;
14. reduced-motion mode preserves all figure content.
- [ ] **Step 4: Update live docs and roadmap**
In `web/README.md`, document the fixed canvas, Scene 6 focus URLs, and keyboard figure controls.
In `docs/current_roadmap.md`:
- mark editorial canvas plus Scene 6 Interactive Figure completed;
- link the live storyboard spec;
- link this plan at its historical path;
- keep AI chat primitives, Schema Form Surface/Approval Session, Guided Run Beat Gates, presenter companion, and Scene 10 product graph as separate next slices.
- [ ] **Step 5: Archive the completed plan**
```powershell
git mv docs/superpowers/plans/2026-07-05-editorial-canvas-interactive-figure.md docs/historical/superpowers/plans/2026-07-05-editorial-canvas-interactive-figure.md
```
Update the roadmap link to the historical path.
- [ ] **Step 6: Verify documentation paths and final status**
```powershell
rg -n -F '2026-07-05-editorial-canvas-interactive-figure.md' docs web/README.md
git diff --check
git status --short
```
Expected: only the historical plan and its historical roadmap link remain; no live doc points to the old active plan path.
- [ ] **Step 7: Commit documentation and archival**
```powershell
git add web/README.md docs/current_roadmap.md docs/runbooks/presentation-visual-review.md docs/superpowers/specs/2026-07-04-defense-presentation-storyboard-design.md
git add -A -- docs/superpowers/plans docs/historical/superpowers/plans
git commit -m "docs: record editorial presentation foundation"
```
---
## Final Review Gate
Before reporting completion:
1. Confirm Tailwind Preflight is absent from the built stylesheet and `/console` has no reset regression.
2. Confirm there is one fixed `1280x720` canvas and no responsive scene reflow.
3. Confirm Interactive Figure callers know only catalog, Focus Path, active node, motion flag, and change callback.
4. Confirm two-level recursive expansion works from click, keyboard, and deep link.
5. Confirm invalid Focus Paths fail closed to the root figure.
6. Confirm all factual architecture nodes have evidence pointers and future OpenAPI is labeled future.
7. Confirm navigation to another beat restores that beat's canonical Focus Path.
8. Confirm discussion return preserves the exact originating Focus Path.
9. Confirm unchanged figure nodes do not remount when only active state changes.
10. Confirm removed theme/chrome interfaces have no production callers or stale tests.
11. Confirm `/console` remains functionally and visually intact.
12. Run the complete automated and browser verification from Task 8 again after the final review fix.