ChirpPairingFlow.tsx

6.9 KB · raw

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.1415import { Component, Show, createEffect, createSignal } from "solid-js";16import type { PairingProgress } from "../../shared/sync-types";1718export 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}2728const PAIRED_DISMISS_MS = 2500;2930export const ChirpPairingFlow: Component<ChirpPairingFlowProps> = (props) => {31  const [dismissArmed, setDismissArmed] = createSignal(false);3233  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  });4142  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>5152      <PairingCopy progress={props.progress} />5354      <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};7273function canCancel(phase: PairingProgress["phase"]): boolean {74  return phase === "listening" || phase === "chirping" || phase === "handshake";75}7677function isTerminalError(phase: PairingProgress["phase"]): boolean {78  return (79    phase === "timeout" ||80    phase === "handshake_failed" ||81    phase === "version_mismatch" ||82    phase === "mic_denied"83  );84}8586const 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};9495function 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}115116function 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}136137// ---------------------------------------------------------------------------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// ---------------------------------------------------------------------------142143const 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};155156const 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};168169const 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};177178const 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};