da96a6703572

feat: Polish receiver pairing flow (v4.4.3)

- Mirror Android pairing hierarchy with centered birds and QR - Accent the safety code and celebrate successful pairing in place - Continue automatically into initial sync without an Open Peach step

time
2026-07-29 01:05 UTC · 3 months ago
machine
koh2
parent
e6d0005c7660
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+237 −0src/popup/views/AdoptVaultView.test.tsx+61 −3src/popup/views/AdoptVaultView.tsx+99 −41
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.4.2",4+  "version": "4.4.3",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.4.2",5+  "version": "4.4.3",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.4.2",3+  "version": "4.4.3",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/styles/popup.css+237 −0
@@ -7127,6 +7127,16 @@71277127   place-items: center;71287128 }71297129 7130+.peach-adopt-vault--pair {7131+  padding: 16px;7132+  overflow-y: auto;7133+  scrollbar-width: none;7134+}7135+7136+.peach-adopt-vault--pair::-webkit-scrollbar {7137+  display: none;7138+}7139+71307140 .peach-adopt-card {71317141   position: relative;71327142   z-index: 1;@@ -7138,6 +7148,21 @@71387148   box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);71397149 }71407150 7151+.peach-adopt-card--pair {7152+  width: min(100%, 348px);7153+  margin: auto;7154+  padding: 22px 20px 18px;7155+  border-color: color-mix(in srgb, var(--peach-accent) 24%, var(--peach-t0) 10%);7156+  border-radius: 16px;7157+  background:7158+    radial-gradient(7159+      circle at 50% -10%,7160+      color-mix(in srgb, var(--peach-accent) 14%, transparent),7161+      transparent 38%7162+    ),7163+    color-mix(in srgb, var(--peach-surface) 94%, transparent);7164+}7165+71417166 .peach-adopt-card h1 {71427167   margin: 0 0 10px;71437168   font-size: 1.28rem;@@ -7192,6 +7217,7 @@71927217 71937218 .peach-pair-qr {71947219   display: block;7220+  margin-inline: auto;71957221   max-width: min(224px, 100%);71967222   height: auto;71977223   padding: 8px;@@ -7223,6 +7249,217 @@72237249   font-weight: 800;72247250   font-variant-numeric: tabular-nums;72257251   text-align: center;7252+}7253+7254+.peach-adopt-pairing {7255+  text-align: center;7256+}7257+7258+.peach-adopt-pairing h1 {7259+  max-width: 280px;7260+  margin: 0 auto 8px;7261+  color: var(--peach-t0);7262+  font-family: var(--font-display, Georgia, serif);7263+  font-size: 1.55rem;7264+  font-style: italic;7265+  font-weight: 500;7266+  line-height: 1.12;7267+  text-wrap: balance;7268+}7269+7270+.peach-adopt-pairing .peach-adopt-pair-lede {7271+  max-width: 285px;7272+  margin: 0 auto;7273+  color: color-mix(in srgb, var(--peach-t0) 82%, var(--peach-bg));7274+  font-size: 0.78rem;7275+  line-height: 1.5;7276+  text-wrap: pretty;7277+}7278+7279+.peach-adopt-bird-stage {7280+  width: min(100%, 288px);7281+  height: 126px;7282+  margin: -8px auto 4px;7283+}7284+7285+.peach-adopt-bird-stage .peach-pair-bird-slot,7286+.peach-adopt-bird-stage .peach-pair-bird {7287+  width: 106px;7288+  height: 106px;7289+}7290+7291+.peach-adopt-bird-stage .peach-pair-bird-slot-desktop {7292+  left: 16px;7293+}7294+7295+.peach-adopt-bird-stage .peach-pair-bird-slot-mobile {7296+  right: 16px;7297+}7298+7299+.peach-pair-stage.peach-adopt-bird-stage.act-paired .peach-pair-bird-slot-desktop {7300+  --bb-x: 28px;7301+  transform: translateX(28px);7302+}7303+7304+.peach-pair-stage.peach-adopt-bird-stage.act-paired .peach-pair-bird-slot-mobile {7305+  --bb-x: -28px;7306+  transform: translateX(-28px);7307+}7308+7309+.peach-adopt-connection {7310+  position: absolute;7311+  left: 50%;7312+  bottom: 45px;7313+  width: 62px;7314+  height: 1px;7315+  translate: -50% 0;7316+  background: color-mix(in srgb, var(--peach-accent) 62%, transparent);7317+  box-shadow: 0 0 10px color-mix(in srgb, var(--peach-accent) 56%, transparent);7318+}7319+7320+.peach-adopt-connection::after {7321+  content: "";7322+  position: absolute;7323+  left: 50%;7324+  top: 50%;7325+  width: 7px;7326+  height: 7px;7327+  border-radius: 50%;7328+  translate: -50% -50%;7329+  background: var(--peach-accent);7330+  box-shadow:7331+    0 0 0 4px color-mix(in srgb, var(--peach-accent) 17%, transparent),7332+    0 0 14px color-mix(in srgb, var(--peach-accent) 74%, transparent);7333+  animation: peach-adopt-connection-pulse 1.1s ease-in-out infinite;7334+}7335+7336+@keyframes peach-adopt-connection-pulse {7337+  0%, 100% { scale: 0.8; opacity: 0.72; }7338+  50% { scale: 1.14; opacity: 1; }7339+}7340+7341+.peach-adopt-qr-wrap {7342+  min-height: 184px;7343+  display: grid;7344+  place-items: center;7345+}7346+7347+.peach-adopt-qr-wrap .peach-hint-error {7348+  margin: 12px 0;7349+}7350+7351+.peach-adopt-pairing .peach-adopt-pair-status {7352+  margin: 0;7353+  color: var(--peach-t0);7354+  font-size: 0.75rem;7355+}7356+7357+.peach-adopt-confirmation {7358+  margin-top: 12px;7359+}7360+7361+.peach-adopt-confirmation .peach-adopt-confirmation-label {7362+  margin: 0 0 7px;7363+  color: color-mix(in srgb, var(--peach-accent) 72%, var(--peach-t0));7364+  font-family: var(--peach-mono, "IBM Plex Mono", monospace);7365+  font-size: 0.625rem;7366+  font-weight: 700;7367+  letter-spacing: 0.14em;7368+}7369+7370+.peach-adopt-confirmation .peach-adoption-sas {7371+  margin: 0 auto;7372+  padding: 11px 14px;7373+  border-color: color-mix(in srgb, var(--peach-accent) 36%, transparent);7374+  background: color-mix(in srgb, var(--peach-accent) 13%, transparent);7375+}7376+7377+.peach-adopt-confirmation .peach-adoption-sas strong {7378+  color: color-mix(in srgb, var(--peach-accent) 84%, var(--peach-t0));7379+  font-size: 1.5rem;7380+  text-shadow: 0 0 16px color-mix(in srgb, var(--peach-accent) 30%, transparent);7381+}7382+7383+.peach-adopt-confirmation .peach-adopt-confirmation-help {7384+  max-width: 270px;7385+  margin: 8px auto 0;7386+  color: color-mix(in srgb, var(--peach-t0) 78%, var(--peach-bg));7387+  font-size: 0.7rem;7388+  line-height: 1.45;7389+}7390+7391+.peach-adopt-privacy {7392+  display: flex;7393+  align-items: flex-start;7394+  gap: 9px;7395+  margin-top: 14px;7396+  padding: 10px 11px;7397+  border: 1px solid color-mix(in srgb, var(--peach-t0) 13%, transparent);7398+  border-radius: 10px;7399+  background: color-mix(in srgb, var(--peach-t0) 6%, transparent);7400+  text-align: left;7401+}7402+7403+.peach-adopt-privacy svg {7404+  width: 16px;7405+  height: 16px;7406+  flex: 0 0 auto;7407+  margin-top: 1px;7408+  fill: none;7409+  stroke: color-mix(in srgb, var(--peach-accent) 74%, var(--peach-t0));7410+  stroke-linecap: round;7411+  stroke-linejoin: round;7412+  stroke-width: 1.7;7413+}7414+7415+.peach-adopt-pairing .peach-adopt-privacy p {7416+  margin: 0;7417+  color: color-mix(in srgb, var(--peach-t0) 76%, var(--peach-bg));7418+  font-size: 0.655rem;7419+  line-height: 1.45;7420+}7421+7422+.peach-adopt-privacy strong {7423+  color: var(--peach-t0);7424+  font-weight: 700;7425+}7426+7427+.peach-adopt-pair-actions {7428+  margin-top: 12px;7429+}7430+7431+.peach-adopt-pair-actions > button {7432+  width: 100%;7433+}7434+7435+.peach-adopt-paired-status {7436+  display: inline-flex;7437+  align-items: center;7438+  gap: 8px;7439+  margin: 28px auto 22px;7440+  padding: 9px 13px;7441+  border: 1px solid color-mix(in srgb, var(--peach-accent) 30%, transparent);7442+  border-radius: 999px;7443+  background: color-mix(in srgb, var(--peach-accent) 12%, transparent);7444+  color: color-mix(in srgb, var(--peach-accent) 78%, var(--peach-t0));7445+  font-family: var(--peach-mono, "IBM Plex Mono", monospace);7446+  font-size: 0.66rem;7447+  font-weight: 700;7448+  letter-spacing: 0.04em;7449+}7450+7451+.peach-adopt-paired-status span {7452+  width: 7px;7453+  height: 7px;7454+  border-radius: 50%;7455+  background: var(--peach-accent);7456+  box-shadow: 0 0 8px color-mix(in srgb, var(--peach-accent) 68%, transparent);7457+}7458+7459+@media (prefers-reduced-motion: reduce) {7460+  .peach-adopt-connection::after {7461+    animation: none;7462+  }72267463 }72277464 72287465 .peach-onboarding-mobile-card-buttons {

view file

src/popup/views/AdoptVaultView.test.tsx+61 −3
@@ -87,8 +87,10 @@8787     // The browser is the source of the QR now — the phone scans it.8888     // We assert the heading + copy; the actual QR is rendered by a background8989     // call that's out of scope for a jsdom test (chrome.runtime is undefined).90-    expect(view.container.textContent).toContain("Pair with your other device");91-    expect(view.container.textContent).toContain("Scan on the device that holds your vault");90+    expect(view.container.textContent).toContain("Pair a device");91+    expect(view.container.textContent).toContain("Scan this QR in Peach");92+    expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();93+    expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);9294     // The scan button from the old flow is gone — the phone is the scanner now.9395     expect(view.container.textContent).not.toContain("Enter invitation manually");9496   });@@ -136,7 +138,7 @@136138     await flushView();137139 138140     expect(view.container.textContent).toContain("48219371");139-    expect(view.container.textContent).toContain("Pairing continues when you confirm there");141+    expect(view.container.textContent).toContain("Confirm this code on your phone");140142     expect(view.container.textContent).not.toContain("They match");141143     expect(view.container.textContent).not.toContain("They don't match");142144 @@ -152,5 +154,61 @@152154     expect(view.container.textContent).toContain("at least one of these two devices");153155     expect(view.container.textContent).toContain("$39 once");154156     expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();157+  });158+159+  it("continues straight to initial sync when receiver pairing completes", async () => {160+    type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;161+    const listeners: RuntimeListener[] = [];162+    Object.defineProperty(globalThis, "chrome", {163+      configurable: true,164+      value: {165+        runtime: {166+          lastError: undefined,167+          sendMessage(168+            message: { type?: string },169+            callback: (response: unknown) => void,170+          ) {171+            if (message.type === "START_ADOPTION_RECEIVER") {172+              callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });173+            } else {174+              callback({ ok: true });175+            }176+          },177+          onMessage: {178+            addListener(fn: RuntimeListener) { listeners.push(fn); },179+            removeListener(fn: RuntimeListener) {180+              const index = listeners.indexOf(fn);181+              if (index >= 0) listeners.splice(index, 1);182+            },183+          },184+        },185+      },186+    });187+    const onDone = vi.fn();188+    const view = mounted(() => (189+      <AdoptVaultView onCancel={() => {}} onDone={onDone} />190+    ));191+    await enterStrongPassword(view.container);192+    button(view.container, "Continue").click();193+    await flushView();194+195+    for (const listener of listeners) {196+      listener({197+        type: "ADOPTION_SOURCE_RESULT",198+        payload: { status: "complete" },199+      });200+    }201+202+    expect(onDone).not.toHaveBeenCalled();203+    expect(view.container.textContent).toContain("Paired");204+    expect(view.container.textContent).toContain("Secure connection established");205+    expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();206+    expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);207+    expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();208+    expect(view.container.textContent).not.toContain("Joined your vault");209+    expect(view.container.textContent).not.toContain("Open Peach");210+211+    await new Promise((resolve) => window.setTimeout(resolve, 1_050));212+    expect(onDone).toHaveBeenCalledOnce();155213   });156214 });

view file

src/popup/views/AdoptVaultView.tsx+99 −41
@@ -8,12 +8,13 @@88   onCleanup,99 } from "solid-js";1010 import { sendMessage } from "../App";11+import { DesktopBird, MobileBird } from "../components/PairingBirds";1112 import { RuneBackground } from "../components/RuneBackground";1213 import { PairingQrCode } from "../components/PairingQrCode";1314 import { PairingProRequired } from "../components/PairingProRequired";1415 import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1516 16-type AdoptPhase = "password" | "pair" | "success" | "error";17+type AdoptPhase = "password" | "pair" | "error";1718 1819 export interface AdoptVaultViewProps {1920   onCancel(): void;@@ -22,6 +23,7 @@2223 2324 const MIN_LENGTH = 12;2425 const MIN_ZXCVBN_SCORE = 2;26+const PAIRING_CELEBRATION_MS = 1_000;2527 2628 let zxcvbnLoader: ((password: string) => { score: number }) | null = null;2729 async function getZxcvbn() {@@ -48,8 +50,11 @@4850   const [receiverInvitation, setReceiverInvitation] = createSignal("");4951   const [receiverSas, setReceiverSas] = createSignal("");5052   const [receiverError, setReceiverError] = createSignal("");53+  const [receiverComplete, setReceiverComplete] = createSignal(false);54+  let completionTimer: number | undefined;5155 5256   onCleanup(() => {57+    if (completionTimer !== undefined) window.clearTimeout(completionTimer);5358     // Tell the background to release the relay room if we still hold it.5459     void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);5560   });@@ -91,7 +96,12 @@9196           error?: string;9297         } | undefined;9398         if (p?.status === "complete") {94-          setPhase("success");99+          if (receiverComplete()) return;100+          setReceiverComplete(true);101+          completionTimer = window.setTimeout(() => {102+            completionTimer = undefined;103+            void props.onDone();104+          }, PAIRING_CELEBRATION_MS);95105         } else if (p?.status === "error") {96106           setReceiverError(p.error ?? "adoption_failed");97107           setPhase("error");@@ -141,9 +151,15 @@141151   }142152 143153   return (144-    <section class="peach-screen peach-theme peach-adopt-vault">154+    <section155+      class="peach-screen peach-theme peach-adopt-vault"156+      classList={{ "peach-adopt-vault--pair": phase() === "pair" }}157+    >145158       <RuneBackground opacity={0.07} />146-      <div class="peach-adopt-card">159+      <div160+        class="peach-adopt-card"161+        classList={{ "peach-adopt-card--pair": phase() === "pair" }}162+      >147163         <Switch>148164           <Match when={phase() === "password"}>149165             <h1>Set a password for this device.</h1>@@ -184,46 +200,88 @@184200           </Match>185201 186202           <Match when={phase() === "pair"}>187-            <h1>Pair with your other device.</h1>188-            <p>Scan on the device that holds your vault. It will send the vault here.</p>189-            <Show190-              when={receiverInvitation()}191-              fallback={192-                <Show193-                  when={receiverError()}194-                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}195-                >196-                  <p class="peach-hint peach-hint-error">{receiverError()}</p>197-                </Show>198-              }203+            <div204+              class="peach-adopt-pairing"205+              classList={{ "is-complete": receiverComplete() }}199206             >200-              <PairingQrCode value={receiverInvitation()} size={160} />201-            </Show>202-            <Show when={receiverSas()}>203-              <p class="peach-hint">204-                Make sure this matches the 8-digit safety code on your phone.205-                Pairing continues when you confirm there.206-              </p>207-              <div class="peach-adoption-sas" aria-label="Pairing confirmation code">208-                <strong>{receiverSas()}</strong>207+              <div208+                class={`peach-pair-stage peach-adopt-bird-stage ${209+                  receiverComplete()210+                    ? "act-paired"211+                    : receiverSas()212+                      ? "act-handshake"213+                      : "act-listening"214+                }`}215+                aria-hidden="true"216+              >217+                <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">218+                  <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />219+                </div>220+                <Show when={receiverComplete()}>221+                  <span class="peach-adopt-connection" />222+                </Show>223+                <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">224+                  <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />225+                </div>209226               </div>210-            </Show>211-            <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>212-            <div class="peach-adopt-actions">213-              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>214-            </div>215-          </Match>216227 217-          <Match when={phase() === "success"}>218-            <h1>Joined your vault.</h1>219-            <p>220-              This extension now holds the same vault as your other221-              devices. You can unlock it with the password you just set.222-            </p>223-            <div class="peach-adopt-actions">224-              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>225-                Open Peach226-              </button>228+              <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>229+              <p class="peach-adopt-pair-lede" aria-live="polite">230+                {receiverComplete()231+                  ? "Your devices are connected. Getting your vault ready…"232+                  : "Scan this QR in Peach on the device that already has your vault."}233+              </p>234+235+              <Show236+                when={!receiverComplete()}237+                fallback={238+                  <div class="peach-adopt-paired-status" role="status">239+                    <span />240+                    Secure connection established241+                  </div>242+                }243+              >244+                <div class="peach-adopt-qr-wrap" aria-live="polite">245+                  <Show246+                    when={receiverInvitation()}247+                    fallback={248+                      <Show249+                        when={receiverError()}250+                        fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}251+                      >252+                        <p class="peach-hint peach-hint-error">{receiverError()}</p>253+                      </Show>254+                    }255+                  >256+                    <PairingQrCode value={receiverInvitation()} size={168} />257+                  </Show>258+                </div>259+260+                <Show when={receiverSas()}>261+                  <div class="peach-adopt-confirmation">262+                    <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>263+                    <div class="peach-adoption-sas" aria-label="Pairing confirmation code">264+                      <strong>{receiverSas()}</strong>265+                    </div>266+                    <p class="peach-adopt-confirmation-help">267+                      Confirm this code on your phone to finish pairing.268+                    </p>269+                  </div>270+                </Show>271+272+                <div class="peach-adopt-privacy">273+                  <svg viewBox="0 0 24 24" aria-hidden="true">274+                    <path d="M7 10V8a5 5 0 0 1 10 0v2" />275+                    <rect x="5" y="10" width="14" height="10" rx="3" />276+                    <path d="M12 14v2" />277+                  </svg>278+                  <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>279+                </div>280+281+                <div class="peach-adopt-actions peach-adopt-pair-actions">282+                  <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>283+                </div>284+              </Show>227285             </div>228286           </Match>229287 

view file