feat: add presentation pairing panel
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
|
"react-qr-code": "2.2.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-shimmer": "^0.4.11",
|
"tw-shimmer": "^0.4.11",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+47
@@ -68,6 +68,9 @@ importers:
|
|||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: 10.1.0
|
specifier: 10.1.0
|
||||||
version: 10.1.0(@types/[email protected])([email protected])
|
version: 10.1.0(@types/[email protected])([email protected])
|
||||||
|
react-qr-code:
|
||||||
|
specifier: 2.2.0
|
||||||
|
version: 2.2.0([email protected])
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.18.1
|
specifier: ^7.18.1
|
||||||
version: 7.18.1([email protected]([email protected]))([email protected])
|
version: 7.18.1([email protected]([email protected]))([email protected])
|
||||||
@@ -2049,6 +2052,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -2206,6 +2213,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -2234,6 +2245,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||||
|
|
||||||
@@ -2244,6 +2258,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==}
|
resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2262,6 +2279,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.7
|
react: ^19.2.7
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -2271,6 +2291,11 @@ packages:
|
|||||||
'@types/react': '>=18'
|
'@types/react': '>=18'
|
||||||
react: '>=18'
|
react: '>=18'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4505,6 +4530,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
@@ -4786,6 +4815,8 @@ snapshots:
|
|||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4820,12 +4851,20 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
object-assign: 4.1.1
|
||||||
|
react-is: 16.13.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
|
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.5
|
'@radix-ui/primitive': 1.1.5
|
||||||
@@ -4894,6 +4933,8 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
@@ -4914,6 +4955,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
prop-types: 15.8.1
|
||||||
|
qrcode-generator: 2.0.4
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user