1234567891011121314151617181920212223import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";24import type { PairingPhase, PairingProgress } from "../../shared/sync-types";2526export interface ChirpPairingFlowProps {27 progress: PairingProgress;28 29 onRetry: () => void;30 31 onCancel: () => void;32 33 onDismiss: () => void;34}3536const PAIRED_DISMISS_MS = 2_500;373839const HANDSHAKE_DESKTOP_WAVE_MS = 900;40const HANDSHAKE_MOBILE_WAVE_MS = 1_900;4142export const ChirpPairingFlow: Component<ChirpPairingFlowProps> = (props) => {43 const [dismissArmed, setDismissArmed] = createSignal(false);44 const [waveSide, setWaveSide] = createSignal<"desktop" | "mobile" | null>(null);4546 createEffect(() => {47 if (props.progress.phase === "paired" && !dismissArmed()) {48 setDismissArmed(true);49 const t = setTimeout(() => props.onDismiss(), PAIRED_DISMISS_MS);50 onCleanup(() => clearTimeout(t));51 }52 });5354 55 createEffect(() => {56 if (props.progress.phase !== "handshake") {57 setWaveSide(null);58 return;59 }60 setWaveSide("desktop");61 const t1 = setTimeout(() => setWaveSide("mobile"), HANDSHAKE_DESKTOP_WAVE_MS);62 const t2 = setTimeout(() => setWaveSide(null), HANDSHAKE_MOBILE_WAVE_MS);63 onCleanup(() => { clearTimeout(t1); clearTimeout(t2); });64 });6566 const phase = createMemo(() => props.progress.phase);67 const desktopPose = createMemo(() => poseFor("desktop", phase()));68 const mobilePose = createMemo(() => poseFor("mobile", phase()));6970 const showDesktopChirp = createMemo(() => {71 const p = phase();72 return p === "listening" || p === "chirping" || (p === "handshake" && waveSide() !== "mobile");73 });74 const showMobileChirp = createMemo(() => phase() === "handshake" && waveSide() === "mobile");7576 return (77 <div78 class="peach-chirp-pair"79 data-phase={phase()}80 role="dialog"81 aria-label="Pair a device"82 onClick={() => phase() === "paired" && props.onDismiss()}83 >84 <PairingHeader onBack={() => props.onCancel()} phase={phase()} />8586 <div class="peach-chirp-chip-row">87 <StatusChip phase={phase()} />88 </div>8990 <div class={`peach-chirp-stage act-${phase()}`} aria-hidden="true">91 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">92 <DesktopBird pose={desktopPose()} />93 <Show when={showDesktopChirp()}>94 <ChirpArcs direction="right" />95 </Show>96 </div>97 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">98 <MobileBird pose={mobilePose()} />99 <Show when={showMobileChirp()}>100 <ChirpArcs direction="left" />101 </Show>102 </div>103 <div class="peach-chirp-flight-track">104 <FlightWave from="desktop" visible={waveSide() === "desktop"} />105 <FlightWave from="mobile" visible={waveSide() === "mobile"} />106 <ConnectionDot visible={phase() === "paired"} />107 </div>108 <div class="peach-chirp-motes">109 <For8>110 {(i) => <span class="peach-chirp-mote" style={{ "--mote-i": i }} />}111 </For8>112 </div>113 </div>114115 <PairingCopy progress={props.progress} />116 <PairingAction117 progress={props.progress}118 onCancel={() => props.onCancel()}119 onRetry={() => props.onRetry()}120 onDone={() => props.onDismiss()}121 />122123 <Show when={phase() === "mic_denied"}>124 <p class="peach-chirp-mic-help">125 Update your browser's site settings to allow Peach to access the microphone,126 then try again.127 </p>128 </Show>129130 <PairingFooter phase={phase()} />131132 {133}134 <div class="peach-sr-only" aria-live="polite">{liveText(props.progress)}</div>135 </div>136 );137};138139function poseFor(side: "desktop" | "mobile", phase: PairingPhase): string {140 if (side === "desktop") {141 switch (phase) {142 case "listening":143 case "chirping": return "chirping";144 case "handshake": return "attentive";145 case "paired": return "happy";146 case "timeout":147 case "handshake_failed":148 case "version_mismatch": return "sad";149 default: return "idle";150 }151 }152 switch (phase) {153 case "handshake": return "attentive";154 case "paired": return "happy";155 default: return "hidden";156 }157}158159function liveText(p: PairingProgress): string {160 switch (p.phase) {161 case "listening":162 case "chirping": return "Listening for the chirp";163 case "handshake": return "Pairing — exchanging keys";164 case "paired": return p.peerDeviceName ? `Paired — connected to ${p.peerDeviceName}` : "Paired";165 case "timeout": return "Couldn't find your phone";166 case "mic_denied": return "Microphone access needed";167 case "version_mismatch": return "Update Peach on both devices";168 case "handshake_failed": return "Pairing failed — try again";169 default: return "";170 }171}172173174175176177const PairingHeader: Component<{ onBack: () => void; phase: PairingPhase }> = (props) => (178 <header class="peach-chirp-head">179 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>180 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">181 <path d="M10 3 L5 8 L10 13" />182 </svg>183 </button>184 <div class="peach-chirp-title">Sync</div>185 <div class="peach-chirp-head-spacer" />186 </header>187);188189const StatusChip: Component<{ phase: PairingPhase }> = (props) => {190 const label = () => {191 switch (props.phase) {192 case "listening":193 case "chirping":194 case "idle": return "listening";195 case "handshake": return "handshake";196 case "paired": return "paired";197 case "mic_denied": return "mic blocked";198 case "version_mismatch": return "version mismatch";199 case "timeout": return "no response";200 case "handshake_failed": return "failed";201 }202 };203 return (204 <div class="peach-chirp-chip" data-phase={props.phase}>205 <span class="peach-chirp-chip-dot" />206 <span class="peach-chirp-chip-text">{label()}</span>207 </div>208 );209};210211const PairingFooter: Component<{ phase: PairingPhase }> = (props) => (212 <footer class="peach-chirp-foot">213 <div class="peach-chirp-foot-divider" />214 <div class="peach-chirp-foot-text">215 {props.phase === "paired"216 ? "Long-term sync keys exchanged over Noise NK. Stored locally on both devices."217 : "Chirp Sync stays on your local network. No relay servers, no cloud accounts — just your devices in the same room."}218 </div>219 </footer>220);221222223224225226const PairingCopy: Component<{ progress: PairingProgress }> = (props) => {227 228 const phaseKey = createMemo(() => props.progress.phase);229 return (230 <div class="peach-chirp-copy-host">231 <Show when={phaseKey()} keyed>232 {(_p) => (233 <div class={`peach-chirp-copy peach-chirp-copy-${props.progress.phase}`}>234 <div class="peach-chirp-headline">{headline(props.progress)}</div>235 <div class="peach-chirp-sub">{subtext(props.progress)}</div>236 </div>237 )}238 </Show>239 </div>240 );241};242243function headline(p: PairingProgress): string {244 switch (p.phase) {245 case "idle":246 case "listening":247 case "chirping": return "Hold your phone near your laptop";248 case "handshake": return "Pairing…";249 case "paired": return "Paired!";250 case "timeout": return "Couldn’t find your phone";251 case "mic_denied": return "Microphone access needed";252 case "version_mismatch": return "Update Peach on both devices";253 case "handshake_failed": return "Something went wrong";254 }255}256257function subtext(p: PairingProgress): string {258 switch (p.phase) {259 case "idle":260 case "listening":261 case "chirping": return "Peach will pair automatically when your phone is close.";262 case "handshake": return "Listening for the chirp.";263 case "paired": return p.peerDeviceName ? `Connected to ${p.peerDeviceName}` : "Connected";264 case "timeout": return "Make sure Peach is open on your phone and try again.";265 case "mic_denied": return "Peach uses your microphone to pair. No audio is recorded or stored.";266 case "version_mismatch": return "The other device is running a different protocol version.";267 case "handshake_failed": return p.errorMessage ?? "Please try again.";268 }269}270271const PairingAction: Component<{272 progress: PairingProgress;273 onCancel: () => void;274 onRetry: () => void;275 onDone: () => void;276}> = (props) => {277 const phase = () => props.progress.phase;278 return (279 <div class="peach-chirp-action">280 <Show when={phase() === "listening" || phase() === "chirping" || phase() === "idle"}>281 <div class="peach-chirp-action-waiting">282 <button class="peach-chirp-cancel peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>283 Cancel284 </button>285 <div class="peach-chirp-listening">286 <span class="peach-chirp-listen-dot" />287 <span class="peach-chirp-listen-dot" />288 <span class="peach-chirp-listen-dot" />289 <span class="peach-chirp-listen-label">listening</span>290 </div>291 </div>292 </Show>293294 <Show when={phase() === "handshake"}>295 <div class="peach-chirp-action-handshake">296 <div class="peach-chirp-meter">297 <span class="peach-chirp-meter-bar" />298 </div>299 <span class="peach-chirp-meter-label">handshake · noise NK</span>300 </div>301 </Show>302303 <Show when={phase() === "paired"}>304 <div class="peach-chirp-action-paired">305 <div class="peach-chirp-device-confirm">306 <span class="peach-chirp-device-confirm-glyph" aria-hidden="true">307 <svg viewBox="0 0 16 16" width="14" height="14">308 <rect x="3" y="2" width="10" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.2" />309 <circle cx="8" cy="12" r="0.9" fill="currentColor" />310 </svg>311 </span>312 <span class="peach-chirp-device-confirm-name">313 {props.progress.peerDeviceName ?? "Paired device"}314 </span>315 <span class="peach-chirp-device-confirm-trust">direct</span>316 </div>317 <button class="peach-chirp-done peach-btn peach-btn-primary" onClick={() => props.onDone()}>318 Done319 </button>320 </div>321 </Show>322323 <Show when={isRetryable(phase())}>324 <button class="peach-chirp-retry peach-btn peach-btn-primary" onClick={() => props.onRetry()}>325 Try again326 </button>327 </Show>328 </div>329 );330};331332function isRetryable(phase: PairingPhase): boolean {333 return phase === "timeout" || phase === "handshake_failed" || phase === "version_mismatch" || phase === "mic_denied";334}335336337338339340const ChirpArcs: Component<{ direction: "left" | "right" }> = (props) => (341 <div class={`peach-chirp-arcs peach-chirp-arcs-${props.direction}`}>342 <span class="peach-chirp-arc" style={{ "animation-delay": "0ms" }} />343 <span class="peach-chirp-arc" style={{ "animation-delay": "560ms" }} />344 <span class="peach-chirp-arc" style={{ "animation-delay": "1120ms" }} />345 </div>346);347348const FlightWave: Component<{ from: "desktop" | "mobile"; visible: boolean }> = (props) => (349 <svg350 class="peach-chirp-flight-wave"351 classList={{ "is-visible": props.visible }}352 data-from={props.from}353 viewBox="0 0 220 80"354 preserveAspectRatio="none"355 >356 <path class="peach-chirp-flight-path" d="M 10 50 Q 60 5, 110 40 T 210 30" />357 <path class="peach-chirp-flight-path-2" d="M 10 55 Q 60 14, 110 45 T 210 35" />358 </svg>359);360361const ConnectionDot: Component<{ visible: boolean }> = (props) => (362 <div class="peach-chirp-dot" classList={{ "is-visible": props.visible }}>363 <span class="peach-chirp-dot-glow" />364 <span class="peach-chirp-dot-core" />365 <svg class="peach-chirp-dot-ring" viewBox="0 0 60 60">366 <circle cx="30" cy="30" r="22" fill="none" stroke-width="1" stroke-dasharray="2 4" />367 </svg>368 </div>369);370371372const For8: Component<{ children: (i: number) => unknown }> = (props) => (373 <>374 {props.children(0)}375 {props.children(1)}376 {props.children(2)}377 {props.children(3)}378 {props.children(4)}379 {props.children(5)}380 {props.children(6)}381 {props.children(7)}382 </>383);384385386387388389390391392const FINCH = {393 hi: "#f4a6b6", mid: "#dc8294", lo: "#a85870", shade: "#7a3e54",394 cream: "#f5d8c8", beak: "#f5c764", beakShade: "#c8932e", legs: "#7a3e54",395};396const SWALLOW = {397 hi: "#f4c8a0", mid: "#dc9968", lo: "#a86b3e", shade: "#7a4a26",398 cream: "#f7e0c8", beak: "#f5c764", beakShade: "#b87a22", legs: "#5e3a1f",399};400401export const DesktopBird: Component<{ pose: string }> = (props) => {402 const p = FINCH;403 return (404 <div class={`peach-chirp-bird peach-chirp-bird-desktop peach-chirp-bird-pose-${props.pose}`}>405 <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet">406 <defs>407 <radialGradient id="peach-chirp-finch-body" cx="0.42" cy="0.32" r="0.72">408 <stop offset="0%" stop-color={p.hi} />409 <stop offset="55%" stop-color={p.mid} />410 <stop offset="100%" stop-color={p.lo} />411 </radialGradient>412 <radialGradient id="peach-chirp-finch-head" cx="0.5" cy="0.4" r="0.7">413 <stop offset="0%" stop-color={p.hi} />414 <stop offset="100%" stop-color={p.mid} />415 </radialGradient>416 <linearGradient id="peach-chirp-finch-belly" x1="0" y1="0" x2="0" y2="1">417 <stop offset="0%" stop-color={p.cream} stop-opacity="0" />418 <stop offset="50%" stop-color={p.cream} stop-opacity="0.85" />419 <stop offset="100%" stop-color={p.cream} stop-opacity="0.5" />420 </linearGradient>421 </defs>422 <ellipse class="peach-chirp-bird-shadow" cx="78" cy="138" rx="40" ry="3.5" fill="rgba(0,0,0,0.45)" />423 <g class="peach-chirp-bird-aura" opacity="0">424 <ellipse cx="76" cy="74" rx="60" ry="46" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />425 </g>426 <g class="peach-chirp-bird-tail">427 <path d="M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z" fill={p.lo} />428 <path d="M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z" fill={p.shade} opacity="0.85" />429 <path d="M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z" fill={p.lo} opacity="0.9" />430 </g>431 <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.6" stroke-linecap="round" fill="none">432 <line x1="62" y1="118" x2="60" y2="134" />433 <path d="M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138" stroke-width="1.2" />434 <line x1="84" y1="118" x2="86" y2="134" />435 <path d="M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138" stroke-width="1.2" />436 </g>437 <g class="peach-chirp-bird-body">438 <path d="M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z" fill="url(#peach-chirp-finch-body)" />439 <path d="M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z" fill="url(#peach-chirp-finch-belly)" />440 <g class="peach-chirp-bird-wing">441 <path d="M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z" fill={p.lo} />442 <g stroke={p.shade} stroke-width="0.7" stroke-linecap="round" fill="none" opacity="0.55">443 <path d="M 64 64 Q 72 70, 78 80" />444 <path d="M 74 62 Q 82 70, 88 82" />445 <path d="M 84 64 Q 90 72, 94 84" />446 </g>447 <path d="M 56 60 C 68 56, 88 60, 102 68" stroke={p.shade} stroke-width="1" fill="none" opacity="0.4" />448 </g>449 </g>450 <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "95px 52px" }}>451 <g class="peach-chirp-bird-tuft">452 <path d="M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z" fill={p.lo} />453 <path d="M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z" fill={p.shade} opacity="0.95" />454 <path d="M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z" fill={p.lo} opacity="0.85" />455 </g>456 <circle cx="98" cy="48" r="26" fill="url(#peach-chirp-finch-head)" />457 <path d="M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z" fill={p.shade} opacity="0.35" />458 <g class="peach-chirp-bird-eye-group">459 <circle cx="108" cy="42" r="4.5" fill="#1f0a10" />460 <circle cx="109.4" cy="40.5" r="1.4" fill="rgba(255,240,225,0.95)" />461 <circle cx="106.5" cy="44" r="0.5" fill="rgba(255,240,225,0.6)" />462 </g>463 <g class="peach-chirp-bird-beak" style={{ "transform-origin": "120px 52px" }}>464 <path d="M 120 48 L 138 50 L 122 56 Z" fill={p.beak} />465 <path d="M 120 48 L 138 50 L 128 52 Z" fill={p.beakShade} opacity="0.55" />466 <path d="M 122 52 L 134 51" stroke={p.beakShade} stroke-width="0.6" opacity="0.6" />467 </g>468 </g>469 </svg>470 </div>471 );472};473474export const MobileBird: Component<{ pose: string }> = (props) => {475 const p = SWALLOW;476 return (477 <div class={`peach-chirp-bird peach-chirp-bird-mobile peach-chirp-bird-pose-${props.pose}`}>478 <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet" style={{ transform: "scaleX(-1)" }}>479 <defs>480 <radialGradient id="peach-chirp-swallow-body" cx="0.55" cy="0.35" r="0.75">481 <stop offset="0%" stop-color={p.hi} />482 <stop offset="50%" stop-color={p.mid} />483 <stop offset="100%" stop-color={p.lo} />484 </radialGradient>485 <radialGradient id="peach-chirp-swallow-head" cx="0.45" cy="0.4" r="0.7">486 <stop offset="0%" stop-color={p.hi} />487 <stop offset="100%" stop-color={p.mid} />488 </radialGradient>489 <linearGradient id="peach-chirp-swallow-throat" x1="0" y1="0" x2="0" y2="1">490 <stop offset="0%" stop-color={p.cream} stop-opacity="0.95" />491 <stop offset="100%" stop-color={p.cream} stop-opacity="0.2" />492 </linearGradient>493 </defs>494 <ellipse class="peach-chirp-bird-shadow" cx="86" cy="140" rx="34" ry="3" fill="rgba(0,0,0,0.45)" />495 <g class="peach-chirp-bird-aura" opacity="0">496 <ellipse cx="80" cy="78" rx="60" ry="42" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />497 </g>498 <g class="peach-chirp-bird-tail">499 <path d="M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z" fill={p.lo} />500 <path d="M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z" fill={p.shade} opacity="0.7" />501 </g>502 <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.5" stroke-linecap="round" fill="none">503 <line x1="76" y1="118" x2="74" y2="136" />504 <path d="M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140" stroke-width="1.1" />505 <line x1="96" y1="118" x2="98" y2="136" />506 <path d="M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140" stroke-width="1.1" />507 </g>508 <g class="peach-chirp-bird-body">509 <path d="M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z" fill="url(#peach-chirp-swallow-body)" />510 <path d="M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z" fill={p.shade} opacity="0.5" />511 <path d="M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z" fill="url(#peach-chirp-swallow-throat)" />512 <g class="peach-chirp-bird-wing">513 <path d="M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z" fill={p.lo} />514 <g fill={p.shade} opacity="0.85">515 <path d="M 96 88 L 110 92 L 100 96 Z" />516 <path d="M 88 92 L 102 98 L 92 100 Z" />517 <path d="M 80 94 L 92 100 L 84 102 Z" />518 </g>519 <g stroke={p.shade} stroke-width="0.6" stroke-linecap="round" fill="none" opacity="0.5">520 <path d="M 58 76 Q 70 82, 80 90" />521 <path d="M 68 72 Q 80 80, 90 88" />522 <path d="M 80 70 Q 90 78, 100 86" />523 </g>524 <path d="M 50 70 C 64 64, 84 66, 100 72" stroke={p.shade} stroke-width="1" fill="none" opacity="0.55" />525 </g>526 </g>527 <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "116px 64px" }}>528 <circle cx="118" cy="64" r="20" fill="url(#peach-chirp-swallow-head)" />529 <path d="M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z" fill={p.shade} opacity="0.55" />530 <g class="peach-chirp-bird-eye-group">531 <circle cx="126" cy="60" r="4" fill="#1f0a10" />532 <circle cx="127.2" cy="58.8" r="1.2" fill="rgba(255,240,225,0.95)" />533 </g>534 <g class="peach-chirp-bird-beak" style={{ "transform-origin": "136px 64px" }}>535 <path d="M 136 60 L 154 62 L 138 66 Z" fill={p.beak} />536 <path d="M 136 60 L 154 62 L 145 63 Z" fill={p.beakShade} opacity="0.5" />537 <path d="M 138 63 L 152 63" stroke={p.beakShade} stroke-width="0.5" opacity="0.6" />538 </g>539 </g>540 </svg>541 </div>542 );543};