From 34f22a4f2474a588feea59c248a919c07f860fb1 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 09:39:14 +0800 Subject: [PATCH] feat(web): show a spinner while the first QR is being generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing page used to show a static skeleton block before the bot pushed the first session.qr event through SSE — visually quiet, easy to mistake for a stalled page. Replace it with a labelled, accessible spinner: - lucide Loader2 icon with Tailwind animate-spin - role="status" + aria-live="polite" + aria-label="Generating QR code" so assistive tech announces it as soon as the page loads - Same size-64 footprint as the rendered QR — no layout jump when the image lands Tests (+5, 104 passing total): - pair-live.test.tsx: covers the initial 'waiting' state — spinner attributes, animated icon, helper text, no premature QR/countdown/ Save button, and the size-64 placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/pair-live.test.tsx | 78 ++++++++++++++++++++++ apps/web/src/components/pair-live.tsx | 26 ++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/pair-live.test.tsx diff --git a/apps/web/src/components/pair-live.test.tsx b/apps/web/src/components/pair-live.test.tsx new file mode 100644 index 0000000..9bab9f4 --- /dev/null +++ b/apps/web/src/components/pair-live.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +// PairLive uses these client-only modules. Stub them so the component +// can be rendered server-side for assertion. The default state on first +// render is `phase: "waiting"` — exactly the case we want to verify here. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record) => ( + + {children} + + ), +})); +// useEvents subscribes to the SSE stream; on the server-render side we +// just want it to no-op so the component stays in its initial state. +vi.mock("@/hooks/use-events", () => ({ + useEvents: () => {}, +})); + +import { PairLive } from "./pair-live"; + +describe("PairLive — initial 'waiting' state shows a spinner placeholder", () => { + it("renders an aria-live spinner block before any QR arrives", () => { + const html = renderToStaticMarkup( + , + ); + + // The placeholder is a labelled, aria-live region so screen readers + // announce it as soon as it appears. + expect(html).toMatch(/data-testid="pair-loading"/); + expect(html).toMatch(/role="status"/); + expect(html).toMatch(/aria-live="polite"/); + expect(html).toMatch(/aria-label="Generating QR code"/); + }); + + it("uses an animated spinner icon (lucide Loader2)", () => { + const html = renderToStaticMarkup( + , + ); + // Lucide tags every icon with a stable `lucide-` class. + expect(html).toMatch(/lucide-loader-?2|lucide-loader/); + // The animation hook (Tailwind's animate-spin) must be applied so + // the spinner actually rotates. + expect(html).toContain("animate-spin"); + }); + + it("shows the 'Generating QR…' helper text", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Generating QR…"); + }); + + it("does NOT yet render the QR image, countdown bar, or Save QR button", () => { + const html = renderToStaticMarkup( + , + ); + // No for the QR. + expect(html).not.toMatch(/]+alt="WhatsApp QR code"/); + // No "QR expires" / "Pairing expires" countdown copy. + expect(html).not.toContain("Pairing expires"); + // No download CTA. + expect(html).not.toContain("Save QR"); + }); + + it("placeholder occupies the same 64x64 footprint as the eventual QR", () => { + // Avoids a layout jump when the QR finally lands. Both the spinner + // tile and the rendered QR use Tailwind's `size-64` (16rem). + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/class="[^"]*\bsize-64\b/); + }); +}); diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx index daab21b..49b372a 100644 --- a/apps/web/src/components/pair-live.tsx +++ b/apps/web/src/components/pair-live.tsx @@ -3,9 +3,14 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucide-react"; +import { + CheckCircle2Icon, + XCircleIcon, + ScanLineIcon, + DownloadIcon, + Loader2Icon, +} from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { useEvents } from "@/hooks/use-events"; import { countdownRender } from "@/lib/qr-dedupe"; @@ -133,10 +138,21 @@ export function PairLive({ accountId, label }: PairLiveProps) { {label} - {/* State display */} + {/* State display — spinner placeholder until the bot pushes the + first session.qr event through SSE. The 64x64-tile area is the + same size as the rendered QR so the layout doesn't jump when + the image arrives. */} {pairingState.phase === "waiting" && ( -
- +
+
+ +

Generating QR…

)}