964d7e074a21

rewrite Export.tsx and Import.tsx as unified screens — PeachScript primary, QR + CSV secondary, legacy CJK collapsed in import

time
2026-04-27 16:07 UTC · 6 months ago
machine
koh2
parent
ab37b5888aca
6 files src/popup/App.tsx+1 −1src/popup/styles/popup.css+70 −0src/popup/styles/vars.css+8 −0src/popup/views/Export.tsx+499 −225src/popup/views/Import.tsx+200 −47src/popup/views/contracts.test.tsx+12 −66
src/popup/App.tsx+1 −1
@@ -1011,7 +1011,7 @@10111011             </Show>10121012             <Show when={view() === "export"}>10131013               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1014-                <ExportView onBack={handleBack} onRequiresUnlock={handleLock} />1014+                <ExportView onBack={handleBack} />10151015               </Suspense>10161016             </Show>10171017             <Show when={view() === "health"}>

view file

src/popup/styles/popup.css+70 −0
@@ -1575,6 +1575,76 @@15751575   display: grid;15761576   gap: 10px;15771577 }1578+.peach-script-string {1579+  font-family: "PeachScript", monospace;1580+  font-size: 11px;1581+  line-height: 1.9;1582+  letter-spacing: 0.06em;1583+  color: var(--peach-t0);1584+  word-break: break-all;1585+  user-select: all;1586+  background: var(--peach-input-bg, var(--surface-card));1587+  border: 1px solid var(--peach-t3);1588+  border-radius: 6px;1589+  padding: 12px;1590+  margin: 0 0 8px;1591+  max-height: 180px;1592+  overflow-y: auto;1593+  white-space: pre-wrap;1594+}1595+.peach-script-input {1596+  font-family: "PeachScript", monospace;1597+  font-size: 11px;1598+  line-height: 1.9;1599+  letter-spacing: 0.06em;1600+  color: var(--peach-t0);1601+  background: var(--peach-input-bg, var(--surface-card));1602+  border: 1px solid var(--peach-t3);1603+  border-radius: 6px;1604+  padding: 12px;1605+  width: 100%;1606+  min-height: 96px;1607+  resize: vertical;1608+  word-break: break-all;1609+}1610+.peach-qr-canvas-wrap {1611+  display: flex;1612+  justify-content: center;1613+  padding: 12px 0 6px;1614+}1615+.peach-qr-canvas-wrap canvas {1616+  display: block;1617+  background: #ffffff;1618+  border-radius: 6px;1619+  max-width: 100%;1620+  height: auto;1621+}1622+.peach-csv-warning {1623+  background: color-mix(in srgb, var(--peach-breach) 14%, transparent);1624+  border: 1px solid color-mix(in srgb, var(--peach-breach) 40%, transparent);1625+  color: var(--peach-t0);1626+  border-radius: 6px;1627+  padding: 10px 12px;1628+  margin-bottom: 8px;1629+  font-size: 12px;1630+  line-height: 1.45;1631+}1632+.peach-legacy-cjk-summary {1633+  cursor: pointer;1634+  color: var(--peach-t2);1635+  font-family: "IBM Plex Mono", monospace;1636+  font-size: 10px;1637+  text-transform: uppercase;1638+  letter-spacing: 0.14em;1639+  padding: 6px 0;1640+}1641+.peach-legacy-cjk-note {1642+  color: var(--peach-t2);1643+  font-size: 11px;1644+  line-height: 1.5;1645+  margin: 6px 0 0;1646+}1647+15781648 .peach-export-card--primary {15791649   background:15801650     linear-gradient(180deg, color-mix(in srgb, var(--peach-accent) 8%, transparent), color-mix(in srgb, var(--peach-accent) 2%, transparent));

view file

src/popup/styles/vars.css+8 −0
@@ -70,6 +70,14 @@7070   font-display: optional;7171 }7272 73+@font-face {74+  font-family: "PeachScript";75+  font-weight: 400;76+  font-style: normal;77+  src: url("/fonts/PeachScript-Regular.otf") format("opentype");78+  font-display: block;79+}80+7381 :root {7482   --bg: #0C0A06;7583   --s1: #131109;

view file

src/popup/views/Export.tsx+499 −225
@@ -1,307 +1,581 @@11 // Signal-conditioned views in this file must use <Show>, never a22 // signal-conditioned early return (`if (signal()) return <X/>`). Early33 // returns in SolidJS run once at mount and never re-execute when signals4-// change — the v1.4.21 CJK export regression silently stopped re-rendering5-// because of this. See Projects.md §4.1.4+// change — see Projects.md §4.1.65 import { createMemo, createSignal, Show } from "solid-js";6+77 import { sendMessage } from "../App";8+import { buildPayload } from "../../crypto/peachPayload";9+import { encodePeachScript } from "../../crypto/peachScript";10+import {11+  QR_MAX_BYTES,12+  renderPeachQR,13+  renderPeachQRDataURL,14+} from "../../crypto/peachQR";815 import type { PeachLicenseExport, VaultEntry } from "../../shared/types";9-import { exportEntriesToPeachJson } from "../../shared/backup";10-import { CJKExportView } from "./CJKExport";1116 17+type ExportSubview = "menu" | "peachscript" | "qr" | "csv";18+1219 interface ExportProps {1320   onBack: () => void;14-  onRequiresUnlock?: () => void;1521 }1622 17-const MIN_EXPORT_PASSWORD = 12;23+const MIN_BACKUP_PASSWORD = 8;1824 1925 export function ExportView(props: ExportProps) {20-  const [showCjkExport, setShowCjkExport] = createSignal(false);26+  const [view, setView] = createSignal<ExportSubview>("menu");2127   return (22-    <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>23-      <CJKExportView24-        onBack={() => setShowCjkExport(false)}25-        onRequiresUnlock={props.onRequiresUnlock}26-      />27-    </Show>28+    <div class="peach-overlay-view ov-enter-right" style="display:flex">29+      <Show when={view() === "menu"}>30+        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />31+      </Show>32+      <Show when={view() === "peachscript"}>33+        <PeachScriptExport onBack={() => setView("menu")} />34+      </Show>35+      <Show when={view() === "qr"}>36+        <QRExport onBack={() => setView("menu")} />37+      </Show>38+      <Show when={view() === "csv"}>39+        <CSVExport onBack={() => setView("menu")} />40+      </Show>41+    </div>2842   );2943 }3044 31-function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {32-  const [exportPassword, setExportPassword] = createSignal("");33-  const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");34-  const [exporting, setExporting] = createSignal(false);35-  const [result, setResult] = createSignal<string | null>(null);45+// ---------------------------------------------------------------------------46+// Menu47+// ---------------------------------------------------------------------------48+49+function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {50+  return (51+    <>52+      <OverlayHeader title="export" onBack={props.onBack} />53+      <div class="peach-settings-body">54+        <div class="peach-settings-group peach-export-card peach-export-card--primary">55+          <p class="peach-settings-label">peachscript</p>56+          <p class="peach-export-card-copy">57+            Your vault as a string of Peach symbols you can paste into Peach58+            on another device. Designed for Peach-to-Peach transfer.59+          </p>60+          <button61+            class="peach-btn peach-btn--subtle peach-btn--sm"62+            onClick={() => props.onPick("peachscript")}63+          >64+            Create PeachScript →65+          </button>66+        </div>67+68+        <div class="peach-settings-group peach-export-card">69+          <p class="peach-settings-label">print backup</p>70+          <p class="peach-export-card-copy">71+            Save a QR code you can print and store. Scan it with Peach on any72+            device to restore.73+          </p>74+          <button75+            class="peach-btn peach-btn--subtle peach-btn--sm"76+            onClick={() => props.onPick("qr")}77+          >78+            Save QR code →79+          </button>80+        </div>81+82+        <div class="peach-settings-group peach-export-card">83+          <p class="peach-settings-label">CSV (leaving peach)</p>84+          <p class="peach-export-card-copy">85+            Plain-text export for moving into another password manager. Anyone86+            with the file can read your passwords.87+          </p>88+          <button89+            class="peach-btn peach-btn--ghost peach-btn--sm"90+            onClick={() => props.onPick("csv")}91+          >92+            Export as CSV →93+          </button>94+        </div>95+      </div>96+    </>97+  );98+}99+100+// ---------------------------------------------------------------------------101+// PeachScript102+// ---------------------------------------------------------------------------103+104+function PeachScriptExport(props: { onBack: () => void }) {105+  const [pass, setPass] = createSignal("");106+  const [confirm, setConfirm] = createSignal("");107+  const [working, setWorking] = createSignal(false);108+  const [script, setScript] = createSignal<string | null>(null);36109   const [error, setError] = createSignal<string | null>(null);37-  const [showCsvWarning, setShowCsvWarning] = createSignal(false);110+  const [entryCount, setEntryCount] = createSignal(0);111+  const [stats, setStats] = createSignal<{112+    cards: number;113+    passkeys: number;114+    notes: number;115+    logins: number;116+  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });117+  const [copied, setCopied] = createSignal(false);38118 39-  const passwordsMatch = createMemo(40-    () => exportPassword().length >= MIN_EXPORT_PASSWORD41-      && exportPassword() === exportPasswordConfirm()119+  const passReady = createMemo(120+    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()42121   );43122 44-  async function handleEncryptedExport() {123+  async function handleEncode() {45124     setError(null);46-    setResult(null);47-48-    if (!passwordsMatch()) {49-      setError("Passwords do not match or are too short.");125+    setScript(null);126+    setCopied(false);127+    if (!passReady()) {128+      setError("Backup passwords don't match or are too short.");50129       return;51130     }131+    setWorking(true);132+    try {133+      const env = await assembleEnvelope(pass());134+      setEntryCount(env.entryCount);135+      setStats(env.stats);136+      setScript(encodePeachScript(env.bytes));137+    } catch (err) {138+      setError(err instanceof Error ? err.message : String(err));139+    } finally {140+      setWorking(false);141+    }142+  }52143 53-    setExporting(true);144+  async function handleCopy() {145+    const s = script();146+    if (!s) return;147+    try {148+      await navigator.clipboard.writeText(s);149+      setCopied(true);150+      setTimeout(() => setCopied(false), 2000);151+    } catch (err) {152+      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");153+    }154+  }54155 55-    const response = await sendMessage({ type: "GET_ENTRIES" });56-    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];156+  return (157+    <>158+      <OverlayHeader title="peachscript" onBack={props.onBack} />159+      <div class="peach-settings-body">160+        <Show161+          when={script()}162+          fallback={163+            <div class="peach-settings-group peach-export-card peach-export-card--primary">164+              <p class="peach-settings-label">backup password</p>165+              <p class="peach-export-card-copy">166+                Choose a password to lock this PeachScript. You'll need it167+                to restore on another device.168+              </p>169+              <div class="peach-settings-stack peach-export-password-stack">170+                <input171+                  class="peach-settings-input"172+                  type="password"173+                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}174+                  value={pass()}175+                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}176+                />177+                <input178+                  class="peach-settings-input"179+                  type="password"180+                  placeholder="confirm backup password"181+                  value={confirm()}182+                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}183+                />184+                <Show when={confirm().length > 0 && pass() !== confirm()}>185+                  <p class="peach-export-note" style="color:var(--peach-t2)">186+                    Passwords don't match187+                  </p>188+                </Show>189+                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>190+                  <p class="peach-export-note" style="color:var(--peach-t2)">191+                    At least {MIN_BACKUP_PASSWORD} characters192+                  </p>193+                </Show>194+              </div>195+              <button196+                class="peach-btn peach-btn--subtle peach-btn--sm"197+                onClick={() => void handleEncode()}198+                disabled={working() || !passReady()}199+              >200+                {working() ? "encoding…" : "Generate PeachScript"}201+              </button>202+            </div>203+          }204+        >205+          <div class="peach-settings-group peach-export-card peach-export-card--primary">206+            <p class="peach-settings-label">peachscript</p>207+            <div class="peach-script-string">{script()}</div>208+            <p class="peach-export-card-copy" style="margin:0 0 8px">209+              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}210+              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}211+              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}212+              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}213+            </p>214+            <button215+              class="peach-btn peach-btn--subtle peach-btn--sm"216+              onClick={() => void handleCopy()}217+            >218+              {copied() ? "copied ✓" : "Copy PeachScript"}219+            </button>220+            <p class="peach-export-card-copy" style="margin-top:8px">221+              Paste this into Peach on your other device to restore your222+              vault. It won't work anywhere else.223+            </p>224+          </div>225+        </Show>226+        <Show when={error()}>227+          <div class="peach-settings-group">228+            <div class="peach-restore-error">{error()}</div>229+          </div>230+        </Show>231+      </div>232+    </>233+  );234+}57235 58-    if (entries.length === 0) {59-      setError("Your vault has no entries to export.");60-      setExporting(false);61-      return;62-    }236+// ---------------------------------------------------------------------------237+// QR238+// ---------------------------------------------------------------------------63239 64-    const trashedCount = entries.filter((e) => !!e.deletedAt).length;65-    const activeCount = entries.length - trashedCount;66-    const countStr = trashedCount > 067-      ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`68-      : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;240+function QRExport(props: { onBack: () => void }) {241+  const [pass, setPass] = createSignal("");242+  const [confirm, setConfirm] = createSignal("");243+  const [working, setWorking] = createSignal(false);244+  const [pngUrl, setPngUrl] = createSignal<string | null>(null);245+  const [error, setError] = createSignal<string | null>(null);246+  const [entryCount, setEntryCount] = createSignal(0);247+  let canvasHost: HTMLDivElement | undefined;69248 249+  const passReady = createMemo(250+    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()251+  );252+253+  async function handleEncode() {254+    setError(null);255+    setPngUrl(null);256+    if (!passReady()) {257+      setError("Backup passwords don't match or are too short.");258+      return;259+    }260+    setWorking(true);70261     try {71-      // Include license in the export if Pro, so a restore reactivates automatically.72-      let license: PeachLicenseExport | undefined;73-      const licenseRes = await sendMessage({ type: "GET_LICENSE" });74-      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;75-      if (licenseInfo?.valid && licenseInfo.plan === "pro") {76-        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });77-        const keyStr = (keyRes as { ok: boolean; data?: string }).data;78-        if (keyStr) {79-          license = {80-            key: keyStr,81-            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),82-          };83-        }262+      const env = await assembleEnvelope(pass());263+      if (env.bytes.length > QR_MAX_BYTES) {264+        setError(265+          `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`266+        );267+        return;84268       }85-86-      const blob = await exportEntriesToPeachJson(entries, exportPassword(), license);87-      const filename = `peach-backup-${dateStamp()}.json`;88-      downloadBlob(89-        JSON.stringify(blob, null, 2),90-        "application/json",91-        filename92-      );93-      setResult(94-        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`95-      );96-      setExportPassword("");97-      setExportPasswordConfirm("");269+      setEntryCount(env.entryCount);270+      const canvas = await renderPeachQR(env.bytes);271+      const url = await renderPeachQRDataURL(env.bytes);272+      if (canvasHost) {273+        canvasHost.innerHTML = "";274+        canvasHost.appendChild(canvas);275+      }276+      setPngUrl(url);98277     } catch (err) {99-      setError(err instanceof Error ? err.message : "Export failed.");278+      setError(err instanceof Error ? err.message : String(err));100279     } finally {101-      setExporting(false);280+      setWorking(false);102281     }103282   }104283 105-  async function handleCSVExport() {106-    setShowCsvWarning(false);107-    setExporting(true);108-    setResult(null);109-    setError(null);284+  function handleSave() {285+    const url = pngUrl();286+    if (!url) return;287+    const a = document.createElement("a");288+    a.href = url;289+    a.download = `peach-qr-backup-${dateStamp()}.png`;290+    document.body.appendChild(a);291+    a.click();292+    document.body.removeChild(a);293+  }110294 111-    const response = await sendMessage({ type: "GET_ENTRIES" });112-    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];113-    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");114-    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;295+  return (296+    <>297+      <OverlayHeader title="print backup" onBack={props.onBack} />298+      <div class="peach-settings-body">299+        <Show300+          when={pngUrl()}301+          fallback={302+            <div class="peach-settings-group peach-export-card peach-export-card--primary">303+              <p class="peach-settings-label">backup password</p>304+              <p class="peach-export-card-copy">305+                Set a password to lock this QR backup. You'll need it to306+                restore — without it the printed code is unreadable.307+              </p>308+              <div class="peach-settings-stack peach-export-password-stack">309+                <input310+                  class="peach-settings-input"311+                  type="password"312+                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}313+                  value={pass()}314+                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}315+                />316+                <input317+                  class="peach-settings-input"318+                  type="password"319+                  placeholder="confirm backup password"320+                  value={confirm()}321+                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}322+                />323+              </div>324+              <button325+                class="peach-btn peach-btn--subtle peach-btn--sm"326+                onClick={() => void handleEncode()}327+                disabled={working() || !passReady()}328+              >329+                {working() ? "encoding…" : "Generate QR code"}330+              </button>331+            </div>332+          }333+        >334+          <div class="peach-settings-group peach-export-card peach-export-card--primary">335+            <div class="peach-qr-canvas-wrap" ref={canvasHost} />336+            <p class="peach-export-card-copy" style="text-align:center">337+              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}338+            </p>339+            <button340+              class="peach-btn peach-btn--subtle peach-btn--sm"341+              onClick={handleSave}342+            >343+              Save PNG344+            </button>345+            <p class="peach-export-card-copy" style="margin-top:8px">346+              Print this and keep it somewhere safe. Scan it with Peach to347+              restore your vault on any device.348+            </p>349+          </div>350+        </Show>351+        <Show when={error()}>352+          <div class="peach-settings-group">353+            <div class="peach-restore-error">{error()}</div>354+          </div>355+        </Show>356+      </div>357+    </>358+  );359+}115360 116-    if (active.length === 0) {117-      setError("Your vault has no entries to export.");118-      setExporting(false);119-      return;120-    }361+// ---------------------------------------------------------------------------362+// CSV — non-skippable warning, every time, by design (spec §5.2).363+// ---------------------------------------------------------------------------121364 122-    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];123-    const rows = active.map((e) => {124-      const type = e.type === "note" ? "note" : "login";125-      return [126-        type,127-        e.title,128-        e.url ?? "",129-        e.username ?? "",130-        e.password ?? "",131-        e.totp ?? "",132-        Array.isArray(e.tags) ? e.tags.join("|") : "",133-        e.notes ?? "",134-      ]135-        // Flatten embedded newlines before RFC 4180 quote escaping so the136-        // CSV stays single-line-per-row for tools that do not handle quoted137-        // multi-line cells.138-        .map((v) =>139-          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`140-        )141-        .join(",");142-    });143-    const csv = [headers.join(","), ...rows].join("\n");365+function CSVExport(props: { onBack: () => void }) {366+  const [acknowledged, setAcknowledged] = createSignal(false);367+  const [working, setWorking] = createSignal(false);368+  const [error, setError] = createSignal<string | null>(null);369+  const [done, setDone] = createSignal<string | null>(null);144370 145-    const filename = `peach-passwords-${dateStamp()}.csv`;371+  async function handleExport() {372+    setError(null);373+    setDone(null);374+    setWorking(true);146375     try {376+      const res = await sendMessage({ type: "GET_ENTRIES" });377+      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];378+      const active = entries.filter(379+        (e) => e.deletedAt === undefined && e.type !== "passkey"380+      );381+      const excludedPasskeys = entries.filter(382+        (e) => e.deletedAt === undefined && e.type === "passkey"383+      ).length;384+385+      if (active.length === 0) {386+        setError("Your vault has no entries to export.");387+        return;388+      }389+390+      const csv = buildCsv(active);391+      const filename = `peach-export-${dateStamp()}.csv`;147392       downloadBlob(csv, "text/csv", filename);148393       const suffix = excludedPasskeys > 0149-        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`394+        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`150395         : "";151-      setResult(152-        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} as CSV. Filename: ${filename}.${suffix} Store this file somewhere safe — the passwords inside are in plain text.`396+      setDone(397+        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`153398       );154399     } catch (err) {155-      setError(err instanceof Error ? err.message : "Export failed.");400+      setError(err instanceof Error ? err.message : String(err));156401     } finally {157-      setExporting(false);402+      setWorking(false);158403     }159404   }160405 161406   return (162-    <div class="peach-overlay-view ov-enter-right" style="display:flex">163-      <div class="peach-overlay-header">164-        <button165-          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"166-          onClick={props.onBack}167-          aria-label="Back"168-        >169-          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">170-            <path d="M6 1L1 6L6 11" />171-          </svg>172-        </button>173-        <span class="peach-overlay-title">export</span>174-        <div class="peach-overlay-spacer"></div>175-      </div>176-407+    <>408+      <OverlayHeader title="export as CSV" onBack={props.onBack} />177409       <div class="peach-settings-body">178-179-        {/* ── Backup phrase ── */}180-        <div class="peach-settings-group peach-export-card peach-export-card--primary">181-          <p class="peach-settings-label">backup phrase</p>182-          <p class="peach-export-card-copy">183-            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.184-          </p>185-          <button186-            class="peach-btn peach-btn--subtle peach-btn--sm"187-            onClick={props.onOpenCjk}188-          >189-            Create backup phrase190-          </button>191-        </div>192-193-        {/* ── Encrypted file ── */}194410         <div class="peach-settings-group peach-export-card">195-          <p class="peach-settings-label">encrypted file</p>196-          <p class="peach-export-card-copy">197-            Download a file backup. Set a password to lock it — you'll need that password to restore.198-          </p>199-          <div class="peach-settings-stack peach-export-password-stack">200-            <input201-              class="peach-settings-input"202-              type="password"203-              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}204-              value={exportPassword()}205-              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}206-            />207-            <input208-              id="export-confirm-password"209-              class="peach-settings-input"210-              type="password"211-              placeholder="confirm backup password"212-              value={exportPasswordConfirm()}213-              onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}214-            />215-            <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>216-              <p class="peach-export-note" style="color:var(--peach-t2)">217-                Passwords don't match218-              </p>219-            </Show>220-            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>221-              <p class="peach-export-note" style="color:var(--peach-t2)">222-                At least {MIN_EXPORT_PASSWORD} characters223-              </p>224-            </Show>411+          <div class="peach-csv-warning">412+            This export is unencrypted. Anyone who gets this file can read413+            your passwords. Only use this to move to another password414+            manager — delete it once the import is done.225415           </div>226-          <button227-            class="peach-btn peach-btn--subtle peach-btn--sm"228-            onClick={() => void handleEncryptedExport()}229-            disabled={exporting() || !passwordsMatch()}230-          >231-            {exporting() ? "saving…" : "Download backup file"}232-          </button>233-        </div>234-235-        {/* ── CSV ── */}236-        <div class="peach-settings-group peach-export-card">237-          <p class="peach-settings-label">CSV for other apps</p>238-          <p class="peach-export-card-copy">239-            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.240-          </p>241-          <Show242-            when={!showCsvWarning()}243-            fallback={244-              <div class="peach-settings-actions">245-                <button246-                  class="peach-btn peach-btn--ghost peach-btn--sm"247-                  onClick={() => void handleCSVExport()}248-                  disabled={exporting()}249-                >250-                  {exporting() ? "saving…" : "Download CSV"}251-                </button>252-                <button253-                  class="peach-btn peach-btn--ghost peach-btn--sm"254-                  onClick={() => setShowCsvWarning(false)}255-                >256-                  cancel257-                </button>258-              </div>259-            }260-          >416+          <Show when={!acknowledged()}>261417             <button262418               class="peach-btn peach-btn--ghost peach-btn--sm"263-              onClick={() => setShowCsvWarning(true)}419+              onClick={() => setAcknowledged(true)}264420             >265-              Export as CSV421+              I understand — continue266422             </button>267423           </Show>424+          <Show when={acknowledged()}>425+            <button426+              class="peach-btn peach-btn--subtle peach-btn--sm"427+              onClick={() => void handleExport()}428+              disabled={working()}429+            >430+              {working() ? "exporting…" : "Download CSV"}431+            </button>432+          </Show>268433         </div>269-270434         <Show when={error()}>271435           <div class="peach-settings-group">272436             <div class="peach-restore-error">{error()}</div>273437           </div>274438         </Show>275-276-        <Show when={result()}>439+        <Show when={done()}>277440           <div class="peach-settings-group">278441             <div class="peach-export-success">279442               <span class="peach-export-success-check">✓</span>280-              <span>{result()}</span>443+              <span>{done()}</span>281444             </div>282445           </div>283446         </Show>284447       </div>448+    </>449+  );450+}451+452+// ---------------------------------------------------------------------------453+// Shared helpers454+// ---------------------------------------------------------------------------455+456+function OverlayHeader(props: { title: string; onBack: () => void }) {457+  return (458+    <div class="peach-overlay-header">459+      <button460+        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"461+        onClick={props.onBack}462+        aria-label="Back"463+      >464+        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">465+          <path d="M6 1L1 6L6 11" />466+        </svg>467+      </button>468+      <span class="peach-overlay-title">{props.title}</span>469+      <div class="peach-overlay-spacer"></div>285470     </div>286471   );287472 }288473 474+interface AssembledEnvelope {475+  bytes: Uint8Array;476+  entryCount: number;477+  stats: { cards: number; passkeys: number; notes: number; logins: number };478+}479+480+async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {481+  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });482+  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];483+484+  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });485+  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};486+487+  let license: PeachLicenseExport | undefined;488+  try {489+    const info = await sendMessage({ type: "GET_LICENSE" });490+    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;491+    if (data?.valid && data.plan === "pro") {492+      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });493+      const keyStr = (keyRes as { ok: boolean; data?: string }).data;494+      if (keyStr) {495+        license = {496+          key: keyStr,497+          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),498+        };499+      }500+    }501+  } catch {502+    // License absent — proceed without it.503+  }504+505+  const stats = countTypes(entries);506+  const bytes = await buildPayload({507+    entries,508+    settings: settings as Parameters<typeof buildPayload>[0]["settings"],509+    license,510+    passphrase,511+  });512+  return { bytes, entryCount: entries.length, stats };513+}514+515+function countTypes(entries: VaultEntry[]): {516+  cards: number;517+  passkeys: number;518+  notes: number;519+  logins: number;520+} {521+  let cards = 0;522+  let passkeys = 0;523+  let notes = 0;524+  let logins = 0;525+  for (const e of entries) {526+    if (e.deletedAt) continue;527+    if (e.type === "card") cards++;528+    else if (e.type === "passkey") passkeys++;529+    else if (e.type === "note") notes++;530+    else logins++;531+  }532+  return { cards, passkeys, notes, logins };533+}534+535+function buildCsv(entries: VaultEntry[]): string {536+  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];537+  const rows = entries.map((e) => {538+    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";539+    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";540+    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";541+    return [542+      e.title,543+      e.url ?? "",544+      username,545+      password,546+      e.notes ?? "",547+      type,548+      Array.isArray(e.tags) ? e.tags.join("|") : "",549+      e.totp ?? "",550+    ]551+      .map((v) =>552+        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`553+      )554+      .join(",");555+  });556+  return [headers.join(","), ...rows].join("\n");557+}558+289559 function dateStamp(): string {290560   return new Date().toISOString().split("T")[0];291561 }292562 563+function monthYear(): string {564+  const d = new Date();565+  const months = [566+    "January", "February", "March", "April", "May", "June",567+    "July", "August", "September", "October", "November", "December",568+  ];569+  return `${months[d.getMonth()]} ${d.getFullYear()}`;570+}571+293572 function downloadBlob(content: string, mimeType: string, filename: string): void {294-  try {295-    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));296-    const a = document.createElement("a");297-    a.href = url;298-    a.download = filename;299-    document.body.appendChild(a);300-    a.click();301-    document.body.removeChild(a);302-    URL.revokeObjectURL(url);303-  } catch (err) {304-    console.error("[Peach] Download failed:", err);305-    throw err;306-  }573+  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));574+  const a = document.createElement("a");575+  a.href = url;576+  a.download = filename;577+  document.body.appendChild(a);578+  a.click();579+  document.body.removeChild(a);580+  URL.revokeObjectURL(url);307581 }

view file

src/popup/views/Import.tsx+200 −47
@@ -1,7 +1,5 @@11 // Signal-conditioned views in this file must use <Show>, never a2-// signal-conditioned early return (`if (signal()) return <X/>`). Early3-// returns in SolidJS run once at mount and never re-execute when signals4-// change — see Projects.md §4.1.2+// signal-conditioned early return — see Projects.md §4.1.53 import { createMemo, createSignal, Show } from "solid-js";64 75 import { sendMessage } from "../App";@@ -10,6 +8,12 @@108   parseImportSource,119   type ImportParseResult,1210 } from "../../shared/import";11+import {12+  decodePeachScript,13+  isPeachScript,14+  normalisePeachScript,15+} from "../../crypto/peachScript";16+import { parsePayload } from "../../crypto/peachPayload";1317 import type { VaultEntry } from "../../shared/types";1418 import { CJKImportView } from "./CJKImport";1519 @@ -52,6 +56,14 @@5256 function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {5357   const mode = () => props.mode ?? "existing";5458 59+  // PeachScript paste flow60+  const [scriptInput, setScriptInput] = createSignal("");61+  const [scriptPass, setScriptPass] = createSignal("");62+  const [scriptWorking, setScriptWorking] = createSignal(false);63+  const [scriptError, setScriptError] = createSignal<string | null>(null);64+  const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);65+66+  // File flow (existing format detection)5567   const [preview, setPreview] = createSignal<ImportParseResult | null>(null);5668   const [rawImport, setRawImport] = createSignal<string | null>(null);5769   const [backupPassword, setBackupPassword] = createSignal("");@@ -72,6 +84,8 @@7284       masterPassword().length >= 8 && masterPassword() === masterConfirm()7385   );7486 87+  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));88+7589   async function refreshExistingCount() {7690     if (mode() !== "existing") return;7791     const res = await sendMessage({ type: "GET_ENTRIES" });@@ -81,6 +95,69 @@8195 8296   void refreshExistingCount();8397 98+  // ---------------------------------------------------------------------99+  // PeachScript flow100+  // ---------------------------------------------------------------------101+102+  async function handlePeachScriptImport() {103+    setScriptError(null);104+    setScriptResult(null);105+    if (!scriptLooksValid()) {106+      setScriptError("Paste your PeachScript first.");107+      return;108+    }109+    if (!scriptPass().trim()) {110+      setScriptError("Enter the backup password.");111+      return;112+    }113+    setScriptWorking(true);114+    try {115+      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));116+      const payload = await parsePayload(bytes, scriptPass());117+118+      // Onboarding: create a fresh vault first.119+      if (mode() === "onboarding") {120+        if (!masterPasswordReady()) {121+          throw new Error("MASTER_PASSWORD_REQUIRED");122+        }123+        const createRes = await sendMessage({124+          type: "CREATE_VAULT",125+          payload: { password: masterPassword() },126+        });127+        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");128+      }129+130+      const restoreRes = await sendMessage({131+        type: "RESTORE_ENTRIES",132+        payload: {133+          entries: payload.entries,134+          mode: "merge",135+          settings: payload.settings,136+        },137+      });138+      if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");139+140+      if (payload.license?.key) {141+        await sendMessage({142+          type: "ACTIVATE_LICENSE",143+          payload: { key: payload.license.key },144+        }).catch(() => {});145+      }146+147+      setScriptResult({ entryCount: payload.entries.length });148+      await props.onImport();149+    } catch (err) {150+      const code = err instanceof Error ? err.message : String(err);151+      setScriptError(getImportErrorCopy(code));152+    } finally {153+      setScriptWorking(false);154+    }155+  }156+157+  // ---------------------------------------------------------------------158+  // File flow (Bitwarden / 1Password / etc.)159+  // ---------------------------------------------------------------------160+84161   async function parseSource(text: string, password?: string) {85162     try {86163       const result = await parseImportSource(text, password);@@ -168,9 +245,6 @@168245     setImportProgress(null);169246 170247     try {171-      // ⚠️  Guard: verify entries exist BEFORE touching any vault.172-      // Without this, CREATE_VAULT would already have wiped the old vault173-      // before we discover the file has nothing importable.174248       if (currentPreview.entries.length === 0) {175249         throw new Error("NO_ENTRIES_IMPORTED");176250       }@@ -209,7 +283,6 @@209283         imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;210284         setImportProgress({ current: imported, total: currentPreview.entries.length });211285       } else {212-        // Fallback to sequential213286         for (const entry of currentPreview.entries) {214287           const response = await sendMessage({215288             type: "ADD_ENTRY",@@ -224,7 +297,6 @@224297         throw new Error("NO_ENTRIES_IMPORTED");225298       }226299 227-      // Apply license last — silent on failure, never blocks a successful import.228300       if (currentPreview.licenseKey) {229301         await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});230302       }@@ -255,14 +327,9 @@255327               aria-label="Back"256328             >257329               <svg258-                width="7"259-                height="12"260-                viewBox="0 0 7 12"261-                fill="none"262-                stroke="currentColor"263-                stroke-width="1.1"264-                stroke-linecap="round"265-                stroke-linejoin="round"330+                width="7" height="12" viewBox="0 0 7 12"331+                fill="none" stroke="currentColor"332+                stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"266333               >267334                 <path d="M6 1L1 6L6 11" />268335               </svg>@@ -277,7 +344,7 @@277344           <button278345             class="peach-lock-restore-link"279346             onClick={props.onBack}280-            disabled={importing()}347+            disabled={importing() || scriptWorking()}281348           >282349             ‹ back283350           </button>@@ -285,23 +352,88 @@285352       </Show>286353 287354       <div class="peach-settings-body">355+        <Show when={mode() === "onboarding" && props.replacesExistingVault}>356+          <div class="peach-settings-group">357+            <div class="peach-restore-warning">358+              Restoring now replaces the locked local vault on this device with359+              a fresh one from your backup or export file.360+            </div>361+          </div>362+        </Show>363+364+        {/* PeachScript paste — primary path */}288365         <div class="peach-settings-group peach-restore-group">289-          <p class="peach-settings-label">290-            {mode() === "onboarding" ? "bring data into peach" : "import passwords"}291-          </p>366+          <p class="peach-settings-label">paste peachscript</p>292367           <p class="peach-restore-copy">293368             {mode() === "onboarding"294-              ? "Import a Peach backup, FIDO credential-exchange export, Proton Pass export, Bitwarden export, or a common browser CSV. We'll create a fresh local vault first."295-              : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}369+              ? "Paste a PeachScript backup from another Peach device to restore your vault."370+              : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}296371           </p>297-298-          <Show when={mode() === "onboarding" && props.replacesExistingVault}>299-            <div class="peach-restore-warning">300-              Restoring now replaces the locked local vault on this device with301-              a fresh one from your backup or export file.372+          <textarea373+            class="peach-script-input"374+            placeholder="paste PeachScript here…"375+            value={scriptInput()}376+            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}377+          />378+          <input379+            class="peach-restore-input"380+            type="password"381+            placeholder="PeachScript password"382+            value={scriptPass()}383+            onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}384+            style="margin-top:8px"385+          />386+          <Show when={mode() === "onboarding"}>387+            <div class="peach-auth-grid" style="margin-top:8px">388+              <input389+                class="peach-restore-input"390+                type="password"391+                placeholder="new master password"392+                value={masterPassword()}393+                onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}394+              />395+              <input396+                class="peach-restore-input"397+                type="password"398+                placeholder="confirm master password"399+                value={masterConfirm()}400+                onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}401+              />302402             </div>303403           </Show>404+          <div class="peach-settings-actions" style="margin-top:8px">405+            <button406+              class="peach-btn peach-btn--subtle peach-btn--sm"407+              onClick={() => void handlePeachScriptImport()}408+              disabled={409+                scriptWorking() ||410+                !scriptLooksValid() ||411+                !scriptPass().trim() ||412+                (mode() === "onboarding" && !masterPasswordReady())413+              }414+            >415+              {scriptWorking() ? "decoding…" : "Restore from PeachScript"}416+            </button>417+          </div>418+          <Show when={scriptError()}>419+            <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>420+          </Show>421+          <Show when={scriptResult()}>422+            <div class="peach-export-success" style="margin-top:8px">423+              <span class="peach-export-success-check">✓</span>424+              <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>425+            </div>426+          </Show>427+        </div>304428 429+        {/* File import — for migrating from other managers */}430+        <div class="peach-settings-group peach-restore-group">431+          <p class="peach-settings-label">import a file</p>432+          <p class="peach-restore-copy">433+            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO434+            credential-exchange passkeys, or a Peach JSON backup.435+          </p>436+305437           <div class="peach-provider-chips">306438             <span class="peach-provider-chip">Peach JSON</span>307439             <span class="peach-provider-chip">FIDO passkeys</span>@@ -310,30 +442,13 @@310442             <span class="peach-provider-chip">1Password JSON</span>311443             <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>312444           </div>313-        </div>314445 315-        <Show when={mode() === "existing"}>316-          <div class="peach-settings-group">317-            <p class="peach-settings-label">restore from CJK phrase</p>318-            <p class="peach-restore-copy">319-              Use a Peach CJK backup phrase instead of a file.320-            </p>321-            <button322-              class="peach-btn peach-btn--subtle peach-btn--sm"323-              onClick={props.onOpenCjk}324-            >325-              Restore from CJK phrase326-            </button>327-          </div>328-        </Show>329-330-        <div class="peach-settings-group">331-          <p class="peach-settings-label">select file</p>332446           <input333447             class="peach-restore-file"334448             type="file"335449             accept=".csv,.json"336450             onChange={(event) => void handleFileSelect(event)}451+            style="margin-top:8px"337452           />338453         </div>339454 @@ -451,7 +566,7 @@451566             <p class="peach-settings-label">new master password</p>452567             <p class="peach-restore-copy">453568               This password unlocks the Peach vault created on this device after454-              import. It is separate from any export password.569+              import.455570             </p>456571             <div class="peach-auth-grid">457572               <input@@ -530,6 +645,30 @@530645             </div>531646           </div>532647         </Show>648+649+        {/* Legacy CJK — collapsed, secondary */}650+        <Show when={mode() === "existing"}>651+          <div class="peach-settings-group">652+            <details>653+              <summary class="peach-legacy-cjk-summary">654+                Have an old CJK phrase backup?655+              </summary>656+              <p class="peach-legacy-cjk-note">657+                CJK phrase backups from earlier versions of Peach are still658+                supported. After restoring, we recommend creating a fresh659+                PeachScript or QR backup.660+              </p>661+              <div class="peach-settings-actions" style="margin-top:6px">662+                <button663+                  class="peach-btn peach-btn--ghost peach-btn--sm"664+                  onClick={props.onOpenCjk}665+                >666+                  Restore from CJK phrase667+                </button>668+              </div>669+            </details>670+          </div>671+        </Show>533672       </div>534673     </div>535674   );@@ -540,13 +679,27 @@540679     case "UNSUPPORTED_IMPORT_FORMAT":541680       return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";542681     case "INVALID_EXPORT_PASSWORD":543-      return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";682+    case "INVALID_PASSPHRASE":683+      return "Wrong backup password. This is the password you chose when you created the backup.";544684     case "INVALID_PEACH_EXPORT":545685       return "This Peach backup file looks corrupted or incomplete.";686+    case "INVALID_PEACHSCRIPT_CHARACTER":687+      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";688+    case "PAYLOAD_TOO_SHORT":689+      return "PeachScript looks truncated. Copy the full backup string and try again.";690+    case "BAD_MAGIC":691+    case "UNSUPPORTED_VERSION":692+      return "This PeachScript was created by a different version of Peach.";693+    case "CHECKSUM_FAILED":694+      return "PeachScript appears corrupted. Re-copy from the source device.";546695     case "CREATE_VAULT_FAILED":547696       return "Peach couldn't create a new local vault before importing.";548697     case "CLEAR_VAULT_FAILED":549698       return "Peach couldn't clear the existing vault before replacing it.";699+    case "MASTER_PASSWORD_REQUIRED":700+      return "Choose a master password for your new vault first.";701+    case "RESTORE_FAILED":702+      return "Restore failed. Your existing vault is unchanged.";550703     case "NO_ENTRIES_IMPORTED":551704       return "No importable password entries were found in that file.";552705     default:

view file

src/popup/views/contracts.test.tsx+12 −66
@@ -236,74 +236,15 @@236236     expect(onUnlock).toHaveBeenCalled();237237   });238238 239-  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {239+  it("Export menu surfaces the three v2.5.0 backup formats", async () => {240240     const container = mount(<ExportView onBack={vi.fn()} />);241-242-    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;243-    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;244-    const button = Array.from(container.querySelectorAll("button")).find(245-      (candidate) => candidate.textContent?.trim() === "Download backup file"246-    ) as HTMLButtonElement;247-248-    inputText(password, "too-short");249-    inputText(confirm, "too-short");250241     await flushView();251242 252-    expect(button.disabled).toBe(true);253-    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();243+    expect(container.textContent).toContain("PeachScript");244+    expect(container.textContent).toContain("print backup");245+    expect(container.textContent).toContain("CSV");254246   });255247 256-  it("Export keeps encrypted export disabled when passwords do not match", async () => {257-    const container = mount(<ExportView onBack={vi.fn()} />);258-259-    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260-    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;261-    const button = Array.from(container.querySelectorAll("button")).find(262-      (candidate) => candidate.textContent?.trim() === "Download backup file"263-    ) as HTMLButtonElement;264-265-    inputText(password, "correct horse battery staple");266-    inputText(confirm, "different horse battery staple");267-    await flushView();268-269-    expect(button.disabled).toBe(true);270-    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();271-  });272-273-  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {274-    sendMessageMock.mockResolvedValue({275-      ok: true,276-      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],277-    });278-    exportEntriesToPeachJsonMock.mockResolvedValue({279-      version: 2,280-      type: "peach-export",281-    });282-    const container = mount(<ExportView onBack={vi.fn()} />);283-284-    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;285-    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;286-    const button = Array.from(container.querySelectorAll("button")).find(287-      (candidate) => candidate.textContent?.trim() === "Download backup file"288-    ) as HTMLButtonElement;289-290-    inputText(password, "correct horse battery staple");291-    inputText(confirm, "correct horse battery staple");292-    await flushView();293-    button.click();294-    await flushView();295-296-    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });297-    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(298-      [299-        expect.objectContaining({ id: "entry-1" }),300-        expect.objectContaining({ id: "deleted", deletedAt: 2 }),301-      ],302-      "correct horse battery staple",303-      undefined304-    );305-  });306-307248   it("Settings requires current, new, and confirm passwords before enabling save", async () => {308249     sendMessageMock.mockResolvedValue({ ok: true, data: null });309250     const container = mount(@@ -733,9 +674,14 @@733674     expect(createButton.disabled).toBe(true);734675     expect(container.textContent).toContain("Restoring now replaces the locked local vault");735676 736-    const inputs = container.querySelectorAll('input[type="password"]');737-    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");738-    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");677+    const masterInput = container.querySelector(678+      'input[placeholder="new master password"]'679+    ) as HTMLInputElement;680+    const confirmInput = container.querySelector(681+      'input[placeholder="confirm master password"]'682+    ) as HTMLInputElement;683+    inputText(masterInput, "fresh-master-password");684+    inputText(confirmInput, "fresh-master-password");739685     await flushView();740686 741687     expect(createButton.disabled).toBe(false);

view file