da96a6703572
feat: Polish receiver pairing flow (v4.4.3)
- time
- 2026-07-29 01:05 UTC · 3 months ago
- machine
- koh2
- parent
- e6d0005c7660
↓ peach-browser-da96a670.tar.gzatlas at this save
files changed6 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 −41manifest.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": {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",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",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 {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 });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