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
↓ peach-browser-7395ace4.tar.gzatlas at this save
files changed8 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 −2manifest.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": {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",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",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={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 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 );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 () => {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