62 KiB
Adaptive Presentation Canvas 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: Make /present use the available stage continuously from 4:3 through 16:9, and replace the resizing right evidence drawer with a bottom-row receipt and explicit centered inspector.
Architecture: Keep a fixed logical height of 720px, derive a clamped logical width from the viewport, and scale the resulting canvas as one unit. Use CSS container queries for compact composition. Migrate evidence from geometric peek/open modes to semantic hidden/receipt/inspector states, project existing EvidenceRecord data through focused receipt and inspector components, and remove the old drawer without a compatibility path.
Tech Stack: React 19, TypeScript 6, Vitest, Testing Library, CSS container queries, Motion's existing reduced-motion boundary, and Playwright browser automation for real layout checks.
Global Constraints
- Follow the approved adaptive presentation canvas and evidence inspector design.
- Preserve one logical height of exactly
720px. - Clamp logical width to exactly
960pxthrough1280px, covering4:3through16:9. - Do not add a URL, query-string, local-storage, or hidden aspect-ratio override.
- Aspect ratio may change placement and wrapping, but not claims, graph nodes, controls, labels, or evidence records.
- Evidence availability must never resize the primary presentation region.
- Beat metadata may request only
hiddenorreceipt; openinginspectoris always an explicit action. - Compact chat and evidence must not overlap; the inspector takes precedence while open.
- Preserve scene, beat, Figure Focus Path, replay, and canonical evidence data contracts.
- Do not change scene-specific graph layout, Dagre spacing, Figure Node positions, captions, or overflow tuning in this slice. Those visuals are a separate slice.
- Do not add a second evidence store, transport, responsive framework, dialog dependency, or compatibility wrapper for
EvidenceDrawer. - Keep comments around non-obvious viewport math, focus restoration, and container-query ownership.
- Use test-first changes and commit after every task.
File Structure
Create
web/apps/console/src/presentation/evidence/evidence-model.ts: safe projection from canonicalEvidenceRecordvalues to receipt and inspector view data.web/apps/console/src/presentation/evidence/evidence-model.test.ts: projection, status extraction, malformed value, and empty-state tests.web/apps/console/src/presentation/evidence/EvidenceReceipt.tsx: compact bottom-row evidence affordance.web/apps/console/src/presentation/evidence/EvidenceReceipt.test.tsx: visible, disabled, status, count, and activation tests.web/apps/console/src/presentation/evidence/EvidenceInspector.tsx: modal interpreted/raw evidence inspection.web/apps/console/src/presentation/evidence/EvidenceInspector.test.tsx: tabs, record selection, focus, malformed raw value, and close tests.web/apps/console/src/presentation/PresentationFooter.tsx: stable composition of scene progress and evidence receipt.web/apps/console/src/presentation/PresentationFooter.test.tsx: footer composition and receipt visibility tests.
Modify
web/apps/console/src/presentation/canvas-fit.ts: return adaptive logical dimensions with scale and offsets.web/apps/console/src/presentation/canvas-fit.test.ts: pin supported and extreme aspect-ratio math.web/apps/console/src/presentation/PresentationCanvas.tsx: render the adaptive logical width.web/apps/console/src/presentation/PresentationCanvas.test.tsx: pin resize behavior at representative viewports.web/apps/console/src/presentation/storyboard.ts: replacepeek/openbeat vocabulary withreceiptand prevent beats from requestinginspector.web/apps/console/src/presentation/presentation-state.ts: model transient inspector state and clear it on beat navigation.web/apps/console/src/presentation/presentation-state.test.ts: pin navigation, close priority, and receipt behavior.web/apps/console/src/presentation/PresentationStage.tsx: compose primary, footer, chat, and inspector without an evidence grid column.web/apps/console/src/presentation/PresentationRoute.tsx: dispatchinspectorfor explicit evidence actions.web/apps/console/src/presentation/PresentationRoute.test.tsx: verify no automatic dialog, explicit opening, and navigation closure.web/apps/console/src/presentation/presentation.css: replace drawer geometry with footer, overlay, and compact container-query rules.web/apps/console/src/presentation/styles/editorial.css: establish the named inline-size container on the logical canvas.web/README.md: document adaptive geometry and receipt/inspector behavior.docs/current_roadmap.md: mark this slice complete and link the archived plan.
Delete
web/apps/console/src/presentation/EvidenceDrawer.tsxweb/apps/console/src/presentation/EvidenceDrawer.test.tsx
Task 1: Adaptive Logical Canvas Geometry
Files:
- Modify:
web/apps/console/src/presentation/canvas-fit.ts - Modify:
web/apps/console/src/presentation/canvas-fit.test.ts - Modify:
web/apps/console/src/presentation/PresentationCanvas.tsx - Modify:
web/apps/console/src/presentation/PresentationCanvas.test.tsx
Interfaces:
-
Produces:
fitPresentationCanvas(viewport: ViewportSize): CanvasFit -
Produces:
CanvasFitwithlogicalWidth,logicalHeight,scale,offsetX, andoffsetY -
Produces:
PRESENTATION_MIN_WIDTH = 960,PRESENTATION_MAX_WIDTH = 1280, andPRESENTATION_HEIGHT = 720 -
Consumes: browser
innerWidthandinnerHeight; no observer or persistent setting -
Step 1: Replace fixed-fit expectations with adaptive math tests
Update
canvas-fit.test.tsto assert exact dimensions and approximately equal floating-point scale values:import { describe, expect, it } from "vitest"; import { fitPresentationCanvas } from "./canvas-fit.js"; describe("fitPresentationCanvas", () => { it.each([ [{ width: 1280, height: 720 }, 1280, 1, 0, 0], [{ width: 1024, height: 768 }, 960, 1024 / 960, 0, 0], [{ width: 1200, height: 800 }, 1080, 1200 / 1080, 0, 0], [{ width: 800, height: 800 }, 960, 800 / 960, 0, 100], [{ width: 1920, height: 720 }, 1280, 1, 320, 0], ])("fits %o into the supported logical ratio range", ( viewport, logicalWidth, scale, offsetX, offsetY, ) => { const fit = fitPresentationCanvas(viewport); expect(fit.logicalWidth).toBe(logicalWidth); expect(fit.logicalHeight).toBe(720); expect(fit.scale).toBeCloseTo(scale); expect(fit.offsetX).toBeCloseTo(offsetX); expect(fit.offsetY).toBeCloseTo(offsetY); }); it("returns the default logical size with zero scale before viewport measurement", () => { expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({ logicalWidth: 1280, logicalHeight: 720, scale: 0, offsetX: 0, offsetY: 0, }); }); }); -
Step 2: Run the geometry test and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- canvas-fit.test.tsExpected: FAIL because
CanvasFitdoes not expose logical dimensions and1024x768still produces the fixed-canvas0.8scale with96pxvertical letterboxing. -
Step 3: Implement clamped logical canvas math
Replace the fixed-width calculation in
canvas-fit.tswith:export const PRESENTATION_MIN_WIDTH = 960; export const PRESENTATION_MAX_WIDTH = 1280; export const PRESENTATION_HEIGHT = 720; export type ViewportSize = { readonly width: number; readonly height: number; }; export type CanvasFit = { readonly logicalWidth: number; readonly logicalHeight: number; readonly scale: number; readonly offsetX: number; readonly offsetY: number; }; const clamp = (value: number, minimum: number, maximum: number): number => Math.min(maximum, Math.max(minimum, value)); export const fitPresentationCanvas = (viewport: ViewportSize): CanvasFit => { if (viewport.width <= 0 || viewport.height <= 0) { return { logicalWidth: PRESENTATION_MAX_WIDTH, logicalHeight: PRESENTATION_HEIGHT, scale: 0, offsetX: 0, offsetY: 0, }; } // Width follows the viewport ratio only inside the reviewed 4:3-16:9 range. const logicalWidth = clamp( PRESENTATION_HEIGHT * (viewport.width / viewport.height), PRESENTATION_MIN_WIDTH, PRESENTATION_MAX_WIDTH, ); const scale = Math.min( viewport.width / logicalWidth, viewport.height / PRESENTATION_HEIGHT, ); return { logicalWidth, logicalHeight: PRESENTATION_HEIGHT, scale, offsetX: (viewport.width - logicalWidth * scale) / 2, offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2, }; }; -
Step 4: Make
PresentationCanvasconsume returned dimensionsRemove
PRESENTATION_WIDTHfrom the component import, update the comment to describe clamped adaptive geometry, and set:style={{ width: fit.logicalWidth, height: fit.logicalHeight, left: fit.offsetX, top: fit.offsetY, transform: `scale(${fit.scale})`, }}Do not add
ResizeObserver. The logical canvas depends only on the viewport, so the existingwindow.resizelistener is the correct ownership boundary. -
Step 5: Update component resize tests
In
PresentationCanvas.test.tsx, replace the fixed-canvas assertions with:it("fills a 16:9 viewport with the maximum logical width", () => { setViewport(1280, 720); render(<PresentationCanvas><div>Scene</div></PresentationCanvas>); expect(screen.getByTestId("presentation-canvas")).toHaveStyle({ width: "1280px", height: "720px", transform: "scale(1)", left: "0px", top: "0px", }); }); it("recomputes the logical width after resizing to 4:3", () => { setViewport(1280, 720); render(<PresentationCanvas><div>Scene</div></PresentationCanvas>); setViewport(1024, 768); act(() => window.dispatchEvent(new Event("resize"))); const canvas = screen.getByTestId("presentation-canvas"); expect(canvas).toHaveStyle({ width: "960px", height: "720px" }); expect(canvas.style.left).toBe("0px"); expect(canvas.style.top).toBe("0px"); expect(Number(canvas.style.transform.match(/scale\((.+)\)/)?.[1])).toBeCloseTo(1024 / 960); }); it("uses an intermediate logical width instead of selecting a preset", () => { setViewport(1200, 800); render(<PresentationCanvas><div>Scene</div></PresentationCanvas>); expect(screen.getByTestId("presentation-canvas")).toHaveStyle({ width: "1080px", height: "720px", }); }); -
Step 6: Run focused tests and commit
Run:
pnpm --dir web --filter @lda/console test -- canvas-fit.test.ts PresentationCanvas.test.tsx pnpm --dir web --filter @lda/console typecheckExpected: all focused tests pass and typecheck exits
0.Commit:
git add 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 git commit -m "feat: adapt presentation canvas ratio"
Task 2: Semantic Evidence State and Navigation
Files:
- Modify:
web/apps/console/src/presentation/storyboard.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/scenes/ArchitectureScene.test.tsx - Modify:
web/apps/console/src/presentation/PresentationRoute.tsx - Modify:
web/apps/console/src/presentation/PresentationStage.tsx - Modify:
web/apps/console/src/presentation/EvidenceDrawer.tsx - Modify:
web/apps/console/src/presentation/EvidenceDrawer.test.tsx - Modify:
web/apps/console/src/demo/agent/events.ts - Modify:
web/apps/console/src/demo/agent/recipes.ts - Modify:
web/apps/console/src/demo/agent/tools.ts - Modify:
web/apps/console/src/demo/agent/tools.test.ts - Modify:
web/apps/console/src/demo/agent/preparedRecipeDriver.ts - Modify:
web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts
Interfaces:
-
Produces:
EvidencePresentation = "hidden" | "receipt" | "inspector" -
Produces:
BeatEvidencePresentation = "hidden" | "receipt" -
Produces:
set_evidence_presentationreducer action -
Consumes: existing beat composition, navigation, and global
close_overlay -
Step 1: Write reducer tests for the new semantics
Replace the old
open/peekassertions and add these cases inpresentation-state.test.ts:it("derives a receipt from beat metadata without opening an inspector", () => { const state = presentationReducer(initialPresentationState, { type: "jump", location: { kind: "main", sceneId: "architecture", beatId: "node-use", focusPath: ["node-use"] }, }); expect(compositionForState(state).evidencePresentation).toBe("receipt"); expect(state.evidencePresentationOverride).toBeNull(); }); it("closes an explicit inspector before the node spotlight", () => { const withNode = presentationReducer(initialPresentationState, { type: "select_node", nodeId: "review_issues", }); const withInspector = presentationReducer(withNode, { type: "set_evidence_presentation", presentation: "inspector", }); const firstEscape = presentationReducer(withInspector, { type: "close_overlay" }); expect(firstEscape.evidencePresentationOverride).toBe("hidden"); expect(firstEscape.selectedNodeId).toBe("review_issues"); const secondEscape = presentationReducer(firstEscape, { type: "close_overlay" }); expect(secondEscape.selectedNodeId).toBeNull(); }); it("closes the inspector and recomputes receipt state when the beat changes", () => { const atReceiptBeat = presentationReducer(initialPresentationState, { type: "jump", location: { kind: "main", sceneId: "architecture", beatId: "node-use", focusPath: ["node-use"] }, }); const opened = presentationReducer(atReceiptBeat, { type: "set_evidence_presentation", presentation: "inspector", }); const advanced = presentationReducer(opened, { type: "next" }); expect(advanced.evidencePresentationOverride).toBeNull(); expect(compositionForState(advanced).evidencePresentation).not.toBe("inspector"); }); it("does not treat a receipt as an Escape-closeable overlay", () => { const receipt = presentationReducer(initialPresentationState, { type: "jump", location: { kind: "main", sceneId: "authoring", beatId: "diagnose", focusPath: [] }, }); expect(presentationReducer(receipt, { type: "close_overlay" })).toEqual(receipt); });Update the prepared-agent expectations before implementation so the recipe ends at trace evidence without opening presentation chrome:
expect(toolNames).toEqual([ "inspectDeployment", "startPreparedReportRun", "selectWorkflowNode", "resumeIssueReview", "readRunTrace", ]); expect(isAllowedAgentToolName("openEvidence")).toBe(false); -
Step 2: Run the reducer test and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- presentation-state.test.ts preparedRecipeDriver.test.ts tools.test.tsExpected: FAIL because the new type names, action, and composition property do not exist, and the prepared recipe still emits
openEvidence. -
Step 3: Replace geometric evidence vocabulary in the storyboard
In
storyboard.ts, define separate beat and runtime types:export type EvidencePresentation = "hidden" | "receipt" | "inspector"; export type BeatEvidencePresentation = Exclude<EvidencePresentation, "inspector">; export type SceneBeatDefinition = { readonly id: string; readonly title: string; readonly caption: string; readonly chatMode: ChatMode; readonly chatTheme: ChatTheme; readonly evidencePresentation: BeatEvidencePresentation; readonly figure: FigureBeatDefinition | null; };Rename the beat option and constructor default from
evidenceModetoevidencePresentation. Change thenode-useanddiagnosebeats frompeektoreceipt. Change thetracebeat fromopentoreceipt; detailed evidence must no longer auto-open on navigation.Update
ArchitectureScene.test.tsxfixture data to useevidencePresentation: "hidden". -
Step 4: Migrate reducer state and clear transient inspection on navigation
In
presentation-state.ts:readonly evidencePresentationOverride: EvidencePresentation | null;Rename the action to:
| { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation; }Return
evidencePresentationfromcompositionForState. Add a small helper used bynext,previous,jump,jump_hash,open_discussion, andclose_discussion:const moveToLocation = ( state: PresentationState, location: PresentationLocation, ): PresentationState => ({ ...state, location, evidencePresentationOverride: null, });Preserve
set_focus_pathwithout clearing evidence because it changes figure focus, not the current beat. Inclose_overlay, close an explicit inspector first, thenselectedNodeId, then a discussion. Do not hide a receipt onEscape. -
Step 5: Search for and remove old internal vocabulary
Run:
rg -n 'EvidenceMode|evidenceMode|evidenceModeOverride|set_evidence_mode|evidenceMode: "(peek|open)"' web/apps/console/src/presentationUpdate every presentation caller and test found by the search to the semantic names. In
PresentationRoute.tsx, the explicitopenEvidencecallback passed toPresentationStagedispatches:dispatch({ type: "set_evidence_presentation", presentation: "inspector", });In
PresentationStage.tsx, consumecomposition.evidencePresentationand exposedata-evidence-presentation. Temporarily pass the semantic presentation value toEvidenceDrawer; it still renders every non-hidden value until Task 5 removes it. UpdateEvidenceDrawer's prop type toEvidencePresentation.Remove the prepared recipe's final
open-evidencestep and removeopenEvidencefromRecipeTool,PresentationToolAction,PresentationToolName,AGENT_TOOLS, and the driver switch. Remove the correspondingPresentationRoutepending-action case. The trace beat's receipt is the automatic affordance; only the footer or operation button may open the inspector. Do not retain aliases for either old internal contract.Confirm the prepared-agent surface is clean:
rg -n -F 'openEvidence' web/apps/console/src/demo/agentExpected: no matches. Presentation component callbacks named
openEvidenceremain valid because they represent explicit human controls, not agent tools. -
Step 6: Run focused tests and commit
Run:
pnpm --dir web --filter @lda/console test -- presentation-state.test.ts storyboard.test.ts ArchitectureScene.test.tsx preparedRecipeDriver.test.ts tools.test.ts pnpm --dir web --filter @lda/console typecheckExpected: focused tests pass and typecheck exits
0.Commit:
git add web/apps/console/src/presentation/storyboard.ts web/apps/console/src/presentation/presentation-state.ts web/apps/console/src/presentation/presentation-state.test.ts web/apps/console/src/presentation/scenes/ArchitectureScene.test.tsx web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/PresentationStage.tsx web/apps/console/src/presentation/EvidenceDrawer.tsx web/apps/console/src/presentation/EvidenceDrawer.test.tsx web/apps/console/src/demo/agent git commit -m "refactor: name presentation evidence states"
Task 3: Evidence Projection and Bottom-Row Receipt
Files:
- Create:
web/apps/console/src/presentation/evidence/evidence-model.ts - Create:
web/apps/console/src/presentation/evidence/evidence-model.test.ts - Create:
web/apps/console/src/presentation/evidence/EvidenceReceipt.tsx - Create:
web/apps/console/src/presentation/evidence/EvidenceReceipt.test.tsx - Create:
web/apps/console/src/presentation/PresentationFooter.tsx - Create:
web/apps/console/src/presentation/PresentationFooter.test.tsx - Modify:
web/apps/console/src/presentation/SceneProgress.tsx
Interfaces:
-
Produces:
projectEvidenceReceipt(records: readonly EvidenceRecord[]): EvidenceReceiptModel -
Produces:
projectEvidenceDetail(record: EvidenceRecord): EvidenceDetailModel -
Produces:
formatEvidenceValue(value: unknown): FormattedEvidenceValue -
Produces:
EvidenceReceiptandPresentationFooter -
Consumes: canonical
EvidenceRecord[]; latest record wins for the compact receipt -
Step 1: Write safe projection tests
Create
evidence-model.test.tswith cases for a normal result, no records, missing status, and cyclic raw data:import { describe, expect, it } from "vitest"; import type { EvidenceRecord } from "../../app/state.js"; import { formatEvidenceValue, projectEvidenceDetail, projectEvidenceReceipt, } from "./evidence-model.js"; const record = (response: unknown): EvidenceRecord => ({ id: "run-start", operation: "workflow.runs.start", label: "Start run", equivalentCli: "uv run wf run start demo.default", request: { deployment_id: "demo.default" }, response, durationMs: 88, }); describe("evidence projection", () => { it("uses the latest record and extracts a nested result status", () => { const model = projectEvidenceReceipt([ record({ result: { status: "interrupted" } }), { ...record({ result: { status: "completed" } }), id: "trace", operation: "workflow.runs.trace" }, ]); expect(model).toMatchObject({ available: true, operation: "workflow.runs.trace", status: "completed", recordCount: 2, }); }); it("returns an unavailable receipt for no records", () => { expect(projectEvidenceReceipt([])).toEqual({ available: false, operation: "Evidence unavailable", status: null, recordCount: 0, }); }); it("projects run and deployment identifiers without requiring status", () => { expect(projectEvidenceDetail({ ...record({ result: { run_id: "run_demo" } }), request: { deployment_id: "demo.default" }, })).toMatchObject({ status: null, durationMs: 88, deploymentId: "demo.default", runId: "run_demo", equivalentCli: "uv run wf run start demo.default", }); }); it("returns bounded text and a note when raw evidence is not JSON serializable", () => { const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic; const formatted = formatEvidenceValue(cyclic); expect(formatted.text).toBe("[object Object]"); expect(formatted.note).toMatch(/could not format as json/i); }); it("truncates oversized evidence before it reaches the inspector", () => { const formatted = formatEvidenceValue("x".repeat(120_000)); expect(formatted.text.length).toBeLessThanOrEqual(100_003); expect(formatted.note).toMatch(/truncated/i); }); }); -
Step 2: Run the model test and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- evidence-model.test.tsExpected: FAIL because the model module does not exist.
-
Step 3: Implement narrow projection helpers
Create
evidence-model.tswith exported readonly model types. Use a guarded object helper to readresponse.result.status; do not assume the response shape or cast the complete payload.import type { EvidenceRecord } from "../../app/state.js"; export type EvidenceReceiptModel = { readonly available: boolean; readonly operation: string; readonly status: string | null; readonly recordCount: number; }; export type EvidenceDetailModel = EvidenceReceiptModel & { readonly id: string; readonly label: string; readonly equivalentCli: string; readonly durationMs: number; readonly deploymentId: string | null; readonly runId: string | null; readonly request: FormattedEvidenceValue; readonly response: FormattedEvidenceValue; }; export type FormattedEvidenceValue = { readonly text: string; readonly note: string | null; }; const MAX_EVIDENCE_TEXT_LENGTH = 100_000; const objectValue = (value: unknown): Readonly<Record<string, unknown>> | null => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Readonly<Record<string, unknown>> : null; const responseStatus = (response: unknown): string | null => { const result = objectValue(objectValue(response)?.result); return typeof result?.status === "string" ? result.status : null; }; const stringField = ( value: Readonly<Record<string, unknown>> | null, field: string, ): string | null => { const candidate = value?.[field]; return typeof candidate === "string" ? candidate : null; }; const boundEvidenceText = ( text: string, note: string | null, ): FormattedEvidenceValue => { if (text.length <= MAX_EVIDENCE_TEXT_LENGTH) return { text, note }; const truncation = `Evidence truncated to ${MAX_EVIDENCE_TEXT_LENGTH} characters.`; return { text: `${text.slice(0, MAX_EVIDENCE_TEXT_LENGTH)}...`, note: note ? `${note} ${truncation}` : truncation, }; }; export const formatEvidenceValue = (value: unknown): FormattedEvidenceValue => { try { const encoded = JSON.stringify(value, null, 2); return boundEvidenceText(encoded ?? String(value), null); } catch { let text: string; try { text = String(value); } catch { text = "[Unprintable evidence]"; } return boundEvidenceText( text, "Could not format as JSON; showing a bounded text representation.", ); } }; export const projectEvidenceReceipt = ( records: readonly EvidenceRecord[], ): EvidenceReceiptModel => { const latest = records.at(-1); if (!latest) { return { available: false, operation: "Evidence unavailable", status: null, recordCount: 0 }; } return { available: true, operation: latest.operation, status: responseStatus(latest.response), recordCount: records.length, }; }; export const projectEvidenceDetail = (record: EvidenceRecord): EvidenceDetailModel => { const request = objectValue(record.request); const result = objectValue(objectValue(record.response)?.result); return { ...projectEvidenceReceipt([record]), id: record.id, label: record.label, equivalentCli: record.equivalentCli, durationMs: record.durationMs, deploymentId: stringField(result, "deployment_id") ?? stringField(request, "deployment_id"), runId: stringField(result, "run_id") ?? stringField(request, "run_id"), request: formatEvidenceValue(record.request), response: formatEvidenceValue(record.response), }; };The cast is limited to the guarded object helper. Add a comment there if the final implementation uses a less obvious schema guard.
-
Step 4: Write receipt and footer tests
Define a local fixture in
EvidenceReceipt.test.tsx:const record: EvidenceRecord = { id: "run-start", operation: "workflow.runs.start", label: "Start run", equivalentCli: "uv run wf run start demo.default", request: { deployment_id: "demo.default" }, response: { result: { status: "interrupted", run_id: "run_demo" } }, durationMs: 88, };Then verify:
it("shows latest operation, status, count, and opens inspection", async () => { const inspect = vi.fn(); render(<EvidenceReceipt records={[record]} visible onInspect={inspect} />); expect(screen.getByText("workflow.runs.start")).toBeInTheDocument(); expect(screen.getByText("interrupted")).toBeInTheDocument(); expect(screen.getByText(/1 record/i)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /inspect evidence/i })); expect(inspect).toHaveBeenCalledOnce(); }); it("renders no receipt when the beat keeps evidence hidden", () => { render(<EvidenceReceipt records={[record]} visible={false} onInspect={vi.fn()} />); expect(screen.queryByRole("button", { name: /inspect evidence/i })).not.toBeInTheDocument(); }); it("disables inspection when evidence is unavailable", () => { render(<EvidenceReceipt records={[]} visible onInspect={vi.fn()} />); expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeDisabled(); });In
PresentationFooter.test.tsx, use a complete local record and verify both progress and provenance occupy one footer:it("combines scene progress and evidence provenance", () => { const evidence: EvidenceRecord = { id: "trace", operation: "workflow.runs.trace", label: "Inspect trace", equivalentCli: "uv run wf run trace run_demo", request: { run_id: "run_demo" }, response: { result: { status: "completed" } }, durationMs: 34, }; render( <PresentationFooter location={{ kind: "main", sceneId: "architecture", beatId: "runtime", focusPath: [], }} evidence={[evidence]} showEvidenceReceipt inspectEvidence={vi.fn()} />, ); const footer = screen.getByRole("contentinfo", { name: /presentation footer/i }); expect(within(footer).getByText("6 / 12")).toBeInTheDocument(); expect(within(footer).getByText("3 / 4")).toBeInTheDocument(); expect(within(footer).getByText("workflow.runs.trace")).toBeInTheDocument(); }); -
Step 5: Run component tests and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- EvidenceReceipt.test.tsx PresentationFooter.test.tsxExpected: FAIL because both components do not exist.
-
Step 6: Implement the receipt and footer
EvidenceReceiptaccepts:type EvidenceReceiptProps = { readonly records: readonly EvidenceRecord[]; readonly visible: boolean; readonly onInspect: () => void; };Render one button with accessible name
Inspect evidence. UseprojectEvidenceReceipt; do not inspect response objects in JSX:export const EvidenceReceipt = ({ records, visible, onInspect, }: EvidenceReceiptProps) => { if (!visible) return null; const receipt = projectEvidenceReceipt(records); const countLabel = `${receipt.recordCount} ${receipt.recordCount === 1 ? "record" : "records"}`; return ( <button type="button" className="evidence-receipt" aria-label="Inspect evidence" disabled={!receipt.available} onClick={onInspect} > <span>Evidence: {receipt.operation}</span> {receipt.status && <span data-status={receipt.status}>{receipt.status}</span>} <span>{countLabel}</span> {receipt.available && <span aria-hidden="true">Inspect</span>} </button> ); };PresentationFooteraccepts:type PresentationFooterProps = { readonly location: MainLocation; readonly evidence: readonly EvidenceRecord[]; readonly showEvidenceReceipt: boolean; readonly inspectEvidence: () => void; };Render
<SceneProgress>and<EvidenceReceipt>inside one footer. KeepSceneProgressresponsible only for scene and beat numbering:export const PresentationFooter = ({ location, evidence, showEvidenceReceipt, inspectEvidence, }: PresentationFooterProps) => ( <footer className="presentation-footer" aria-label="presentation footer"> <SceneProgress location={location} /> <EvidenceReceipt records={evidence} visible={showEvidenceReceipt} onInspect={inspectEvidence} /> </footer> ); -
Step 7: Run focused tests and commit
Run:
pnpm --dir web --filter @lda/console test -- evidence-model.test.ts EvidenceReceipt.test.tsx PresentationFooter.test.tsx SceneProgress.test.tsx pnpm --dir web --filter @lda/console typecheckExpected: all focused tests pass and typecheck exits
0.Commit:
git add web/apps/console/src/presentation/evidence web/apps/console/src/presentation/PresentationFooter.tsx web/apps/console/src/presentation/PresentationFooter.test.tsx web/apps/console/src/presentation/SceneProgress.tsx git commit -m "feat: add presentation evidence receipt"
Task 4: Accessible Centered Evidence Inspector
Files:
- Create:
web/apps/console/src/presentation/evidence/EvidenceInspector.tsx - Create:
web/apps/console/src/presentation/evidence/EvidenceInspector.test.tsx
Interfaces:
- Produces:
EvidenceInspector - Consumes:
records,open, andonClose - Consumes:
projectEvidenceDetailfrom Task 3 - Owns: selected record and
interpreted/rawlocal view state
type EvidenceInspectorProps = {
readonly records: readonly EvidenceRecord[];
readonly open: boolean;
readonly onClose: () => void;
};
-
Step 1: Write inspector behavior tests
Define complete local records in
EvidenceInspector.test.tsx:const record: EvidenceRecord = { id: "run-start", operation: "workflow.runs.start", label: "Start run", equivalentCli: "uv run wf run start demo.default", request: { deployment_id: "demo.default" }, response: { result: { status: "interrupted", run_id: "run_demo" } }, durationMs: 88, }; const traceRecord: EvidenceRecord = { ...record, id: "run-trace", operation: "workflow.runs.trace", label: "Inspect trace", equivalentCli: "uv run wf run trace run_demo", response: { result: { status: "completed", frames: [] } }, durationMs: 34, };Create tests for interpreted-first rendering, raw switching, record stability, focus restoration, close, and malformed data:
it("opens on interpreted evidence and switches to raw request and response", async () => { render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />); expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument(); expect(screen.getByText("workflow.runs.start")).toBeInTheDocument(); expect(screen.getByText("88 ms")).toBeInTheDocument(); expect(screen.getByText("demo.default")).toBeInTheDocument(); expect(screen.getByText("run_demo")).toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: /raw/i })); expect(screen.getByText(/uv run wf run start/i)).toBeInTheDocument(); expect(screen.getByText(/deployment_id/i)).toBeInTheDocument(); expect(screen.getByText(/interrupted/i)).toBeInTheDocument(); }); it("keeps the selected record while changing views", async () => { render(<EvidenceInspector records={[record, traceRecord]} open onClose={vi.fn()} />); await userEvent.selectOptions(screen.getByLabelText(/evidence record/i), record.id); await userEvent.click(screen.getByRole("tab", { name: /raw/i })); expect(screen.getByLabelText(/evidence record/i)).toHaveValue(record.id); }); it("focuses close on open and restores the previous trigger on unmount", () => { const trigger = document.createElement("button"); document.body.append(trigger); trigger.focus(); const view = render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />); expect(screen.getByRole("button", { name: /close evidence/i })).toHaveFocus(); view.unmount(); expect(trigger).toHaveFocus(); trigger.remove(); });Add explicit focus-wrap, empty-state, and malformed-value tests:
it("wraps keyboard focus inside the inspector", async () => { render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />); const close = screen.getByRole("button", { name: /close evidence/i }); const raw = screen.getByRole("tab", { name: /raw/i }); close.focus(); await userEvent.tab({ shift: true }); expect(raw).toHaveFocus(); await userEvent.tab(); expect(close).toHaveFocus(); }); it("renders a bounded unavailable state with no records", () => { render(<EvidenceInspector records={[]} open onClose={vi.fn()} />); expect(screen.getByText(/evidence unavailable/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/evidence record/i)).not.toBeInTheDocument(); }); it("shows a formatting note for non-serializable raw evidence", async () => { const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic; render(<EvidenceInspector records={[{ ...record, response: cyclic }]} open onClose={vi.fn()} />); await userEvent.click(screen.getByRole("tab", { name: /raw/i })); expect(screen.getByText(/could not format as json/i)).toBeInTheDocument(); }); -
Step 2: Run the inspector test and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- EvidenceInspector.test.tsxExpected: FAIL because
EvidenceInspectordoes not exist. -
Step 3: Implement modal semantics without a new dependency
Import
useEffect,useRef,useState, and React'sKeyboardEventtype:import { useEffect, useRef, useState, type KeyboardEvent } from "react";Initialize the local selection and view from the latest record:
const [view, setView] = useState<"interpreted" | "raw">("interpreted"); const [selectedRecordId, setSelectedRecordId] = useState( () => records.at(-1)?.id ?? "", ); const selectedRecord = records.find((record) => record.id === selectedRecordId) ?? records.at(-1) ?? null; const detail = selectedRecord ? projectEvidenceDetail(selectedRecord) : null;Preserve
selectedRecordIdwhile it remains present and switch to the latest record only when the current record disappears:useEffect(() => { setSelectedRecordId((current) => records.some((record) => record.id === current) ? current : records.at(-1)?.id ?? "", ); }, [records]);After all hooks, return
nullwhenopenis false. Then use a stage-local overlay:if (!open) return null;<div className="evidence-inspector-layer"> <section ref={dialogRef} className="evidence-inspector" role="dialog" aria-modal="true" aria-label="Evidence inspector" onKeyDown={trapTabWithinDialog} > <header> <h2>Evidence</h2> <button ref={closeButtonRef} type="button" onClick={onClose}> Close evidence </button> </header> {selectedRecord && ( <label> Evidence record <select value={selectedRecord.id} onChange={(event) => setSelectedRecordId(event.currentTarget.value)} > {records.map((record) => ( <option key={record.id} value={record.id}>{record.operation}</option> ))} </select> </label> )} <div role="tablist" aria-label="Evidence view"> <button role="tab" aria-selected={view === "interpreted"} onClick={() => setView("interpreted")}> Interpreted </button> <button role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}> Raw </button> </div> <div className="evidence-inspector__body"> {!detail ? ( <p>Evidence unavailable</p> ) : view === "interpreted" ? ( <dl> <dt>Operation</dt><dd>{detail.operation}</dd> <dt>Status</dt><dd>{detail.status ?? "Unavailable"}</dd> <dt>Duration</dt><dd>{detail.durationMs} ms</dd> <dt>Deployment</dt><dd>{detail.deploymentId ?? "Unavailable"}</dd> <dt>Run</dt><dd>{detail.runId ?? "Unavailable"}</dd> </dl> ) : ( <> <h3>Equivalent CLI</h3><pre><code>{detail.equivalentCli}</code></pre> <h3>Request</h3><pre><code>{detail.request.text}</code></pre> {detail.request.note && <p>{detail.request.note}</p>} <h3>Response</h3><pre><code>{detail.response.text}</code></pre> {detail.response.note && <p>{detail.response.note}</p>} </> )} </div> </section> </div>Keep the focus lifecycle explicit:
useEffect(() => { if (!open) return; const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; closeButtonRef.current?.focus(); return () => previouslyFocused?.focus(); }, [open]);Keep the Tab trap local and bounded to the dialog:
const trapTabWithinDialog = (event: KeyboardEvent<HTMLElement>) => { if (event.key !== "Tab") return; const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>( "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])", ) ?? [])].filter((element) => !element.hasAttribute("disabled")); const first = focusable.at(0); const last = focusable.at(-1); if (!first || !last) return; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } };The global
PresentationRouteEscape listener remains the single Escape owner. The inspector traps only Tab. Handling Escape locally as well would dispatch two closes and could accidentally close the node spotlight or discussion behind it.Initialize selection to the latest record. When
recordschanges, preserve the selected id if it still exists; otherwise select the latest available record. Use two tabs,InterpretedandRaw. The raw view shows equivalent CLI, request, and response with the formatting note beside malformed values. -
Step 4: Bound inspector content
Ensure raw evidence never expands the dialog beyond the stage:
<div className="evidence-inspector__body"> <pre><code>{detail.request.text}</code></pre> <pre><code>{detail.response.text}</code></pre> </div>CSS ownership arrives in Task 5, but the DOM must expose stable classes and semantic headings for
Equivalent CLI,Request, andResponse. -
Step 5: Run focused tests and commit
Run:
pnpm --dir web --filter @lda/console test -- EvidenceInspector.test.tsx evidence-model.test.ts pnpm --dir web --filter @lda/console typecheckExpected: inspector tests pass and typecheck exits
0.Commit:
git add web/apps/console/src/presentation/evidence/EvidenceInspector.tsx web/apps/console/src/presentation/evidence/EvidenceInspector.test.tsx git commit -m "feat: add presentation evidence inspector"
Task 5: Stage Composition and Compact Container Queries
Files:
- Modify:
web/apps/console/src/presentation/PresentationStage.tsx - Modify:
web/apps/console/src/presentation/presentation.css - Modify:
web/apps/console/src/presentation/styles/editorial.css - Delete:
web/apps/console/src/presentation/EvidenceDrawer.tsx - Delete:
web/apps/console/src/presentation/EvidenceDrawer.test.tsx - Modify:
web/apps/console/src/presentation/PresentationRoute.test.tsx
Interfaces:
-
Consumes:
PresentationFooterandEvidenceInspector -
Consumes: semantic
composition.evidencePresentation -
Produces: one primary grid region, one footer row, and transient overlays
-
Produces: named
presentation-canvasinline-size container -
Step 1: Write structural integration tests
Update the stable-region test in
PresentationRoute.test.tsx:it("renders stable chat, primary, progress, and transient evidence surfaces", () => { render(<PresentationRoute />); expect(screen.getByLabelText(/agent chat region/i)).toBeInTheDocument(); expect(screen.getByLabelText(/primary presentation region/i)).toBeInTheDocument(); expect(screen.getByLabelText(/presentation footer/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/evidence region/i)).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument(); });Add a trace-beat test asserting the receipt is present but the inspector is not:
it("shows a receipt without auto-opening evidence on an evidence beat", () => { window.location.hash = "#scene/interrupt-evidence/trace"; render(<PresentationRoute />); expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument(); });Add explicit opening and navigation tests in the same file:
// Add `act` to the existing Testing Library import. it("opens the inspector from an explicit operation action", async () => { window.location.hash = "#scene/workflow-demo/operation"; render(<PresentationRoute />); await userEvent.click(await screen.findByRole("button", { name: /view raw evidence/i })); expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument(); }); it("closes the inspector when navigation moves to another beat", async () => { window.location.hash = "#scene/workflow-demo/operation"; render(<PresentationRoute />); await userEvent.click(await screen.findByRole("button", { name: /view raw evidence/i })); act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); }); expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument(); });Dispatching on
windowmodels presenter navigation outside an interactive inspector control. The route deliberately ignores advance keys originating inside a button, tab, select, or raw-evidence control. -
Step 2: Run the route tests and verify the red phase
Run:
pnpm --dir web --filter @lda/console test -- PresentationRoute.test.tsxExpected: FAIL because the old evidence region remains and the
tracebeat still renders drawer behavior. -
Step 3: Replace the evidence column in
PresentationStageRemove
EvidenceDrawerand the.presentation-stage__evidenceaside. After the primary section, render:{state.location.kind === "main" && ( <PresentationFooter location={state.location} evidence={evidence} showEvidenceReceipt={composition.evidencePresentation !== "hidden"} inspectEvidence={openEvidence} /> )} <EvidenceInspector records={evidence} open={composition.evidencePresentation === "inspector"} onClose={closeOverlay} />Rename the stage data attribute to
data-evidence-presentation. DeleteEvidenceDrawer.tsxand its test after no imports remain. -
Step 4: Establish the container-query owner
In
styles/editorial.css, add:.presentation-canvas { container-name: presentation-canvas; container-type: inline-size; }Container queries apply to descendants, not the container itself. Keep adaptive width on
.presentation-canvasand query its child.presentation-stage. -
Step 5: Replace evidence-width grid geometry
In
presentation.css, remove--evidence-width,[data-evidence-mode],.presentation-stage__evidence, and.evidence-drawerrules. The stage becomes:.presentation-stage { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; display: grid; grid-template: "chat primary" minmax(0, 1fr) "footer footer" auto / var(--chat-width) minmax(0, 1fr); } .presentation-stage[data-chat-mode="hidden"] { grid-template: "primary" minmax(0, 1fr) "footer" auto / minmax(0, 1fr); } .presentation-stage__chat { grid-area: chat; } .presentation-stage__primary { grid-area: primary; } .presentation-footer { grid-area: footer; }Replace
height: 100dvhon the stage withheight: 100%. The stage is inside a transformed logical canvas; viewport units would bypass that ownership and are a common cause of 4:3 overflow. -
Step 6: Add compact chat behavior and inspector precedence
Add:
@container presentation-canvas (max-width: 1080px) { .presentation-stage { grid-template: "primary" minmax(0, 1fr) "footer" auto / minmax(0, 1fr); } .presentation-stage__chat { position: absolute; inset: 0 auto var(--presentation-footer-height, 2.5rem) 0; width: min(18rem, 44%); z-index: 20; } .presentation-stage[data-evidence-presentation="inspector"] .presentation-stage__chat { visibility: hidden; pointer-events: none; } }The threshold is a visual tuning constant, not a second state model. Do not read container width in React.
-
Step 7: Style receipt and inspector without changing scene visuals
Add a stable footer and overlay geometry. Adapt colors to the existing presentation tokens, but preserve these dimensions and ownership rules:
.presentation-stage { --presentation-footer-height: 2.5rem; } .presentation-footer { min-height: var(--presentation-footer-height); display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.35rem 1rem; border-top: 1px solid var(--stage-line); overflow: hidden; } .presentation-footer .scene-progress { flex: 0 0 auto; padding: 0; } .evidence-receipt { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 0.65rem; border: 0; background: transparent; color: var(--text-secondary); font: 600 0.72rem/1.2 var(--font-evidence, monospace); cursor: pointer; } .evidence-receipt > :first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .evidence-receipt:disabled { cursor: default; opacity: 0.65; } .evidence-receipt:focus-visible, .evidence-inspector button:focus-visible, .evidence-inspector select:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; } .evidence-inspector-layer { box-sizing: border-box; position: absolute; inset: 0; z-index: 30; display: grid; place-items: center; padding: 2.5rem 5%; background: oklch(0.08 0.02 250 / 0.68); } .evidence-inspector { box-sizing: border-box; width: min(70cqi, 56rem); max-width: calc(100% - 2rem); max-height: calc(100% - var(--presentation-footer-height)); min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; border: 1px solid var(--stage-line); background: var(--stage-surface); color: var(--text-primary); } .evidence-inspector__body { min-height: 0; overflow: auto; padding: 1rem; } .evidence-inspector pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; }Add a short opacity transition to
.evidence-inspector-layer; the existing reduced-motion selectors must collapse it to effectively immediate. Do not animate stage grid dimensions or primary width.Do not adjust
.interactive-figure, React Flow nodes, Dagre layout,.workflow-graph-stage, scene caption sizes, or graph-specific overflow in this task. -
Step 8: Run focused tests and commit
Run:
pnpm --dir web --filter @lda/console test -- PresentationRoute.test.tsx PresentationFooter.test.tsx EvidenceReceipt.test.tsx EvidenceInspector.test.tsx pnpm --dir web --filter @lda/console typecheckExpected: focused tests pass and typecheck exits
0.Confirm the old implementation is gone:
rg -n 'EvidenceDrawer|evidence-drawer|evidence-width|data-evidence-mode' web/apps/console/src/presentationExpected: no matches.
Commit:
git add web/apps/console/src/presentation git commit -m "feat: replace presentation evidence drawer"
Task 6: Browser Geometry and Review Runbook
Files:
- Modify:
docs/runbooks/presentation-visual-review.md
Interfaces:
-
Documents: repeatable adaptive-canvas and evidence-inspector browser checks
-
Verifies: real browser geometry at three viewport ratios
-
Preserves: screenshots as ignored review artifacts rather than committed product files
-
Step 1: Replace fixed-canvas runbook assumptions with the adaptive matrix
Replace the runbook's single-viewport introduction and its first two fixed-canvas checklist items. Keep the existing architecture and console review states, then add the evidence route and matrix:
## Adaptive Canvas Matrix Review these routes at `1024x768`, `1200x800`, and `1280x720`, always at 100% browser zoom: 1. `/present#scene/architecture/node-use/focus/node-use` 2. `/present#scene/interrupt-evidence/trace` The logical canvas widths must be `960`, `1080`, and `1280` respectively. Capture the receipt state and open-inspector state at each viewport. Confirm no page scroll, unchanged primary geometry, bounded raw evidence, focus restoration, compact chat exclusion, and a clean browser console.Replace checklist claims that
1024x768scales a fixed1280x720canvas to0.8; that behavior is intentionally removed. Preserve the separate architecture-figure checks because graph visual repair is outside this slice. -
Step 2: Run the browser at representative ratios
Start the existing dev server if it is not already running:
pnpm --dir web devUse the available Playwright automation to capture
/present#scene/architecture/node-use/focus/node-useand/present#scene/interrupt-evidence/traceat:1024x768 1200x800 1280x720For each viewport, verify:
document.documentElement.scrollWidth === window.innerWidthandscrollHeight === window.innerHeight;- the logical canvas width is respectively
960,1080, and1280CSS pixels before transform; - the current claim, graph, progress, and evidence receipt remain present;
- opening the inspector does not change the primary region's width or height;
- raw evidence scrolls inside the inspector rather than the page;
Escapecloses the inspector and returns focus to the triggering control;- at
1024x768, any visible chat is hidden while the inspector is open; - no new browser-console errors appear.
Measure primary geometry before and after opening:
const primary = document.querySelector('[aria-label="primary presentation region"]'); const before = primary?.getBoundingClientRect(); // Click Inspect evidence, then read the same rectangle again. const after = primary?.getBoundingClientRect(); ({ before: { width: before?.width, height: before?.height }, after: { width: after?.width, height: after?.height } });Expected: before and after dimensions are equal at each viewport. Screenshots are review artifacts; save them under an ignored temporary directory and do not commit them.
-
Step 3: Troubleshoot browser-only failures before continuing
Use these diagnostics rather than compensating with scene-specific CSS:
- If
@containerrules do not fire, inspect.presentation-canvasand confirm computedcontainer-type: inline-sizeandcontainer-name: presentation-canvas. A component cannot query itself; the stage must be its descendant. - If the canvas is still letterboxed at
1024x768, inspect the inline logical width. It must be960px; a remainingPRESENTATION_WIDTHimport usually means the component still forces1280px. - If stage content is taller than the canvas, search for
100dvhbelow.presentation-canvas. Descendants must useheight: 100%so the logical canvas owns height. - If opening evidence changes graph fit, search for a remaining evidence grid column, width variable, or React Flow resize observer triggered by layout width. The inspector must be positioned over the stage.
- If one Escape closes two layers, remove component-local Escape handling. The route's window listener is the only Escape owner.
- If focus is lost after closing, ensure the inspector captures
document.activeElementbefore focusing its close button and restores it from the effect cleanup. - If JSDOM tests pass but compact layout fails, do not add DOM-size mocks. Container queries and transformed geometry require the browser checks above.
- If
-
Step 4: Commit the repeatable visual-review procedure
git add docs/runbooks/presentation-visual-review.md git commit -m "docs: verify adaptive presentation geometry"
Task 7: Documentation, Regression, and Plan Archival
Files:
- Modify:
web/README.md - Modify:
docs/current_roadmap.md - Move:
docs/superpowers/plans/2026-07-06-adaptive-presentation-canvas.mdtodocs/historical/superpowers/plans/2026-07-06-adaptive-presentation-canvas.md
Interfaces:
-
Documents: adaptive
4:3through16:9behavior -
Documents: evidence receipt and explicit inspector
-
Preserves: console product evidence drawer wording where it describes
/console, not/present -
Step 1: Update user-facing presentation documentation
In
web/README.md, replace only presentation-specific fixed-canvas and evidence-drawer language. The resulting wording must state:The presentation keeps a 720px logical height and adapts continuously from a 960px-wide 4:3 canvas to a 1280px-wide 16:9 canvas. More extreme viewports letterbox at the nearest reviewed ratio. Evidence beats update a compact receipt in the progress row. Raw and interpreted protocol evidence opens only through the centered inspector, so evidence never resizes the active graph or scene.Do not replace
/consoledocumentation that accurately calls its own product surface an evidence drawer. -
Step 2: Mark the roadmap item completed
Change roadmap item 12 from
ThentoCompleted, preserve the design-spec link, and add the historical implementation-plan link. Renumbering is unnecessary unless another active change has inserted a colliding item. -
Step 3: Archive the completed plan
Run:
New-Item -ItemType Directory -Force docs/historical/superpowers/plans | Out-Null git mv docs/superpowers/plans/2026-07-06-adaptive-presentation-canvas.md docs/historical/superpowers/plans/2026-07-06-adaptive-presentation-canvas.mdUpdate the roadmap link to the historical path. Search for the active path and ensure no live link remains:
rg -n -F 'superpowers/plans/2026-07-06-adaptive-presentation-canvas.md' docs web skillsExpected: only the historical path appears where the completed implementation is cited.
-
Step 4: Run complete verification
Run:
pnpm --dir web test pnpm --dir web typecheck pnpm --dir web build git diff --checkExpected:
- all console, RPC, and server tests pass;
- all package typechecks exit
0; - the production build succeeds;
git diff --checkreports no whitespace errors.
-
Step 5: Run React diagnostics
Use the repository's
react-doctorskill or its documented local command againstweb/apps/console. Review every new finding. Fix findings caused by this slice; record unrelated pre-existing findings without broad cleanup. -
Step 6: Review the scope boundary
Confirm the final diff does not modify:
web/apps/console/src/presentation/figures/* layout behavior web/apps/console/src/presentation/WorkflowGraphStage.tsx node placement Dagre spacing or graph dimensions scene-specific caption sizes to suppress overflow canonical evidence recording dataIf visual overflow remains inside an individual graph after the canvas and inspector work passes, report it as the next visual slice rather than hiding it with unrelated CSS here.
-
Step 7: Commit documentation and archive
git add web/README.md docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-06-adaptive-presentation-canvas.md git commit -m "docs: record adaptive presentation canvas"
Final Review Checklist
Before reporting completion, verify each item directly rather than relying on task-level results:
1024x768uses a960x720logical canvas without vertical letterboxing.1200x800uses a1080x720logical canvas.1280x720uses a1280x720logical canvas.- Ratios outside
4:3through16:9letterbox at the nearest supported ratio. - Scene and graph semantic content is identical across the three reviewed ratios.
node-use,diagnose, andtracebeats show a receipt but never auto-open the inspector.View raw evidenceand the receipt open the same centered inspector.- Opening the inspector leaves primary geometry unchanged.
- Interpreted evidence appears before raw request/response data.
- Equivalent CLI, request, response, status, duration, and record selection remain inspectable.
- Empty, missing-status, and non-serializable evidence do not crash the route.
- Escape closes the inspector before node spotlight or discussion and restores focus.
- Beat navigation closes the inspector and restores beat-derived receipt state.
- Compact chat is hidden while the inspector is open.
- Reduced motion preserves all information and controls.
EvidenceDrawer,peek, and presentation-owned evidenceopenvocabulary are removed.- No scene-specific graph visual or overflow changes entered this slice.
- Full web tests, typechecks, build, React diagnostics, and browser checks have fresh passing evidence.