Files
lda-wf/docs/historical/superpowers/plans/2026-07-05-editorial-canvas-interactive-figure.md
T

57 KiB

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

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:

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:

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:

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(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
    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(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
    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:

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:

import tailwindcss from "@tailwindcss/vite";

plugins: [react(), tailwindcss()],

Create styles/editorial.css using explicit Tailwind imports that omit preflight.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:

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:

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:

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 (
    <div className="presentation-viewport">
      <div
        className="presentation-canvas"
        data-testid="presentation-canvas"
        style={{
          width: PRESENTATION_WIDTH,
          height: PRESENTATION_HEIGHT,
          left: fit.offsetX,
          top: fit.offsetY,
          transform: `scale(${fit.scale})`,
        }}
      >
        {children}
      </div>
    </div>
  );
};

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:

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

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:

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:

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:

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:

export type FigureNodeKind =
  | "actor"
  | "operation"
  | "artifact"
  | "runtime"
  | "boundary"
  | "evidence";

export type FigureLayout =
  | { readonly kind: "layered" }
  | { readonly kind: "flow" }
  | {
      readonly kind: "explicit";
      readonly positions: Readonly<Record<string, { readonly x: number; readonly y: number }>>;
    };

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:

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

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:

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
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:<nodeId>").

Return:

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:

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

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(<div onKeyDown={outerKeyDown}>{figureElement()}</div>);
    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
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

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
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/<scene>/<beat>/focus/<segment>/<segment>; 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:

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:

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

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:

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:

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

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
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 <div> implementation completely.

  • Step 6: Run Scene 6 and figure regression tests
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
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:

it("shows only quiet audience progress chrome", () => {
  render(<PresentationRoute />);
  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(<PresentationRoute />);
  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:

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(<SceneProgress location={{
      kind: "main",
      sceneId: "architecture",
      beatId: "runtime",
      focusPath: ["runtime-providers"],
    }} />);
    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
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
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
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

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:

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

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