5c295ba6e302
redesign chirp pairing screen — Finch+Swallow stage, chirp arcs, flight wave, connection dot, status copy/action, sr-live region; bump v3.2.2 → v3.3.0
- time
- 2026-05-18 13:05 UTC · 5 months ago
- machine
- koh2
- parent
- 60c812553e03
↓ peach-browser-5c295ba6.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/ChirpPairingFlow.tsx+465 −107src/popup/styles/popup.css+626 −102manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.2.2",4+ "version": "3.3.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.2.2",4+ "version": "3.3.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.2.2",3+ "version": "3.3.0",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/popup/components/ChirpPairingFlow.tsx+465 −107
@@ -1,19 +1,27 @@11 // ChirpPairingFlow — the audio pairing screen.22 //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!"3+// Renders the three-act bird animation specified in the brief §6.2:4+// Act 1 (waiting) — desktop finch chirps, soft rings pulse from its beak5+// Act 2 (handshake) — mobile swallow hops in, sound-wave arcs fly between6+// the two birds, both heads tilt toward each other7+// Act 3 (paired) — waves resolve into a glowing connection dot; both8+// birds wing-flutter and bob together99 //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.10+// Phase mapping (PairingPhase from sync-types):11+// idle | listening | chirping → Act 112+// handshake → Act 213+// paired → Act 314+// timeout → desktop bird droops, retry CTA15+// mic_denied → static stage, browser-settings hint16+// version_mismatch → static stage, update hint, retry CTA17+// handshake_failed → retry CTA18+//19+// The orchestrator (popup/chirp-pair.ts) pushes phase updates into20+// `progress` and the component renders them. This component owns no21+// microphone or socket state of its own.1422 15-import { Component, Show, createEffect, createSignal } from "solid-js";16-import type { PairingProgress } from "../../shared/sync-types";23+import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";24+import type { PairingPhase, PairingProgress } from "../../shared/sync-types";1725 1826 export interface ChirpPairingFlowProps {1927 progress: PairingProgress;@@ -25,69 +33,209 @@2533 onDismiss: () => void;2634 }2735 28-const PAIRED_DISMISS_MS = 2500;36+const PAIRED_DISMISS_MS = 2_500;37+// During handshake, swap which bird the flight-wave emanates from so the38+// chirp feels reciprocated. Desktop wave first, then mobile.39+const HANDSHAKE_DESKTOP_WAVE_MS = 900;40+const HANDSHAKE_MOBILE_WAVE_MS = 1_900;2941 3042 export const ChirpPairingFlow: Component<ChirpPairingFlowProps> = (props) => {3143 const [dismissArmed, setDismissArmed] = createSignal(false);44+ const [waveSide, setWaveSide] = createSignal<"desktop" | "mobile" | null>(null);3245 3346 createEffect(() => {3447 if (props.progress.phase === "paired" && !dismissArmed()) {3548 setDismissArmed(true);3649 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;50+ onCleanup(() => clearTimeout(t));3951 }4052 });4153 54+ // Swap which side the flight-wave plays from during Act 2.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+ });65+66+ const phase = createMemo(() => props.progress.phase);67+ const desktopPose = createMemo(() => poseFor("desktop", phase()));68+ const mobilePose = createMemo(() => poseFor("mobile", phase()));69+70+ 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");75+4276 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} />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()} />85+86+ <div class="peach-chirp-chip-row">87+ <StatusChip phase={phase()} />5088 </div>5189 90+ <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>114+52115 <PairingCopy progress={props.progress} />116+ <PairingAction117+ progress={props.progress}118+ onCancel={() => props.onCancel()}119+ onRetry={() => props.onRetry()}120+ onDone={() => props.onDismiss()}121+ />53122 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"}>123+ <Show when={phase() === "mic_denied"}>65124 <p class="peach-chirp-mic-help">66- Update your browser's site settings to allow Peach to access the microphone, then try again.125+ Update your browser's site settings to allow Peach to access the microphone,126+ then try again.67127 </p>68128 </Show>129+130+ <PairingFooter phase={phase()} />131+132+ {/* Polite live region so the act transitions are announced to133+ assistive tech even though the stage itself is decorative. */}134+ <div class="peach-sr-only" aria-live="polite">{liveText(props.progress)}</div>69135 </div>70136 );71137 };72138 73-function canCancel(phase: PairingProgress["phase"]): boolean {74- return phase === "listening" || phase === "chirping" || phase === "handshake";139+function 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+ }75157 }76158 77-function isTerminalError(phase: PairingProgress["phase"]): boolean {159+function 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+}172+173+// ---------------------------------------------------------------------------174+// Header, chip row, footer175+// ---------------------------------------------------------------------------176+177+const 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+);188+189+const 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+ };78203 return (79- phase === "timeout" ||80- phase === "handshake_failed" ||81- phase === "version_mismatch" ||82- phase === "mic_denied"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>83208 );84-}209+};85210 211+const 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+);221+222+// ---------------------------------------------------------------------------223+// Copy + action area (animates on phase change via key remount)224+// ---------------------------------------------------------------------------225+86226 const PairingCopy: Component<{ progress: PairingProgress }> = (props) => {227+ // Remount on phase change so the copy-in keyframe replays.228+ const phaseKey = createMemo(() => props.progress.phase);87229 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>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>91239 </div>92240 );93241 };@@ -96,20 +244,13 @@96244 switch (p.phase) {97245 case "idle":98246 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";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";113254 }114255 }115256 @@ -117,69 +258,286 @@117258 switch (p.phase) {118259 case "idle":119260 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";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.";134268 }135269 }136270 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) => {271+const PairingAction: Component<{272+ progress: PairingProgress;273+ onCancel: () => void;274+ onRetry: () => void;275+ onDone: () => void;276+}> = (props) => {277+ const phase = () => props.progress.phase;144278 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>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>293+294+ <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>302+303+ <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>322+323+ <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>153329 );154330 };155331 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" />332+function isRetryable(phase: PairingPhase): boolean {333+ return phase === "timeout" || phase === "handshake_failed" || phase === "version_mismatch" || phase === "mic_denied";334+}335+336+// ---------------------------------------------------------------------------337+// Stage children — chirp arcs, flight wave, connection dot338+// ---------------------------------------------------------------------------339+340+const 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+);347+348+const 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+);360+361+const 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" />165367 </svg>166- );368+ </div>369+);370+371+// 8-mote helper. createMemo over a fixed array is overkill — index is enough.372+const 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+);384+385+// ---------------------------------------------------------------------------386+// Birds — Finch (desktop) and Swallow (mobile)387+// Ported from the Chirp Pairing prototype. Bird coral/apricot hexes are388+// illustration colours, not brand accent — they live alongside the brand389+// peach exception in §3.1.390+// ---------------------------------------------------------------------------391+392+const FINCH = {393+ hi: "#f4a6b6", mid: "#dc8294", lo: "#a85870", shade: "#7a3e54",394+ cream: "#f5d8c8", beak: "#f5c764", beakShade: "#c8932e", legs: "#7a3e54",167395 };396+const SWALLOW = {397+ hi: "#f4c8a0", mid: "#dc9968", lo: "#a86b3e", shade: "#7a4a26",398+ cream: "#f7e0c8", beak: "#f5c764", beakShade: "#b87a22", legs: "#5e3a1f",399+};168400 169-const SoundWaves: Component<{ phase: PairingProgress["phase"] }> = (props) => {401+const DesktopBird: Component<{ pose: string }> = (props) => {402+ const p = FINCH;170403 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>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>175471 );176472 };177473 178-const ConnectionDot: Component<{ phase: PairingProgress["phase"] }> = (props) => {474+const MobileBird: Component<{ pose: string }> = (props) => {475+ const p = SWALLOW;179476 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" />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>183541 </div>184542 );185543 };src/popup/styles/popup.css+626 −102
@@ -8725,168 +8725,692 @@87258725 }87268726 87278727 /* ──────────────────────────────────────────────────────────────────────────8728- Chirp pairing flow — audio bootstrap (Phase 1)8728+ Chirp pairing flow — bird-stage animation (Phase 1)87298729 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.8730+ Three-act animation gated on the [data-phase] attribute of8731+ .peach-chirp-pair; child layers (birds, chirp arcs, flight wave, dot)8732+ each react via either pose class or visibility flag.8733+ Tokens: accent surfaces use var(--peach-accent); bird illustration8734+ colours are baked into the SVG per the brand-peach exception in §3.1.87328735 ────────────────────────────────────────────────────────────────────── */8736+87338737 .peach-chirp-pair {87348738 position: relative;87358739 display: flex;87368740 flex-direction: column;8737- align-items: center;8738- justify-content: center;8739- padding: 40px 24px 32px;8740- min-height: 380px;8741- gap: 24px;8741+ padding: 16px 18px 14px;8742+ min-height: 100%;8743+ gap: 0;87428744 background:8743- radial-gradient(ellipse at top, color-mix(in srgb, var(--peach-accent) 5%, transparent), transparent 65%),8745+ radial-gradient(ellipse 70% 50% at 30% 0%, color-mix(in srgb, var(--peach-accent) 8%, transparent), transparent 70%),87448746 var(--peach-bg);87458747 color: var(--peach-text);8748+ overflow: hidden;8749+ isolation: isolate;87468750 }8751+.peach-chirp-pair::before {8752+ content: '';8753+ position: absolute;8754+ inset: 0;8755+ border-radius: inherit;8756+ pointer-events: none;8757+ background: radial-gradient(ellipse 90% 30% at 50% 0%, color-mix(in srgb, var(--peach-accent) 6%, transparent), transparent 70%);8758+ z-index: 0;8759+}8760+.peach-chirp-pair > * { position: relative; z-index: 1; }8761+8762+/* Header --------------------------------------------------------------- */8763+.peach-chirp-head {8764+ display: flex; align-items: center; gap: 10px;8765+}8766+.peach-chirp-back {8767+ width: 32px; height: 32px;8768+ display: inline-flex; align-items: center; justify-content: center;8769+ background: color-mix(in srgb, var(--peach-text) 4%, transparent);8770+ border: 1px solid color-mix(in srgb, var(--peach-text) 8%, transparent);8771+ border-radius: 8px;8772+ color: var(--peach-text-dim);8773+ cursor: pointer;8774+ transition: background 0.15s ease, color 0.15s ease;8775+}8776+.peach-chirp-back:hover {8777+ background: color-mix(in srgb, var(--peach-text) 7%, transparent);8778+ color: var(--peach-text);8779+}8780+.peach-chirp-title {8781+ font-family: var(--peach-serif, Georgia, serif);8782+ font-style: italic;8783+ font-size: 22px;8784+ letter-spacing: 0.01em;8785+ color: var(--peach-text);8786+ line-height: 1;8787+}8788+.peach-chirp-head-spacer { flex: 1; }8789+8790+/* Chip row ------------------------------------------------------------- */8791+.peach-chirp-chip-row {8792+ display: flex;8793+ align-items: center;8794+ justify-content: flex-start;8795+ margin-top: 12px;8796+}8797+.peach-chirp-chip {8798+ display: inline-flex; align-items: center; gap: 8px;8799+ padding: 5px 10px 5px 8px;8800+ background: color-mix(in srgb, var(--peach-accent) 10%, transparent);8801+ border: 1px solid color-mix(in srgb, var(--peach-accent) 20%, transparent);8802+ border-radius: 999px;8803+ font-family: var(--peach-mono, ui-monospace, monospace);8804+ font-size: 10.5px;8805+ letter-spacing: 0.04em;8806+ color: var(--peach-accent);8807+}8808+.peach-chirp-chip-dot {8809+ width: 6px; height: 6px; border-radius: 50%;8810+ background: var(--peach-accent);8811+ box-shadow: 0 0 6px color-mix(in srgb, var(--peach-accent) 50%, transparent);8812+}8813+.peach-chirp-chip[data-phase="paired"] {8814+ background: color-mix(in srgb, #4f9eda 12%, transparent);8815+ border-color: color-mix(in srgb, #4f9eda 28%, transparent);8816+ color: #76b6e8;8817+}8818+.peach-chirp-chip[data-phase="paired"] .peach-chirp-chip-dot {8819+ background: #76b6e8;8820+ box-shadow: 0 0 6px rgba(118, 182, 232, 0.55);8821+}8822+.peach-chirp-chip[data-phase="timeout"],8823+.peach-chirp-chip[data-phase="mic_denied"],8824+.peach-chirp-chip[data-phase="version_mismatch"],8825+.peach-chirp-chip[data-phase="handshake_failed"] {8826+ background: color-mix(in srgb, var(--peach-warn, #d29257) 14%, transparent);8827+ border-color: color-mix(in srgb, var(--peach-warn, #d29257) 28%, transparent);8828+ color: var(--peach-warn, #d29257);8829+}8830+.peach-chirp-chip[data-phase="timeout"] .peach-chirp-chip-dot,8831+.peach-chirp-chip[data-phase="mic_denied"] .peach-chirp-chip-dot,8832+.peach-chirp-chip[data-phase="version_mismatch"] .peach-chirp-chip-dot,8833+.peach-chirp-chip[data-phase="handshake_failed"] .peach-chirp-chip-dot {8834+ background: var(--peach-warn, #d29257);8835+ box-shadow: 0 0 6px color-mix(in srgb, var(--peach-warn, #d29257) 55%, transparent);8836+}8837+8838+/* Bird stage ----------------------------------------------------------- */87478839 .peach-chirp-stage {87488840 position: relative;8749- width: 320px;8750- height: 130px;8841+ margin: 14px -4px 8px;8842+ height: 180px;8843+ border-radius: 12px;8844+ background:8845+ radial-gradient(ellipse 70% 60% at 50% 30%, color-mix(in srgb, var(--peach-accent) 8%, transparent), transparent 70%),8846+ radial-gradient(ellipse 90% 100% at 50% 100%, color-mix(in srgb, #d8748a 12%, transparent), transparent 70%),8847+ color-mix(in srgb, var(--peach-bg) 92%, #000);8848+ border: 1px solid color-mix(in srgb, var(--peach-text) 6%, transparent);8849+ overflow: hidden;8850+ isolation: isolate;8851+}8852+.peach-chirp-stage::after {8853+ content: '';8854+ position: absolute;8855+ left: 12%; right: 12%;8856+ bottom: 36px;8857+ height: 1px;8858+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--peach-text) 12%, transparent), transparent);8859+}8860+8861+.peach-chirp-bird-slot {8862+ position: absolute;8863+ bottom: 8px;8864+ width: 100px;8865+ height: 110px;87518866 display: flex;87528867 align-items: flex-end;8753- justify-content: space-between;8868+ justify-content: center;87548869 }8870+.peach-chirp-bird-slot-desktop { left: 28px; }8871+.peach-chirp-bird-slot-mobile { right: 28px; }8872+8873+/* Mobile bird hops in from the right when handshake begins */8874+.peach-chirp-bird-slot-mobile {8875+ opacity: 1;8876+ transform: translateX(-6px);8877+ transition: opacity 0.4s ease, transform 0.65s cubic-bezier(.5, 1.7, .4, 1);8878+}8879+.peach-chirp-stage.act-listening .peach-chirp-bird-slot-mobile,8880+.peach-chirp-stage.act-chirping .peach-chirp-bird-slot-mobile,8881+.peach-chirp-stage.act-idle .peach-chirp-bird-slot-mobile {8882+ opacity: 0;8883+ transform: translateX(60px) rotate(8deg);8884+}8885+.peach-chirp-stage.act-handshake .peach-chirp-bird-slot-desktop,8886+.peach-chirp-stage.act-paired .peach-chirp-bird-slot-desktop {8887+ transform: translateX(6px);8888+ transition: transform 0.5s cubic-bezier(.4, 1.4, .4, 1);8889+}8890+8891+/* Paired: both birds bob together */8892+.peach-chirp-stage.act-paired .peach-chirp-bird-slot {8893+ animation: peach-chirp-happy-bob 1.2s ease-in-out infinite;8894+}8895+.peach-chirp-stage.act-paired .peach-chirp-bird-slot-mobile { animation-delay: 0.2s; }8896+.peach-chirp-stage.act-paired .peach-chirp-bird-slot-desktop { --bb-x: 6px; }8897+.peach-chirp-stage.act-paired .peach-chirp-bird-slot-mobile { --bb-x: -6px; }8898+@keyframes peach-chirp-happy-bob {8899+ 0%, 100% { transform: translateX(var(--bb-x, 0)) translateY(0); }8900+ 50% { transform: translateX(var(--bb-x, 0)) translateY(-3px); }8901+}8902+8903+/* Bird internal -------------------------------------------------------- */87558904 .peach-chirp-bird {8756- position: relative;8757- transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 320ms ease;8905+ width: 108px;8906+ height: 108px;8907+ transform-origin: 50% 95%;87588908 }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; }8909+.peach-chirp-bird svg { width: 100%; height: 100%; overflow: visible; }87648910 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;8911+.peach-chirp-bird-body {8912+ transform-origin: 50% 80%;8913+ animation: peach-chirp-bird-bob 2.4s ease-in-out infinite;87708914 }8771-.peach-chirp-bird-desktop[data-phase="timeout"] {8772- transform: translateY(8px) rotate(-4deg);8773- opacity: 0.7;8915+@keyframes peach-chirp-bird-bob {8916+ 0%, 100% { transform: translateY(0) scale(1, 1); }8917+ 50% { transform: translateY(-1.5px) scale(1.01, 0.99); }87748918 }87758919 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);8920+.peach-chirp-bird-eye-group {8921+ animation: peach-chirp-blink 5.2s steps(1, end) infinite;8922+ transform-origin: center;8923+ transform-box: fill-box;87808924 }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;8925+@keyframes peach-chirp-blink {8926+ 0%, 92%, 96%, 100% { transform: scaleY(1); }8927+ 94% { transform: scaleY(0.05); }87868928 }8787-.peach-chirp-bird-mobile[data-phase="handshake_failed"] {8788- opacity: 0.7;8789- transform: translateX(0) translateY(6px) rotate(4deg);8929+8930+/* Pose: chirping — beak opens, head bobs, aura blooms */8931+.peach-chirp-bird-pose-chirping .peach-chirp-bird-body {8932+ animation:8933+ peach-chirp-bird-bob 2.4s ease-in-out infinite,8934+ peach-chirp-squeeze 1.7s ease-in-out infinite;87908935 }8936+@keyframes peach-chirp-squeeze {8937+ 0%, 70%, 100% { transform-origin: 50% 85%; }8938+ 78% { transform: scale(1.04, 0.96) translateY(1px); }8939+ 86% { transform: scale(0.98, 1.02); }8940+}8941+.peach-chirp-bird-pose-chirping .peach-chirp-bird-head-group {8942+ animation: peach-chirp-head-bob 1.7s ease-in-out infinite;8943+}8944+@keyframes peach-chirp-head-bob {8945+ 0%, 70%, 100% { transform: translateY(0) rotate(0); }8946+ 78% { transform: translateY(-3px) rotate(-4deg); }8947+ 86% { transform: translateY(-1px) rotate(-2deg); }8948+}8949+.peach-chirp-bird-pose-chirping .peach-chirp-bird-beak {8950+ animation: peach-chirp-beak-open 1.7s ease-in-out infinite;8951+}8952+@keyframes peach-chirp-beak-open {8953+ 0%, 70%, 100% { transform: rotate(0) scaleX(1); }8954+ 78% { transform: rotate(-14deg) scaleX(1.05) translateY(-1px); }8955+ 86% { transform: rotate(-6deg); }8956+}8957+.peach-chirp-bird-pose-chirping .peach-chirp-bird-aura {8958+ animation: peach-chirp-aura 1.7s ease-in-out infinite;8959+}8960+@keyframes peach-chirp-aura {8961+ 0%, 100% { opacity: 0; }8962+ 50% { opacity: 0.8; }8963+}8964+.peach-chirp-bird-pose-chirping .peach-chirp-bird-tail {8965+ animation: peach-chirp-tail-twitch 1.7s ease-in-out infinite;8966+ transform-origin: 100% 50%;8967+}8968+@keyframes peach-chirp-tail-twitch {8969+ 0%, 75%, 100% { transform: rotate(0); }8970+ 82% { transform: rotate(-3deg) translateX(-1px); }8971+}87918972 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 {8973+/* Pose: attentive — head tilts toward partner */8974+.peach-chirp-bird-pose-attentive .peach-chirp-bird-head-group {8975+ animation: peach-chirp-head-tilt 0.6s cubic-bezier(.4, 1.6, .5, 1) both;8976+}8977+@keyframes peach-chirp-head-tilt {8978+ 0% { transform: rotate(0) translateY(0); }8979+ 60% { transform: rotate(-10deg) translateY(-1px) translateX(2px); }8980+ 100% { transform: rotate(-6deg); }8981+}8982+.peach-chirp-bird-pose-attentive .peach-chirp-bird-tail {8983+ animation: peach-chirp-tail-cock 0.6s cubic-bezier(.4, 1.6, .5, 1) both;8984+ transform-origin: 100% 50%;8985+}8986+@keyframes peach-chirp-tail-cock {8987+ 0% { transform: rotate(0); }8988+ 100% { transform: rotate(-4deg); }8989+}8990+8991+/* Pose: happy — wing flutter, tuft perk, tail wag */8992+.peach-chirp-bird-pose-happy .peach-chirp-bird-wing {8993+ transform-origin: 80% 60%;8994+ animation: peach-chirp-wing-flutter 0.32s ease-in-out 5;8995+}8996+@keyframes peach-chirp-wing-flutter {8997+ 0%, 100% { transform: rotate(0) translateY(0); }8998+ 50% { transform: rotate(-16deg) translateY(-2px); }8999+}9000+.peach-chirp-bird-pose-happy .peach-chirp-bird-tuft {9001+ transform-origin: 0% 100%;9002+ animation: peach-chirp-tuft-perk 0.4s ease-in-out 3;9003+}9004+@keyframes peach-chirp-tuft-perk {9005+ 0%, 100% { transform: rotate(0); }9006+ 50% { transform: rotate(-15deg) translateY(-1px); }9007+}9008+.peach-chirp-bird-pose-happy .peach-chirp-bird-tail {9009+ transform-origin: 100% 50%;9010+ animation: peach-chirp-tail-wag 0.5s ease-in-out 4;9011+}9012+@keyframes peach-chirp-tail-wag {9013+ 0%, 100% { transform: rotate(0); }9014+ 25% { transform: rotate(6deg); }9015+ 75% { transform: rotate(-6deg); }9016+}9017+.peach-chirp-bird-pose-happy .peach-chirp-bird-head-group {9018+ animation: peach-chirp-head-perk 0.5s ease-out;9019+}9020+@keyframes peach-chirp-head-perk {9021+ 0% { transform: translateY(0); }9022+ 40% { transform: translateY(-3px) rotate(2deg); }9023+ 100% { transform: translateY(0); }9024+}9025+9026+/* Pose: sad — body droops forward */9027+.peach-chirp-bird-pose-sad .peach-chirp-bird-body {9028+ transform: translateY(5px) rotate(-2deg);9029+ animation: none;9030+}9031+.peach-chirp-bird-pose-sad .peach-chirp-bird-head-group {9032+ transform: translateY(4px) rotate(8deg);9033+}9034+.peach-chirp-bird-pose-sad .peach-chirp-bird-tuft { transform: rotate(15deg) translateY(2px); }9035+.peach-chirp-bird-pose-sad .peach-chirp-bird-tail { transform: rotate(8deg) translateY(2px); }9036+9037+.peach-chirp-bird-pose-hidden { opacity: 0; }9038+9039+/* Chirp arcs ----------------------------------------------------------- */9040+.peach-chirp-arcs {87959041 position: absolute;8796- left: 0;8797- right: 0;8798- top: 0;8799- bottom: 0;8800- margin: auto;9042+ top: 18px;9043+ width: 96px;9044+ height: 70px;88019045 pointer-events: none;88029046 }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 */9047+.peach-chirp-arcs-right { right: -60px; }9048+.peach-chirp-arcs-left { left: -60px; transform: scaleX(-1); }9049+.peach-chirp-arc {9050+ position: absolute;9051+ top: 50%;9052+ left: 0;9053+ width: 40px;9054+ height: 40px;9055+ margin-top: -20px;9056+ border: 1.2px solid #f4d5a8;9057+ border-color: transparent #f4d5a8 transparent transparent;9058+ border-radius: 50%;9059+ opacity: 0;9060+ animation: peach-chirp-arc-expand 1.7s cubic-bezier(.2, .6, .2, 1) infinite;9061+}9062+@keyframes peach-chirp-arc-expand {9063+ 0% { transform: scale(0.4); opacity: 0; }9064+ 20% { opacity: 0.9; }9065+ 100% { transform: scale(1.6); opacity: 0; }9066+}88069067 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;9068+/* Flight wave ---------------------------------------------------------- */9069+.peach-chirp-flight-track {9070+ position: absolute;9071+ inset: 32px 36px 38px 36px;9072+ pointer-events: none;88119073 }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;9074+.peach-chirp-flight-wave {9075+ position: absolute;9076+ top: 8px;9077+ left: 76px;9078+ right: 76px;9079+ height: 60px;9080+ width: auto;9081+ opacity: 0;9082+ transition: opacity 0.15s ease;9083+ stroke: #f4d5a8;9084+ fill: none;88169085 }8817-.peach-chirp-waves[data-phase="handshake_failed"] .peach-chirp-wave-out {8818- opacity: 0.5;8819- stroke-dasharray: 4 8;9086+.peach-chirp-flight-wave[data-from="mobile"] { transform: scaleX(-1); }9087+.peach-chirp-flight-wave.is-visible { opacity: 1; }9088+.peach-chirp-flight-path,9089+.peach-chirp-flight-path-2 {9090+ stroke-dasharray: 240;9091+ stroke-dashoffset: 240;9092+ stroke-linecap: round;9093+ fill: none;88209094 }9095+.peach-chirp-flight-path { stroke-width: 1.6; stroke: #f4d5a8; }9096+.peach-chirp-flight-path-2 { stroke-width: 1; stroke: #f4d5a8; opacity: 0.55; }9097+.peach-chirp-flight-wave.is-visible .peach-chirp-flight-path {9098+ animation: peach-chirp-fly 700ms cubic-bezier(.2,.6,.2,1) forwards;9099+}9100+.peach-chirp-flight-wave.is-visible .peach-chirp-flight-path-2 {9101+ animation: peach-chirp-fly 700ms cubic-bezier(.2,.6,.2,1) 80ms forwards;9102+}9103+@keyframes peach-chirp-fly {9104+ 0% { stroke-dashoffset: 240; opacity: 0; }9105+ 20% { opacity: 1; }9106+ 100% { stroke-dashoffset: -120; opacity: 0; }9107+}88219108 8822-/* Glowing connection dot — appears for Act 3, settles after the initial pulse */9109+/* Connection dot ------------------------------------------------------- */88239110 .peach-chirp-dot {88249111 position: absolute;8825- left: 50%;8826- top: 50%;8827- width: 18px;8828- height: 18px;8829- margin-left: -9px;8830- margin-top: -9px;9112+ top: 50%; left: 50%;9113+ transform: translate(-50%, -50%);9114+ width: 40px; height: 40px;88319115 opacity: 0;8832- transition: opacity 300ms ease;9116+ pointer-events: none;9117+ transition: opacity 0.3s ease;9118+ color: var(--peach-accent);88339119 }8834-.peach-chirp-dot[data-phase="paired"] { opacity: 1; }9120+.peach-chirp-dot.is-visible { opacity: 1; }9121+.peach-chirp-dot-glow {9122+ position: absolute; top: 50%; left: 50%;9123+ transform: translate(-50%, -50%);9124+ width: 38px; height: 38px;9125+ border-radius: 50%;9126+ filter: blur(10px);9127+ opacity: 0.55;9128+ background: var(--peach-accent);9129+ animation: peach-chirp-dot-bloom 1.4s ease-out;9130+}88359131 .peach-chirp-dot-core {8836- position: absolute;8837- inset: 4px;8838- border-radius: 999px;9132+ position: absolute; top: 50%; left: 50%;9133+ transform: translate(-50%, -50%);9134+ width: 8px; height: 8px;9135+ border-radius: 50%;88399136 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;9137+ box-shadow: 0 0 12px currentColor;9138+ animation: peach-chirp-dot-core 1.4s ease-out;88429139 }8843-.peach-chirp-dot-halo {9140+.peach-chirp-dot-ring {9141+ position: absolute; top: 50%; left: 50%;9142+ width: 60px; height: 60px;9143+ transform: translate(-50%, -50%);9144+ animation: peach-chirp-dot-ring 1.4s linear;9145+ stroke: var(--peach-accent);9146+}9147+@keyframes peach-chirp-dot-bloom {9148+ 0% { width: 4px; height: 4px; opacity: 0; }9149+ 35% { width: 60px; height: 60px; opacity: 0.7; }9150+ 100% { width: 38px; height: 38px; opacity: 0.55; }9151+}9152+@keyframes peach-chirp-dot-core {9153+ 0% { width: 0; height: 0; }9154+ 35% { width: 12px; height: 12px; }9155+ 100% { width: 8px; height: 8px; }9156+}9157+@keyframes peach-chirp-dot-ring {9158+ 0% { transform: translate(-50%, -50%) scale(0.4); opacity: 0; }9159+ 35% { transform: translate(-50%, -50%) scale(1.0); opacity: 0.6; }9160+ 100% { transform: translate(-50%, -50%) scale(1.4); opacity: 0; }9161+}9162+9163+/* Motes — drift upward across the stage */9164+.peach-chirp-motes {88449165 position: absolute;88459166 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;9167+ pointer-events: none;88499168 }9169+.peach-chirp-mote {9170+ position: absolute;9171+ width: 2px; height: 2px;9172+ background: color-mix(in srgb, var(--peach-text) 35%, transparent);9173+ border-radius: 50%;9174+ bottom: 32px;9175+ left: calc(50% + (var(--mote-i, 0) - 4) * 24px);9176+ opacity: 0;9177+ animation: peach-chirp-mote-drift 6s linear infinite;9178+ animation-delay: calc(var(--mote-i, 0) * 0.7s);9179+}9180+@keyframes peach-chirp-mote-drift {9181+ 0% { transform: translateY(0); opacity: 0; }9182+ 10% { opacity: 0.6; }9183+ 90% { opacity: 0.1; }9184+ 100% { transform: translateY(-120px) translateX(8px); opacity: 0; }9185+}88509186 8851-.peach-chirp-copy { text-align: center; max-width: 360px; }9187+/* Status copy ---------------------------------------------------------- */9188+.peach-chirp-copy-host {9189+ margin: 4px 4px 10px;9190+ text-align: center;9191+ min-height: 50px;9192+}9193+.peach-chirp-copy {9194+ animation: peach-chirp-copy-in 0.35s ease both;9195+}9196+@keyframes peach-chirp-copy-in {9197+ from { opacity: 0; transform: translateY(4px); }9198+ to { opacity: 1; transform: translateY(0); }9199+}88529200 .peach-chirp-headline {8853- font-size: 18px;8854- font-weight: 600;8855- margin: 0 0 6px;9201+ font-family: var(--peach-serif, Georgia, serif);9202+ font-style: italic;9203+ font-size: 20px;9204+ line-height: 1.15;9205+ letter-spacing: 0.01em;88569206 color: var(--peach-text);9207+ margin-bottom: 5px;88579208 }8858-.peach-chirp-sub {8859- font-size: 13px;8860- margin: 0;9209+.peach-chirp-copy-listening .peach-chirp-headline,9210+.peach-chirp-copy-chirping .peach-chirp-headline,9211+.peach-chirp-copy-idle .peach-chirp-headline {9212+ font-family: inherit;9213+ font-style: normal;9214+ font-weight: 500;9215+ font-size: 14.5px;88619216 color: var(--peach-text-dim);9217+ letter-spacing: 0;88629218 }8863-.peach-chirp-mic-help {9219+.peach-chirp-copy-paired .peach-chirp-headline {9220+ color: var(--peach-text);9221+ font-weight: 600;9222+}9223+.peach-chirp-sub {88649224 font-size: 12px;9225+ line-height: 1.45;88659226 color: var(--peach-text-muted);8866- text-align: center;8867- max-width: 320px;9227+ max-width: 280px;9228+ margin: 0 auto;9229+ text-wrap: pretty;88689230 }8869-.peach-chirp-cancel,9231+9232+/* Status action -------------------------------------------------------- */9233+.peach-chirp-action {9234+ margin: 2px 4px 10px;9235+}9236+.peach-chirp-action-waiting {9237+ display: flex;9238+ align-items: center;9239+ justify-content: space-between;9240+ gap: 12px;9241+}9242+.peach-chirp-cancel {9243+ min-width: 96px;9244+}9245+.peach-chirp-listening {9246+ display: inline-flex; align-items: center; gap: 4px;9247+ font-family: var(--peach-mono, ui-monospace, monospace);9248+ font-size: 9.5px;9249+ text-transform: uppercase;9250+ letter-spacing: 0.12em;9251+ color: var(--peach-text-muted);9252+ padding: 8px 10px;9253+}9254+.peach-chirp-listen-dot {9255+ width: 4px; height: 4px;9256+ border-radius: 50%;9257+ background: color-mix(in srgb, var(--peach-accent) 55%, transparent);9258+ animation: peach-chirp-listen-pulse 1.2s ease-in-out infinite;9259+}9260+.peach-chirp-listen-dot:nth-child(2) { animation-delay: 0.2s; }9261+.peach-chirp-listen-dot:nth-child(3) { animation-delay: 0.4s; }9262+.peach-chirp-listen-label { margin-left: 6px; }9263+@keyframes peach-chirp-listen-pulse {9264+ 0%, 100% { opacity: 0.25; transform: scale(0.9); }9265+ 50% { opacity: 1; transform: scale(1.15); }9266+}9267+9268+.peach-chirp-action-handshake {9269+ display: flex;9270+ flex-direction: column;9271+ align-items: center;9272+ gap: 8px;9273+ padding: 8px 4px;9274+}9275+.peach-chirp-meter {9276+ width: 100%;9277+ max-width: 280px;9278+ height: 2px;9279+ background: color-mix(in srgb, var(--peach-text) 8%, transparent);9280+ border-radius: 2px;9281+ position: relative;9282+ overflow: hidden;9283+}9284+.peach-chirp-meter-bar {9285+ position: absolute;9286+ top: 0; bottom: 0;9287+ width: 30%;9288+ left: -30%;9289+ background: linear-gradient(90deg, transparent, var(--peach-accent), transparent);9290+ animation: peach-chirp-meter-sweep 1.4s ease-in-out infinite;9291+}9292+@keyframes peach-chirp-meter-sweep {9293+ 0% { left: -30%; }9294+ 100% { left: 100%; }9295+}9296+.peach-chirp-meter-label {9297+ font-family: var(--peach-mono, ui-monospace, monospace);9298+ font-size: 9.5px;9299+ text-transform: uppercase;9300+ letter-spacing: 0.14em;9301+ color: var(--peach-text-muted);9302+}9303+9304+.peach-chirp-action-paired {9305+ display: flex;9306+ align-items: center;9307+ gap: 10px;9308+}9309+.peach-chirp-device-confirm {9310+ flex: 1;9311+ display: inline-flex;9312+ align-items: center;9313+ gap: 8px;9314+ padding: 9px 10px 9px 12px;9315+ border-radius: 10px;9316+ background: color-mix(in srgb, #76b6e8 10%, transparent);9317+ border: 1px solid color-mix(in srgb, #76b6e8 26%, transparent);9318+ font-size: 12.5px;9319+ color: var(--peach-text);9320+ min-width: 0;9321+}9322+.peach-chirp-device-confirm-glyph {9323+ display: inline-flex;9324+ color: #76b6e8;9325+}9326+.peach-chirp-device-confirm-name {9327+ flex: 1;9328+ overflow: hidden;9329+ text-overflow: ellipsis;9330+ white-space: nowrap;9331+}9332+.peach-chirp-device-confirm-trust {9333+ font-family: var(--peach-mono, ui-monospace, monospace);9334+ font-size: 9.5px;9335+ text-transform: uppercase;9336+ letter-spacing: 0.1em;9337+ color: color-mix(in srgb, #76b6e8 85%, var(--peach-text));9338+ padding: 2px 6px;9339+ border-radius: 4px;9340+ background: color-mix(in srgb, #76b6e8 12%, transparent);9341+}9342+.peach-chirp-done {9343+ min-width: 80px;9344+}88709345 .peach-chirp-retry {88719346 align-self: center;9347+ display: block;9348+ margin: 6px auto 0;88729349 min-width: 120px;88739350 }9351+.peach-chirp-mic-help {9352+ font-size: 12px;9353+ color: var(--peach-text-muted);9354+ text-align: center;9355+ max-width: 320px;9356+ margin: 0 auto;9357+}88749358 8875-@keyframes peach-chirp-bob {8876- 0%, 100% { transform: translateY(0); }8877- 50% { transform: translateY(-3px); }9359+/* Footer --------------------------------------------------------------- */9360+.peach-chirp-foot {9361+ margin: 10px -4px -2px;9362+ margin-top: auto;88789363 }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); }9364+.peach-chirp-foot-divider {9365+ height: 1px;9366+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--peach-text) 14%, transparent), transparent);9367+ margin-bottom: 10px;88829368 }8883-@keyframes peach-chirp-dot-pulse {8884- 0%, 100% { transform: scale(1); }8885- 50% { transform: scale(1.18); }9369+.peach-chirp-foot-text {9370+ font-size: 11px;9371+ line-height: 1.5;9372+ color: var(--peach-text-muted);9373+ text-align: center;9374+ padding: 0 8px;9375+ text-wrap: pretty;88869376 }8887-@keyframes peach-chirp-dot-halo {8888- 0% { transform: scale(0.6); opacity: 0.6; }8889- 100% { transform: scale(2.4); opacity: 0; }9377+9378+/* Screen-reader-only live region. Kept off the visual flow but read by9379+ assistive tech as the pairing phase advances. */9380+.peach-sr-only {9381+ position: absolute;9382+ width: 1px; height: 1px;9383+ padding: 0; margin: -1px;9384+ overflow: hidden;9385+ clip: rect(0, 0, 0, 0);9386+ white-space: nowrap;9387+ border: 0;9388+}9389+9390+/* Reduced motion — stage simplifies to opacity transitions only */9391+@media (prefers-reduced-motion: reduce) {9392+ .peach-chirp-bird-body,9393+ .peach-chirp-bird-head-group,9394+ .peach-chirp-bird-beak,9395+ .peach-chirp-bird-wing,9396+ .peach-chirp-bird-tuft,9397+ .peach-chirp-bird-tail,9398+ .peach-chirp-bird-eye-group,9399+ .peach-chirp-bird-aura,9400+ .peach-chirp-arc,9401+ .peach-chirp-flight-path,9402+ .peach-chirp-flight-path-2,9403+ .peach-chirp-dot-glow,9404+ .peach-chirp-dot-core,9405+ .peach-chirp-dot-ring,9406+ .peach-chirp-mote,9407+ .peach-chirp-meter-bar,9408+ .peach-chirp-listen-dot,9409+ .peach-chirp-bird-slot,9410+ .peach-chirp-bird-slot-mobile {9411+ animation: none !important;9412+ transition: opacity 0.2s linear !important;9413+ }88909414 }88919415 88929416 /* ─────────────────────────────────────────────────────────────────