feat: add read-only defense presenter route
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+27
-27
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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&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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user