feat: synchronize presenter controls
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import type { PresenterBeatNote } from "./presenter-notes.js";
|
||||
import { presenterHashForNote } from "./presenter-navigation.js";
|
||||
import { PresentationPairingPanel } from "../sync/PresentationPairingPanel.js";
|
||||
import type { PresentationSyncController } from "../sync/presentation-sync-state.js";
|
||||
|
||||
type PresenterNavigationBarProps = {
|
||||
readonly currentIndex: number;
|
||||
readonly total: number;
|
||||
readonly previous: PresenterBeatNote | null;
|
||||
readonly next: PresenterBeatNote | null;
|
||||
readonly syncController: PresentationSyncController;
|
||||
};
|
||||
|
||||
const DirectionLink = ({ note, children }: { readonly note: PresenterBeatNote | null; readonly children: string }) =>
|
||||
@@ -13,10 +16,11 @@ const DirectionLink = ({ note, children }: { readonly note: PresenterBeatNote |
|
||||
? <a href={presenterHashForNote(note)}>{children}</a>
|
||||
: <span aria-disabled="true">{children}</span>;
|
||||
|
||||
export const PresenterNavigationBar = ({ currentIndex, total, previous, next }: PresenterNavigationBarProps) => (
|
||||
export const PresenterNavigationBar = ({ currentIndex, total, previous, next, syncController }: PresenterNavigationBarProps) => (
|
||||
<nav className="presenter-navigation" aria-label="Presenter note navigation">
|
||||
<DirectionLink note={previous}>← Previous</DirectionLink>
|
||||
<span>{currentIndex + 1} / {total}</span>
|
||||
<DirectionLink note={next}>Next →</DirectionLink>
|
||||
<PresentationPairingPanel role="presenter" controller={syncController} />
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PresentationSyncController } from "../sync/presentation-sync-state.js";
|
||||
import { usePresentationSync } from "../sync/usePresentationSync.js";
|
||||
import { PresenterRoute } from "./PresenterRoute.js";
|
||||
|
||||
vi.mock("../sync/usePresentationSync.js", () => ({
|
||||
usePresentationSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUsePresentationSync = vi.mocked(usePresentationSync);
|
||||
const idleController = (): PresentationSyncController => ({
|
||||
state: { kind: "standalone" },
|
||||
startSession: vi.fn(async () => undefined),
|
||||
joinSession: vi.fn(async () => undefined),
|
||||
retry: vi.fn(),
|
||||
leaveSession: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUsePresentationSync.mockReturnValue(idleController());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.location.hash = "";
|
||||
@@ -44,6 +64,101 @@ describe("PresenterRoute", () => {
|
||||
expect(window.location.hash).toBe("#scene/thesis/substrate");
|
||||
});
|
||||
|
||||
it("mounts presenter synchronization beside stable navigation and publishes canonical hashes", async () => {
|
||||
window.location.hash = "#scene/thesis/substrate";
|
||||
render(<PresenterRoute />);
|
||||
|
||||
const navigation = screen.getByRole("navigation", { name: /presenter note navigation/i });
|
||||
expect(navigation).toContainElement(screen.getByRole("button", { name: /pair presentation/i }));
|
||||
expect(screen.getByRole("link", { name: /^Next →$/i })).toHaveAttribute(
|
||||
"href",
|
||||
"#scene/problem/direct-actions",
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: /Where is the AI agent/i })[0]).toHaveAttribute(
|
||||
"href",
|
||||
"#discuss/where-is-ai-agent",
|
||||
);
|
||||
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
role: "presenter",
|
||||
currentHash: "#scene/thesis/substrate",
|
||||
}));
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /previous/i }));
|
||||
expect(window.location.hash).toBe("#scene/thesis/title");
|
||||
fireEvent(window, new HashChangeEvent("hashchange"));
|
||||
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
currentHash: "#scene/thesis/title",
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies audience hashes to notes and Q&A without publishing the old hash", () => {
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
render(<PresenterRoute />);
|
||||
const applyRemoteHash = mockedUsePresentationSync.mock.calls.at(-1)?.[0].applyRemoteHash;
|
||||
expect(applyRemoteHash).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
applyRemoteHash?.("#scene/problem/direct-actions");
|
||||
fireEvent(window, new HashChangeEvent("hashchange"));
|
||||
});
|
||||
expect(screen.getByRole("region", { name: "Beat goal" })).toHaveTextContent(
|
||||
/Show why one successful chat is not yet automation/i,
|
||||
);
|
||||
expect(window.location.hash).toBe("#scene/problem/direct-actions");
|
||||
|
||||
act(() => {
|
||||
applyRemoteHash?.("#discuss/where-is-ai-agent");
|
||||
fireEvent(window, new HashChangeEvent("hashchange"));
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: /Where is the AI agent/i })).toBeInTheDocument();
|
||||
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
currentHash: "#discuss/where-is-ai-agent",
|
||||
}));
|
||||
});
|
||||
|
||||
it("ends presenter sessions after confirmation and displays ended state", async () => {
|
||||
let controllerState: PresentationSyncController["state"] = {
|
||||
kind: "connected",
|
||||
grant: {
|
||||
sessionId: "session-1",
|
||||
code: "ABC123",
|
||||
connectionToken: "token",
|
||||
websocketPath: "/api/presentation-sync/ws",
|
||||
snapshot: { hash: "#scene/thesis/title", revision: 1 },
|
||||
},
|
||||
snapshot: { hash: "#scene/thesis/title", revision: 1 },
|
||||
presence: { presenters: 1, audience: 1 },
|
||||
};
|
||||
const controller = {
|
||||
...idleController(),
|
||||
get state() { return controllerState; },
|
||||
};
|
||||
mockedUsePresentationSync.mockReturnValue(controller);
|
||||
const { rerender } = render(<PresenterRoute />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "End presentation" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "End presentation now" }));
|
||||
expect(controller.endSession).toHaveBeenCalledOnce();
|
||||
|
||||
controllerState = { kind: "ended", reason: "presenter_ended" };
|
||||
rerender(<PresenterRoute />);
|
||||
expect(screen.getByText("The presenter ended this session.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps local navigation available after synchronization fails", () => {
|
||||
mockedUsePresentationSync.mockReturnValue({
|
||||
...idleController(),
|
||||
state: { kind: "failed", message: "Socket unavailable", retryable: true },
|
||||
});
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
render(<PresenterRoute />);
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
|
||||
expect(window.location.hash).toBe("#scene/thesis/substrate");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Socket unavailable");
|
||||
});
|
||||
|
||||
it("advances from the latest hash during rapid consecutive navigation", () => {
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
render(<PresenterRoute />);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { findDiscussionBranch } from "../storyboard.js";
|
||||
import { usePresentationSync } from "../sync/usePresentationSync.js";
|
||||
import { PresenterNote } from "./PresenterNote.js";
|
||||
import { PresenterNavigationBar } from "./PresenterNavigationBar.js";
|
||||
import { PresenterShell } from "./PresenterShell.js";
|
||||
@@ -19,6 +20,14 @@ const moveFromCurrentHash = (direction: "next" | "previous") => {
|
||||
export const PresenterRoute = () => {
|
||||
const [navigation, setNavigation] = useState(readHash);
|
||||
const [covered, setCovered] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const presentationSync = usePresentationSync({
|
||||
role: "presenter",
|
||||
currentHash: window.location.hash || "#scene/thesis/title",
|
||||
applyRemoteHash: (hash) => {
|
||||
// Hash assignment keeps the existing parser as the single navigation path.
|
||||
if (window.location.hash !== hash) window.location.hash = hash;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const syncHash = () => setNavigation(readHash());
|
||||
@@ -62,6 +71,7 @@ export const PresenterRoute = () => {
|
||||
total={presenterNotes.length}
|
||||
previous={navigation.previous}
|
||||
next={navigation.next}
|
||||
syncController={presentationSync}
|
||||
/>
|
||||
)}
|
||||
{navigation.note && (
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
.presenter-sidebar__qna a[aria-current="page"] { color: #155b49; font-weight: 700; }
|
||||
|
||||
.presenter-route__reader { min-width: 0; padding: 1.25rem clamp(1.5rem, 5vw, 6rem) 5rem; touch-action: pan-y; }
|
||||
.presenter-navigation { max-width: 72ch; min-height: 2.5rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; margin: 0 auto 2rem; border-bottom: 1px solid #d7d5d0; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
|
||||
.presenter-navigation { max-width: 72ch; min-height: 2.5rem; display: grid; grid-template-columns: 1fr auto 1fr auto; align-items: center; gap: 1rem; margin: 0 auto 2rem; border-bottom: 1px solid #d7d5d0; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
|
||||
.presenter-navigation a { color: #155b49; font-weight: 650; text-decoration: none; }
|
||||
.presenter-navigation a:last-child, .presenter-navigation > span:last-child { text-align: right; }
|
||||
.presenter-navigation > a:nth-child(3), .presenter-navigation > span:nth-child(3) { text-align: right; }
|
||||
.presenter-navigation [aria-disabled="true"] { opacity: 0.35; }
|
||||
.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; }
|
||||
@@ -77,7 +77,26 @@
|
||||
.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)); }
|
||||
.presenter-navigation { position: sticky; top: 0; z-index: 3; margin-inline: -0.5rem; padding-inline: 0.5rem; background: #f7f7f5; }
|
||||
.presenter-navigation {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
isolation: isolate;
|
||||
padding-inline: 0.5rem;
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* Extend the sticky surface without replacing the nav's centered auto margins. */
|
||||
.presenter-navigation::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 50%;
|
||||
z-index: -1;
|
||||
width: 100vw;
|
||||
transform: translateX(-50%);
|
||||
border-bottom: 1px solid #d7d5d0;
|
||||
background: #f7f7f5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.presenter-note__goal p { font-size: 1.42rem; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user