feat: add presentation pairing panel
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-qr-code": "2.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user