ChirpPairingFlow.tsx

23.7 KB · raw

1// ChirpPairingFlow — the audio pairing screen.2//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 together9//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.2223import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";24import type { PairingPhase, PairingProgress } from "../../shared/sync-types";2526export interface ChirpPairingFlowProps {27  progress: PairingProgress;28  /** Called when the user taps Retry on a terminal-error state. */29  onRetry: () => void;30  /** Called when the user taps Cancel/back on the idle/listening state. */31  onCancel: () => void;32  /** Called once the paired-state dismissal animation completes. */33  onDismiss: () => void;34}3536const 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.39const 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  // 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  });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      {/* 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>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}172173// ---------------------------------------------------------------------------174// Header, chip row, footer175// ---------------------------------------------------------------------------176177const 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);221222// ---------------------------------------------------------------------------223// Copy + action area (animates on phase change via key remount)224// ---------------------------------------------------------------------------225226const PairingCopy: Component<{ progress: PairingProgress }> = (props) => {227  // Remount on phase change so the copy-in keyframe replays.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}335336// ---------------------------------------------------------------------------337// Stage children — chirp arcs, flight wave, connection dot338// ---------------------------------------------------------------------------339340const 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);370371// 8-mote helper. createMemo over a fixed array is overkill — index is enough.372const 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);384385// ---------------------------------------------------------------------------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// ---------------------------------------------------------------------------391392const 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};