feat: synchronize presenter controls
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Task 7 Report: Presenter Synchronization And Session End
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented bidirectional LAN presentation synchronization for `/presenter` using the existing `usePresentationSync` controller and `PresentationPairingPanel` UI with the `presenter` role.
|
||||
|
||||
The presenter URL hash remains canonical. Local navigation continues to use the existing hash parser and `presenterHashForNote` paths, while remote locations assign `window.location.hash` only when the requested hash differs. The existing hook's remote-in-flight guard consumes that update without publishing a feedback revision.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Mounted `usePresentationSync` in `PresenterRoute` with the current URL hash and presenter role.
|
||||
- Applied remote note and Q&A hashes through `window.location.hash`, preserving the existing `hashchange` parser and rendering flow.
|
||||
- Passed the controller to `PresenterNavigationBar` and rendered the shared pairing panel beside Previous/Next controls, outside speaker-note content.
|
||||
- Enabled the presenter's existing shared end-session confirmation and ended-state UI.
|
||||
- Preserved rapid key navigation, swipe callbacks and exclusions, sidebar/Q&A links, covered state, and initial auto-scroll behavior.
|
||||
- Preserved the intentional mobile navigation CSS: centered auto margins remain in place, the viewport-wide sticky surface uses `::before`, and no negative `margin-inline` was introduced.
|
||||
- Expanded the navigation grid for the pairing control and kept Next alignment explicit after adding the fourth grid item.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
RED was observed with four route tests failing because the presenter route did not mount the panel/controller, apply remote hashes, expose end-session UI, or render failure state. Existing presenter shell tests remained green.
|
||||
|
||||
GREEN coverage verifies:
|
||||
|
||||
- the pairing panel is inside the stable presenter navigation area;
|
||||
- canonical Previous/Next and Q&A links and hook hash updates;
|
||||
- audience-originated note and Q&A hashes update presenter content;
|
||||
- presenter end confirmation calls `endSession` and ended state is displayed;
|
||||
- local arrow-key navigation remains available after synchronization failure;
|
||||
- existing rapid key navigation, swipe behavior/exclusions, sidebar behavior, and auto-scroll tests remain passing.
|
||||
|
||||
Remote-update feedback suppression is additionally covered by the existing `usePresentationSync` tests and implemented by its `remoteHashInFlightRef` guard.
|
||||
|
||||
## Verification
|
||||
|
||||
```text
|
||||
pnpm --dir web --filter @lda/console test -- src/presentation/presenter
|
||||
5 test files passed; 28 tests passed.
|
||||
|
||||
pnpm --dir web --filter @lda/console typecheck
|
||||
@lda/presentation-sync build and console TypeScript project build passed.
|
||||
|
||||
git diff --check
|
||||
Passed with no whitespace errors.
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
No critical or important findings. The change is limited to the presenter synchronization seam and tests. The untracked active LAN synchronization plan was not staged. The pre-existing intentional `presenter.css` mobile sticky-navigation edit was retained and verified as part of this task.
|
||||
@@ -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