c626a5aa8656
put 'Show a code instead' under the QR: the same invitation as selectable text with a copy button, for a camera that will not focus (v4.2.5)
- time
- 2026-07-26 16:57 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync settings ux and relay surfacing
- parent
- 25d58a4fe204
↓ peach-browser-c626a5aa.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+16 −0src/popup/views/PairDevice.test.tsx+23 −3src/popup/views/PairDevice.tsx+41 −4manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.2.4",4+ "version": "4.2.5",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 Password Manager",4- "version": "4.2.4",4+ "version": "4.2.5",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": "4.2.4",3+ "version": "4.2.5",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/styles/popup.css+16 −0
@@ -7168,6 +7168,22 @@71687168 background: #fff;71697169 }71707170 7171+/* The same invitation the QR carries, as text — the fallback when a camera7172+ won't cooperate. Monospace and full width so it can be read out loud. */7173+.peach-pair-invitation-text {7174+ width: 100%;7175+ resize: none;7176+ padding: 8px 10px;7177+ border-radius: 10px;7178+ border: 1px solid var(--peach-border);7179+ background: color-mix(in srgb, var(--peach-t0) 3%, transparent);7180+ color: var(--peach-t1);7181+ font-family: var(--peach-mono, ui-monospace, SFMono-Regular, Menlo, monospace);7182+ font-size: 10px;7183+ line-height: 1.5;7184+ word-break: break-all;7185+}7186+71717187 .peach-adopt-card > .peach-btn + .peach-btn {71727188 margin-top: 8px;71737189 }src/popup/views/PairDevice.test.tsx+23 −3
@@ -88,11 +88,31 @@8888 type: "START_ADOPTION_SOURCE",8989 });9090 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();91- // The invitation is carried by the QR alone — the manual textarea and its92- // Copy button duplicated the pairing-code path and were removed.91+ // The QR is the default — the text form is behind an explicit fallback.9392 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();94- expect(container.textContent).not.toContain("or copy the invitation");9593 expect(container.textContent).toContain("single-use token");94+ });95+96+ it("offers the same invitation as text when a camera won't cooperate", async () => {97+ const { container } = mount();98+ await vi.waitFor(() => {99+ expect(container.textContent).toContain("Scan this in Peach");100+ });101+102+ const toggle = () =>103+ [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) =>104+ /Show a code instead|Show the QR instead/.test(b.textContent ?? ""),105+ )!;106+ toggle().click();107+108+ const text = container.querySelector<HTMLTextAreaElement>("#pairing-invitation-manual");109+ expect(text?.value).toBe(SOURCE_INVITATION);110+ // One invitation, two renderings — never two live codes on screen.111+ expect(container.querySelector("svg.peach-pair-qr")).toBeNull();112+113+ toggle().click();114+ expect(container.querySelector("#pairing-invitation-manual")).toBeNull();115+ expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();96116 });97117 98118 // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.src/popup/views/PairDevice.tsx+41 −4
@@ -6,9 +6,11 @@66 //77 // There is one path because there can only be one: a browser extension cannot88 // listen and has no camera it can point at anything, so it is always the side9-// that *displays* an invitation for another device to scan. LAN pairing codes10-// are retired along with LAN sync (docs/sync-lan-retirement.md), so there is no11-// code to type here either.9+// that *displays* an invitation for another device to scan. The invitation can10+// be shown as a QR or as its text, which are the same string — the text is the11+// fallback when a camera won't cooperate, not a second pairing mode. LAN12+// pairing codes are retired along with LAN sync13+// (docs/sync-lan-retirement.md), so there is nothing to type in here.1214 //1315 // The pairing itself runs in the background (relay-adoption-source-bg.ts) and1416 // reaches this view via the ADOPTION_SOURCE_* messages.@@ -43,12 +45,15 @@4345 const [receiverName, setReceiverName] = createSignal("New device");4446 const [shareError, setShareError] = createSignal("");4547 const [changingRelay, setChangingRelay] = createSignal(false);48+ const [showTextCode, setShowTextCode] = createSignal(false);49+ const [copied, setCopied] = createSignal(false);4650 4751 async function startShareFlow(): Promise<void> {4852 setSharePhase("opening");4953 setShareError("");5054 setInvitation("");5155 setSourceSas("");56+ setCopied(false);5257 try {5358 const response = (await sendMessage({5459 type: "START_ADOPTION_SOURCE",@@ -211,7 +216,39 @@211216 Open Peach on your phone, choose “Scan a code”, and point it here.212217 The code carries only the relay address and a single-use token.213218 </div>214- <PairingQrCode value={invitation()} size={188} />219+ <Show220+ when={showTextCode()}221+ fallback={<PairingQrCode value={invitation()} size={188} />}222+ >223+ {/* Fallback for a camera that won't focus, a phone with no224+ camera permission, or pairing two windows on one machine. */}225+ <textarea226+ id="pairing-invitation-manual"227+ class="peach-pair-invitation-text"228+ readonly229+ rows={4}230+ aria-label="Pairing invitation"231+ onFocus={(event) => (event.target as HTMLTextAreaElement).select()}232+ >{invitation()}</textarea>233+ </Show>234+ <button235+ class="peach-btn peach-btn--ghost peach-btn--sm"236+ onClick={() => {237+ setCopied(false);238+ setShowTextCode(!showTextCode());239+ }}240+ >241+ {showTextCode() ? "Show the QR instead" : "Show a code instead"}242+ </button>243+ <Show when={showTextCode()}>244+ <button245+ class="peach-btn peach-btn--subtle peach-btn--sm"246+ onClick={() => {247+ void navigator.clipboard?.writeText(invitation()).catch(() => undefined);248+ setCopied(true);249+ }}250+ >{copied() ? "copied" : "copy"}</button>251+ </Show>215252 </Show>216253 {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs217254 here because a relay is about to be used, but it is not a step —