7395ace43e28

rebuild the onboarding restore surface as a glass source-picker card (PeachScript, Codex, CSV subviews) in the create-vault screen's language; file imports survive the focus-killing action popup via drop-anywhere plus resume-on-picked-source instead of the rejected detach popout; bump 4.10.19

time
2026-08-20 23:37 UTC · 2 months ago
machine
koh2
agent
qwen3.8-max
session
restore surface redesign
parent
4f017bfc1d11
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+23 −1src/popup/styles/popup.css+117 −176src/popup/views/Lock.tsx+261 −108src/popup/views/contracts.test.tsx+65 −2tests/lock-screen.spec.ts+23 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.18",4+  "version": "4.10.19",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.10.18",5+  "version": "4.10.19",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.10.18",3+  "version": "4.10.19",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/App.tsx+23 −1
@@ -10,7 +10,7 @@1010   INITIAL_SYNC_PENDING_DEVICE_KEY,1111   type InitialSyncDevice,1212 } from "../shared/initial-sync";13-import { LockView } from "./views/Lock";13+import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";1414 import {1515   ONBOARDING_CARD_STORAGE_KEY,1616   ONBOARDING_LAST_CARD,@@ -152,6 +152,10 @@152152   const [hasVault, setHasVault] = createSignal(false);153153   const [onboarded, setOnboarded] = createSignal(true);154154   const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);155+  // The restore source picked before the popup closed (the OS file picker156+  // kills it), so reopening resumes the same source form instead of the157+  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.158+  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);155159   // Onboarding remounts whenever it leaves (create/adopt/restore) and comes156160   // back. Those flows all exit from the final card, so resume there — a fresh157161   // install's first mount stays at 0.@@ -520,6 +524,8 @@520524   }521525   function clearOnboardingExit() {522526     void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});527+    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});528+    setResumeRestoreSource(null);523529   }524530 525531   onMount(async () => {@@ -554,6 +560,7 @@554560         await storageSet("peach_onboarded", "true");555561         await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});556562         await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});563+        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});557564         setOnboarded(true);558565       } else {559566         setOnboarded(seen);@@ -566,6 +573,14 @@566573             setOnboardingCardAtExit(ONBOARDING_LAST_CARD);567574             setOnboarded(true);568575             setStartOnboardingRestore(exit === "restore");576+            if (exit === "restore") {577+              // The OS file picker kills the action popup mid-restore, so the578+              // surface resumes on the source the user had already picked.579+              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);580+              if (source === "symbols" || source === "codex" || source === "csv") {581+                setResumeRestoreSource(source);582+              }583+            }569584             if (exit === "adopt") setView("adopting");570585           } else {571586             // Otherwise resume mid-onboarding where the user left off instead@@ -1574,11 +1589,18 @@15741589                     isPro={proStatus()}15751590                     forceRecoveryWords={forceRecoveryWords()}15761591                     initialRestore={startOnboardingRestore()}1592+                    initialRestoreSource={resumeRestoreSource() ?? undefined}15771593                     onUnlock={() => {15781594                       // RecoveryWordDisplay clears the storage flag before15791595                       // calling onDone, so handleUnlock will see no pending15801596                       // confirmation and proceed through the normal path.15811597                       if (forceRecoveryWords()) setForceRecoveryWords(false);1598+                      // The create/restore flow is done — a lock later in this1599+                      // window must show the unlock screen, not re-enter the1600+                      // onboarding restore surface.1601+                      setStartOnboardingRestore(false);1602+                      setResumeRestoreSource(null);1603+                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});15821604                       return handleUnlock();15831605                     }}15841606                     onBackToOnboarding={

view file

