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
↓ peach-browser-976cd94c.tar.gzatlas at this save
files changed2 files
src/popup/styles/popup.css+251 −133src/popup/views/Lock.tsx+710 −299src/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 */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