f60106bc57c6
add ChirpPairingFlow component with three-act bird animation and full brief copy
- time
- 2026-05-18 02:24 UTC · 5 months ago
- machine
- koh2
- parent
- de900e337df3
↓ peach-browser-f60106bc.tar.gzatlas at this save
files changed3 files
src/popup/components/ChirpPairingFlow.test.tsx+102 −0src/popup/components/ChirpPairingFlow.tsx+185 −0src/popup/styles/popup.css+165 −0src/popup/components/ChirpPairingFlow.test.tsx+102 −0
@@ -0,0 +1,102 @@1+// @vitest-environment jsdom2+3+import { afterEach, describe, expect, it, vi } from "vitest";4+import { ChirpPairingFlow } from "./ChirpPairingFlow";5+import type { PairingProgress } from "../../shared/sync-types";6+import { renderView } from "../views/view-test-utils";7+8+function progress(phase: PairingProgress["phase"], extra: Partial<PairingProgress> = {}): PairingProgress {9+ return { phase, ...extra };10+}11+12+describe("ChirpPairingFlow", () => {13+ const cleanups: Array<() => void> = [];14+ afterEach(() => {15+ while (cleanups.length) cleanups.pop()!();16+ });17+18+ function mount(progress: PairingProgress, handlers: { onCancel?: () => void; onRetry?: () => void; onDismiss?: () => void } = {}) {19+ const r = renderView(20+ <ChirpPairingFlow21+ progress={progress}22+ onCancel={handlers.onCancel ?? (() => {})}23+ onRetry={handlers.onRetry ?? (() => {})}24+ onDismiss={handlers.onDismiss ?? (() => {})}25+ />,26+ );27+ cleanups.push(r.dispose);28+ return r;29+ }30+31+ it("renders the listening copy in Act 1", () => {32+ const { container } = mount(progress("listening"));33+ expect(container.textContent).toContain("Hold your phone near your laptop");34+ expect(container.textContent).toContain("Peach will pair automatically");35+ });36+37+ it("renders the handshake copy in Act 2", () => {38+ const { container } = mount(progress("handshake"));39+ expect(container.textContent).toContain("Pairing…");40+ });41+42+ it("renders the paired copy with the peer device name", () => {43+ const { container } = mount(progress("paired", { peerDeviceName: "June's Pixel 9" }));44+ expect(container.textContent).toContain("Paired!");45+ expect(container.textContent).toContain("Connected to June's Pixel 9");46+ });47+48+ it("Cancel button calls onCancel in the listening state", () => {49+ const onCancel = vi.fn();50+ const { container } = mount(progress("listening"), { onCancel });51+ const btn = Array.from(container.querySelectorAll("button")).find(52+ (b) => b.textContent === "Cancel",53+ );54+ expect(btn).toBeTruthy();55+ btn!.click();56+ expect(onCancel).toHaveBeenCalled();57+ });58+59+ it("Try again button calls onRetry on timeout", () => {60+ const onRetry = vi.fn();61+ const { container } = mount(progress("timeout"), { onRetry });62+ expect(container.textContent).toContain("Couldn’t find your phone");63+ const btn = Array.from(container.querySelectorAll("button")).find(64+ (b) => b.textContent === "Try again",65+ );66+ expect(btn).toBeTruthy();67+ btn!.click();68+ expect(onRetry).toHaveBeenCalled();69+ });70+71+ it("shows the microphone-permission help text on mic_denied", () => {72+ const { container } = mount(progress("mic_denied"));73+ expect(container.textContent).toContain("Microphone access needed");74+ expect(container.textContent).toContain("No audio is recorded or stored");75+ });76+77+ it("shows the version-mismatch copy", () => {78+ const { container } = mount(progress("version_mismatch"));79+ expect(container.textContent).toContain("Update Peach on both devices");80+ });81+82+ it("auto-dismisses after 2.5s in the paired state", () => {83+ vi.useFakeTimers();84+ try {85+ const onDismiss = vi.fn();86+ mount(progress("paired", { peerDeviceName: "Pixel" }), { onDismiss });87+ // The createEffect that arms the dismissal runs synchronously on88+ // mount because the phase is already "paired"; advancing the timers89+ // by 2500ms fires the setTimeout callback.90+ vi.advanceTimersByTime(2500);91+ expect(onDismiss).toHaveBeenCalled();92+ } finally {93+ vi.useRealTimers();94+ }95+ });96+97+ it("data-phase attribute mirrors the progress phase", () => {98+ const { container } = mount(progress("chirping"));99+ const root = container.querySelector(".peach-chirp-pair") as HTMLElement;100+ expect(root.dataset.phase).toBe("chirping");101+ });102+});src/popup/components/ChirpPairingFlow.tsx+185 −0
@@ -0,0 +1,185 @@1+// ChirpPairingFlow — the audio pairing screen.2+//3+// Implements the three-act bird animation specified in the brief §6.2:4+// Act 1 (Waiting) — desktop bird idles, soft pulsing chirp ring5+// Act 2 (Contact) — mobile bird hops in, sound-wave arcs in both6+// directions, head-tilt reaction7+// Act 3 (Paired) — sound waves resolve into a glowing connection dot,8+// both birds wing-flutter, copy switches to "Paired!"9+//10+// The acts are bound to the `PairingPhase` enum from sync-types; the11+// background coordinator drives the phase forward as the audio + Noise12+// rounds make progress. The component stays a pure renderer: it does not13+// touch the microphone or the WebSocket itself.14+15+import { Component, Show, createEffect, createSignal } from "solid-js";16+import type { PairingProgress } from "../../shared/sync-types";17+18+export interface ChirpPairingFlowProps {19+ progress: PairingProgress;20+ /** Called when the user taps Retry on a terminal-error state. */21+ onRetry: () => void;22+ /** Called when the user taps Cancel/back on the idle/listening state. */23+ onCancel: () => void;24+ /** Called once the paired-state dismissal animation completes. */25+ onDismiss: () => void;26+}27+28+const PAIRED_DISMISS_MS = 2500;29+30+export const ChirpPairingFlow: Component<ChirpPairingFlowProps> = (props) => {31+ const [dismissArmed, setDismissArmed] = createSignal(false);32+33+ createEffect(() => {34+ if (props.progress.phase === "paired" && !dismissArmed()) {35+ setDismissArmed(true);36+ const t = setTimeout(() => props.onDismiss(), PAIRED_DISMISS_MS);37+ // No need to clean up on unmount — onDismiss is a no-op if parent unmounted.38+ void t;39+ }40+ });41+42+ return (43+ <div class="peach-chirp-pair" data-phase={props.progress.phase}44+ onClick={() => props.progress.phase === "paired" && props.onDismiss()}>45+ <div class="peach-chirp-stage">46+ <DesktopBird phase={props.progress.phase} />47+ <SoundWaves phase={props.progress.phase} />48+ <MobileBird phase={props.progress.phase} />49+ <ConnectionDot phase={props.progress.phase} />50+ </div>51+52+ <PairingCopy progress={props.progress} />53+54+ <Show when={canCancel(props.progress.phase)}>55+ <button class="peach-btn peach-btn-ghost peach-chirp-cancel" onClick={() => props.onCancel()}>56+ Cancel57+ </button>58+ </Show>59+ <Show when={isTerminalError(props.progress.phase)}>60+ <button class="peach-btn peach-btn-primary peach-chirp-retry" onClick={() => props.onRetry()}>61+ Try again62+ </button>63+ </Show>64+ <Show when={props.progress.phase === "mic_denied"}>65+ <p class="peach-chirp-mic-help">66+ Update your browser's site settings to allow Peach to access the microphone, then try again.67+ </p>68+ </Show>69+ </div>70+ );71+};72+73+function canCancel(phase: PairingProgress["phase"]): boolean {74+ return phase === "listening" || phase === "chirping" || phase === "handshake";75+}76+77+function isTerminalError(phase: PairingProgress["phase"]): boolean {78+ return (79+ phase === "timeout" ||80+ phase === "handshake_failed" ||81+ phase === "version_mismatch" ||82+ phase === "mic_denied"83+ );84+}85+86+const PairingCopy: Component<{ progress: PairingProgress }> = (props) => {87+ return (88+ <div class="peach-chirp-copy">89+ <h2 class="peach-chirp-headline">{headline(props.progress)}</h2>90+ <p class="peach-chirp-sub">{subtext(props.progress)}</p>91+ </div>92+ );93+};94+95+function headline(p: PairingProgress): string {96+ switch (p.phase) {97+ case "idle":98+ case "listening":99+ case "chirping":100+ return "Hold your phone near your laptop";101+ case "handshake":102+ return "Pairing…";103+ case "paired":104+ return "Paired!";105+ case "timeout":106+ return "Couldn’t find your phone";107+ case "mic_denied":108+ return "Microphone access needed";109+ case "version_mismatch":110+ return "Update Peach on both devices";111+ case "handshake_failed":112+ return "Something went wrong";113+ }114+}115+116+function subtext(p: PairingProgress): string {117+ switch (p.phase) {118+ case "idle":119+ case "listening":120+ case "chirping":121+ return "Peach will pair automatically when your phone is close";122+ case "handshake":123+ return "Hold still — exchanging keys";124+ case "paired":125+ return p.peerDeviceName ? `Connected to ${p.peerDeviceName}` : "Connected";126+ case "timeout":127+ return "Make sure Peach is open on your phone and try again";128+ case "mic_denied":129+ return "Peach uses your microphone to pair. No audio is recorded or stored.";130+ case "version_mismatch":131+ return "The other device is running a different protocol version";132+ case "handshake_failed":133+ return p.errorMessage ?? "Please try again";134+ }135+}136+137+// ---------------------------------------------------------------------------138+// Inline SVG birds — kept deliberately schematic so the focus stays on the139+// motion. A future polish pass can swap these for the production140+// illustrations without changing the component shape.141+// ---------------------------------------------------------------------------142+143+const DesktopBird: Component<{ phase: PairingProgress["phase"] }> = (props) => {144+ return (145+ <svg class="peach-chirp-bird peach-chirp-bird-desktop"146+ width="92" height="92" viewBox="0 0 92 92" data-phase={props.phase}>147+ <ellipse cx="46" cy="58" rx="28" ry="22" class="peach-chirp-bird-body" />148+ <circle cx="58" cy="42" r="14" class="peach-chirp-bird-head" />149+ <circle cx="62" cy="40" r="2.2" class="peach-chirp-bird-eye" />150+ <path d="M 70 42 L 78 42 L 72 47 Z" class="peach-chirp-bird-beak" />151+ <path d="M 30 48 Q 22 56 30 64" class="peach-chirp-bird-wing" fill="none" />152+ </svg>153+ );154+};155+156+const MobileBird: Component<{ phase: PairingProgress["phase"] }> = (props) => {157+ return (158+ <svg class="peach-chirp-bird peach-chirp-bird-mobile"159+ width="74" height="74" viewBox="0 0 74 74" data-phase={props.phase}>160+ <ellipse cx="36" cy="48" rx="22" ry="18" class="peach-chirp-bird-body" />161+ <circle cx="26" cy="34" r="11" class="peach-chirp-bird-head" />162+ <circle cx="22" cy="32" r="1.8" class="peach-chirp-bird-eye" />163+ <path d="M 14 34 L 7 34 L 12 38 Z" class="peach-chirp-bird-beak" />164+ <path d="M 50 40 Q 58 48 50 56" class="peach-chirp-bird-wing" fill="none" />165+ </svg>166+ );167+};168+169+const SoundWaves: Component<{ phase: PairingProgress["phase"] }> = (props) => {170+ return (171+ <svg class="peach-chirp-waves" width="320" height="120" viewBox="0 0 320 120" data-phase={props.phase}>172+ <path class="peach-chirp-wave peach-chirp-wave-out" d="M 80 60 Q 160 20 240 60" fill="none" />173+ <path class="peach-chirp-wave peach-chirp-wave-in" d="M 240 60 Q 160 100 80 60" fill="none" />174+ </svg>175+ );176+};177+178+const ConnectionDot: Component<{ phase: PairingProgress["phase"] }> = (props) => {179+ return (180+ <div class="peach-chirp-dot" data-phase={props.phase}>181+ <span class="peach-chirp-dot-core" />182+ <span class="peach-chirp-dot-halo" />183+ </div>184+ );185+};src/popup/styles/popup.css+165 −0
@@ -8723,3 +8723,168 @@87238723 [data-theme="light"] .peach-entry-row.is-selected {87248724 background: rgba(26, 18, 8, 0.06);87258725 }8726+8727+/* ──────────────────────────────────────────────────────────────────────────8728+ Chirp pairing flow — audio bootstrap (Phase 1)8729+ See src/popup/components/ChirpPairingFlow.tsx and the brief §6.2.8730+ The three-act animation is gated on the [data-phase] attribute on8731+ .peach-chirp-pair; each act adjusts opacity / transform on child layers.8732+ ────────────────────────────────────────────────────────────────────── */8733+.peach-chirp-pair {8734+ position: relative;8735+ display: flex;8736+ flex-direction: column;8737+ align-items: center;8738+ justify-content: center;8739+ padding: 40px 24px 32px;8740+ min-height: 380px;8741+ gap: 24px;8742+ background:8743+ radial-gradient(ellipse at top, color-mix(in srgb, var(--peach-accent) 5%, transparent), transparent 65%),8744+ var(--peach-bg);8745+ color: var(--peach-text);8746+}8747+.peach-chirp-stage {8748+ position: relative;8749+ width: 320px;8750+ height: 130px;8751+ display: flex;8752+ align-items: flex-end;8753+ justify-content: space-between;8754+}8755+.peach-chirp-bird {8756+ position: relative;8757+ transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 320ms ease;8758+}8759+.peach-chirp-bird-body { fill: color-mix(in srgb, var(--peach-accent) 75%, #2A1A0E); }8760+.peach-chirp-bird-head { fill: color-mix(in srgb, var(--peach-accent) 65%, #2A1A0E); }8761+.peach-chirp-bird-eye { fill: var(--peach-text); }8762+.peach-chirp-bird-beak { fill: #C9A47A; }8763+.peach-chirp-bird-wing { stroke: color-mix(in srgb, var(--peach-accent) 55%, #1A0E04); stroke-width: 2.2; stroke-linecap: round; }8764+8765+/* Idle bob + blink, applied while waiting for the peer */8766+.peach-chirp-bird-desktop[data-phase="idle"],8767+.peach-chirp-bird-desktop[data-phase="listening"],8768+.peach-chirp-bird-desktop[data-phase="chirping"] {8769+ animation: peach-chirp-bob 2.4s ease-in-out infinite;8770+}8771+.peach-chirp-bird-desktop[data-phase="timeout"] {8772+ transform: translateY(8px) rotate(-4deg);8773+ opacity: 0.7;8774+}8775+8776+/* The mobile bird is hidden until Act 2, then hops in from the right edge */8777+.peach-chirp-bird-mobile {8778+ opacity: 0;8779+ transform: translateX(60px) translateY(8px);8780+}8781+.peach-chirp-bird-mobile[data-phase="handshake"],8782+.peach-chirp-bird-mobile[data-phase="paired"] {8783+ opacity: 1;8784+ transform: translateX(0) translateY(0);8785+ animation: peach-chirp-bob 2.6s ease-in-out infinite;8786+}8787+.peach-chirp-bird-mobile[data-phase="handshake_failed"] {8788+ opacity: 0.7;8789+ transform: translateX(0) translateY(6px) rotate(4deg);8790+}8791+8792+/* Sound waves between the birds — pulse out during chirping, swap colors in8793+ handshake, fade and merge into the connection dot in the paired state */8794+.peach-chirp-waves {8795+ position: absolute;8796+ left: 0;8797+ right: 0;8798+ top: 0;8799+ bottom: 0;8800+ margin: auto;8801+ pointer-events: none;8802+}8803+.peach-chirp-wave { stroke-width: 2.5; opacity: 0; transition: opacity 240ms ease; }8804+.peach-chirp-wave-out { stroke: color-mix(in srgb, var(--peach-accent) 90%, white 0%); }8805+.peach-chirp-wave-in { stroke: #5E8FC9; } /* desktop receives, draw in a calmer blue */8806+8807+.peach-chirp-waves[data-phase="listening"] .peach-chirp-wave-out,8808+.peach-chirp-waves[data-phase="chirping"] .peach-chirp-wave-out {8809+ opacity: 0.7;8810+ animation: peach-chirp-wave-pulse 1.6s ease-in-out infinite;8811+}8812+.peach-chirp-waves[data-phase="handshake"] .peach-chirp-wave-out,8813+.peach-chirp-waves[data-phase="handshake"] .peach-chirp-wave-in {8814+ opacity: 0.85;8815+ animation: peach-chirp-wave-pulse 0.9s ease-in-out infinite;8816+}8817+.peach-chirp-waves[data-phase="handshake_failed"] .peach-chirp-wave-out {8818+ opacity: 0.5;8819+ stroke-dasharray: 4 8;8820+}8821+8822+/* Glowing connection dot — appears for Act 3, settles after the initial pulse */8823+.peach-chirp-dot {8824+ position: absolute;8825+ left: 50%;8826+ top: 50%;8827+ width: 18px;8828+ height: 18px;8829+ margin-left: -9px;8830+ margin-top: -9px;8831+ opacity: 0;8832+ transition: opacity 300ms ease;8833+}8834+.peach-chirp-dot[data-phase="paired"] { opacity: 1; }8835+.peach-chirp-dot-core {8836+ position: absolute;8837+ inset: 4px;8838+ border-radius: 999px;8839+ background: var(--peach-accent);8840+ box-shadow: 0 0 16px 2px color-mix(in srgb, var(--peach-accent) 60%, transparent);8841+ animation: peach-chirp-dot-pulse 1.8s ease-in-out infinite;8842+}8843+.peach-chirp-dot-halo {8844+ position: absolute;8845+ inset: 0;8846+ border-radius: 999px;8847+ border: 2px solid color-mix(in srgb, var(--peach-accent) 40%, transparent);8848+ animation: peach-chirp-dot-halo 1.8s ease-out infinite;8849+}8850+8851+.peach-chirp-copy { text-align: center; max-width: 360px; }8852+.peach-chirp-headline {8853+ font-size: 18px;8854+ font-weight: 600;8855+ margin: 0 0 6px;8856+ color: var(--peach-text);8857+}8858+.peach-chirp-sub {8859+ font-size: 13px;8860+ margin: 0;8861+ color: var(--peach-text-dim);8862+}8863+.peach-chirp-mic-help {8864+ font-size: 12px;8865+ color: var(--peach-text-muted);8866+ text-align: center;8867+ max-width: 320px;8868+}8869+.peach-chirp-cancel,8870+.peach-chirp-retry {8871+ align-self: center;8872+ min-width: 120px;8873+}8874+8875+@keyframes peach-chirp-bob {8876+ 0%, 100% { transform: translateY(0); }8877+ 50% { transform: translateY(-3px); }8878+}8879+@keyframes peach-chirp-wave-pulse {8880+ 0%, 100% { opacity: 0.3; transform: scale(0.95); }8881+ 50% { opacity: 0.95; transform: scale(1.05); }8882+}8883+@keyframes peach-chirp-dot-pulse {8884+ 0%, 100% { transform: scale(1); }8885+ 50% { transform: scale(1.18); }8886+}8887+@keyframes peach-chirp-dot-halo {8888+ 0% { transform: scale(0.6); opacity: 0.6; }8889+ 100% { transform: scale(2.4); opacity: 0; }8890+}