// ChirpPairingFlow — the audio pairing screen. // // Renders the three-act bird animation specified in the brief §6.2: // Act 1 (waiting) — desktop finch chirps, soft rings pulse from its beak // Act 2 (handshake) — mobile swallow hops in, sound-wave arcs fly between // the two birds, both heads tilt toward each other // Act 3 (paired) — waves resolve into a glowing connection dot; both // birds wing-flutter and bob together // // Phase mapping (PairingPhase from sync-types): // idle | listening | chirping → Act 1 // handshake → Act 2 // paired → Act 3 // timeout → desktop bird droops, retry CTA // mic_denied → static stage, browser-settings hint // version_mismatch → static stage, update hint, retry CTA // handshake_failed → retry CTA // // The orchestrator (popup/chirp-pair.ts) pushes phase updates into // `progress` and the component renders them. This component owns no // microphone or socket state of its own. import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import type { PairingPhase, PairingProgress } from "../../shared/sync-types"; export interface ChirpPairingFlowProps { progress: PairingProgress; /** Called when the user taps Retry on a terminal-error state. */ onRetry: () => void; /** Called when the user taps Cancel/back on the idle/listening state. */ onCancel: () => void; /** Called once the paired-state dismissal animation completes. */ onDismiss: () => void; } const PAIRED_DISMISS_MS = 2_500; // During handshake, swap which bird the flight-wave emanates from so the // chirp feels reciprocated. Desktop wave first, then mobile. const HANDSHAKE_DESKTOP_WAVE_MS = 900; const HANDSHAKE_MOBILE_WAVE_MS = 1_900; export const ChirpPairingFlow: Component = (props) => { const [dismissArmed, setDismissArmed] = createSignal(false); const [waveSide, setWaveSide] = createSignal<"desktop" | "mobile" | null>(null); createEffect(() => { if (props.progress.phase === "paired" && !dismissArmed()) { setDismissArmed(true); const t = setTimeout(() => props.onDismiss(), PAIRED_DISMISS_MS); onCleanup(() => clearTimeout(t)); } }); // Swap which side the flight-wave plays from during Act 2. createEffect(() => { if (props.progress.phase !== "handshake") { setWaveSide(null); return; } setWaveSide("desktop"); const t1 = setTimeout(() => setWaveSide("mobile"), HANDSHAKE_DESKTOP_WAVE_MS); const t2 = setTimeout(() => setWaveSide(null), HANDSHAKE_MOBILE_WAVE_MS); onCleanup(() => { clearTimeout(t1); clearTimeout(t2); }); }); const phase = createMemo(() => props.progress.phase); const desktopPose = createMemo(() => poseFor("desktop", phase())); const mobilePose = createMemo(() => poseFor("mobile", phase())); const showDesktopChirp = createMemo(() => { const p = phase(); return p === "listening" || p === "chirping" || (p === "handshake" && waveSide() !== "mobile"); }); const showMobileChirp = createMemo(() => phase() === "handshake" && waveSide() === "mobile"); return ( ); }; function poseFor(side: "desktop" | "mobile", phase: PairingPhase): string { if (side === "desktop") { switch (phase) { case "listening": case "chirping": return "chirping"; case "handshake": return "attentive"; case "paired": return "happy"; case "timeout": case "handshake_failed": case "version_mismatch": return "sad"; default: return "idle"; } } switch (phase) { case "handshake": return "attentive"; case "paired": return "happy"; default: return "hidden"; } } function liveText(p: PairingProgress): string { switch (p.phase) { case "listening": case "chirping": return "Listening for the chirp"; case "handshake": return "Pairing — exchanging keys"; case "paired": return p.peerDeviceName ? `Paired — connected to ${p.peerDeviceName}` : "Paired"; case "timeout": return "Couldn't find your phone"; case "mic_denied": return "Microphone access needed"; case "version_mismatch": return "Update Peach on both devices"; case "handshake_failed": return "Pairing failed — try again"; default: return ""; } } // --------------------------------------------------------------------------- // Header, chip row, footer // --------------------------------------------------------------------------- const PairingHeader: Component<{ onBack: () => void; phase: PairingPhase }> = (props) => (
Sync
); const StatusChip: Component<{ phase: PairingPhase }> = (props) => { const label = () => { switch (props.phase) { case "listening": case "chirping": case "idle": return "listening"; case "handshake": return "handshake"; case "paired": return "paired"; case "mic_denied": return "mic blocked"; case "version_mismatch": return "version mismatch"; case "timeout": return "no response"; case "handshake_failed": return "failed"; } }; return (
{label()}
); }; const PairingFooter: Component<{ phase: PairingPhase }> = (props) => ( ); // --------------------------------------------------------------------------- // Copy + action area (animates on phase change via key remount) // --------------------------------------------------------------------------- const PairingCopy: Component<{ progress: PairingProgress }> = (props) => { // Remount on phase change so the copy-in keyframe replays. const phaseKey = createMemo(() => props.progress.phase); return (
{(_p) => (
{headline(props.progress)}
{subtext(props.progress)}
)}
); }; function headline(p: PairingProgress): string { switch (p.phase) { case "idle": case "listening": case "chirping": return "Hold your phone near your laptop"; case "handshake": return "Pairing…"; case "paired": return "Paired!"; case "timeout": return "Couldn’t find your phone"; case "mic_denied": return "Microphone access needed"; case "version_mismatch": return "Update Peach on both devices"; case "handshake_failed": return "Something went wrong"; } } function subtext(p: PairingProgress): string { switch (p.phase) { case "idle": case "listening": case "chirping": return "Peach will pair automatically when your phone is close."; case "handshake": return "Listening for the chirp."; case "paired": return p.peerDeviceName ? `Connected to ${p.peerDeviceName}` : "Connected"; case "timeout": return "Make sure Peach is open on your phone and try again."; case "mic_denied": return "Peach uses your microphone to pair. No audio is recorded or stored."; case "version_mismatch": return "The other device is running a different protocol version."; case "handshake_failed": return p.errorMessage ?? "Please try again."; } } const PairingAction: Component<{ progress: PairingProgress; onCancel: () => void; onRetry: () => void; onDone: () => void; }> = (props) => { const phase = () => props.progress.phase; return (
listening
handshake · noise NK
{props.progress.peerDeviceName ?? "Paired device"} direct
); }; function isRetryable(phase: PairingPhase): boolean { return phase === "timeout" || phase === "handshake_failed" || phase === "version_mismatch" || phase === "mic_denied"; } // --------------------------------------------------------------------------- // Stage children — chirp arcs, flight wave, connection dot // --------------------------------------------------------------------------- const ChirpArcs: Component<{ direction: "left" | "right" }> = (props) => (
); const FlightWave: Component<{ from: "desktop" | "mobile"; visible: boolean }> = (props) => ( ); const ConnectionDot: Component<{ visible: boolean }> = (props) => (
); // 8-mote helper. createMemo over a fixed array is overkill — index is enough. const For8: Component<{ children: (i: number) => unknown }> = (props) => ( <> {props.children(0)} {props.children(1)} {props.children(2)} {props.children(3)} {props.children(4)} {props.children(5)} {props.children(6)} {props.children(7)} ); // --------------------------------------------------------------------------- // Birds — Finch (desktop) and Swallow (mobile) // Ported from the Chirp Pairing prototype. Bird coral/apricot hexes are // illustration colours, not brand accent — they live alongside the brand // peach exception in §3.1. // --------------------------------------------------------------------------- const FINCH = { hi: "#f4a6b6", mid: "#dc8294", lo: "#a85870", shade: "#7a3e54", cream: "#f5d8c8", beak: "#f5c764", beakShade: "#c8932e", legs: "#7a3e54", }; const SWALLOW = { hi: "#f4c8a0", mid: "#dc9968", lo: "#a86b3e", shade: "#7a4a26", cream: "#f7e0c8", beak: "#f5c764", beakShade: "#b87a22", legs: "#5e3a1f", }; const DesktopBird: Component<{ pose: string }> = (props) => { const p = FINCH; return (
); }; const MobileBird: Component<{ pose: string }> = (props) => { const p = SWALLOW; return (
); };