feat: add read-only defense presenter route
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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&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