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
+4 -2
View File
@@ -419,8 +419,10 @@ Presentation wishlist / defense readiness:
[`interactive graphs and Scenes 3-6 choreography`](historical/superpowers/plans/2026-07-13-scenes-3-6-kernel-choreography.md) [`interactive graphs and Scenes 3-6 choreography`](historical/superpowers/plans/2026-07-13-scenes-3-6-kernel-choreography.md)
adds restrained Scene 3-5 choreography, a semantic-zoom architecture map, adds restrained Scene 3-5 choreography, a semantic-zoom architecture map,
the readable `wf_core` and NodeUse figures, and a factual prepared-run graph the readable `wf_core` and NodeUse figures, and a factual prepared-run graph
inspector. The next follow-up is inspector. Completed:
[`read-only presenter route`](superpowers/plans/2026-07-13-read-only-presenter-route.md). [`read-only presenter route`](historical/superpowers/plans/2026-07-13-read-only-presenter-route.md)
provides a static speech, evidence, timing, fallback, and Q&A reader without
RPC, replay, or audience-window synchronization.
- Defense Q&A branch set: - Defense Q&A branch set:
[`defense Q&A runbook`](runbooks/defense-qna.md) collects answers for [`defense Q&A runbook`](runbooks/defense-qna.md) collects answers for
"Where is the AI agent?", evaluation validity, security boundaries, demo "Where is the AI agent?", evaluation validity, security boundaries, demo
@@ -23,11 +23,11 @@
- Modify: `web/apps/console/src/app/AppRoutes.tsx` - Modify: `web/apps/console/src/app/AppRoutes.tsx`
- Modify: route tests - Modify: route tests
- [ ] Add `/presenter` without redirecting existing routes. - [x] Add `/presenter` without redirecting existing routes.
- [ ] Support `#scene/<scene>/<beat>` and `#discuss/<branch>` inside the presenter route using the existing route vocabulary. - [x] Support `#scene/<scene>/<beat>` and `#discuss/<branch>` inside the presenter route using the existing route vocabulary.
- [ ] Invalid hashes fail closed to the first presenter note. - [x] Invalid hashes fail closed to the first presenter note.
- [ ] Expose previous, current, and next note metadata without mutating `/present`. - [x] Expose previous, current, and next note metadata without mutating `/present`.
- [ ] Provide an “Open audience slide” link targeting the corresponding `/present#scene/...` route. - [x] Provide an “Open audience slide” link targeting the corresponding `/present#scene/...` route.
## Task 2: Build the reading layout ## Task 2: Build the reading layout
@@ -35,13 +35,13 @@
- Create: `PresenterShell.tsx`, `PresenterNote.tsx`, `PresenterSidebar.tsx`, and `presenter.css` - Create: `PresenterShell.tsx`, `PresenterNote.tsx`, `PresenterSidebar.tsx`, and `presenter.css`
- Create component tests - Create component tests
- [ ] Use a restrained two-column layout: compact scene index and a 60-72 character reading column. - [x] Use a restrained two-column layout: compact scene index and a 60-72 character reading column.
- [ ] Show scene/beat, elapsed target, current must-say text, optional detail, claim warning, fallback, evidence pointers, and linked Q&A. - [x] Show scene/beat, elapsed target, current must-say text, optional detail, claim warning, fallback, evidence pointers, and linked Q&A.
- [ ] Place the next beat preview below the current note, not beside it. - [x] Place the next beat preview below the current note, not beside it.
- [ ] Collapse optional detail, evidence, and Q&A by default using accessible disclosure controls. - [x] Collapse optional detail, evidence, and Q&A by default using accessible disclosure controls.
- [ ] Typeset must-say text at 20-24px with at least 1.5 line height. - [x] Typeset must-say text at 20-24px with at least 1.5 line height.
- [ ] Use distinct but quiet treatments for `Say`, `Optional`, `Warning`, `Fallback`, and `Evidence`; avoid dashboard cards around every paragraph. - [x] Use distinct but quiet treatments for `Say`, `Optional`, `Warning`, `Fallback`, and `Evidence`; avoid dashboard cards around every paragraph.
- [ ] Add a print stylesheet that produces a readable speech outline. - [x] Add a print stylesheet that produces a readable speech outline.
## Task 3: Move presenter-only discussion content out of the audience panel ## Task 3: Move presenter-only discussion content out of the audience panel
@@ -50,26 +50,26 @@
- Modify: `web/apps/console/src/presentation/DiscussionPanel.test.tsx` - Modify: `web/apps/console/src/presentation/DiscussionPanel.test.tsx`
- Modify: presenter Q&A components and tests - Modify: presenter Q&A components and tests
- [ ] Delete the commented `speakerHint` block from `DiscussionPanel.tsx`. - [x] Delete the commented `speakerHint` block from `DiscussionPanel.tsx`.
- [ ] Render `speakerHint` only in `/presenter` as presenter guidance. - [x] Render `speakerHint` only in `/presenter` as presenter guidance.
- [ ] Keep the audience discussion panel limited to question, answer, context, and evidence. - [x] Keep the audience discussion panel limited to question, answer, context, and evidence.
- [ ] Verify every prioritized Q&A branch has a short answer and evidence pointer; show missing expanded answers as absent, not placeholders. - [x] Verify every prioritized Q&A branch has a short answer and evidence pointer; show missing expanded answers as absent, not placeholders.
## Task 4: Add static timing and rehearsal affordances ## Task 4: Add static timing and rehearsal affordances
- [ ] Show target time per note and cumulative target time. - [x] Show target time per note and cumulative target time.
- [ ] Add a local-only manual “mark covered” checkbox state if it can remain component-local; do not persist or synchronize it in this slice. - [x] Add a local-only manual “mark covered” checkbox state if it can remain component-local; do not persist or synchronize it in this slice.
- [ ] Add keyboard navigation within `/presenter` only, with a visible help summary. - [x] Add keyboard navigation within `/presenter` only, with a visible help summary.
- [ ] Do not add an automatic timer until presenter behavior is rehearsed. - [x] Do not add an automatic timer until presenter behavior is rehearsed.
## Task 5: Verification and documentation ## Task 5: Verification and documentation
- [ ] Test route isolation, hash parsing, note completeness, Q&A rendering, keyboard navigation, and audience links. - [x] Test route isolation, hash parsing, note completeness, Q&A rendering, keyboard navigation, and audience links.
- [ ] Run React Doctor after focused tests. - [x] Run React Doctor after focused tests.
- [ ] Run full console tests, typecheck, and build. - [x] Run full console tests, typecheck, and build.
- [ ] Capture `/presenter` at `1920x1080`, `1280x720`, and `1024x768`. - [x] Capture `/presenter` at `1920x1080`, `1280x720`, and `1024x768`.
- [ ] Confirm no audience-only RPC or replay effects run on `/presenter`. - [x] Confirm no audience-only RPC or replay effects run on `/presenter`.
- [ ] Update `web/README.md`, `docs/runbooks/defense-presentation.md`, and `docs/current_roadmap.md`. - [x] Update `web/README.md`, `docs/runbooks/defense-presentation.md`, and `docs/current_roadmap.md`.
- [ ] Archive this plan and commit. - [x] Archive this plan and commit.
**Completion gate:** `/presenter` is usable as a standalone rehearsal and defense aid, while `/present` remains unchanged and audience-clean. **Completion gate:** `/presenter` is usable as a standalone rehearsal and defense aid, while `/present` remains unchanged and audience-clean.
+13
View File
@@ -74,6 +74,16 @@ Open:
http://127.0.0.1:5173/present http://127.0.0.1:5173/present
``` ```
Open the read-only presenter notes on the presenter laptop or a second window:
```text
http://127.0.0.1:5173/presenter#scene/thesis/title
```
`/presenter` does not control `/present`. It is a static speech, evidence, and
Q&A reader with local covered-state checkboxes. Use its “Open audience slide”
links when you need to align the audience window manually.
### Console And Live Workflow Server ### Console And Live Workflow Server
Use this when showing `/console` or live RPC-backed workflow operations. Use this when showing `/console` or live RPC-backed workflow operations.
@@ -183,6 +193,9 @@ visual hierarchy, chat mode, evidence mode, and fallback route.
- In interactive figures: `Tab` enters the current figure node, arrow keys move - In interactive figures: `Tab` enters the current figure node, arrow keys move
between nodes, `Enter` expands an expandable node, and `Escape` pops one between nodes, `Enter` expands an expandable node, and `Escape` pops one
figure focus level. figure focus level.
- In `/presenter`: `ArrowRight` advances one presenter note and `ArrowLeft`
returns to the previous note. Disclosure controls and covered checkboxes are
local and do not affect the audience route.
Avoid relying on undocumented shortcuts during the defense. If a control is not Avoid relying on undocumented shortcuts during the defense. If a control is not
visible or listed here, treat it as rehearsal-only. visible or listed here, treat it as rehearsal-only.
+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 stage regions, discussion branches, one editorial canvas, persistent scene-aware
assistant surfaces, and keyboard navigation. 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 The final presentation beats frame the evaluation as bounded evidence, make
claim boundaries and future work explicit, and end on the canonical defense claim boundaries and future work explicit, and end on the canonical defense
discussion index rather than a benchmark or generic conclusion. 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.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull(); 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 { Navigate, Route, Routes } from "react-router-dom";
import { ConsoleHome } from "./ConsoleHome.js"; import { ConsoleHome } from "./ConsoleHome.js";
import { PresentationRoute } from "../presentation/PresentationRoute.js"; import { PresentationRoute } from "../presentation/PresentationRoute.js";
import { PresenterRoute } from "../presentation/presenter/PresenterRoute.js";
export const AppRoutes = () => ( export const AppRoutes = () => (
<Routes> <Routes>
<Route path="/" element={<ConsoleHome />} /> <Route path="/" element={<ConsoleHome />} />
<Route path="/console" element={<ConsoleHome />} /> <Route path="/console" element={<ConsoleHome />} />
<Route path="/present" element={<PresentationRoute />} /> <Route path="/present" element={<PresentationRoute />} />
<Route path="/presenter" element={<PresenterRoute />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
@@ -110,13 +110,6 @@ export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) =>
<span>Evidence</span> <span>Evidence</span>
<p>{branch.evidencePointer}</p> <p>{branch.evidencePointer}</p>
</section> </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> </aside>
<footer className="discussion-panel__actions" aria-label="discussion actions"> <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__answer-card span,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance span, .discussion-panel[data-presentation-surface="editorial"] .discussion-panel__provenance span {
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note 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))); 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); 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__provenance,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail { .discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail {
display: grid; display: grid;
gap: 0.35rem; 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__provenance p,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__presenter-note p,
.discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail p { .discussion-panel[data-presentation-surface="editorial"] .discussion-panel__detail p {
margin: 0; 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))); 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; 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 { .discussion-panel[data-presentation-surface="editorial"] .discussion-panel__return {
border: 1px solid var(--color-editorial-ink, oklch(0.19 0.015 65)); border: 1px solid var(--color-editorial-ink, oklch(0.19 0.015 65));
border-radius: 0.45rem; 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; }
}