feat: add read-only defense presenter route

This commit is contained in:
lda
2026-07-13 09:16:14 +07:00 Verified
parent 1ea75086b5
commit 1f07f3a4fd
16 changed files with 454 additions and 45 deletions
+7
View File
@@ -175,6 +175,13 @@ now cover evaluation and closing) with an adaptive aspect-ratio canvas, stable
stage regions, discussion branches, one editorial canvas, persistent scene-aware
assistant surfaces, and keyboard navigation.
The companion `/presenter` route is a read-only speech and Q&A reader. It uses
the same `#scene/<scene>/<beat>` and `#discuss/<branch>` hashes, shows target and
cumulative timing, keeps optional detail/evidence/Q&A collapsed, and links to
the corresponding audience slide in a new tab. It performs no workflow RPC,
replay, live-target, or cross-window synchronization. Use ArrowLeft and
ArrowRight to move between notes; covered checkboxes remain local to the page.
The final presentation beats frame the evaluation as bounded evidence, make
claim boundaries and future work explicit, and end on the canonical defense
discussion index rather than a benchmark or generic conclusion.
+12
View File
@@ -196,4 +196,16 @@ describe("App", () => {
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull();
});
it("routes to read-only presenter notes separately from presentation mode", () => {
window.location.hash = "#scene/thesis/title";
render(
<MemoryRouter initialEntries={["/presenter"]}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getByRole("main", { name: /lda.chat presenter notes/i })).toBeInTheDocument();
expect(screen.queryByRole("main", { name: /lda.chat presentation/i })).not.toBeInTheDocument();
});
});
+2
View File
@@ -1,12 +1,14 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { ConsoleHome } from "./ConsoleHome.js";
import { PresentationRoute } from "../presentation/PresentationRoute.js";
import { PresenterRoute } from "../presentation/presenter/PresenterRoute.js";
export const AppRoutes = () => (
<Routes>
<Route path="/" element={<ConsoleHome />} />
<Route path="/console" element={<ConsoleHome />} />
<Route path="/present" element={<PresentationRoute />} />
<Route path="/presenter" element={<PresenterRoute />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
@@ -110,13 +110,6 @@ export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) =>
<span>Evidence</span>
<p>{branch.evidencePointer}</p>
</section>
{/* // Not to show, just to know
branch.speakerHint && (
<aside className="discussion-panel__presenter-note" aria-label="presenter note">
<span>Presenter note</span>
<p>{branch.speakerHint}</p>
</aside>
) */}
</aside>
<footer className="discussion-panel__actions" aria-label="discussion actions">
@@ -1392,8 +1392,7 @@
}
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__answer-card span,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance span,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note span {
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance span {
color: color-mix(in oklch, var(--color-editorial-ink, oklch(0.19 0.015 65)) 68%, var(--color-editorial-muted, oklch(0.48 0.025 65)));
font: 700 0.68rem/1 var(--font-interface);
}
@@ -1405,7 +1404,6 @@
}
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail {
display: grid;
gap: 0.35rem;
@@ -1416,7 +1414,6 @@
}
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance p,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note p,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail p {
margin: 0;
color: color-mix(in oklch, var(--color-editorial-ink, oklch(0.19 0.015 65)) 84%, var(--color-editorial-muted, oklch(0.48 0.025 65)));
@@ -1424,11 +1421,6 @@
line-height: 1.38;
}
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note {
border-style: dashed;
background: color-mix(in oklch, var(--color-human, oklch(0.68 0.17 55)) 8%, var(--color-editorial-paper, oklch(0.975 0.012 82)));
}
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__return {
border: 1px solid var(--color-editorial-ink, oklch(0.19 0.015 65));
border-radius: 0.45rem;
@@ -0,0 +1,69 @@
import { findScene, mainScenes } from "../storyboard.js";
import { discussionBranchForId, type PresenterBeatNote } from "./presenter-notes.js";
import { audienceHrefForNote, formatPresenterTime, presenterHashForNote } from "./presenter-navigation.js";
type PresenterNoteProps = {
readonly note: PresenterBeatNote;
readonly cumulativeSeconds: number;
readonly next: PresenterBeatNote | null;
readonly covered: boolean;
readonly onCoveredChange: (covered: boolean) => void;
};
export const PresenterNote = ({ note, cumulativeSeconds, next, covered, onCoveredChange }: PresenterNoteProps) => {
const scene = findScene(note.sceneId);
const sceneNumber = mainScenes.findIndex((candidate) => candidate.id === note.sceneId) + 1;
return (
<article className="presenter-note" aria-labelledby="presenter-note-title">
<header className="presenter-note__header">
<div>
<span>Scene {sceneNumber || "?"} · {note.beatId}</span>
<h1 id="presenter-note-title">{scene?.title ?? note.sceneId}</h1>
</div>
<div className="presenter-note__timing">
<span>Target {formatPresenterTime(note.targetSeconds)}</span>
<span>Cumulative {formatPresenterTime(cumulativeSeconds)}</span>
</div>
</header>
<section className="presenter-note__say" aria-labelledby="presenter-say">
<span id="presenter-say">Say</span>
<p>{note.mustSay}</p>
</section>
{note.warning && <aside className="presenter-note__warning"><strong>Warning</strong><p>{note.warning}</p></aside>}
{note.fallback && <aside className="presenter-note__fallback"><strong>Fallback</strong><p>{note.fallback}</p></aside>}
{note.optionalDetail && <details><summary>Optional detail</summary><p>{note.optionalDetail}</p></details>}
<details>
<summary>Evidence ({note.evidencePointers.length})</summary>
<ul>{note.evidencePointers.map((pointer) => <li key={pointer}>{pointer}</li>)}</ul>
</details>
{note.qnaBranchIds.length > 0 && (
<details>
<summary>Linked Q&amp;A ({note.qnaBranchIds.length})</summary>
<ul className="presenter-note__qna">
{note.qnaBranchIds.map((branchId) => {
const branch = discussionBranchForId(branchId);
if (!branch) return null;
return <li key={branchId}><a href={`#discuss/${branchId}`}>{branch.question ?? branch.title}</a></li>;
})}
</ul>
</details>
)}
<footer className="presenter-note__actions">
<label><input type="checkbox" checked={covered} onChange={(event) => onCoveredChange(event.target.checked)} /> Mark covered</label>
<a href={audienceHrefForNote(note)} target="_blank" rel="noopener noreferrer">Open audience slide</a>
</footer>
{next && (
<section className="presenter-note__next" aria-label="Next beat preview">
<span>Next · {formatPresenterTime(next.targetSeconds)}</span>
<p>{next.mustSay}</p>
<a href={presenterHashForNote(next)}>Go to next beat</a>
</section>
)}
</article>
);
};
@@ -0,0 +1,44 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { PresenterRoute } from "./PresenterRoute.js";
afterEach(() => {
cleanup();
window.location.hash = "";
});
describe("PresenterRoute", () => {
it("renders the first presenter note without audience runtime surfaces", () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
expect(screen.getByRole("main", { name: /lda.chat presenter notes/i })).toBeInTheDocument();
expect(screen.getByText(/This project began with the goal/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /open audience slide/i })).toHaveAttribute("href", "/present#scene/thesis/title");
expect(screen.queryByText(/live target/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
});
it("keeps covered state local to the route", async () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
const checkbox = screen.getByRole("checkbox", { name: /mark covered/i });
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
it("navigates notes with arrow keys", () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(window.location.hash).toBe("#scene/thesis/substrate");
});
it("renders Q&A speaker guidance only in presenter mode", () => {
window.location.hash = "#discuss/where-is-ai-agent";
render(<PresenterRoute />);
expect(screen.getByRole("heading", { name: /Where is the AI agent/i })).toBeInTheDocument();
expect(screen.getByText(/Answer directly first/i)).toBeInTheDocument();
expect(screen.getByText(/Abstract; Chapter 1 framing/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { findDiscussionBranch } from "../storyboard.js";
import { PresenterNote } from "./PresenterNote.js";
import { PresenterShell } from "./PresenterShell.js";
import { presenterHashForNote, presenterNavigationFromHash } from "./presenter-navigation.js";
import "./presenter.css";
const readHash = () => presenterNavigationFromHash(window.location.hash);
export const PresenterRoute = () => {
const [navigation, setNavigation] = useState(readHash);
const [covered, setCovered] = useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
const syncHash = () => setNavigation(readHash());
window.addEventListener("hashchange", syncHash);
return () => window.removeEventListener("hashchange", syncHash);
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
const destination = event.key === "ArrowRight" ? navigation.next : event.key === "ArrowLeft" ? navigation.previous : null;
if (!destination) return;
event.preventDefault();
window.location.hash = presenterHashForNote(destination);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [navigation.next, navigation.previous]);
const currentKey = navigation.note ? `${navigation.note.sceneId}/${navigation.note.beatId}` : null;
const discussion = navigation.location.kind === "discussion" ? findDiscussionBranch(navigation.location.branchId) : undefined;
return (
<PresenterShell current={navigation.note} covered={covered}>
<div className="presenter-route__help" aria-label="Presenter keyboard help"> previous · next · disclosures stay local</div>
{navigation.note && (
<PresenterNote
note={navigation.note}
cumulativeSeconds={navigation.cumulativeSeconds}
next={navigation.next}
covered={currentKey ? covered.has(currentKey) : false}
onCoveredChange={(isCovered) => {
if (!currentKey) return;
setCovered((existing) => {
const next = new Set(existing);
if (isCovered) next.add(currentKey); else next.delete(currentKey);
return next;
});
}}
/>
)}
{discussion && (
<article className="presenter-qna" aria-labelledby="presenter-qna-title">
<a href="#scene/thesis/title">Back to notes</a>
<span>{discussion.claimClass}</span>
<h1 id="presenter-qna-title">{discussion.question ?? discussion.title}</h1>
<p className="presenter-qna__short">{discussion.shortAnswer ?? discussion.summary}</p>
{discussion.expandedAnswer && <details><summary>Expanded answer</summary><p>{discussion.expandedAnswer}</p></details>}
{discussion.speakerHint && <aside><strong>Presenter guidance</strong><p>{discussion.speakerHint}</p></aside>}
<dl><dt>Evidence</dt><dd>{discussion.evidencePointer}</dd></dl>
</article>
)}
</PresenterShell>
);
};
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import type { PresenterBeatNote } from "./presenter-notes.js";
import { PresenterSidebar } from "./PresenterSidebar.js";
type PresenterShellProps = {
readonly current: PresenterBeatNote | null;
readonly covered: ReadonlySet<string>;
readonly children: ReactNode;
};
export const PresenterShell = ({ current, covered, children }: PresenterShellProps) => (
<main className="presenter-route" aria-label="lda.chat presenter notes">
<PresenterSidebar current={current} covered={covered} />
<div className="presenter-route__reader">{children}</div>
</main>
);
@@ -0,0 +1,40 @@
import { mainScenes } from "../storyboard.js";
import { presenterSceneNotes, type PresenterBeatNote } from "./presenter-notes.js";
import { presenterHashForNote } from "./presenter-navigation.js";
type PresenterSidebarProps = {
readonly current: PresenterBeatNote | null;
readonly covered: ReadonlySet<string>;
};
export const PresenterSidebar = ({ current, covered }: PresenterSidebarProps) => (
<nav className="presenter-sidebar" aria-label="Presenter scene index">
<a className="presenter-sidebar__brand" href="#scene/thesis/title">lda.chat defense</a>
<ol>
{mainScenes.map((scene, sceneIndex) => {
const notes = presenterSceneNotes(scene.id);
const active = current?.sceneId === scene.id;
return (
<li key={scene.id} data-active={active}>
<span>{sceneIndex + 1}</span>
<div>
<strong>{scene.title}</strong>
<div className="presenter-sidebar__beats">
{notes.map((note, beatIndex) => (
<a
key={note.beatId}
href={presenterHashForNote(note)}
aria-current={current === note ? "page" : undefined}
data-covered={covered.has(`${note.sceneId}/${note.beatId}`)}
>
{beatIndex + 1}
</a>
))}
</div>
</div>
</li>
);
})}
</ol>
</nav>
);
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { audienceHrefForNote, formatPresenterTime, presenterNavigationFromHash } from "./presenter-navigation.js";
describe("presenter navigation", () => {
it("resolves scene hashes with previous and next notes", () => {
const navigation = presenterNavigationFromHash("#scene/problem/direct-actions");
expect(navigation.note?.beatId).toBe("direct-actions");
expect(navigation.previous?.beatId).toBe("substrate");
expect(navigation.next?.beatId).toBe("missing-contracts");
expect(navigation.cumulativeSeconds).toBeGreaterThan(0);
});
it("supports discussion hashes without inventing a speech note", () => {
const navigation = presenterNavigationFromHash("#discuss/where-is-ai-agent");
expect(navigation.location).toEqual({ kind: "discussion", branchId: "where-is-ai-agent" });
expect(navigation.note).toBeNull();
});
it("fails closed to the first note", () => {
expect(presenterNavigationFromHash("#scene/nope/nope").note?.beatId).toBe("title");
expect(presenterNavigationFromHash("#%broken").note?.beatId).toBe("title");
});
it("builds audience links and readable timing", () => {
const note = presenterNavigationFromHash("#scene/thesis/title").note;
if (!note) throw new Error("missing note");
expect(audienceHrefForNote(note)).toBe("/present#scene/thesis/title");
expect(formatPresenterTime(83)).toBe("1:23");
});
});
@@ -0,0 +1,51 @@
import { locationFromHash, hashForLocation } from "../storyboard-navigation.js";
import { findDiscussionBranch, mainScenes, type DiscussionLocation, type MainLocation } from "../storyboard.js";
import { presenterBeatNoteFor, presenterNotes, type PresenterBeatNote } from "./presenter-notes.js";
export type PresenterLocation = MainLocation | DiscussionLocation;
export type PresenterNavigation = {
readonly location: PresenterLocation;
readonly note: PresenterBeatNote | null;
readonly index: number;
readonly previous: PresenterBeatNote | null;
readonly next: PresenterBeatNote | null;
readonly cumulativeSeconds: number;
};
const locationForNote = (note: PresenterBeatNote): MainLocation => ({
kind: "main",
sceneId: note.sceneId,
beatId: note.beatId,
focusPath: mainScenes.find((scene) => scene.id === note.sceneId)?.beats.find((beat) => beat.id === note.beatId)?.figure?.focusPath ?? [],
});
export const presenterHashForNote = (note: PresenterBeatNote): string => hashForLocation(locationForNote(note));
export const audienceHrefForNote = (note: PresenterBeatNote): string => `/present${presenterHashForNote(note)}`;
export const presenterNavigationFromHash = (hash: string): PresenterNavigation => {
const parsed = locationFromHash(hash);
if (parsed.kind === "discussion" && findDiscussionBranch(parsed.branchId)) {
return { location: parsed, note: null, index: -1, previous: null, next: null, cumulativeSeconds: 0 };
}
const parsedNote = parsed.kind === "main" ? presenterBeatNoteFor(parsed.sceneId, parsed.beatId) : undefined;
const note = parsedNote ?? presenterNotes[0];
if (!note) throw new Error("presenter note catalog is empty");
const index = presenterNotes.indexOf(note);
return {
location: locationForNote(note),
note,
index,
previous: presenterNotes[index - 1] ?? null,
next: presenterNotes[index + 1] ?? null,
cumulativeSeconds: presenterNotes.slice(0, index + 1).reduce((total, item) => total + item.targetSeconds, 0),
};
};
export const formatPresenterTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
};
@@ -0,0 +1,71 @@
.presenter-route {
min-height: 100vh;
display: grid;
grid-template-columns: 18rem minmax(0, 1fr);
background: #f7f7f5;
color: #20201e;
font-family: "Source Sans 3", sans-serif;
}
.presenter-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
padding: 1.25rem 1rem;
border-right: 1px solid #d7d5d0;
background: #efefec;
}
.presenter-sidebar__brand { display: block; margin-bottom: 1.2rem; color: inherit; font: 700 1.25rem "Barlow Condensed", sans-serif; text-transform: uppercase; }
.presenter-sidebar ol { display: grid; gap: 0.55rem; margin: 0; padding: 0; list-style: none; }
.presenter-sidebar li { display: grid; grid-template-columns: 1.6rem 1fr; gap: 0.45rem; opacity: 0.62; }
.presenter-sidebar li[data-active="true"] { opacity: 1; }
.presenter-sidebar li > span { font-family: "IBM Plex Mono", monospace; font-size: 0.75rem; }
.presenter-sidebar strong { display: block; font-size: 0.9rem; line-height: 1.15; }
.presenter-sidebar__beats { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.3rem; }
.presenter-sidebar__beats a { width: 1.55rem; height: 1.55rem; display: grid; place-items: center; border: 1px solid #c8c5bf; color: inherit; font: 0.7rem "IBM Plex Mono", monospace; text-decoration: none; }
.presenter-sidebar__beats a[aria-current="page"] { border-color: #1e6b55; background: #1e6b55; color: white; }
.presenter-sidebar__beats a[data-covered="true"]:not([aria-current="page"]) { background: #dce9e2; }
.presenter-route__reader { min-width: 0; padding: 1.25rem clamp(1.5rem, 5vw, 6rem) 5rem; }
.presenter-route__help { max-width: 72ch; margin: 0 auto 2rem; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
.presenter-note, .presenter-qna { max-width: 72ch; margin: 0 auto; }
.presenter-note__header { display: flex; justify-content: space-between; gap: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #20201e; }
.presenter-note__header span, .presenter-note__timing { color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
.presenter-note h1, .presenter-qna h1 { margin: 0.25rem 0 0; font: 700 2.5rem/1 "Barlow Condensed", sans-serif; text-transform: uppercase; }
.presenter-note__timing { display: grid; align-content: start; gap: 0.25rem; text-align: right; }
.presenter-note__say { margin: 2rem 0; }
.presenter-note__say > span { color: #1e6b55; font: 700 0.8rem "IBM Plex Mono", monospace; text-transform: uppercase; }
.presenter-note__say p { margin: 0.55rem 0 0; font-size: 1.42rem; line-height: 1.58; text-wrap: pretty; }
.presenter-note__warning, .presenter-note__fallback, .presenter-qna aside { margin: 1.2rem 0; padding: 0.8rem 0; border-block: 1px solid #a36919; }
.presenter-note__fallback { border-color: #3c6380; }
.presenter-note__warning p, .presenter-note__fallback p, .presenter-qna aside p { margin: 0.2rem 0 0; line-height: 1.5; }
.presenter-note details, .presenter-qna details { margin-top: 0.7rem; border-top: 1px solid #d7d5d0; padding: 0.7rem 0; }
.presenter-note summary, .presenter-qna summary { cursor: pointer; font-weight: 650; }
.presenter-note details p, .presenter-note details ul, .presenter-qna details p { line-height: 1.5; }
.presenter-note__actions { display: flex; justify-content: space-between; gap: 1rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 2px solid #20201e; }
.presenter-note__actions a, .presenter-note__next a, .presenter-qna a { color: #155b49; font-weight: 650; }
.presenter-note__next { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #a7a49e; color: #4f4d48; }
.presenter-note__next > span { font: 0.75rem "IBM Plex Mono", monospace; text-transform: uppercase; }
.presenter-note__next p { font-size: 1.05rem; line-height: 1.5; }
.presenter-qna > span { display: block; margin-top: 2rem; color: #67655f; font: 0.75rem "IBM Plex Mono", monospace; text-transform: uppercase; }
.presenter-qna__short { font-size: 1.35rem; line-height: 1.55; }
.presenter-qna dl { margin-top: 2rem; border-top: 1px solid #d7d5d0; padding-top: 1rem; }
.presenter-qna dt { font-weight: 700; }
.presenter-qna dd { margin: 0.25rem 0 0; }
@media (max-width: 900px) {
.presenter-route { grid-template-columns: 1fr; }
.presenter-sidebar { position: static; height: auto; max-height: 15rem; border-right: 0; border-bottom: 1px solid #d7d5d0; }
.presenter-sidebar ol { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media print {
.presenter-route { display: block; background: white; }
.presenter-sidebar, .presenter-route__help, .presenter-note__actions { display: none; }
.presenter-route__reader { padding: 0; }
.presenter-note { max-width: none; }
.presenter-note details { display: block; }
.presenter-note details > * { display: block; }
}