Style mobile pairing QR codes (#2775)

## Summary
- render mobile pairing QR codes locally with Wallet-inspired styling
- hide the raw pairing URI behind a matching copy button
- add focused rendering and dialog coverage

## Test plan
- desktop lint and 3,489 desktop tests
- focused pairing dialog Playwright test
- decoded the rendered QR back to the exact pairing URI
This commit is contained in:
klopez4212
2026-07-24 19:46:59 -07:00
committed by GitHub
parent 384c72dee6
commit 50655ac097
9 changed files with 556 additions and 38 deletions
+1
View File
@@ -66,6 +66,7 @@
"jdenticon": "^3.3.0",
"lucide-react": "^1.0.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
+1
View File
@@ -112,6 +112,7 @@ export default defineConfig({
"**/onboarding-backup.spec.ts",
"**/onboarding-agent-defaults.spec.ts",
"**/nostr-bind.spec.ts",
"**/mobile-pairing-qr.spec.ts",
"**/profile-nsec-reveal.spec.ts",
"**/signout-confirmation.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import {
Check,
Copy,
@@ -19,6 +18,7 @@ import {
startPairing,
} from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
import {
Dialog,
DialogContent,
@@ -176,11 +176,11 @@ function PairingDialog({
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-md overflow-hidden p-0"
className="max-w-md gap-0 overflow-hidden border-0 px-6 pb-6 pt-6"
data-testid="mobile-pairing-dialog"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogHeader className="shrink-0 pb-5 pr-8">
<DialogTitle>Pair Mobile Device</DialogTitle>
<DialogDescription>
{step === "sas"
@@ -191,7 +191,7 @@ function PairingDialog({
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
{step === "error" && error ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
@@ -205,35 +205,30 @@ function PairingDialog({
</p>
</div>
) : step === "qr" && qrUri ? (
<div className="space-y-4">
<div className="flex justify-center rounded-lg border border-border/70 bg-white p-4">
<QRCodeSVG
<div className="mx-auto grid w-fit gap-4">
<div
className="rounded-lg border border-border/70 bg-white p-3"
data-testid="mobile-pairing-qr-container"
>
<StyledQrCode
centerImageSrc="/app-icon@2x.png"
data-testid="mobile-pairing-qr"
level="M"
size={240}
title="Mobile pairing QR code"
value={qrUri}
/>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Pairing code
</p>
<button
className="flex w-full min-w-0 items-center gap-2 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/70"
data-testid="copy-pairing-code"
onClick={handleCopy}
title="Copy pairing code"
type="button"
>
<code className="min-w-0 flex-1 break-all">{qrUri}</code>
<Copy className="h-4 w-4 shrink-0" />
</button>
</div>
<p className="text-center text-xs text-muted-foreground">
Waiting for mobile device to scan...
</p>
<Button
className="w-full"
data-testid="copy-pairing-code"
onClick={handleCopy}
type="button"
variant="outline"
>
<Copy className="mr-1.5 h-4 w-4" />
Copy pairing code
</Button>
</div>
) : step === "sas" && sasCode ? (
<div className="space-y-4">
@@ -297,17 +292,6 @@ function PairingDialog({
</div>
) : null}
</div>
<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="mobile-pairing-done"
onClick={() => handleOpenChange(false)}
size="sm"
variant="outline"
>
{step === "done" ? "Done" : "Close"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { StyledQrCode } from "./styled-qr-code.tsx";
const TEST_PAIRING_URI =
"nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
test("renders the Wallet-style QR geometry locally", () => {
const html = renderToStaticMarkup(
React.createElement(StyledQrCode, {
centerImageSrc: "/app-icon@2x.png",
title: "Mobile pairing QR code",
value: TEST_PAIRING_URI,
}),
);
assert.match(html, /aria-label="Mobile pairing QR code"/);
assert.match(html, /data-qr-matrix-size="57"/);
assert.equal((html.match(/data-qr-finder-pattern=""/g) ?? []).length, 3);
assert.ok(
(html.match(/<circle /g) ?? []).length > 100,
"expected the QR payload to render as individual circular data cells",
);
assert.match(html, /href="\/app-icon@2x\.png"/);
});
test("uses a deterministic lower-density matrix for the same payload", () => {
const first = renderToStaticMarkup(
React.createElement(StyledQrCode, { value: TEST_PAIRING_URI }),
);
const second = renderToStaticMarkup(
React.createElement(StyledQrCode, { value: TEST_PAIRING_URI }),
);
assert.equal(first, second);
});
+203
View File
@@ -0,0 +1,203 @@
import { useId, useMemo, type ReactNode, type SVGProps } from "react";
import { create } from "qrcode";
const CELL_SPACING_RATIO = 0.2;
const FINDER_PATTERN_SIZE = 7;
const QUIET_ZONE_SIZE = 4;
const MAX_CENTER_OBSCURED_RATIO = 0.1;
const CENTER_ICON_SIZE_RATIO = 0.8;
type StyledQrCodeProps = Omit<
SVGProps<SVGSVGElement>,
"children" | "height" | "title" | "width"
> & {
backgroundColor?: string;
centerImageSrc?: string;
foregroundColor?: string;
size?: number;
title?: string;
value: string;
};
function isFinderCell(row: number, column: number, matrixSize: number) {
const isTop = row < FINDER_PATTERN_SIZE;
const isLeft = column < FINDER_PATTERN_SIZE;
const isRight = column >= matrixSize - FINDER_PATTERN_SIZE;
const isBottom = row >= matrixSize - FINDER_PATTERN_SIZE;
return (isTop && isLeft) || (isTop && isRight) || (isBottom && isLeft);
}
function getCenterAreaSize(matrixSize: number) {
const maxArea = matrixSize * matrixSize * MAX_CENTER_OBSCURED_RATIO;
const maxSide = Math.floor(Math.sqrt(maxArea));
return maxSide % 2 === 0 ? maxSide - 1 : maxSide;
}
function FinderPattern({
backgroundColor,
foregroundColor,
x,
y,
}: {
backgroundColor: string;
foregroundColor: string;
x: number;
y: number;
}) {
return (
<g data-qr-finder-pattern="">
<rect
fill={foregroundColor}
height={FINDER_PATTERN_SIZE}
rx={2}
width={FINDER_PATTERN_SIZE}
x={x}
y={y}
/>
<rect
fill={backgroundColor}
height={FINDER_PATTERN_SIZE - 2}
rx={0.8}
width={FINDER_PATTERN_SIZE - 2}
x={x + 1}
y={y + 1}
/>
<rect
fill={foregroundColor}
height={FINDER_PATTERN_SIZE - 4}
rx={0.4}
width={FINDER_PATTERN_SIZE - 4}
x={x + 2}
y={y + 2}
/>
</g>
);
}
export function StyledQrCode({
backgroundColor = "#ffffff",
centerImageSrc,
foregroundColor = "#000000",
size = 240,
title = "QR code",
value,
...svgProps
}: StyledQrCodeProps) {
const clipId = `styled-qr-logo-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const matrix = useMemo(
() => create(value, { errorCorrectionLevel: "M" }).modules,
[value],
);
const viewBoxSize = matrix.size + QUIET_ZONE_SIZE * 2;
const dataCellSize = 1 - CELL_SPACING_RATIO;
const dataCellRadius = dataCellSize / 2;
const centerAreaSize = centerImageSrc ? getCenterAreaSize(matrix.size) : 0;
const centerAreaStart = (matrix.size - centerAreaSize) / 2;
const centerAreaEnd = centerAreaStart + centerAreaSize;
const centerImageSize = centerAreaSize * CENTER_ICON_SIZE_RATIO;
const centerImageStart = (matrix.size - centerImageSize) / 2;
const cells: ReactNode[] = [];
for (let row = 0; row < matrix.size; row += 1) {
for (let column = 0; column < matrix.size; column += 1) {
const isCenterCell =
centerImageSrc &&
row >= centerAreaStart &&
row < centerAreaEnd &&
column >= centerAreaStart &&
column < centerAreaEnd;
if (
!matrix.get(row, column) ||
isFinderCell(row, column, matrix.size) ||
isCenterCell
) {
continue;
}
cells.push(
<circle
cx={column + 0.5}
cy={row + 0.5}
fill={foregroundColor}
key={`${row}-${column}`}
r={dataCellRadius}
/>,
);
}
}
return (
<svg
{...svgProps}
aria-label={title}
data-qr-matrix-size={matrix.size}
height={size}
role="img"
viewBox={`${-QUIET_ZONE_SIZE} ${-QUIET_ZONE_SIZE} ${viewBoxSize} ${viewBoxSize}`}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<title>{title}</title>
<rect
fill={backgroundColor}
height={viewBoxSize}
width={viewBoxSize}
x={-QUIET_ZONE_SIZE}
y={-QUIET_ZONE_SIZE}
/>
<g data-qr-data-cells="">{cells}</g>
<FinderPattern
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
x={0}
y={0}
/>
<FinderPattern
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
x={matrix.size - FINDER_PATTERN_SIZE}
y={0}
/>
<FinderPattern
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
x={0}
y={matrix.size - FINDER_PATTERN_SIZE}
/>
{centerImageSrc ? (
<>
<defs>
<clipPath id={clipId}>
<rect
height={centerImageSize}
rx={2}
width={centerImageSize}
x={centerImageStart}
y={centerImageStart}
/>
</clipPath>
</defs>
<rect
fill={foregroundColor}
height={centerImageSize}
rx={2}
width={centerImageSize}
x={centerImageStart}
y={centerImageStart}
/>
<image
clipPath={`url(#${clipId})`}
height={centerImageSize}
href={centerImageSrc}
preserveAspectRatio="xMidYMid meet"
width={centerImageSize}
x={centerImageStart}
y={centerImageStart}
/>
</>
) : null}
</svg>
);
}
+5
View File
@@ -10902,6 +10902,11 @@ export function maybeInstallE2eTauriMocks() {
case "plugin:event|listen":
// Tauri event system (pairing, huddle) — no-op in e2e, return unlisten fn ID
return Math.floor(Math.random() * 1_000_000);
case "start_pairing":
return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
case "cancel_pairing":
case "confirm_pairing_sas":
return null;
// ── NIP-IA identity archival ────────────────────────────────────────
// These mocks drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden
+17
View File
@@ -0,0 +1,17 @@
declare module "qrcode" {
type ErrorCorrectionLevel = "L" | "M" | "Q" | "H";
type BitMatrix = {
get(row: number, column: number): number;
size: number;
};
type QrCode = {
modules: BitMatrix;
};
export function create(
value: string,
options?: { errorCorrectionLevel?: ErrorCorrectionLevel },
): QrCode;
}
@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SCREENSHOT_DIR = "test-results/mobile-pairing-qr";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("mobile pairing uses the local Wallet-style QR renderer", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
await page.getByTestId("pair-mobile-button").click();
const dialog = page.getByTestId("mobile-pairing-dialog");
const qrCode = page.getByTestId("mobile-pairing-qr");
const qrContainer = page.getByTestId("mobile-pairing-qr-container");
const copyButton = dialog.getByTestId("copy-pairing-code");
await expect(dialog).toBeVisible();
await expect(qrCode).toBeVisible();
await expect(copyButton).toHaveText("Copy pairing code");
await expect(dialog).toHaveCSS("border-radius", "16px");
await expect(dialog).toHaveCSS("border-left-width", "0px");
await expect(dialog).toHaveCSS("padding-left", "24px");
await expect(dialog).toHaveCSS("padding-right", "24px");
await expect(dialog).toHaveCSS("padding-top", "24px");
await expect(dialog.getByTestId("mobile-pairing-done")).toHaveCount(0);
await expect(
dialog.getByRole("button", { name: "Close", exact: true }),
).toHaveCount(1);
await expect(
dialog.getByText("Waiting for mobile device to scan..."),
).toHaveCount(0);
await expect(dialog.locator("code")).toHaveCount(0);
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
expect(await qrCode.locator("circle").count()).toBeGreaterThan(100);
await expect(qrCode.locator("image")).toHaveAttribute(
"href",
"/app-icon@2x.png",
);
await waitForAnimations(page);
const qrContainerWidth = await qrContainer.evaluate(
(element) => getComputedStyle(element).width,
);
await expect(copyButton).toHaveCSS("width", qrContainerWidth);
mkdirSync(SCREENSHOT_DIR, { recursive: true });
await dialog.screenshot({ path: `${SCREENSHOT_DIR}/pairing-dialog.png` });
await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` });
});
+210
View File
@@ -191,6 +191,9 @@ importers:
motion:
specifier: ^12.38.0
version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
qrcode:
specifier: ^1.5.4
version: 1.5.4
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.7)
@@ -2120,6 +2123,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
@@ -2190,6 +2197,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -2228,10 +2239,20 @@ packages:
clean-git-ref@2.0.1:
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -2278,6 +2299,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2316,6 +2341,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -2345,6 +2373,9 @@ packages:
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.21.5:
resolution: {integrity: sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==}
engines: {node: '>=10.13.0'}
@@ -2417,6 +2448,10 @@ packages:
picomatch:
optional: true
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -2452,6 +2487,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -2546,6 +2585,10 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
@@ -2692,6 +2735,10 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -2946,6 +2993,18 @@ packages:
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -2955,6 +3014,10 @@ packages:
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2983,6 +3046,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -3063,6 +3130,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
react-diff-view@3.3.3:
resolution: {integrity: sha512-CPveApk6n7ZbkW7T6PoptR7LWAvD9hohTHZ7WnKnu3GZkTfUB5rvg486apPo94iYVi4fZd3Nt+rtBZ5877exoQ==}
peerDependencies:
@@ -3152,10 +3224,17 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3188,6 +3267,9 @@ packages:
resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
engines: {node: '>=10'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3232,12 +3314,20 @@ packages:
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -3529,6 +3619,9 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -3538,6 +3631,10 @@ packages:
engines: {node: '>=8'}
hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -3560,6 +3657,9 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3568,6 +3668,14 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -5233,6 +5341,10 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansis@4.3.0: {}
@@ -5306,6 +5418,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
camelcase@5.3.1: {}
caniuse-lite@1.0.30001793: {}
canvas-renderer@2.2.1:
@@ -5336,8 +5450,20 @@ snapshots:
clean-git-ref@2.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
comma-separated-tokens@2.0.3: {}
convert-source-map@2.0.0: {}
@@ -5373,6 +5499,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -5405,6 +5533,8 @@ snapshots:
diff@8.0.4: {}
dijkstrajs@1.0.3: {}
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
@@ -5431,6 +5561,8 @@ snapshots:
emoji-mart@5.6.0: {}
emoji-regex@8.0.0: {}
enhanced-resolve@5.21.5:
dependencies:
graceful-fs: 4.2.11
@@ -5480,6 +5612,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -5503,6 +5640,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -5626,6 +5765,8 @@ snapshots:
is-decimal@2.0.1: {}
is-fullwidth-code-point@3.0.0: {}
is-hexadecimal@2.0.1: {}
is-plain-obj@4.1.0: {}
@@ -5757,6 +5898,10 @@ snapshots:
linkifyjs@4.3.2: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash@4.18.1: {}
longest-streak@3.1.0: {}
@@ -6209,6 +6354,16 @@ snapshots:
orderedmap@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
pako@1.0.11: {}
parse-entities@4.0.2:
@@ -6225,6 +6380,8 @@ snapshots:
dependencies:
entities: 8.0.0
path-exists@4.0.0: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -6243,6 +6400,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@6.0.10:
@@ -6351,6 +6510,12 @@ snapshots:
dependencies:
react: 19.2.7
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
react-diff-view@3.3.3(react@19.2.7):
dependencies:
classnames: 2.5.1
@@ -6480,8 +6645,12 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -6521,6 +6690,8 @@ snapshots:
seroval@1.5.4: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -6572,6 +6743,12 @@ snapshots:
std-env@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -6581,6 +6758,10 @@ snapshots:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
@@ -6814,6 +6995,8 @@ snapshots:
tr46: 6.0.0
webidl-conversions: 8.0.1
which-module@2.0.1: {}
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -6829,6 +7012,12 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
ws@8.21.1: {}
@@ -6837,10 +7026,31 @@ snapshots:
xmlchars@2.2.0: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
zod@4.4.3: {}
zwitch@2.0.4: {}