feat: add presentation pairing panel

This commit is contained in:
lda
2026-07-14 14:10:47 +07:00 Verified
parent c7e74f7a13
commit eb36e97bb0
5 changed files with 794 additions and 0 deletions
@@ -0,0 +1,182 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PresentationRole, SessionGrant } from "@lda/presentation-sync";
import type {
PresentationSyncController,
PresentationSyncState,
} from "./presentation-sync-state.js";
import { PresentationPairingPanel } from "./PresentationPairingPanel.js";
afterEach(() => cleanup());
const grant: SessionGrant = {
sessionId: "session-1",
code: "ABC123",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws",
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
const connectedState = (
kind: "waiting" | "connected" | "reconnecting" = "connected",
): PresentationSyncState => ({
kind,
grant,
snapshot: grant.snapshot,
presence: { presenters: 1, audience: 2 },
});
const controllerFor = (
state: PresentationSyncState,
): PresentationSyncController => ({
state,
startSession: vi.fn(async () => {}),
joinSession: vi.fn(async () => {}),
retry: vi.fn(),
leaveSession: vi.fn(),
endSession: vi.fn(),
});
const renderPanel = (
role: PresentationRole,
state: PresentationSyncState,
) => {
const controller = controllerFor(state);
const view = render(
<PresentationPairingPanel role={role} controller={controller} />,
);
return { controller, ...view };
};
describe("PresentationPairingPanel", () => {
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn(async () => {}) },
});
});
it("keeps the standalone surface collapsed until Pair presentation is opened", async () => {
const user = userEvent.setup();
renderPanel("audience", { kind: "standalone" });
const trigger = screen.getByRole("button", { name: "Pair presentation" });
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "Start session" })).toBeNull();
await user.click(trigger);
expect(screen.getByRole("button", { name: "Start session" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Pairing code" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Join session" })).toBeInTheDocument();
});
it("disables creation and joining controls while an operation is in flight", () => {
const { rerender } = renderPanel("presenter", { kind: "creating" });
expect(screen.getByRole("button", { name: "Start session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Join session" })).toBeDisabled();
rerender(
<PresentationPairingPanel
role="presenter"
controller={controllerFor({ kind: "joining", code: "ABC123" })}
/>,
);
expect(screen.getByRole("button", { name: "Start session" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Join session" })).toBeDisabled();
expect(screen.getByRole("textbox", { name: "Pairing code" })).toBeDisabled();
});
it("shows the waiting code, QR value, join link, and peer counts", async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const { controller } = renderPanel("presenter", connectedState("waiting"));
const joinUrl = `${window.location.origin}/present?pair=ABC123`;
const qr = screen.getByLabelText("Pairing QR code");
expect(screen.getByText("ABC123")).toBeInTheDocument();
expect(qr).toHaveAttribute("data-qr-value", joinUrl);
expect(screen.getByRole("link", { name: "Copyable join URL" })).toHaveAttribute(
"href",
joinUrl,
);
expect(screen.getByText("1 presenter · 2 audiences")).toBeInTheDocument();
expect(controller.startSession).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Copy join link" }));
expect(writeText).toHaveBeenCalledWith(joinUrl);
expect(screen.getByText("Join link copied")).toBeInTheDocument();
});
it("uses the presenter route when an audience device shares its join URL", () => {
renderPanel("audience", connectedState("waiting"));
const joinUrl = `${window.location.origin}/presenter?pair=ABC123`;
expect(screen.getByLabelText("Pairing QR code")).toHaveAttribute(
"data-qr-value",
joinUrl,
);
expect(screen.getByRole("link", { name: "Copyable join URL" })).toHaveAttribute(
"href",
joinUrl,
);
});
it("uses concise connected and reconnecting status copy", () => {
const { rerender } = renderPanel("audience", connectedState("connected"));
expect(screen.getByRole("status", { name: "Connected" })).toBeInTheDocument();
rerender(
<PresentationPairingPanel
role="audience"
controller={controllerFor(connectedState("reconnecting"))}
/>,
);
expect(screen.getByRole("status", { name: "Reconnecting" })).toBeInTheDocument();
});
it("offers retry for a retryable failure", async () => {
const user = userEvent.setup();
const controller = controllerFor({
kind: "failed",
message: "The pairing server is unavailable.",
retryable: true,
});
render(<PresentationPairingPanel role="audience" controller={controller} />);
expect(screen.getByText("The pairing server is unavailable.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry pairing" }));
expect(controller.retry).toHaveBeenCalledOnce();
});
it("explains an ended session", () => {
renderPanel("audience", { kind: "ended", reason: "presenter_ended" });
expect(screen.getByRole("status", { name: "Presentation ended" })).toBeInTheDocument();
expect(screen.getByText("The presenter ended this session.")).toBeInTheDocument();
expect(screen.queryByLabelText("Pairing QR code")).toBeNull();
});
it("requires presenter confirmation before ending the session", async () => {
const user = userEvent.setup();
const { controller } = renderPanel("presenter", connectedState());
await user.click(screen.getByRole("button", { name: "End presentation" }));
expect(screen.getByText("End presentation for everyone?")).toBeInTheDocument();
expect(controller.endSession).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "End presentation now" }));
expect(controller.endSession).toHaveBeenCalledOnce();
});
it("does not expose an end action to the audience", () => {
renderPanel("audience", connectedState());
expect(screen.queryByRole("button", { name: /End presentation/ })).toBeNull();
});
});
@@ -0,0 +1,278 @@
import { useEffect, useId, useState, type FormEvent } from "react";
import QRCode from "react-qr-code";
import {
JOIN_CODE_LENGTH,
normalizeJoinCode,
type PresentationRole,
} from "@lda/presentation-sync";
import type {
PresentationSyncController,
PresentationSyncState,
} from "./presentation-sync-state.js";
import "./presentation-sync.css";
export type PresentationPairingPanelProps = {
readonly role: PresentationRole;
readonly controller: PresentationSyncController;
};
const oppositePathFor = (role: PresentationRole): "/present" | "/presenter" =>
role === "presenter" ? "/present" : "/presenter";
const joinUrlFor = (role: PresentationRole, code: string): string =>
`${window.location.origin}${oppositePathFor(role)}?pair=${normalizeJoinCode(code)}`;
const statusFor = (state: PresentationSyncState): string | null => {
switch (state.kind) {
case "creating":
return "Creating session";
case "joining":
return "Joining session";
case "waiting":
return "Waiting for another device";
case "connected":
return "Connected";
case "reconnecting":
return "Reconnecting";
case "failed":
return "Pairing failed";
case "ended":
return "Presentation ended";
case "standalone":
return null;
}
};
const pluralize = (count: number, singular: string): string =>
`${count} ${singular}${count === 1 ? "" : "s"}`;
const endedMessageFor = (
reason: Extract<PresentationSyncState, { readonly kind: "ended" }>["reason"],
): string => {
switch (reason) {
case "presenter_ended":
return "The presenter ended this session.";
case "expired":
return "This pairing session expired.";
case "left":
return "You left this pairing session.";
}
};
const isBusy = (state: PresentationSyncState): boolean =>
state.kind === "creating" || state.kind === "joining";
const isActive = (
state: PresentationSyncState,
): state is Extract<
PresentationSyncState,
{ readonly kind: "waiting" | "connected" | "reconnecting" }
> =>
state.kind === "waiting" ||
state.kind === "connected" ||
state.kind === "reconnecting";
export const PresentationPairingPanel = ({
role,
controller,
}: PresentationPairingPanelProps) => {
const { state } = controller;
const [isOpen, setIsOpen] = useState(() => state.kind !== "standalone");
const [code, setCode] = useState("");
const [copyStatus, setCopyStatus] = useState("");
const [isConfirmingEnd, setIsConfirmingEnd] = useState(false);
const status = statusFor(state);
const idPrefix = useId();
const panelId = `${idPrefix}-panel`;
const inputId = `${idPrefix}-code`;
const inputCode = state.kind === "joining" ? state.code : code;
const normalizedInputCode = normalizeJoinCode(inputCode);
useEffect(() => {
if (state.kind !== "standalone") setIsOpen(true);
}, [state.kind]);
const openPanel = (): void => setIsOpen((open) => !open);
const startSession = (): void => {
void controller.startSession();
};
const joinSession = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (normalizedInputCode.length !== JOIN_CODE_LENGTH) return;
void controller.joinSession(normalizedInputCode);
};
const copyJoinUrl = async (joinUrl: string): Promise<void> => {
if (navigator.clipboard === undefined) {
setCopyStatus("Copy unavailable; use the link directly");
return;
}
try {
await navigator.clipboard.writeText(joinUrl);
setCopyStatus("Join link copied");
} catch {
setCopyStatus("Copy unavailable; use the link directly");
}
};
const endSession = (): void => {
controller.endSession();
setIsConfirmingEnd(false);
};
return (
<aside
className="presentation-pairing"
data-open={isOpen}
data-role={role}
data-state={state.kind}
aria-label="Presentation pairing"
>
<button
className="presentation-pairing__trigger"
type="button"
aria-controls={panelId}
aria-expanded={isOpen}
onClick={openPanel}
>
<span>Pair presentation</span>
{status && <span className="presentation-pairing__trigger-status">{status}</span>}
</button>
{isOpen && (
<section className="presentation-pairing__body" id={panelId}>
<header className="presentation-pairing__header">
<div>
<span className="presentation-pairing__eyebrow">LAN presentation</span>
<h2>Pair presentation</h2>
</div>
<span
className="presentation-pairing__status"
role="status"
aria-label={status ?? "Not paired"}
>
{status ?? "Not paired"}
</span>
</header>
{(state.kind === "standalone" || isBusy(state)) && (
<div className="presentation-pairing__setup">
<button
className="presentation-pairing__primary"
type="button"
onClick={startSession}
disabled={isBusy(state)}
aria-busy={state.kind === "creating"}
aria-label="Start session"
>
{state.kind === "creating" ? "Creating session…" : "Start session"}
</button>
<div className="presentation-pairing__divider" aria-hidden="true">
<span>or join with a code</span>
</div>
<form onSubmit={joinSession}>
<label htmlFor={inputId}>Pairing code</label>
<div className="presentation-pairing__join-row">
<input
id={inputId}
value={inputCode}
onChange={(event) => setCode(normalizeJoinCode(event.target.value))}
inputMode="text"
autoComplete="off"
maxLength={6}
spellCheck={false}
disabled={isBusy(state)}
/>
<button
type="submit"
disabled={isBusy(state) || normalizedInputCode.length !== JOIN_CODE_LENGTH}
aria-label="Join session"
>
{state.kind === "joining" ? "Joining…" : "Join session"}
</button>
</div>
</form>
</div>
)}
{isActive(state) && (
<div className="presentation-pairing__active">
<div className="presentation-pairing__qr" role="img" aria-label="Pairing QR code" data-qr-value={joinUrlFor(role, state.grant.code)}>
<QRCode value={joinUrlFor(role, state.grant.code)} size={156} title="Scan to pair this presentation" />
</div>
<div className="presentation-pairing__details">
<p className="presentation-pairing__instruction">
{state.kind === "waiting"
? "Scan this code or enter it on the other device."
: "Use this code to add another device."}
</p>
<p className="presentation-pairing__code" aria-label="Pairing code">
{normalizeJoinCode(state.grant.code)}
</p>
<dl className="presentation-pairing__presence">
<div>
<dt>Peers</dt>
<dd>
{`${pluralize(state.presence.presenters, "presenter")} · ${pluralize(state.presence.audience, "audience")}`}
</dd>
</div>
</dl>
<div className="presentation-pairing__link-row">
<a href={joinUrlFor(role, state.grant.code)}>Copyable join URL</a>
<button
type="button"
className="presentation-pairing__copy"
onClick={() => void copyJoinUrl(joinUrlFor(role, state.grant.code))}
>
Copy join link
</button>
</div>
<span className="presentation-pairing__copy-status" role="status" aria-live="polite">
{copyStatus}
</span>
{role === "presenter" && (
<div className="presentation-pairing__end">
{isConfirmingEnd ? (
<div className="presentation-pairing__confirmation" role="alertdialog" aria-label="End presentation confirmation">
<strong>End presentation for everyone?</strong>
<div>
<button type="button" onClick={endSession}>End presentation now</button>
<button type="button" onClick={() => setIsConfirmingEnd(false)}>Keep session</button>
</div>
</div>
) : (
<button type="button" onClick={() => setIsConfirmingEnd(true)}>End presentation</button>
)}
</div>
)}
{role === "audience" && (
<button type="button" className="presentation-pairing__leave" onClick={controller.leaveSession}>
Leave session
</button>
)}
</div>
</div>
)}
{state.kind === "failed" && (
<div className="presentation-pairing__message" role="alert">
<p>{state.message}</p>
{state.retryable && (
<button type="button" onClick={controller.retry}>Retry pairing</button>
)}
</div>
)}
{state.kind === "ended" && (
<div className="presentation-pairing__message">
<p>{endedMessageFor(state.reason)}</p>
</div>
)}
</section>
)}
</aside>
);
};
@@ -0,0 +1,286 @@
.presentation-pairing {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 10;
width: min(22rem, calc(100vw - 2rem));
color: var(--color-editorial-ink, #20201e);
font-family: var(--font-interface, "Source Sans 3", sans-serif);
}
.presentation-pairing__trigger,
.presentation-pairing__body {
border: 1px solid color-mix(in oklch, var(--color-editorial-ink, #20201e) 28%, transparent);
background: var(--color-editorial-paper, #f7f7f5);
box-shadow: 0 0.45rem 1.5rem rgb(32 32 30 / 12%);
}
.presentation-pairing__trigger {
display: flex;
align-items: baseline;
justify-content: space-between;
width: 100%;
gap: 1rem;
padding: 0.65rem 0.8rem;
color: inherit;
font: 700 0.85rem/1.1 var(--font-interface, "Source Sans 3", sans-serif);
text-align: left;
cursor: pointer;
}
.presentation-pairing__trigger-status,
.presentation-pairing__eyebrow,
.presentation-pairing__status,
.presentation-pairing__presence dt,
.presentation-pairing__copy-status {
color: var(--color-editorial-muted, #67655f);
font: 0.68rem/1.2 var(--font-evidence, "IBM Plex Mono", monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.presentation-pairing__body {
margin-bottom: 0.35rem;
padding: 0.85rem;
}
.presentation-pairing__header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.65rem;
border-bottom: 1px solid color-mix(in oklch, var(--color-editorial-ink, #20201e) 22%, transparent);
}
.presentation-pairing h2 {
margin: 0.2rem 0 0;
font: 700 1.35rem/0.95 "Barlow Condensed", sans-serif;
text-transform: uppercase;
}
.presentation-pairing__status {
max-width: 9rem;
color: var(--color-runtime, #1e6b55);
text-align: right;
}
.presentation-pairing__setup {
display: grid;
gap: 0.75rem;
padding-top: 0.8rem;
}
.presentation-pairing button,
.presentation-pairing input {
min-height: 2.25rem;
border: 1px solid color-mix(in oklch, var(--color-editorial-ink, #20201e) 32%, transparent);
border-radius: 2px;
background: transparent;
color: inherit;
font: 0.82rem/1.1 var(--font-interface, "Source Sans 3", sans-serif);
}
.presentation-pairing button {
padding: 0.45rem 0.65rem;
font-weight: 700;
cursor: pointer;
}
.presentation-pairing button:hover:not(:disabled),
.presentation-pairing button:focus-visible {
border-color: var(--color-runtime, #1e6b55);
}
.presentation-pairing button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.presentation-pairing__primary {
border-color: var(--color-runtime, #1e6b55) !important;
background: var(--color-runtime, #1e6b55) !important;
color: var(--color-editorial-paper, #f7f7f5) !important;
}
.presentation-pairing__divider {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--color-editorial-muted, #67655f);
font: 0.68rem var(--font-evidence, "IBM Plex Mono", monospace);
text-transform: uppercase;
}
.presentation-pairing__divider::before,
.presentation-pairing__divider::after {
flex: 1;
height: 1px;
background: color-mix(in oklch, var(--color-editorial-ink, #20201e) 18%, transparent);
content: "";
}
.presentation-pairing form label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.78rem;
font-weight: 700;
}
.presentation-pairing__join-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.45rem;
}
.presentation-pairing input {
width: 100%;
padding: 0.45rem 0.6rem;
font: 700 1rem var(--font-evidence, "IBM Plex Mono", monospace);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.presentation-pairing__active {
display: grid;
grid-template-columns: 9.5rem minmax(0, 1fr);
gap: 0.85rem;
padding-top: 0.8rem;
}
.presentation-pairing__qr {
display: grid;
place-items: center;
align-self: start;
padding: 0.45rem;
background: white;
}
.presentation-pairing__qr svg {
display: block;
width: 100%;
height: auto;
}
.presentation-pairing__details {
min-width: 0;
}
.presentation-pairing__instruction,
.presentation-pairing__message p {
margin: 0;
color: var(--color-editorial-muted, #67655f);
font-size: 0.82rem;
line-height: 1.35;
}
.presentation-pairing__code {
margin: 0.45rem 0 0;
font: 700 1.45rem/1 var(--font-evidence, "IBM Plex Mono", monospace);
letter-spacing: 0.16em;
}
.presentation-pairing__presence {
margin: 0.7rem 0 0;
}
.presentation-pairing__presence > div {
display: grid;
gap: 0.15rem;
}
.presentation-pairing__presence dd {
margin: 0;
font-size: 0.8rem;
}
.presentation-pairing__link-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem 0.55rem;
margin-top: 0.8rem;
}
.presentation-pairing__link-row a {
min-width: 0;
overflow: hidden;
color: var(--color-runtime, #1e6b55);
font: 0.68rem var(--font-evidence, "IBM Plex Mono", monospace);
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-pairing__copy,
.presentation-pairing__leave {
padding: 0.25rem 0.4rem !important;
font-size: 0.72rem !important;
}
.presentation-pairing__copy-status {
display: block;
min-height: 0.85rem;
margin-top: 0.35rem;
color: var(--color-runtime, #1e6b55);
text-transform: none;
}
.presentation-pairing__end {
margin-top: 0.8rem;
padding-top: 0.7rem;
border-top: 1px solid color-mix(in oklch, var(--color-editorial-ink, #20201e) 18%, transparent);
}
.presentation-pairing__confirmation {
display: grid;
gap: 0.5rem;
}
.presentation-pairing__confirmation strong {
font-size: 0.82rem;
}
.presentation-pairing__confirmation > div {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.presentation-pairing__message {
display: grid;
gap: 0.6rem;
padding-top: 0.8rem;
}
.presentation-pairing__message button {
justify-self: start;
}
.presentation-pairing :focus-visible {
outline: 2px solid var(--color-runtime, #1e6b55);
outline-offset: 2px;
}
@media (max-width: 640px) {
.presentation-pairing {
right: 0.5rem;
bottom: 0.5rem;
width: min(22rem, calc(100vw - 1rem));
}
.presentation-pairing__active {
grid-template-columns: 1fr;
}
.presentation-pairing__qr {
width: min(10rem, 100%);
justify-self: center;
}
}
@media (prefers-reduced-motion: reduce) {
.presentation-pairing * {
scroll-behavior: auto;
transition: none;
}
}