976cd94cd777

restore screen tab redesign — PeachScript/Mayan/CSV/From Phone tabs, import animation, intro popup, scrollbar suppression

time
2026-05-01 06:01 UTC · 5 months ago
machine
koh2
parent
f710b8557c65
2 files src/popup/styles/popup.css+251 −133src/popup/views/Lock.tsx+710 −299
src/popup/styles/popup.css+251 −133
@@ -3590,192 +3590,310 @@35903590   border-top: 1px solid rgba(240, 232, 216, 0.06);35913591 }35923592 3593-/* Restore picker panel — method option cards (legacy; kept for any callers3594-   that still mount the old shape). The v2.12.0 restore picker uses the3595-   .peach-restore-stack glass cards below instead. */3596-.peach-lock-panel--restore {3597-  gap: 10px;3593+/* ── Restore tab view ─────────────────────────────────────────────────────3594+   Tab-based restore screen replacing the old three-card picker. */3595+3596+.peach-restore-tabview {3597+  overflow: hidden;35983598 }3599-.peach-lock-option--history {3600-  border-color: rgba(240, 232, 216, 0.06);3601-  opacity: 0.85;3599+3600+.peach-restore-tabcard {3601+  flex: 1;3602+  display: flex;3603+  flex-direction: column;3604+  gap: 0;3605+  padding: 0;3606+  overflow: hidden;3607+  cursor: default;3608+  user-select: none;3609+  animation: peach-onboard-enter 320ms cubic-bezier(0.16, 1, 0.3, 1) both;36023610 }36033611 3604-/* ── Restore picker (v2.12.0) ─────────────────────────────────────────────3605-   Three onboarding-style glass cards (PeachScript / Mayan code / CSV) that3606-   replace the flat option list. Visually echoes .peach-onboarding-card so3607-   the restore surface reads as part of the onboarding family. */3608-.peach-restore-screen {3609-  /* overflow: hidden from lock-screen--vault-stage clips the card stack3610-     when three cards exceed the popup height — allow scroll instead. */3611-  overflow-y: auto !important;3612-  padding-bottom: 14px;3612+/* ── Tab selector ─────────────────────────────────────────────────────── */3613+3614+.peach-restore-tabs {3615+  display: flex;3616+  justify-content: space-around;3617+  padding: 14px 10px 10px;3618+  border-bottom: 1px solid var(--peach-t3);3619+  gap: 2px;3620+  overflow: hidden;3621+  scrollbar-width: none;36133622 }3623+.peach-restore-tabs::-webkit-scrollbar { display: none; }36143624 3615-.peach-restore-screen .peach-lock-hero {3616-  margin-bottom: 4px;3625+.peach-restore-tab {3626+  background: none;3627+  border: none;3628+  cursor: pointer;3629+  padding: 3px 4px;3630+  font-family: 'Cormorant Garamond', Georgia, serif;3631+  font-style: italic;3632+  font-size: 13px;3633+  color: var(--peach-t2);3634+  transition: color 150ms ease, text-shadow 150ms ease;3635+  flex: 1;3636+  text-align: center;3637+  white-space: nowrap;3638+  overflow: hidden;36173639 }3640+.peach-restore-tab:hover {3641+  color: var(--peach-accent);3642+  text-shadow:3643+    0 0 8px color-mix(in srgb, var(--peach-accent) 50%, transparent),3644+    0 0 18px color-mix(in srgb, var(--peach-accent) 25%, transparent);3645+}3646+.peach-restore-tab.is-active {3647+  color: var(--peach-accent);3648+  text-shadow:3649+    0 0 10px color-mix(in srgb, var(--peach-accent) 60%, transparent),3650+    0 0 22px color-mix(in srgb, var(--peach-accent) 30%, transparent);3651+}36183652 3619-.peach-restore-stack {3620-  position: relative;3621-  z-index: 1;3653+/* ── Content area ─────────────────────────────────────────────────────── */3654+3655+.peach-restore-content {3656+  padding: 16px 16px 14px;36223657   display: flex;36233658   flex-direction: column;3624-  gap: 9px;3625-  margin-top: 4px;3659+  gap: 8px;3660+  overflow: hidden;3661+  scrollbar-width: none;3662+  flex: 1;36263663 }3664+.peach-restore-content::-webkit-scrollbar { display: none; }3665+.peach-restore-content > * {3666+  overflow: hidden;3667+  scrollbar-width: none;3668+}3669+.peach-restore-content > *::-webkit-scrollbar { display: none; }36273670 3628-.peach-restore-card {3629-  position: relative;3630-  width: 100%;3671+/* ── Shared tab inner layout ──────────────────────────────────────────── */3672+3673+.peach-rt-symbols,3674+.peach-rt-mayan,3675+.peach-rt-csv,3676+.peach-rt-phone {36313677   display: flex;36323678   flex-direction: column;3633-  gap: 6px;3634-  padding: 14px 16px 13px;3635-  border-radius: 14px;3679+  gap: 8px;3680+  overflow: hidden;3681+}3682+3683+.peach-rt-submit {3684+  width: 100%;3685+}3686+3687+.peach-rt-error {3688+  margin: 0;3689+  font-size: 0.6875rem;3690+  color: var(--peach-breach);3691+  line-height: 1.4;3692+}3693+3694+/* ── PeachScript tab ──────────────────────────────────────────────────── */3695+3696+.peach-restore-symbols-textarea {3697+  font-family: 'PeachScript', monospace;3698+  font-size: 13px;3699+  line-height: 1.9;3700+  letter-spacing: 0.1em;3701+  word-break: break-all;3702+  resize: none;3703+  width: 100%;3704+  min-height: 56px;3705+  max-height: 72px;3706+  overflow: hidden;3707+  scrollbar-width: none;3708+  -ms-overflow-style: none;3709+  box-sizing: border-box;3710+  padding: 6px 8px;3711+  border-radius: 8px;36363712   border: 1px solid var(--peach-t3);3637-  background:3638-    linear-gradient(180deg, rgba(240, 232, 216, 0.035), rgba(240, 232, 216, 0.012));3639-  backdrop-filter: blur(3px);3640-  -webkit-backdrop-filter: blur(3px);3641-  box-shadow:3642-    inset 0 1px 0 rgba(240, 232, 216, 0.04),3643-    0 8px 18px rgba(0, 0, 0, 0.18);3713+  background: rgba(240, 232, 216, 0.03);36443714   color: var(--peach-t0);3645-  text-align: left;3646-  cursor: pointer;3647-  transition:3648-    border-color 160ms ease,3649-    transform 160ms ease,3650-    box-shadow 160ms ease,3651-    background 160ms ease;3652-  animation: peach-card-in 240ms cubic-bezier(0.16, 1, 0.3, 1) both;36533715 }3716+.peach-restore-symbols-textarea::-webkit-scrollbar { display: none; }3717+.peach-restore-symbols-textarea:focus { outline: none; border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); }36543718 3655-.peach-restore-card:hover {3656-  border-color: color-mix(in srgb, var(--peach-accent) 32%, transparent);3657-  background:3658-    linear-gradient(180deg, rgba(240, 232, 216, 0.05), rgba(240, 232, 216, 0.02));3659-  transform: translateY(-1px);3660-  box-shadow:3661-    inset 0 1px 0 rgba(240, 232, 216, 0.05),3662-    0 12px 24px rgba(0, 0, 0, 0.22);3719+.peach-restore-symbols-pass {3720+  width: 100%;3721+  box-sizing: border-box;3722+  padding: 7px 10px;3723+  border-radius: 8px;3724+  border: 1px solid var(--peach-t3);3725+  background: rgba(240, 232, 216, 0.03);3726+  color: var(--peach-t0);3727+  font-size: 0.8125rem;36633728 }3729+.peach-restore-symbols-pass::placeholder { color: var(--peach-t2); }3730+.peach-restore-symbols-pass:focus { outline: none; border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); }36643731 3665-.peach-restore-card:focus-visible {3666-  outline: none;3667-  border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent);3668-  box-shadow:3669-    inset 0 1px 0 rgba(240, 232, 216, 0.05),3670-    0 0 0 2px color-mix(in srgb, var(--peach-accent) 24%, transparent);3732+.peach-rt-divider {3733+  text-align: center;3734+  font-size: 0.6875rem;3735+  color: var(--peach-t2);3736+  font-style: italic;36713737 }36723738 3673-/* The Mayan card is interactive but routes to a "decoder coming soon"3674-   stub — we still want the hover affordance, just slightly toned down. */3675-.peach-restore-card:has(.peach-restore-card-soon) {3676-  opacity: 0.78;3739+.peach-rt-file-label {3740+  width: 100%;3741+  justify-content: center;36773742 }3678-.peach-restore-card:has(.peach-restore-card-soon):hover {3679-  opacity: 0.92;3743+3744+.peach-rt-anim-canvas {3745+  width: 100%;3746+  height: 180px;3747+  border-radius: 10px;3748+  overflow: hidden;3749+  display: block;36803750 }36813751 3682-/* Used by MayanComingSoonView for the explanatory card — non-interactive. */3683-.peach-restore-card--static {3684-  cursor: default;3752+/* ── Mayan Code tab ───────────────────────────────────────────────────── */3753+3754+.peach-rt-upload-zone {3755+  display: flex;3756+  align-items: center;3757+  justify-content: center;3758+  height: 64px;3759+  border-radius: 10px;3760+  border: 1px dashed var(--peach-t3);3761+  background: rgba(240, 232, 216, 0.02);3762+  cursor: pointer;3763+  transition: border-color 150ms ease, background 150ms ease;3764+  overflow: hidden;36853765 }3686-.peach-restore-card--static:hover {3687-  border-color: var(--peach-t3);3688-  transform: none;3689-  background:3690-    linear-gradient(180deg, rgba(240, 232, 216, 0.035), rgba(240, 232, 216, 0.012));3691-  box-shadow:3692-    inset 0 1px 0 rgba(240, 232, 216, 0.04),3693-    0 8px 18px rgba(0, 0, 0, 0.18);3766+.peach-rt-upload-zone:hover {3767+  border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);3768+  background: color-mix(in srgb, var(--peach-accent) 4%, transparent);36943769 }3770+.peach-rt-upload-zone.is-busy {3771+  cursor: default;3772+  opacity: 0.6;3773+}36953774 3696-.peach-restore-card-headline {3697-  margin: 0;3698-  font-family: var(--font-display);3775+.peach-rt-upload-label {3776+  font-size: 0.75rem;3777+  color: var(--peach-t2);36993778   font-style: italic;3700-  font-weight: 400;3701-  font-size: 19px;3702-  line-height: 1.05;3703-  color: var(--peach-t0);37043779 }37053780 3706-.peach-restore-card-divider {3707-  margin: 1px 0 3px;3708-  border: 0;3709-  border-top: 1px solid var(--peach-t3);3710-  width: 100%;3711-}3712-3713-.peach-restore-card-body {3781+.peach-rt-entry-line {37143782   margin: 0;37153783   font-size: 0.75rem;3716-  line-height: 1.55;37173784   color: var(--peach-t1);3785+  font-style: italic;3786+  text-align: center;37183787 }37193788 3720-.peach-restore-card-body code {3721-  font-family: "IBM Plex Mono", monospace;3789+/* ── CSV tab ──────────────────────────────────────────────────────────── */3790+3791+.peach-rt-csv-sources {3792+  margin: 0;37223793   font-size: 0.6875rem;3723-  padding: 1px 5px;3724-  border-radius: 4px;3725-  background: rgba(240, 232, 216, 0.06);3726-  color: var(--peach-t0);3794+  color: var(--peach-t2);3795+  line-height: 1.5;3796+  text-align: center;37273797 }37283798 3729-.peach-restore-card-meta {3730-  margin: 2px 0 0;3799+.peach-rt-csv-warning {3800+  margin: 0;37313801   font-family: "IBM Plex Mono", monospace;37323802   font-size: 0.5625rem;3733-  letter-spacing: 0.14em;3734-  text-transform: uppercase;3735-  color: var(--peach-t2);3803+  letter-spacing: 0.12em;3804+  color: var(--peach-breach);3805+  text-align: center;37363806 }37373807 3738-.peach-restore-card-soon {3739-  position: absolute;3740-  top: 12px;3741-  right: 12px;3742-  display: inline-flex;3743-  align-items: center;3744-  padding: 2px 7px;3745-  border-radius: 999px;3746-  border: 1px solid color-mix(in srgb, var(--peach-accent) 26%, transparent);3747-  background: color-mix(in srgb, var(--peach-accent) 10%, transparent);3748-  color: var(--peach-t0);3808+/* ── From Phone tab ───────────────────────────────────────────────────── */3809+3810+.peach-rt-phone-code {3811+  margin: 0;3812+  text-align: center;37493813   font-family: "IBM Plex Mono", monospace;3750-  font-size: 0.5rem;3814+  font-size: 22px;37513815   letter-spacing: 0.18em;3752-  text-transform: uppercase;3816+  color: var(--peach-t0);37533817 }37543818 3755-.peach-restore-footer {3756-  position: relative;3757-  z-index: 1;3758-  margin-top: 6px;3759-  padding-top: 10px;3760-  border-top: 1px solid rgba(240, 232, 216, 0.06);3819+.peach-rt-phone-qr {3820+  display: block;3821+  width: 96px;3822+  height: 96px;3823+  margin: 0 auto;3824+  border-radius: 6px;3825+  image-rendering: pixelated;3826+  overflow: hidden;3827+}3828+3829+.peach-rt-phone-hint {3830+  margin: 0;3831+  font-size: 0.6875rem;3832+  color: var(--peach-t2);3833+  line-height: 1.55;3834+  text-align: center;3835+}3836+3837+.peach-rt-phone-gate-text {3838+  margin: 0;3839+  font-size: 0.8125rem;3840+  color: var(--peach-t1);3841+  text-align: center;3842+}3843+3844+/* ── Intro popup ──────────────────────────────────────────────────────── */3845+3846+.peach-restore-intro-overlay {3847+  position: absolute;3848+  inset: 0;3849+  z-index: 100;37613850   display: flex;3851+  align-items: center;37623852   justify-content: center;3853+  padding: 20px;3854+  background: rgba(12, 10, 6, 0.7);3855+  backdrop-filter: blur(4px);3856+  animation: peach-card-in 280ms cubic-bezier(0.16, 1, 0.3, 1) both;37633857 }37643858 3765-.peach-restore-footer-link {3766-  background: none;3767-  border: none;3768-  padding: 4px 0;3769-  cursor: pointer;3770-  font-family: "IBM Plex Mono", monospace;3771-  font-size: 0.625rem;3772-  letter-spacing: 0.06em;3773-  color: var(--peach-t2);3774-  transition: color 150ms ease;3859+.peach-restore-intro-card {3860+  width: 100%;3861+  display: flex;3862+  flex-direction: column;3863+  gap: 12px;3864+  padding: 22px 20px 20px;3865+  border-radius: 16px;3866+  border: 1px solid var(--peach-t3);3867+  background: linear-gradient(180deg, rgba(240, 232, 216, 0.05), rgba(240, 232, 216, 0.02));3868+  backdrop-filter: blur(4px);3869+  -webkit-backdrop-filter: blur(4px);3870+  box-shadow: inset 0 1px 0 rgba(240, 232, 216, 0.06), 0 16px 36px rgba(0, 0, 0, 0.3);37753871 }37763872 3777-.peach-restore-footer-link:hover {3873+.peach-restore-intro-headline {3874+  margin: 0;3875+  font-family: var(--font-display);3876+  font-style: italic;3877+  font-size: 20px;3878+  font-weight: 400;37783879   color: var(--peach-t0);3880+}3881+3882+.peach-restore-intro-body {3883+  display: flex;3884+  flex-direction: column;3885+  gap: 8px;3886+}3887+.peach-restore-intro-body p {3888+  margin: 0;3889+  font-size: 0.75rem;3890+  color: var(--peach-t1);3891+  line-height: 1.5;3892+}3893+3894+.peach-restore-intro-btn {3895+  width: 100%;3896+  margin-top: 4px;37793897 }37803898 37813899 /* Snapshot restore panel */

view file

src/popup/views/Lock.tsx+710 −299
@@ -1,11 +1,16 @@1-import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";1+import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";22 import { Capacitor } from "@capacitor/core";33 44 import { sendMessage } from "../App";55 import { LockRuneCanvas } from "../components/LockRuneCanvas";66 import { PeachMark, PeachWordmark } from "../components/PeachMark";7+import { RuneBackground } from "../components/RuneBackground";78 import { ImportView } from "./Import";89 import { biometricUnlock, biometricAvailable } from "../../shared/biometric";10+import { storageGet, storageSet } from "../../shared/storage";11+import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";12+import { parsePayload } from "../../crypto/peachPayload";13+import { parseImportSource } from "../../shared/import";914 1015 // Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation1116 let _zxcvbn: ((password: string) => { score: number }) | null = null;@@ -27,9 +32,7 @@2732   onBackToOnboarding?: () => void;2833 }2934 30-// "restore" = picker, "peachscript" / "file" = detail flows,31-// "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).32-type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | null;35+type RestoreSurface = "restore" | null;3336 3437 export function LockView(props: LockProps) {3538   const [password, setPassword] = createSignal("");@@ -148,21 +151,12 @@148151   // ── All surfaces routed reactively via Switch/Match ───────────────────149152   return (150153     <Switch>151-      {/* ── Restore picker ────────────────────────────────────────── */}154+      {/* ── Restore tab view ──────────────────────────────────────── */}152155       <Match when={restoreSurface() === "restore"}>153-        <RestorePickerView154-          onBack={() => setRestoreSurface(null)}155-          onPickPeachScript={() => setRestoreSurface("peachscript")}156-          onPickMayan={() => setRestoreSurface("mayan")}157-          onPickCSV={() => setRestoreSurface("file")}158-        />159-      </Match>160-161-      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}162-      <Match when={restoreSurface() === "mayan"}>163-        <MayanRestoreView156+        <RestoreTabView164157           hasVault={props.hasVault}165-          onBack={() => setRestoreSurface("restore")}158+          isPro={props.isPro ?? false}159+          onBack={() => setRestoreSurface(null)}166160           onUnlock={async () => {167161             setRestoreSurface(null);168162             await props.onUnlock();@@ -170,34 +164,6 @@170164         />171165       </Match>172166 173-      {/* ── PeachScript restore ───────────────────────────────────── */}174-      <Match when={restoreSurface() === "peachscript"}>175-        <ImportView176-          mode="onboarding"177-          replacesExistingVault={props.hasVault}178-          initialView="peachscript"179-          onBack={() => setRestoreSurface("restore")}180-          onImport={async () => {181-            setRestoreSurface(null);182-            await props.onUnlock();183-          }}184-        />185-      </Match>186-187-      {/* ── File restore ──────────────────────────────────────────── */}188-      <Match when={restoreSurface() === "file"}>189-        <ImportView190-          mode="onboarding"191-          replacesExistingVault={props.hasVault}192-          initialView="file"193-          onBack={() => setRestoreSurface("restore")}194-          onImport={async () => {195-            setRestoreSurface(null);196-            await props.onUnlock();197-          }}198-        />199-      </Match>200-201167       {/* ── Unlock (hasVault = true) ───────────────────────────────── */}202168       <Match when={props.hasVault}>203169         <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">@@ -417,19 +383,48 @@417383   );418384 }419385 420-// ── Restore method picker ──────────────────────────────────────────────────386+// ── Restore tab view ───────────────────────────────────────────────────────421387 422-function RestorePickerView(props: {388+type RestoreTab = "symbols" | "mayan" | "csv" | "phone";389+390+function tabLabel(tab: RestoreTab): string {391+  return (392+    { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]393+  );394+}395+396+function RestoreTabView(props: {397+  hasVault: boolean;398+  isPro: boolean;423399   onBack: () => void;424-  onPickPeachScript: () => void;425-  onPickMayan: () => void;426-  onPickCSV: () => void;400+  onUnlock: () => void | Promise<void>;427401 }) {402+  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");403+  const [showIntro, setShowIntro] = createSignal(false);404+  const [pendingUnlock, setPendingUnlock] = createSignal(false);405+406+  async function handleRestoreSuccess() {407+    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);408+    if (!seen) {409+      setShowIntro(true);410+    } else {411+      await props.onUnlock();412+    }413+  }414+415+  async function dismissIntro() {416+    await storageSet("peach_shown_restore_intro", "true").catch(() => {});417+    setShowIntro(false);418+    await props.onUnlock();419+  }420+428421   return (429422     <section430-      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"423+      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"431424       data-motion-phase="enter"432425     >426+      <RuneBackground opacity={0.05} />427+433428       <header class="peach-screen-header lock-screen-header">434429         <PeachWordmark class="peach-wordmark-wrap" />435430         <button class="peach-lock-restore-link" onClick={props.onBack}>@@ -437,69 +432,64 @@437432         </button>438433       </header>439434 440-      <div class="peach-lock-hero">441-        <span class="peach-lock-eyebrow">restore</span>442-        <h1 class="peach-onboarding-headline">443-          bring back444-          <br />445-          your vault.446-        </h1>435+      <div class="peach-newvault-card peach-restore-tabcard">436+        <div class="peach-restore-tabs" role="tablist">437+          <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>438+            {(tab) => (439+              <button440+                class="peach-restore-tab"441+                classList={{ "is-active": activeTab() === tab }}442+                onClick={() => setActiveTab(tab)}443+                role="tab"444+                aria-selected={activeTab() === tab}445+              >446+                {tabLabel(tab)}447+              </button>448+            )}449+          </For>450+        </div>451+        <div class="peach-restore-content">452+          <Show when={activeTab() === "symbols"}>453+            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />454+          </Show>455+          <Show when={activeTab() === "mayan"}>456+            <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />457+          </Show>458+          <Show when={activeTab() === "csv"}>459+            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />460+          </Show>461+          <Show when={activeTab() === "phone"}>462+            <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />463+          </Show>464+        </div>447465       </div>448466 449-      <div class="peach-restore-stack" role="list">450-        <button451-          class="peach-restore-card"452-          role="listitem"453-          onClick={props.onPickPeachScript}454-        >455-          <h2 class="peach-restore-card-headline">PeachScript</h2>456-          <hr class="peach-restore-card-divider" />457-          <p class="peach-restore-card-body">458-            Paste a string of Peach symbols, or open a <code>.peach</code> file459-            you saved from another device.460-          </p>461-          <p class="peach-restore-card-meta">backup password required</p>462-        </button>463-464-        <button465-          class="peach-restore-card"466-          role="listitem"467-          onClick={props.onPickMayan}468-        >469-          <h2 class="peach-restore-card-headline">Mayan code</h2>470-          <hr class="peach-restore-card-divider" />471-          <p class="peach-restore-card-body">472-            Restore from a printed Mayan code — scan or upload a photo.473-          </p>474-          <p class="peach-restore-card-meta">decoder shipping soon</p>475-        </button>476-477-        <button478-          class="peach-restore-card"479-          role="listitem"480-          onClick={props.onPickCSV}481-        >482-          <h2 class="peach-restore-card-headline">CSV</h2>483-          <hr class="peach-restore-card-divider" />484-          <p class="peach-restore-card-body">485-            Move in from Bitwarden, 1Password, Proton Pass, or a browser486-            (Chrome, Safari, Edge) CSV export.487-          </p>488-          <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>489-        </button>490-      </div>491-467+      {/* One-time intro popup after first restore */}468+      <Show when={showIntro()}>469+        <div class="peach-restore-intro-overlay">470+          <div class="peach-restore-intro-card">471+            <p class="peach-restore-intro-headline">welcome to peach.</p>472+            <div class="peach-restore-intro-body">473+              <p>Your vault lives only on this device. No server has a copy.</p>474+              <p>The inline bubble fills passwords automatically when you visit sites.</p>475+              <p>Export your vault from Settings before switching devices.</p>476+            </div>477+            <button478+              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"479+              onClick={() => void dismissIntro()}480+              disabled={pendingUnlock()}481+            >482+              got it483+            </button>484+          </div>485+        </div>486+      </Show>492487     </section>493488   );494489 }495490 496-// ── Mayan code restore ─────────────────────────────────────────────────────491+// ── Mayan image decode helper ──────────────────────────────────────────────497492 498-/**499- * Decode a base64-encoded Mayan code image to the binary backup payload.500- * Returns either { ok: true, payload } or { ok: false, reason } so the UI501- * can surface a precise error per failure mode.502- */503493 async function handleMayanDecode(base64: string): Promise<504494   { ok: true; payload: Uint8Array } | { ok: false; reason: string }505495 > {@@ -512,8 +502,7 @@512502     img.src = dataUrl;513503   });514504   const canvas = document.createElement("canvas");515-  // Cap resolution to keep adaptive-binarize bounded — Mayan codes don't need516-  // more than ~1500×1500 to read every module on a phone-camera capture.505+  // Cap resolution so adaptive-binarize stays bounded.517506   const MAX_DIM = 1500;518507   const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));519508   canvas.width = Math.round(img.width * scale);@@ -526,125 +515,369 @@526515   if (!payload) {527516     return {528517       ok: false,529-      reason:530-        "Couldn't read this Mayan code. Try a closer, sharper photo with even lighting.",518+      reason: "Could not read this Mayan code. Try a clearer image.",531519     };532520   }533521   return { ok: true, payload };534522 }535523 536-function MayanRestoreView(props: {537-  hasVault: boolean;538-  onBack: () => void;539-  onUnlock: () => void | Promise<void>;540-}) {541-  const [decodeError, setDecodeError] = createSignal<string | null>(null);524+// ── PeachScript tab ────────────────────────────────────────────────────────525+526+function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {527+  const [scriptInput, setScriptInput] = createSignal("");528+  const [backupPass, setBackupPass] = createSignal("");529+  const [working, setWorking] = createSignal(false);530+  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);531+  const [error, setError] = createSignal<string | null>(null);532+  let canvasRef: HTMLCanvasElement | undefined;533+  let animFrameId = 0;534+  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];535+536+  const scriptValid = createMemo(() => isPeachScript(scriptInput()));537+538+  function stopAnim() {539+    cancelAnimationFrame(animFrameId);540+    animFrameId = 0;541+  }542+543+  function startChaos(canvas: HTMLCanvasElement) {544+    const ctx = canvas.getContext("2d")!;545+    const W = canvas.width;546+    const H = canvas.height;547+    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>548+      String.fromCodePoint(0xe000 + i)549+    );550+    if (runes.length === 0) {551+      runes = Array.from({ length: 60 }, () => ({552+        x: Math.random() * W,553+        y: Math.random() * H,554+        vx: (Math.random() - 0.5) * 3,555+        vy: (Math.random() - 0.5) * 3,556+        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,557+        size: 14 + Math.random() * 20,558+        angle: Math.random() * Math.PI * 2,559+        va: (Math.random() - 0.5) * 0.1,560+      }));561+    }562+563+    function draw() {564+      ctx.clearRect(0, 0, W, H);565+      for (const r of runes) {566+        r.x = (r.x + r.vx + W) % W;567+        r.y = (r.y + r.vy + H) % H;568+        r.angle += r.va;569+        ctx.save();570+        ctx.translate(r.x, r.y);571+        ctx.rotate(r.angle);572+        ctx.font = `${r.size}px PeachScript, monospace`;573+        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);574+        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;575+        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";576+        ctx.shadowBlur = 8;577+        ctx.fillText(r.char, 0, 0);578+        ctx.restore();579+      }580+      animFrameId = requestAnimationFrame(draw);581+    }582+    draw();583+  }584+585+  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {586+    stopAnim();587+    const ctx = canvas.getContext("2d")!;588+    const W = canvas.width;589+    const H = canvas.height;590+    const cols = 8;591+    const rows = 6;592+    const cellW = W / cols;593+    const cellH = H / rows;594+    const targets = runes.slice(0, cols * rows).map((_, i) => ({595+      x: (i % cols) * cellW + cellW / 2,596+      y: Math.floor(i / cols) * cellH + cellH / 2,597+    }));598+    const start = performance.now();599+    const DURATION = 400;600+601+    function drawSnap(now: number) {602+      const t = Math.min((now - start) / DURATION, 1);603+      const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;604+      ctx.clearRect(0, 0, W, H);605+      runes.slice(0, targets.length).forEach((r, i) => {606+        const tx = targets[i]!.x;607+        const ty = targets[i]!.y;608+        const cx = r.x + (tx - r.x) * ease;609+        const cy = r.y + (ty - r.y) * ease;610+        ctx.font = `16px PeachScript, monospace`;611+        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;612+        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";613+        ctx.shadowBlur = 6;614+        ctx.fillText(r.char, cx - 8, cy + 8);615+      });616+      if (t < 1) {617+        animFrameId = requestAnimationFrame(drawSnap);618+      } else {619+        onDone();620+      }621+    }622+    animFrameId = requestAnimationFrame(drawSnap);623+  }624+625+  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {626+    stopAnim();627+    const ctx = canvas.getContext("2d")!;628+    const W = canvas.width;629+    const H = canvas.height;630+    const start = performance.now();631+    const DURATION = 900;632+633+    function drawBloom(now: number) {634+      const t = Math.min((now - start) / DURATION, 1);635+      ctx.clearRect(0, 0, W, H);636+      // Runes dissolve637+      const runeAlpha = 1 - Math.min(t / 0.4, 1);638+      if (runeAlpha > 0) {639+        for (const r of runes.slice(0, 48)) {640+          ctx.font = `16px PeachScript, monospace`;641+          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;642+          ctx.fillText(r.char, r.x - 8, r.y + 8);643+        }644+      }645+      // Peach mark bloom646+      const markT = Math.max(0, (t - 0.2) / 0.5);647+      const markScale = Math.min(markT * 1.2, 1);648+      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;649+      if (markAlpha > 0) {650+        ctx.save();651+        ctx.translate(W / 2, H / 2);652+        ctx.scale(markScale, markScale);653+        ctx.font = `bold 64px serif`;654+        ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;655+        ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;656+        ctx.shadowBlur = 30;657+        ctx.textAlign = "center";658+        ctx.textBaseline = "middle";659+        ctx.fillText("🍑", 0, 0);660+        ctx.restore();661+      }662+      if (t < 1) {663+        animFrameId = requestAnimationFrame(drawBloom);664+      } else {665+        onDone();666+      }667+    }668+    animFrameId = requestAnimationFrame(drawBloom);669+  }670+671+  onCleanup(() => stopAnim());672+673+  async function handleRestore() {674+    if (!scriptValid() || !backupPass().trim() || working()) return;675+    setError(null);676+    setWorking(true);677+    setAnimating("chaos");678+679+    const canvas = canvasRef;680+    if (canvas) startChaos(canvas);681+682+    try {683+      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));684+      const payload = await parsePayload(bytes, backupPass());685+686+      if (!props.hasVault) {687+        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });688+        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");689+      }690+691+      const rr = await sendMessage({692+        type: "RESTORE_ENTRIES",693+        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },694+      });695+      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");696+697+      if (payload.license?.key) {698+        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});699+      }700+701+      setAnimating("snap");702+      stopAnim();703+      if (canvas) {704+        snapToGrid(canvas, () => {705+          setAnimating("bloom");706+          bloom(canvas, async () => {707+            setAnimating(null);708+            await props.onSuccess();709+          });710+        });711+      } else {712+        await props.onSuccess();713+      }714+    } catch (err) {715+      stopAnim();716+      setAnimating(null);717+      const code = err instanceof Error ? err.message : String(err);718+      setError(719+        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"720+          ? "That passphrase didn't work."721+          : code === "CREATE_VAULT_FAILED"722+            ? "Couldn't create a new local vault."723+            : "Could not read this backup.",724+      );725+    } finally {726+      setWorking(false);727+    }728+  }729+730+  return (731+    <div class="peach-rt-symbols">732+      <Show when={animating() !== null}>733+        <canvas734+          ref={(el) => { canvasRef = el; }}735+          class="peach-rt-anim-canvas"736+          width={280}737+          height={200}738+        />739+      </Show>740+      <Show when={animating() === null}>741+        <textarea742+          class="peach-restore-symbols-textarea"743+          placeholder="paste PeachScript here…"744+          rows={3}745+          value={scriptInput()}746+          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}747+        />748+        <div class="peach-rt-divider">— or —</div>749+        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">750+          open .peach file751+          <input752+            type="file"753+            accept=".peach,text/plain"754+            style="display:none"755+            onChange={async (e) => {756+              const file = (e.currentTarget as HTMLInputElement).files?.[0];757+              if (!file) return;758+              try {759+                setScriptInput(await file.text());760+              } catch {761+                setError("Couldn't read file.");762+              } finally {763+                (e.currentTarget as HTMLInputElement).value = "";764+              }765+            }}766+          />767+        </label>768+        <input769+          class="peach-restore-symbols-pass"770+          type="password"771+          placeholder="backup password"772+          value={backupPass()}773+          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}774+          onKeyDown={(e) => {775+            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {776+              e.preventDefault();777+              void handleRestore();778+            }779+          }}780+        />781+        <button782+          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"783+          onClick={() => void handleRestore()}784+          disabled={!scriptValid() || !backupPass().trim() || working()}785+        >786+          restore vault →787+        </button>788+        <Show when={error()}>789+          <p class="peach-rt-error">{error()}</p>790+        </Show>791+      </Show>792+    </div>793+  );794+}795+796+// ── Mayan Code tab ─────────────────────────────────────────────────────────797+798+function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {542799   const [decoding, setDecoding] = createSignal(false);543-  const [decodedBytes, setDecodedBytes] = createSignal<Uint8Array | null>(null);800+  const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);801+  const [entryLine, setEntryLine] = createSignal<string | null>(null);544802   const [backupPass, setBackupPass] = createSignal("");545-  const [masterPass, setMasterPass] = createSignal("");546-  const [masterConfirm, setMasterConfirm] = createSignal("");547803   const [restoring, setRestoring] = createSignal(false);804+  const [error, setError] = createSignal<string | null>(null);548805 549806   const isAndroid = createMemo(() =>550-    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android",807+    Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"551808   );552-  const masterReady = createMemo(553-    () => masterPass().length >= 8 && masterPass() === masterConfirm(),554-  );555809 556-  async function processBase64(base64: string) {810+  async function processFile(file: File) {557811     setDecoding(true);558-    setDecodeError(null);812+    setDecoded(null);813+    setEntryLine(null);814+    setError(null);559815     try {816+      const reader = new FileReader();817+      const base64 = await new Promise<string>((res, rej) => {818+        reader.onload = () => res(reader.result as string);819+        reader.onerror = () => rej(new Error("read error"));820+        reader.readAsDataURL(file);821+      });560822       const result = await handleMayanDecode(base64);561-      if (!result.ok) {562-        setDecodeError(result.reason);563-        return;564-      }565-      setDecodedBytes(result.payload);823+      if (!result.ok) { setError(result.reason); return; }824+      setDecoded(result.payload);825+    } catch {826+      setError("Could not read this Mayan code. Try a clearer image.");566827     } finally {567828       setDecoding(false);568829     }569830   }570831 571832   async function handleCameraScan() {572-    setDecodeError(null);833+    setError(null);573834     try {574835       const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");575-      const image = await Camera.getPhoto({576-        quality: 90,577-        resultType: CameraResultType.Base64,578-        source: CameraSource.Camera,579-        allowEditing: false,580-      });581-      if (image.base64String) await processBase64(image.base64String);836+      const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });837+      if (!image.base64String) return;838+      setDecoding(true);839+      const result = await handleMayanDecode(image.base64String);840+      setDecoding(false);841+      if (!result.ok) { setError(result.reason); return; }842+      setDecoded(result.payload);582843     } catch (err) {844+      setDecoding(false);583845       const msg = err instanceof Error ? err.message : "";584-      if (msg && !/cancel/i.test(msg)) setDecodeError(msg);846+      if (msg && !/cancel/i.test(msg)) setError(msg);585847     }586848   }587849 588-  function handleFileUpload(e: Event) {589-    const input = e.currentTarget as HTMLInputElement;590-    const file = input.files?.[0];591-    if (!file) return;592-    const reader = new FileReader();593-    reader.onload = () => {594-      const result = reader.result;595-      if (typeof result !== "string") return;596-      void processBase64(result);597-    };598-    reader.readAsDataURL(file);599-  }600-601850   async function handleRestore() {602-    const bytes = decodedBytes();603-    if (!bytes) return;604-    if (!backupPass().trim()) {605-      setDecodeError("Enter the backup password.");606-      return;607-    }608-    if (!props.hasVault && !masterReady()) {609-      setDecodeError("Choose a master password (≥8 chars, must match).");610-      return;611-    }851+    const bytes = decoded();852+    if (!bytes || !backupPass().trim() || restoring()) return;612853     setRestoring(true);613-    setDecodeError(null);854+    setError(null);614855     try {615-      const { parsePayload } = await import("../../crypto/peachPayload");616856       const payload = await parsePayload(bytes, backupPass());857+      const count = payload.entries.length;858+      const date = new Date(859+        Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))860+      );861+      const monthYear = date.getFullYear() > 2000862+        ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })863+        : null;864+      setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);865+617866       if (!props.hasVault) {618-        const createRes = await sendMessage({619-          type: "CREATE_VAULT",620-          payload: { password: masterPass() },621-        });622-        if (!(createRes as { ok: boolean }).ok) {623-          throw new Error("CREATE_VAULT_FAILED");624-        }867+        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });868+        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");625869       }626-      const restoreRes = await sendMessage({627-        type: "RESTORE_ENTRIES",628-        payload: {629-          entries: payload.entries,630-          mode: "merge",631-          settings: payload.settings,632-        },633-      });634-      const env = restoreRes as { ok: boolean; error?: string };635-      if (!env.ok) throw new Error(env.error ?? "RESTORE_FAILED");870+      const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });871+      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");636872       if (payload.license?.key) {637-        await sendMessage({638-          type: "ACTIVATE_LICENSE",639-          payload: { key: payload.license.key },640-        }).catch(() => {});873+        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});641874       }642-      await props.onUnlock();875+      await props.onSuccess();643876     } catch (err) {644877       const code = err instanceof Error ? err.message : String(err);645-      setDecodeError(878+      setError(646879         code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"647-          ? "Wrong backup password — this is the password you set when you created the Mayan code."880+          ? "Wrong backup password."648881           : code === "CREATE_VAULT_FAILED"649882             ? "Couldn't create a new local vault."650883             : "Restore failed. The backup may be corrupted.",@@ -655,127 +888,305 @@655888   }656889 657890   return (658-    <section659-      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"660-      data-motion-phase="enter"661-    >662-      <header class="peach-screen-header lock-screen-header">663-        <PeachWordmark class="peach-wordmark-wrap" />664-        <button class="peach-lock-restore-link" onClick={props.onBack}>665-          ‹ back891+    <div class="peach-rt-mayan">892+      <Show when={decoded() === null}>893+        <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>894+          <span class="peach-rt-upload-label">895+            {decoding() ? "decoding…" : "tap to upload image"}896+          </span>897+          <input898+            type="file"899+            accept="image/*"900+            style="display:none"901+            onChange={(e) => {902+              const file = (e.currentTarget as HTMLInputElement).files?.[0];903+              if (file) void processFile(file);904+              (e.currentTarget as HTMLInputElement).value = "";905+            }}906+            disabled={decoding()}907+          />908+        </label>909+        <Show when={isAndroid()}>910+          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>911+            scan with camera912+          </button>913+        </Show>914+        <Show when={error()}>915+          <p class="peach-rt-error">{error()}</p>916+        </Show>917+      </Show>918+      <Show when={decoded() !== null}>919+        <Show when={entryLine()}>920+          <p class="peach-rt-entry-line">{entryLine()}</p>921+        </Show>922+        <input923+          class="peach-restore-symbols-pass"924+          type="password"925+          placeholder="backup password"926+          ref={(el) => queueMicrotask(() => el.focus())}927+          value={backupPass()}928+          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}929+          onKeyDown={(e) => {930+            if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }931+          }}932+        />933+        <button934+          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"935+          onClick={() => void handleRestore()}936+          disabled={!backupPass().trim() || restoring()}937+        >938+          {restoring() ? "restoring…" : "restore vault →"}666939         </button>667-      </header>940+        <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>941+          ← try another image942+        </button>943+        <Show when={error()}>944+          <p class="peach-rt-error">{error()}</p>945+        </Show>946+      </Show>947+    </div>948+  );949+}668950 669-      <div class="peach-lock-hero">670-        <span class="peach-lock-eyebrow">mayan code</span>671-        <h1 class="peach-onboarding-headline">672-          {decodedBytes() ? "enter password." : "scan or upload."}673-        </h1>951+// ── CSV tab ────────────────────────────────────────────────────────────────952+953+function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {954+  const [importing, setImporting] = createSignal(false);955+  const [error, setError] = createSignal<string | null>(null);956+957+  async function handleFile(e: Event) {958+    const input = e.currentTarget as HTMLInputElement;959+    const file = input.files?.[0];960+    input.value = "";961+    if (!file) return;962+    setImporting(true);963+    setError(null);964+    try {965+      const text = await file.text();966+      const parsed = await parseImportSource(text);967+      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");968+969+      if (props.hasVault === false) {970+        // No vault yet — this path shouldn't normally occur for CSV (no backup password971+        // means no way to create a locked vault). Create an unlocked placeholder vault.972+        // The user can set a master password from settings.973+        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });974+        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");975+      }976+977+      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });978+      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");979+      if (parsed.licenseKey) {980+        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});981+      }982+      await props.onSuccess();983+    } catch (err) {984+      const code = err instanceof Error ? err.message : String(err);985+      setError(986+        code === "NO_ENTRIES" ? "No entries found in this file." :987+        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :988+        "Import failed. Check the file format and try again."989+      );990+    } finally {991+      setImporting(false);992+    }993+  }994+995+  return (996+    <div class="peach-rt-csv">997+      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>998+        {importing() ? "importing…" : "open CSV file"}999+        <input1000+          type="file"1001+          accept=".csv,.json,.peach,application/x-peach"1002+          style="display:none"1003+          onChange={handleFile}1004+          disabled={importing()}1005+        />1006+      </label>1007+      <p class="peach-rt-csv-sources">1008+        Imports from Bitwarden, 1Password, Proton Pass,<br />1009+        Chrome, Safari, and Edge.1010+      </p>1011+      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1012+      <Show when={error()}>1013+        <p class="peach-rt-error">{error()}</p>1014+      </Show>1015+    </div>1016+  );1017+}1018+1019+// ── From Phone tab ─────────────────────────────────────────────────────────1020+1021+type PhoneState = "waiting" | "connected" | "trust" | "error";1022+1023+function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1024+  const [state, setState] = createSignal<PhoneState>("waiting");1025+  const [code, setCode] = createSignal<string | null>(null);1026+  const [qrUrl, setQrUrl] = createSignal<string | null>(null);1027+  const [expired, setExpired] = createSignal(false);1028+  const [deviceName, setDeviceName] = createSignal<string | null>(null);1029+  const [accepting, setAccepting] = createSignal(false);1030+  const [serverError, setServerError] = createSignal<string | null>(null);1031+  let expireTimer: ReturnType<typeof setTimeout> | null = null;1032+1033+  function clearExpire() {1034+    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1035+  }1036+1037+  async function startPairing() {1038+    setExpired(false);1039+    setCode(null);1040+    setQrUrl(null);1041+    setServerError(null);1042+    clearExpire();1043+    const res = await sendMessage({ type: "START_PAIRING_SERVER" });1044+    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1045+    if (!r.ok || !r.data) {1046+      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1047+      return;1048+    }1049+    const token = r.data.token;1050+    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1051+    setCode(formatted);1052+    const qrData = `peach://handoff?token=${token}`;1053+    try {1054+      const QRCode = (await import("qrcode")).default;1055+      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1056+      setQrUrl(url);1057+    } catch {1058+      // QR optional — code is the primary affordance1059+    }1060+    const remaining = Math.max(0, r.data.expiresAt - Date.now());1061+    expireTimer = setTimeout(() => {1062+      setCode(null);1063+      setQrUrl(null);1064+      setExpired(true);1065+    }, remaining);1066+  }1067+1068+  function handleRuntimeMsg(msg: unknown) {1069+    const m = msg as { type?: string; payload?: { deviceName?: string } };1070+    if (m?.type === "PAIRING_COMPLETED") {1071+      clearExpire();1072+      setDeviceName(m.payload?.deviceName ?? "your phone");1073+      setState("trust");1074+    }1075+  }1076+1077+  onMount(() => {1078+    if (!props.isPro) return;1079+    void startPairing();1080+    // @ts-ignore — chrome runtime1081+    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1082+      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1083+    }1084+  });1085+1086+  onCleanup(() => {1087+    clearExpire();1088+    // @ts-ignore1089+    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1090+      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1091+    }1092+    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1093+  });1094+1095+  async function handleAccept(trust: boolean) {1096+    setAccepting(true);1097+    if (trust) {1098+      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1099+    }1100+    await props.onSuccess();1101+  }1102+1103+  if (!props.isPro) {1104+    return (1105+      <div class="peach-rt-phone">1106+        <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1107+        <button1108+          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1109+          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1110+        >1111+          Get Peach Pro1112+        </button>1113+        <button1114+          class="peach-btn peach-btn--ghost peach-btn--sm"1115+          style="margin-top:6px"1116+          onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1117+        >1118+          I already have a license1119+        </button>6741120       </div>1121+    );1122+  }6751123 676-      <Show677-        when={decodedBytes() === null}678-        fallback={679-          <div class="peach-restore-stack">680-            <div class="peach-settings-group peach-export-card peach-export-card--primary">681-              <p class="peach-settings-label">backup password</p>682-              <input683-                ref={(el) => queueMicrotask(() => el.focus())}684-                class="peach-settings-input"685-                type="password"686-                placeholder="backup password"687-                value={backupPass()}688-                onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}689-                onKeyDown={(e) => {690-                  if (e.key === "Enter" && !restoring()) {691-                    e.preventDefault();692-                    void handleRestore();693-                  }694-                }}695-              />696-              <Show when={!props.hasVault}>697-                <p class="peach-settings-label" style="margin-top:12px">698-                  master password699-                </p>700-                <input701-                  class="peach-settings-input"702-                  type="password"703-                  placeholder="master password (min 8 chars)"704-                  value={masterPass()}705-                  onInput={(e) => setMasterPass((e.target as HTMLInputElement).value)}706-                />707-                <input708-                  class="peach-settings-input"709-                  type="password"710-                  placeholder="confirm master password"711-                  value={masterConfirm()}712-                  onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}713-                />714-              </Show>715-              <button716-                class="peach-btn peach-btn--subtle peach-btn--sm"717-                style="margin-top:12px"718-                onClick={() => void handleRestore()}719-                disabled={restoring()}720-              >721-                {restoring() ? "restoring…" : "Restore"}722-              </button>723-            </div>724-            <Show when={decodeError()}>725-              <div class="peach-restore-card peach-restore-card--static">726-                <p class="peach-restore-card-body">{decodeError()}</p>727-              </div>1124+  return (1125+    <div class="peach-rt-phone">1126+      <Show when={state() === "waiting"}>1127+        <Show when={!expired()}>1128+          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1129+            <p class="peach-rt-phone-code">{code()}</p>1130+            <Show when={qrUrl() !== null}>1131+              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />7281132             </Show>729-          </div>730-        }731-      >732-        <div class="peach-restore-stack">733-          <Show when={isAndroid()}>734-            <button735-              class="peach-restore-card"736-              disabled={decoding()}737-              onClick={() => void handleCameraScan()}738-            >739-              <h2 class="peach-restore-card-headline">Scan with camera</h2>740-              <hr class="peach-restore-card-divider" />741-              <p class="peach-restore-card-body">742-                Point your camera at the printed Mayan code.743-              </p>744-            </button>1133+            <p class="peach-rt-phone-hint">1134+              Open Peach on your phone and tap<br />1135+              Settings → Send to desktop.<br />1136+              Same WiFi required.1137+            </p>7451138           </Show>1139+          <Show when={serverError()}>1140+            <p class="peach-rt-error">{serverError()}</p>1141+          </Show>1142+        </Show>1143+        <Show when={expired()}>1144+          <p class="peach-rt-phone-hint">Code expired.</p>1145+          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1146+            Generate new code1147+          </button>1148+        </Show>1149+      </Show>7461150 747-          <label748-            class="peach-restore-card"749-            classList={{ "peach-restore-card--disabled": decoding() }}750-          >751-            <h2 class="peach-restore-card-headline">Choose from gallery</h2>752-            <hr class="peach-restore-card-divider" />753-            <p class="peach-restore-card-body">754-              {decoding() ? "decoding…" : "Pick a saved photo of your Mayan code."}755-            </p>756-            <input757-              type="file"758-              accept="image/*"759-              style="display:none"760-              onChange={handleFileUpload}761-              disabled={decoding()}762-            />763-          </label>1151+      <Show when={state() === "connected"}>1152+        <p class="peach-rt-phone-hint">1153+          Connected to {deviceName() ?? "your phone"}.<br />1154+          Receiving vault…1155+        </p>1156+      </Show>7641157 765-          <Show when={decodeError()}>766-            <div class="peach-restore-card peach-restore-card--static">767-              <p class="peach-restore-card-body">{decodeError()}</p>768-            </div>769-          </Show>770-        </div>1158+      <Show when={state() === "trust"}>1159+        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1160+          Vault received from {deviceName() ?? "your phone"}.<br />1161+          Trust this network for sync?1162+        </p>1163+        <button1164+          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1165+          onClick={() => void handleAccept(true)}1166+          disabled={accepting()}1167+        >1168+          Yes, trust this network1169+        </button>1170+        <button1171+          class="peach-btn peach-btn--ghost peach-btn--sm"1172+          style="margin-top:6px"1173+          onClick={() => void handleAccept(false)}1174+          disabled={accepting()}1175+        >1176+          Not now1177+        </button>7711178       </Show>7721179 773-      <div class="peach-restore-footer">774-        <button class="peach-restore-footer-link" onClick={props.onBack}>775-          ‹ back to restore1180+      <Show when={state() === "error"}>1181+        <p class="peach-rt-phone-hint">1182+          Could not connect. Check that both devices<br />1183+          are on the same WiFi network.1184+        </p>1185+        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1186+          Try again7761187         </button>777-      </div>778-    </section>1188+      </Show>1189+    </div>7791190   );7801191 }7811192 

view file