src/popup/styles/popup.css+117 −176
@@ -3923,186 +3923,159 @@39233923   border-top: 1px solid rgba(240, 232, 216, 0.06);39243924 }39253925 3926-/* ── Restore tab view ─────────────────────────────────────────────────────3927-   Tab-based restore screen replacing the old three-card picker. */3926+/* ── Restore surface ────────────────────────────────────────────────────3927+   The onboarding restore screen. Shares the create-vault screen's glass3928+   card language: Cormorant headline, hairline divider, choice rows. The3929+   card lists the backup sources; picking one swaps the card body to that3930+   source's form. */39283931 3929-.peach-screen.peach-restore-tabview {3930-  overflow: hidden;3931-  /* Wordmark must land on the same pixel as on the onboarding cards and3932-     create-vault screens (16px top/left), not the vault-stage 20/18. */3933-  padding: 16px 16px 18px;3932+/* Content centers in the tall card (mirrors the onboarding cards) and3933+   scrolls as a fallback on shorter viewports. */3934+.peach-restore-card {3935+  justify-content: center;3936+  overflow-y: auto;3937+  scrollbar-width: none;39343938 }3939+.peach-restore-card::-webkit-scrollbar { display: none; }39353940 3936-.peach-restore-tabcard {3937-  flex: 1;3938-  display: flex;3939-  flex-direction: column;3940-  gap: 0;3941-  padding: 0;3942-  overflow: hidden;3943-  cursor: default;3944-  user-select: none;3945-  animation: peach-onboard-enter 320ms cubic-bezier(0.16, 1, 0.3, 1) both;3941+.peach-restore-sources {3942+  display: grid;3943+  gap: 6px;3944+  margin-top: 4px;39463945 }39473946 3948-/* ── Tab selector ─────────────────────────────────────────────────────── */3947+/* Choice rows reuse .peach-onboarding-choice; tighten them to the same3948+   compactness as the final onboarding card's rows. */3949+.peach-restore-source {3950+  min-height: 40px;3951+  padding: 6px 10px;3952+  cursor: pointer;3953+  transition: border-color 140ms ease, background 140ms ease;3954+}3955+.peach-restore-source:hover {3956+  border-color: color-mix(in srgb, var(--peach-accent) 34%, transparent);3957+  background: color-mix(in srgb, var(--peach-accent) 9%, transparent);3958+}39493959 3950-.peach-restore-tabs {3960+/* In-card header shown once a source is picked */3961+.peach-restore-source-head {39513962   display: flex;3952-  justify-content: space-around;3953-  padding: 12px 14px 8px;3954-  border-bottom: 1px solid var(--peach-t3);3955-  gap: 2px;3956-  overflow: hidden;3957-  scrollbar-width: none;3963+  align-items: baseline;3964+  justify-content: space-between;3965+  gap: 10px;39583966 }3959-.peach-restore-tabs::-webkit-scrollbar { display: none; }39603967 3961-.peach-restore-tab {3968+.peach-restore-source-title {3969+  margin: 0;3970+  font-family: var(--font-display);3971+  font-style: italic;3972+  font-weight: 400;3973+  font-size: 18px;3974+  line-height: 1.1;3975+  color: var(--peach-t0);3976+}3977+3978+.peach-restore-picker-back {39623979   background: none;3963-  border: 1px solid transparent;3964-  border-bottom: 2px solid transparent;3980+  border: none;39653981   cursor: pointer;3966-  padding: 4px 8px;3967-  font-family: "IBM Plex Mono", monospace;3968-  font-style: normal;3969-  font-size: 0.6875rem;3970-  letter-spacing: 0.02em;3971-  text-transform: uppercase;3982+  padding: 2px 0;3983+  font-family: var(--font-mono);3984+  font-size: 0.625rem;3985+  letter-spacing: 0.06em;39723986   color: var(--peach-t2);39733987   transition: color 150ms ease;3974-  flex: 0 1 auto;3975-  min-width: 0;3976-  text-align: center;3977-  white-space: nowrap;3978-  overflow: hidden;3979-  text-overflow: ellipsis;39803988 }3981-.peach-restore-tab:hover {3989+.peach-restore-picker-back:hover {39823990   color: var(--peach-t0);39833991 }3984-.peach-restore-tab.is-active {3985-  color: var(--peach-t0);3986-  border-bottom-color: var(--peach-accent);3987-}39883992 3989-/* ── Content area ─────────────────────────────────────────────────────── */3990-3991-.peach-restore-content {3992-  padding: 16px 16px 14px;3993+/* Shared inner form layout */3994+.peach-restore-form {39933995   display: flex;39943996   flex-direction: column;39953997   gap: 8px;3996-  overflow: hidden;3997-  scrollbar-width: none;3998-  flex: 1;39993998 }4000-.peach-restore-content::-webkit-scrollbar { display: none; }4001-.peach-restore-content > * {4002-  overflow: hidden;4003-  scrollbar-width: none;4004-}4005-.peach-restore-content > *::-webkit-scrollbar { display: none; }40063999 4007-/* ── Shared tab inner layout ──────────────────────────────────────────── */4008-4009-.peach-rt-symbols,4010-.peach-rt-csv,4011-.peach-rt-phone,4012-.peach-restore-codex {4013-  display: flex;4014-  flex-direction: column;4015-  gap: 8px;4016-  overflow: hidden;4000+/* Drag-and-drop import — the one file path that survives the action popup4001+   (the popup dismisses itself when the OS file picker steals focus). */4002+.peach-restore-form.is-dragover {4003+  outline: 2px dashed color-mix(in srgb, var(--peach-accent) 55%, transparent);4004+  outline-offset: 6px;4005+  border-radius: 8px;40174006 }40184007 4019-.peach-restore-codex {4020-  min-height: 0;4021-  justify-content: center;4008+.peach-restore-drop-hint {4009+  margin: -2px 0 0;4010+  text-align: center;4011+  font-family: var(--font-mono);4012+  font-size: 0.5625rem;4013+  letter-spacing: 0.06em;4014+  color: var(--peach-t2);40224015 }40234016 4024-.peach-restore-codex-title {4025-  margin: 0;4026-  font-family: var(--font-display);4027-  font-size: 1rem;4028-  font-style: italic;4029-  color: var(--peach-t0);4030-}4031-4032-.peach-restore-codex-copy {4033-  margin: 0;4034-  font-size: 0.75rem;4035-  line-height: 1.5;4036-  color: var(--peach-t1);4037-}4038-4039-.peach-restore-codex-button {4017+.peach-restore-submit {40404018   width: 100%;4041-  justify-content: center;40424019 }40434020 4044-.peach-rt-submit {4021+.peach-restore-file-btn {4022+  display: inline-flex;4023+  align-items: center;4024+  justify-content: center;40454025   width: 100%;40464026 }40474027 4048-.peach-rt-error {4049-  margin: 0;4050-  font-size: 0.6875rem;4051-  color: var(--peach-breach);4052-  line-height: 1.4;4053-}4028+/* ── PeachScript source ───────────────────────────────────────────────── */40544029 4055-/* ── PeachScript tab ──────────────────────────────────────────────────── */4056-4057-.peach-restore-symbols-textarea {4058-  font-family: 'PeachScript', monospace;4059-  font-size: 13px;4060-  line-height: 1.9;4061-  letter-spacing: 0.1em;4062-  word-break: break-all;4063-  resize: none;4030+/* Rune textarea styled as an underline input to match the card's other4031+   fields. */4032+.peach-restore-script {40644033   width: 100%;4034+  box-sizing: border-box;40654035   min-height: 56px;40664036   max-height: 72px;4037+  padding: 6px 0;4038+  resize: none;40674039   overflow: hidden;40684040   scrollbar-width: none;40694041   -ms-overflow-style: none;4070-  box-sizing: border-box;4071-  padding: 6px 8px;4072-  border-radius: 8px;4073-  border: 1px solid var(--peach-t3);4074-  background: rgba(240, 232, 216, 0.03);4042+  border: 0;4043+  border-bottom: 1px solid var(--peach-t3);4044+  border-radius: 0;4045+  background: transparent;40754046   color: var(--peach-t0);4047+  outline: none;4048+  caret-color: var(--peach-accent);4049+  font-family: "PeachScript", monospace;4050+  font-size: 13px;4051+  line-height: 1.9;4052+  letter-spacing: 0.1em;4053+  word-break: break-all;40764054 }4077-.peach-restore-symbols-textarea::-webkit-scrollbar { display: none; }4078-.peach-restore-symbols-textarea:focus { outline: none; border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); }4079-4080-.peach-restore-symbols-pass {4081-  width: 100%;4082-  box-sizing: border-box;4083-  padding: 7px 10px;4084-  border-radius: 8px;4085-  border: 1px solid var(--peach-t3);4086-  background: rgba(240, 232, 216, 0.03);4087-  color: var(--peach-t0);4088-  font-size: 0.8125rem;4055+.peach-restore-script::-webkit-scrollbar { display: none; }4056+.peach-restore-script::placeholder { color: var(--peach-t2); }4057+.peach-restore-script:focus {4058+  border-bottom-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);40894059 }4090-.peach-restore-symbols-pass::placeholder { color: var(--peach-t2); }4091-.peach-restore-symbols-pass:focus { outline: none; border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); }40924060 4093-.peach-rt-divider {4094-  text-align: center;4095-  font-size: 0.6875rem;4061+.peach-restore-or {4062+  display: flex;4063+  align-items: center;4064+  gap: 8px;4065+  font-family: var(--font-mono);4066+  font-size: 0.5625rem;4067+  letter-spacing: 0.14em;4068+  text-transform: uppercase;40964069   color: var(--peach-t2);4097-  font-style: italic;40984070 }4099-4100-.peach-rt-file-label {4101-  width: 100%;4102-  justify-content: center;4071+.peach-restore-or::before,4072+.peach-restore-or::after {4073+  content: "";4074+  flex: 1;4075+  border-top: 1px solid var(--peach-t3);41034076 }41044077 4105-.peach-rt-anim-canvas {4078+.peach-restore-anim-canvas {41064079   width: 100%;41074080   height: 180px;41084081   border-radius: 10px;@@ -4110,54 +4083,22 @@41104083   display: block;41114084 }41124085 4113-/* ── CSV tab ──────────────────────────────────────────────────────────── */4086+/* ── Codex source ─────────────────────────────────────────────────────── */41144087 4115-.peach-rt-csv-sources {4088+.peach-restore-codex-copy {41164089   margin: 0;4117-  font-size: 0.6875rem;4118-  color: var(--peach-t2);4119-  line-height: 1.5;4120-  text-align: center;4090+  font-size: 0.75rem;4091+  line-height: 1.55;4092+  color: var(--peach-t1);41214093 }41224094 4123-.peach-rt-csv-warning {4124-  margin: 0;4125-  font-family: "IBM Plex Mono", monospace;4126-  font-size: 0.5625rem;4127-  letter-spacing: 0.12em;4128-  color: var(--peach-breach);4129-  text-align: center;4130-}4095+/* ── CSV source ───────────────────────────────────────────────────────── */41314096 4132-/* ── From Phone tab ───────────────────────────────────────────────────── */4133-4134-.peach-rt-phone-code {4097+.peach-restore-csv-sources {41354098   margin: 0;41364099   text-align: center;4137-  font-family: "IBM Plex Mono", monospace;4138-  font-size: 22px;4139-  letter-spacing: 0.18em;4140-  color: var(--peach-t0);41414100 }41424101 4143-.peach-rt-phone-qr {4144-  display: block;4145-  width: 96px;4146-  height: 96px;4147-  margin: 0 auto;4148-  border-radius: 6px;4149-  image-rendering: pixelated;4150-  overflow: hidden;4151-}4152-4153-.peach-rt-phone-hint {4154-  margin: 0;4155-  font-size: 0.6875rem;4156-  color: var(--peach-t2);4157-  line-height: 1.55;4158-  text-align: center;4159-}4160-41614102 /* ── Intro popup ──────────────────────────────────────────────────────── */41624103 41634104 .peach-restore-intro-overlay {@@ -7706,12 +7647,12 @@77067647   animation: peach-onboard-enter 320ms cubic-bezier(0.16, 1, 0.3, 1) both;77077648 }77087649 7709-/* Create-vault card only (the restore flow reuses this class but needs a7710-   top-anchored tab bar). The warning hugs the glass edge with a small7650+/* Create-vault card only (the restore card lays out differently — see7651+   .peach-restore-card). The warning hugs the glass edge with a small77117652    margin; leftover height distributes between elements so they breathe77127653    instead of leaving one dead gap. Scroll stays available as a fallback77137654    on shorter viewports. */7714-.peach-newvault-card:not(.peach-restore-tabcard) {7655+.peach-newvault-card:not(.peach-restore-card) {77157656   min-height: 0;77167657   gap: 10px;77177658   padding: 14px 22px 16px;@@ -7720,7 +7661,7 @@77207661   scrollbar-width: none;77217662 }77227663 7723-.peach-newvault-card:not(.peach-restore-tabcard)::-webkit-scrollbar {7664+.peach-newvault-card:not(.peach-restore-card)::-webkit-scrollbar {77247665   display: none;77257666 }77267667 

view file

src/popup/views/Lock.tsx+261 −108
@@ -4,7 +4,7 @@44 import { RecoveryWordEntryView } from "./RecoveryWordEntry";55 import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";66 import { RuneBackground } from "../components/RuneBackground";7-import { storageGet, storageSet } from "../../shared/storage";7+import { storageGet, storageRemove, storageSet } from "../../shared/storage";88 import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";99 import { parsePayload } from "../../crypto/peachPayload";1010 import { parseImportSource } from "../../shared/import";@@ -55,12 +55,21 @@5555    *  Onboarding in the first place. */5656   onBackToOnboarding?: () => void;5757   /** Optional: open the Peach Codex restore flow (camera scan of printed58-   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */58+    *  Codex pages). Surfaced as a small footer link on the new-vault screen. */5959   onOpenCodexRestore?: () => void;6060   /** Open directly on backup restore when onboarding routed the user there. */6161   initialRestore?: boolean;62+  /** Resume the restore surface on the source the user had picked. Set from63+    *  RESTORE_SOURCE_STORAGE_KEY at boot — a popup close (the OS file picker64+    *  kills it) must land back on the same source form, not the picker. */65+  initialRestoreSource?: RestoreSource;6266 }6367 68+/** The restore source picked before a popup close, so boot can resume the69+ *  same source form instead of rewinding to the source picker. Written by70+ *  RestoreView, read/cleared by App. */71+export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";72+6473 type RestoreSurface = "restore" | null;6574 type RecoverSurface = "words" | "new-password" | null;6675 @@ -297,11 +306,12 @@297306   // ── All surfaces routed reactively via Switch/Match ───────────────────298307   return (299308     <Switch>300-      {/* ── Restore tab view ──────────────────────────────────────── */}309+      {/* ── Restore surface ───────────────────────────────────────── */}301310       <Match when={restoreSurface() === "restore"}>302-        <RestoreTabView311+        <RestoreView303312           hasVault={props.hasVault}304313           onOpenCodexRestore={props.onOpenCodexRestore}314+          initialSource={props.initialRestoreSource}305315           onBack={() => {306316             // The restore tab is only reached from the onboarding carousel,307317             // so back returns there — not to the create-vault form.@@ -613,26 +623,37 @@613623   );614624 }615625 616-// ── Restore tab view ───────────────────────────────────────────────────────626+// ── Restore surface ────────────────────────────────────────────────────────617627 618-type RestoreTab = "symbols" | "codex" | "csv";628+export type RestoreSource = "symbols" | "codex" | "csv";619629 620-function tabLabel(tab: RestoreTab): string {621-  return (622-    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]623-  );624-}630+const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [631+  { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },632+  { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },633+  { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },634+];625635 626-function RestoreTabView(props: {636+function RestoreView(props: {627637   hasVault: boolean;628638   onOpenCodexRestore?: () => void;629639   onBack: () => void;630640   onUnlock: () => void | Promise<void>;641+  initialSource?: RestoreSource;631642 }) {632-  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");643+  const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);633644   const [showIntro, setShowIntro] = createSignal(false);634645   const [pendingUnlock] = createSignal(false);635646 647+  // The action popup dies the moment the OS file picker steals focus, so a648+  // mid-restore close must resume the same source form — rewinding to the649+  // picker reads as losing progress. The resume marker mirrors the picked650+  // source; App clears it with the rest of the onboarding-exit state.651+  function chooseSource(next: RestoreSource | null) {652+    setSource(next);653+    if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});654+    else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});655+  }656+636657   async function handleRestoreSuccess() {637658     const seen = await storageGet("peach_shown_restore_intro").catch(() => null);638659     if (!seen) {@@ -650,45 +671,78 @@650671 651672   return (652673     <section653-      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"674+      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"654675       data-motion-phase="enter"655676     >656677       <RuneBackground opacity={0.05} />657678 658-      <header class="peach-screen-header lock-screen-header">679+      <header class="peach-screen-header peach-newvault-header">659680         <PeachWordmark class="peach-wordmark-wrap" />660-        <button class="peach-lock-restore-link" onClick={props.onBack}>661-          ‹ back681+        <button682+          class="peach-newvault-back peach-restore-back"683+          type="button"684+          onClick={props.onBack}685+          aria-label="Back to onboarding"686+        >687+          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">688+            <path d="M7 2L3 5.5L7 9" />689+          </svg>690+          back662691         </button>663692       </header>664693 665-      <div class="peach-newvault-card peach-restore-tabcard">666-        <div class="peach-restore-tabs" role="tablist">667-          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>668-            {(tab) => (669-              <button670-                class="peach-restore-tab"671-                classList={{ "is-active": activeTab() === tab }}672-                onClick={() => setActiveTab(tab)}673-                role="tab"674-                aria-selected={activeTab() === tab}675-              >676-                {tabLabel(tab)}677-              </button>678-            )}679-          </For>680-        </div>681-        <div class="peach-restore-content">682-          <Show when={activeTab() === "symbols"}>683-            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />684-          </Show>685-          <Show when={activeTab() === "codex"}>686-            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />687-          </Show>688-          <Show when={activeTab() === "csv"}>689-            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />690-          </Show>691-        </div>694+      <div class="peach-newvault-card peach-restore-card">695+        <Show696+          when={source()}697+          keyed698+          fallback={699+            <>700+              <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>701+              <hr class="peach-newvault-divider" />702+              <div class="peach-restore-sources">703+                <For each={RESTORE_SOURCES}>704+                  {(entry) => (705+                    <button706+                      type="button"707+                      class="peach-onboarding-choice peach-restore-source"708+                      onClick={() => chooseSource(entry.id)}709+                    >710+                      <span class="peach-onboarding-choice-label">{entry.name}</span>711+                      <span class="peach-onboarding-choice-detail">{entry.detail}</span>712+                    </button>713+                  )}714+                </For>715+              </div>716+            </>717+          }718+        >719+          {(active) => (720+            <>721+              <div class="peach-restore-source-head">722+                <h2 class="peach-restore-source-title">723+                  {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}724+                </h2>725+                <button726+                  type="button"727+                  class="peach-restore-picker-back"728+                  onClick={() => chooseSource(null)}729+                >730+                  ‹ sources731+                </button>732+              </div>733+              <hr class="peach-newvault-divider" />734+              <Show when={active === "symbols"}>735+                <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />736+              </Show>737+              <Show when={active === "codex"}>738+                <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />739+              </Show>740+              <Show when={active === "csv"}>741+                <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />742+              </Show>743+            </>744+          )}745+        </Show>692746       </div>693747 694748       {/* One-time intro popup after first restore */}@@ -715,30 +769,32 @@715769   );716770 }717771 718-// ── Peach Codex tab ────────────────────────────────────────────────────────772+// ── Peach Codex source ─────────────────────────────────────────────────────719773 720774 function RestoreCodex(props: { onOpen: () => void }) {721775   return (722-    <div class="peach-restore-codex">723-      <p class="peach-restore-codex-title">Restore from Peach Codex</p>776+    <div class="peach-restore-form">724777       <p class="peach-restore-codex-copy">725778         Scan printed Codex pages or import the Codex PDF. Codex restore is726779         always available, even before you create a vault.727780       </p>728781       <button729782         type="button"730-        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"783+        class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"731784         onClick={props.onOpen}732785       >733-        open Codex restore →786+        open Codex restore734787       </button>735788     </div>736789   );737790 }738791 739-// ── PeachScript tab ────────────────────────────────────────────────────────792+// ── PeachScript source ─────────────────────────────────────────────────────740793 741-function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {794+function RestoreSymbols(props: {795+  hasVault: boolean;796+  onSuccess: () => void | Promise<void>;797+}) {742798   const [scriptInput, setScriptInput] = createSignal("");743799   const [backupPass, setBackupPass] = createSignal("");744800   const [working, setWorking] = createSignal(false);@@ -882,6 +938,25 @@882938 883939   onCleanup(() => stopAnim());884940 941+  // The action popup dies when the OS file picker steals focus, so dropping942+  // the file is the import path that survives it. (The picker button stays943+  // for contexts that can host the dialog; a close there resumes this form.)944+  const [dragDepth, setDragDepth] = createSignal(0);945+946+  async function handleDrop(e: DragEvent) {947+    e.preventDefault();948+    setDragDepth(0);949+    if (working() || animating() !== null) return;950+    const file = e.dataTransfer?.files?.[0];951+    if (!file) return;952+    try {953+      setScriptInput(await file.text());954+      setError(null);955+    } catch {956+      setError("Couldn't read file.");957+    }958+  }959+885960   async function handleRestore() {886961     if (!scriptValid() || !backupPass().trim() || working()) return;887962     setError(null);@@ -940,25 +1015,33 @@9401015   }9411016 9421017   return (943-    <div class="peach-rt-symbols">1018+    <div1019+      class="peach-restore-form"1020+      classList={{ "is-dragover": dragDepth() > 0 }}1021+      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1022+      onDragOver={(e) => e.preventDefault()}1023+      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1024+      onDrop={(e) => void handleDrop(e)}1025+    >9441026       <Show when={animating() !== null}>9451027         <canvas9461028           ref={(el) => { canvasRef = el; }}947-          class="peach-rt-anim-canvas"1029+          class="peach-restore-anim-canvas"9481030           width={280}9491031           height={200}9501032         />9511033       </Show>9521034       <Show when={animating() === null}>9531035         <textarea954-          class="peach-restore-symbols-textarea"1036+          class="peach-restore-script"9551037           placeholder="paste peachscript…"9561038           rows={3}1039+          aria-label="Paste your PeachScript backup"9571040           value={scriptInput()}9581041           onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}9591042         />960-        <div class="peach-rt-divider">— or —</div>961-        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">1043+        <div class="peach-restore-or" aria-hidden="true">or</div>1044+        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn">9621045           open .peach file9631046           <input9641047             type="file"@@ -977,37 +1060,48 @@9771060             }}9781061           />9791062         </label>980-        <input981-          class="peach-restore-symbols-pass"982-          type="password"983-          placeholder="backup password"984-          value={backupPass()}985-          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}986-          onKeyDown={(e) => {987-            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {988-              e.preventDefault();989-              void handleRestore();990-            }991-          }}992-        />1063+        <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1064+        <div class="peach-newvault-field">1065+          <label class="peach-label" for="restore-backup-pass">1066+            backup password1067+          </label>1068+          <input1069+            id="restore-backup-pass"1070+            class="peach-underline-input peach-newvault-input"1071+            type="password"1072+            placeholder="••••••••••••"1073+            autocomplete="current-password"1074+            value={backupPass()}1075+            onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1076+            onKeyDown={(e) => {1077+              if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1078+                e.preventDefault();1079+                void handleRestore();1080+              }1081+            }}1082+          />1083+        </div>9931084         <button994-          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1085+          class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"9951086           onClick={() => void handleRestore()}9961087           disabled={!scriptValid() || !backupPass().trim() || working()}9971088         >998-          restore vault →1089+          restore vault9991090         </button>10001091         <Show when={error()}>1001-          <p class="peach-rt-error">{error()}</p>1092+          <p class="peach-hint peach-hint-error">{error()}</p>10021093         </Show>10031094       </Show>10041095     </div>10051096   );10061097 }10071098 1008-// ── CSV tab ────────────────────────────────────────────────────────────────1099+// ── CSV source ─────────────────────────────────────────────────────────────10091100 1010-function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {1101+function RestoreCSV(props: {1102+  hasVault: boolean;1103+  onSuccess: () => void | Promise<void>;1104+}) {10111105   const [importing, setImporting] = createSignal(false);10121106   const [error, setError] = createSignal<string | null>(null);10131107   // CSV/JSON imports are plaintext and carry no password, so when there is no@@ -1021,20 +1115,14 @@10211115   const passwordReady = () =>10221116     !needsPassword() ||10231117     (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1118+  // The action popup dies when the OS file picker steals focus — dropping the1119+  // file is the import path that survives it (see RestoreSymbols).1120+  const [dragDepth, setDragDepth] = createSignal(0);10241121 1025-  async function handleFile(e: Event) {1026-    const input = e.currentTarget as HTMLInputElement;1027-    const file = input.files?.[0];1028-    input.value = "";1029-    if (!file) return;1030-    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1031-      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1032-      return;1033-    }1034-    if (needsPassword() && newPass() !== confirmPass()) {1035-      setError("Passwords do not match.");1036-      return;1037-    }1122+  // Every route here (label click, drop, the disabled-attribute-guarded1123+  // input change) passes gatePassword first, so the file is always accepted1124+  // under a real master password — never an empty key.1125+  async function processFile(file: File) {10381126     setImporting(true);10391127     setError(null);10401128     try {@@ -1067,30 +1155,90 @@10671155     }10681156   }10691157 1158+  let csvFileRef: HTMLInputElement | undefined;1159+1160+  function gatePassword(): boolean {1161+    if (importing()) return false;1162+    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1163+      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1164+      return false;1165+    }1166+    if (needsPassword() && newPass() !== confirmPass()) {1167+      setError("Passwords do not match.");1168+      return false;1169+    }1170+    return true;1171+  }1172+1173+  async function handleFile(e: Event) {1174+    const input = e.currentTarget as HTMLInputElement;1175+    const file = input.files?.[0];1176+    input.value = "";1177+    if (!file) return;1178+    await processFile(file);1179+  }1180+1181+  async function handleDrop(e: DragEvent) {1182+    e.preventDefault();1183+    setDragDepth(0);1184+    if (!gatePassword()) return;1185+    const file = e.dataTransfer?.files?.[0];1186+    if (!file) return;1187+    await processFile(file);1188+  }1189+10701190   return (1071-    <div class="peach-rt-csv">1191+    <div1192+      class="peach-restore-form"1193+      classList={{ "is-dragover": dragDepth() > 0 }}1194+      onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1195+      onDragOver={(e) => e.preventDefault()}1196+      onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1197+      onDrop={(e) => void handleDrop(e)}1198+    >10721199       <Show when={needsPassword()}>1073-        <input1074-          class="peach-restore-symbols-pass"1075-          type="password"1076-          placeholder="new master password"1077-          value={newPass()}1078-          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1079-        />1080-        <input1081-          class="peach-restore-symbols-pass"1082-          type="password"1083-          placeholder="confirm master password"1084-          value={confirmPass()}1085-          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1086-        />1200+        <div class="peach-newvault-field">1201+          <label class="peach-label" for="restore-csv-pass">1202+            new master password1203+          </label>1204+          <input1205+            id="restore-csv-pass"1206+            class="peach-underline-input peach-newvault-input"1207+            type="password"1208+            placeholder="••••••••••••"1209+            autocomplete="new-password"1210+            value={newPass()}1211+            onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1212+          />1213+        </div>1214+        <div class="peach-newvault-field">1215+          <label class="peach-label" for="restore-csv-confirm">1216+            confirm master password1217+          </label>1218+          <input1219+            id="restore-csv-confirm"1220+            class="peach-underline-input peach-newvault-input"1221+            type="password"1222+            placeholder="••••••••••••"1223+            autocomplete="new-password"1224+            value={confirmPass()}1225+            onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1226+          />1227+        </div>10871228       </Show>10881229       <label1089-        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1090-        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1230+        class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1231+        onClick={(e) => {1232+          // The label would forward the click to the input even before the1233+          // password gate passes; own the activation so the gate can speak.1234+          e.preventDefault();1235+          if (!gatePassword()) return;1236+          csvFileRef?.click();1237+        }}10911238       >10921239         {importing() ? "importing…" : "open csv file"}10931240         <input1241+          ref={(el) => { csvFileRef = el; }}10941242           type="file"10951243           accept=".csv,.json,.peach,application/x-peach"10961244           style="display:none"@@ -1098,13 +1246,18 @@10981246           disabled={importing() || !passwordReady()}10991247         />11001248       </label>1101-      <p class="peach-rt-csv-sources">1102-        Imports from Bitwarden, 1Password, Proton Pass,<br />1249+      <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1250+      <p class="peach-hint peach-restore-csv-sources">1251+        Imports from Bitwarden, 1Password, Proton Pass,1252+        <br />11031253         Chrome, Safari, and Edge.11041254       </p>1105-      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1255+      <p class="peach-newvault-warning">1256+        <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1257+        <span>Plain-text file — delete it after the import.</span>1258+      </p>11061259       <Show when={error()}>1107-        <p class="peach-rt-error">{error()}</p>1260+        <p class="peach-hint peach-hint-error">{error()}</p>11081261       </Show>11091262     </div>11101263   );

view file

src/popup/views/contracts.test.tsx+65 −2
@@ -583,7 +583,7 @@583583     expect(container.querySelector("#master-password")).toBeNull();584584   });585585 586-  it("Lock restore-tab back returns to onboarding, not the create-vault screen", async () => {586+  it("Lock restore back returns to onboarding, not the create-vault screen", async () => {587587     const onBackToOnboarding = vi.fn();588588     const container = mount(() => (589589       <LockView@@ -596,9 +596,72 @@596596     ));597597     await flushView();598598 599-    (container.querySelector(".peach-lock-restore-link") as HTMLButtonElement).click();599+    (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();600600     await flushView();601601     expect(onBackToOnboarding).toHaveBeenCalledTimes(1);602+  });603+604+  it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {605+    const container = mount(() => (606+      <LockView607+        hasVault={false}608+        ready={true}609+        onUnlock={vi.fn()}610+        initialRestore={true}611+        initialRestoreSource="symbols"612+      />613+    ));614+    await flushView();615+616+    expect(container.querySelector(".peach-restore-script")).toBeTruthy();617+    expect(container.querySelector("#restore-backup-pass")).toBeTruthy();618+    expect(container.querySelector(".peach-restore-sources")).toBeNull();619+  });620+621+  it("Lock restore resumes on the picked CSV source, not the picker", async () => {622+    const container = mount(() => (623+      <LockView624+        hasVault={false}625+        ready={true}626+        onUnlock={vi.fn()}627+        initialRestore={true}628+        initialRestoreSource="csv"629+      />630+    ));631+    await flushView();632+633+    expect(container.querySelector("#restore-csv-pass")).toBeTruthy();634+    expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();635+    expect(container.querySelector(".peach-restore-sources")).toBeNull();636+  });637+638+  it("Lock restore PeachScript form loads a dropped .peach file", async () => {639+    const container = mount(() => (640+      <LockView641+        hasVault={false}642+        ready={true}643+        onUnlock={vi.fn()}644+        initialRestore={true}645+        initialRestoreSource="symbols"646+      />647+    ));648+    await flushView();649+650+    const form = container.querySelector(".peach-restore-form") as HTMLElement;651+    expect(form).toBeTruthy();652+653+    // The OS file picker dismisses the action popup, so drag-and-drop is the654+    // file path that survives; a drop must land the file in the script field.655+    const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });656+    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {657+      dataTransfer?: { files: File[] };658+    };659+    drop.dataTransfer = { files: [file] };660+    form.dispatchEvent(drop);661+    await flushView();662+663+    const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;664+    expect(script.value).toBe("dropped-peach-backup");602665   });603666 604667   it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {

view file

tests/lock-screen.spec.ts+23 −2
@@ -303,13 +303,34 @@303303     await next.click();304304     await popup.waitForTimeout(350);305305     await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();306-    await expect(popup.locator(".peach-restore-tabs")).toBeVisible({ timeout: 5000 });306+    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });307307 308-    await popup.locator(".peach-lock-restore-link").click();308+    await popup.locator(".peach-restore-back").click();309309 310310     await expect(popup.locator(".peach-onboarding-card")).toBeVisible({ timeout: 5000 });311311     await expect(popup.locator(".peach-onboarding-card")).toContainText("Already using Peach?");312312     await expect(popup.locator("#master-password")).toHaveCount(0);313+    await ctx.close();314+  });315+316+  test("LS25 - closing the popup on a restore source resumes that source, not the picker", async () => {317+    const { popup } = await loadPopup(ctx);318+    const next = popup.locator(".peach-onboarding-next");319+    await next.click();320+    await popup.waitForTimeout(350);321+    await next.click();322+    await popup.waitForTimeout(350);323+    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();324+    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });325+    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();326+    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });327+328+    // The OS file picker dismisses the popup mid-restore; reopening must land329+    // back on the same source form instead of rewinding to the source picker.330+    await popup.close();331+    const { popup: reopened } = await loadPopup(ctx);332+    await expect(reopened.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });333+    await expect(reopened.locator(".peach-restore-sources")).toHaveCount(0);313334     await ctx.close();314335   });315336 

view file