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
6 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 −4
manifest.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": {

view file

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",

view file

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",

view file

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 }

view file

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.

view file

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 —

view file