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
+1
View File
@@ -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;
}
}
+47
View File
@@ -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