App.tsx

82.9 KB · raw

1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { storageGet, storageRemove, storageSet } from "../shared/storage";3import { sendMessage } from "../shared/bridge";4import { ACCENT_COLORS } from "../shared/types";5import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";6import { isPasskeyOnly } from "../shared/types";7import {8  hasCompletedInitialSync,9  INITIAL_SYNC_PENDING_DEVICE_KEY,10  type InitialSyncDevice,11} from "../shared/initial-sync";12import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";13import { WhatsNewView } from "./views/WhatsNew";14import { shouldShowWhatsNew } from "../shared/release-notes";15import {16  ONBOARDING_CARD_STORAGE_KEY,17  ONBOARDING_LAST_CARD,18  Onboarding,19} from "./views/Onboarding";2021const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";22import { NotesMode } from "./views/NotesMode";23import { ProCelebration } from "./views/ProCelebration";24import { InitialSyncView } from "./views/InitialSyncView";25import { VaultView, type SortOrder } from "./views/Vault";26import { EntryView } from "./views/Entry";27import { AddView } from "./views/Add";28import { PeachWordmark } from "./components/PeachMark";29import { PasskeyApprovalView } from "./views/PasskeyApproval";30import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";31import {32  isManualSyncPending,33  syncIndicator,34  syncIndicatorLabel,35  type SyncOutcomeSummary,36} from "../shared/sync-outcome";37import { popupCopy } from "./copy";38import {39  DEFAULT_BINDINGS,40  formatKeybinding,41  getActiveBindings,42  matchKeybinding,43  SHORTCUT_DESCRIPTORS,44  type Keybinding,45  type ShortcutAction,46} from "../shared/shortcuts";47import { generateTOTP } from "../crypto/totp";4849export { sendMessage } from "../shared/bridge";5051const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));52const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));53const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));54const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));55const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));56const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));57const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));58const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));59const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));60const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6162type View =63  | "lock"64  | "pro-celebration"65  | "whats-new"66  | "entry"67  | "list"68  | "add"69  | "settings"70  | "pair-device"71  | "import"72  | "export"73  | "health"74  | "trash"75  | "panic"76  | "adopting"77  | "initial-sync"78  | "codex-generate"79  | "codex-restore";80type BootState = "loading" | "ready";8182const INITIAL_SYNC_POLL_MS = 500;83const INITIAL_SYNC_SLOW_MS = 15_000;8485let shellRingAnimation: Animation | null = null;8687export function triggerRing() {88  const shell = document.getElementById("hd-shell");89  if (!shell) return;9091  shellRingAnimation?.cancel();92  shell.dataset.glowing = "true";93  const animation = shell.animate(94    [95      { offset: 0, boxShadow: "var(--copy-shadow)" },96      {97        offset: 0.3,98        boxShadow:99          "0 2px 8px rgba(0,0,0,0.5), 0 14px 48px rgba(0,0,0,0.6), 0 0 0 1.5px color-mix(in srgb, var(--peach-accent) 58%, transparent), 0 0 22px color-mix(in srgb, var(--peach-accent) 18%, transparent)",100      },101      { offset: 1, boxShadow: "var(--copy-shadow)" },102    ],103    {104      duration: 720,105      easing: "cubic-bezier(0.4, 0, 0.2, 1)",106      fill: "none",107    }108  );109  shellRingAnimation = animation;110  const clearGlow = () => {111    if (shellRingAnimation !== animation) return;112    shellRingAnimation = null;113    delete shell.dataset.glowing;114  };115  animation.onfinish = clearGlow;116  animation.oncancel = clearGlow;117}118119export function triggerPipPulse() {120  const pip = document.querySelector(".peach-status-pip, .pip");121  if (pip) {122    pip.classList.remove("pulse");123    void (pip as HTMLElement).offsetWidth;124    pip.classList.add("pulse");125  }126}127128const ENTRY_TRANSITION_SETTLE_MS = 135;129130export default function App() {131  const [bootState, setBootState] = createSignal<BootState>("loading");132  const [view, setView] = createSignal<View>("lock");133  const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");134  const [hasVault, setHasVault] = createSignal(false);135  const [onboarded, setOnboarded] = createSignal(true);136  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);137  // The restore source picked before the popup closed (the OS file picker138  // kills it), so reopening resumes the same source form instead of the139  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.140  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);141  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes142  // back. Those flows all exit from the final card, so resume there — a fresh143  // install's first mount stays at 0.144  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);145  const [entries, setEntries] = createSignal<VaultEntry[]>([]);146  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);147  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);148149  // Rare device-trust alerts stay visible; routine entry sync is silent.150  interface SyncAlert {151    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";152    entryId: string;153    entryTitle: string;154    deviceName: string;155    timestamp: number;156  }157  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);158  // Per-group flip index: which alert within a type-group is currently shown.159  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});160  async function loadSyncAlerts() {161    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {162      ok: boolean;163      data?: SyncAlert[];164    };165    if (r.ok) setSyncAlerts(r.data ?? []);166  }167  async function dismissAlertGroup(type: SyncAlert["type"]) {168    const result = (await sendMessage({169      type: "DISMISS_SYNC_ALERTS",170      payload: { type },171    })) as { ok: boolean };172    if (!result.ok) return;173    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));174    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });175  }176  function flipAlert(type: string, dir: 1 | -1) {177    const group = syncAlerts().filter((a) => a.type === type);178    if (group.length <= 1) return;179    setAlertFlipIdx((m) => {180      const cur = m[type] ?? 0;181      return { ...m, [type]: (cur + dir + group.length) % group.length };182    });183  }184  /** Grouped alert types in display order, each with its alerts. */185  const alertGroups = createMemo(() => {186    const map = new Map<string, SyncAlert[]>();187    for (const a of syncAlerts()) {188      const g = map.get(a.type);189      if (g) g.push(a); else map.set(a.type, [a]);190    }191    return [...map.entries()];192  });193  function syncAlertText(a: SyncAlert): string {194    if (a.type === "DEVICE_REVOKED") {195      return `${a.deviceName} revoked a paired device — review your devices in Settings`;196    }197    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;198  }199  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");200  const [theme, setTheme] = createSignal<"dark" | "light">("dark");201  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");202  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);203  const [lockOnClose, setLockOnClose] = createSignal(false);204  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);205  const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);206  const [breachMonitoring, setBreachMonitoring] = createSignal(true);207  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);208  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);209  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);210  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);211  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);212  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");213  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");214  // Cached Pro status — synchronous for render paths. Background's215  // GET_LICENSE is async, but render-time reads of isPro() must not block.216  // Refreshed at boot, after activate/deactivate, and whenever the popup217  // re-opens onto a stored license that has changed via another window.218  const [proStatus, setProStatus] = createSignal(false);219  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);220  // When the sync card's pairing button opens the pairing view, deep-link221  // straight into that pairing path instead of the rest screen.222  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");223  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);224  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");225  const [listSearchSeed, setListSearchSeed] = createSignal("");226  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);227  const [pendingCelebration, setPendingCelebration] = createSignal(false);228  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);229  // A page asked to be remembered for an entry it did not match. Pages cannot230  // grow an entry's domain set themselves (PB-26-106), so the background queues231  // the request here and this banner is the confirmation gate.232  const [pendingRememberSite, setPendingRememberSite] = createSignal<{233    entryId: string;234    url: string;235    title: string;236  } | null>(null);237  // True while Panic Step 1 has bounced the user out to Export. When Export's238  // onBack fires we return to Panic instead of Settings so the user keeps the239  // destruction context they came from.240  const [returnToPanic, setReturnToPanic] = createSignal(false);241  // The view to return to when the add/edit view is dismissed. Normally "entry"242  // but "health" when the user arrives via the fix-entry path from Vault Health.243  const [editBackView, setEditBackView] = createSignal<View>("entry");244  const [settingsExiting, setSettingsExiting] = createSignal(false);245  const [showNotes, setShowNotes] = createSignal(false);246  // Topbar sync affordance. Always present: gating it on having a paired device247  // meant it never appeared for the person trying to set pairing up, which is248  // exactly when you reach for it.249  const [syncBusy, setSyncBusy] = createSignal(false);250  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);251  // The indicator light + its spoken-word equivalent. Colour alone is not the252  // signal; the title/aria-label always carries the same claim in English.253  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});254  const [pairedCount, setPairedCount] = createSignal(0);255  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);256  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);257  let initialSyncWaitGeneration = 0;258  const syncLight = () =>259    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());260  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());261  async function loadSyncIndicator() {262    const [stateRes, devicesRes] = await Promise.all([263      sendMessage({ type: "GET_SYNC_STATE" }),264      sendMessage({ type: "LIST_PAIRED_DEVICES" }),265    ]);266    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };267    if (s.ok && s.data) setSyncSummary(s.data);268    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };269    if (d.ok) {270      setPairedCount(d.data?.devices?.length ?? 0);271    } else if (d.error === "trust_data_corrupted") {272      setPairedCount(0);273      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });274    }275  }276277  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {278    const response = (await sendMessage({279      type: "LIST_PAIRED_DEVICES",280    })) as {281      ok: boolean;282      error?: string;283      data?: { devices?: InitialSyncPairedDevice[] };284    };285    if (response.ok) return response.data?.devices ?? [];286    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");287    throw new Error(response.error ?? "list_paired_devices_failed");288  }289  interface InitialSyncPairedDevice extends InitialSyncDevice {290    pairedAt?: number;291  }292293294  async function resolveInitialSyncDeviceId(): Promise<string | null> {295    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(296      () => null,297    );298    const devices = await listInitialSyncDevices().catch(() => []);299    if (stored && devices.some((device) => device.deviceId === stored)) {300      return stored;301    }302303    // Fresh adoption has exactly one direct source, but choosing the newest row304    // also repairs the marker if storage failed after the pair itself committed.305    const newest = [...devices].sort(306      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),307    )[0];308    if (!newest) return stored;309    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(310      () => undefined,311    );312    return newest.deviceId;313  }314315  async function finishInitialSync(generation: number): Promise<void> {316    if (generation !== initialSyncWaitGeneration) return;317    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);318    setInitialSyncDeviceId(null);319    setInitialSyncSlow(false);320    await refreshProStatus();321322    // A license transferred during pairing intentionally leaves the one-shot323    // celebration pending. Consume it only now: sync readiness → celebration324    // → populated vault, never celebration → temporarily empty vault.325    const celebration = (await sendMessage({326      type: "SHOULD_PLAY_CELEBRATION",327    })) as { ok: boolean; data?: boolean };328    const shouldCelebrate = celebration.ok && celebration.data === true;329    if (shouldCelebrate) {330      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });331    }332333    const opened = await openVault(undefined, shouldCelebrate);334    if (!opened || generation !== initialSyncWaitGeneration) return;335    if (shouldCelebrate) {336      setView("pro-celebration");337      window.setTimeout(() => {338        if (generation === initialSyncWaitGeneration) setView("entry");339      }, 2500);340    }341  }342343  async function beginInitialSyncWait(deviceId: string): Promise<void> {344    const generation = ++initialSyncWaitGeneration;345    setInitialSyncDeviceId(deviceId);346    setInitialSyncSlow(false);347    setView("initial-sync");348    const startedAt = Date.now();349350    // Best effort: the pairing write already wakes the mesh listener, while351    // this trigger covers the key-order case where this receiver is the dialer.352    void sendMessage({353      type: "TRIGGER_SYNC",354      payload: { reason: "foreground" },355    }).catch(() => undefined);356357    while (358      generation === initialSyncWaitGeneration &&359      view() === "initial-sync"360    ) {361      try {362        const devices = await listInitialSyncDevices();363        if (hasCompletedInitialSync(devices, deviceId)) {364          await finishInitialSync(generation);365          return;366        }367      } catch {368        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.369      }370      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {371        setInitialSyncSlow(true);372      }373      await new Promise((resolve) =>374        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),375      );376    }377  }378379  async function retryInitialSync(): Promise<void> {380    const deviceId =381      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());382    if (deviceId) {383      await beginInitialSyncWait(deviceId);384    } else {385      setInitialSyncSlow(true);386    }387  }388389  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);390  // Set on popup boot when peach_recovery_pending flag is found in session391  // storage — instructs LockView to skip the unlock UI and open the recovery392  // words confirmation surface instead. Cleared via handleUnlock once the393  // user has typed the 3 confirmation words.394  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);395396  let contentPhaseTimer: number | undefined;397  // Set when the popup was opened from the inline "Open Peach to unlock" button.398  // handleUnlock() closes the popup after unlock so the user returns to the page.399  let openedForInlineUnlock = false;400401  onCleanup(() => {402    initialSyncWaitGeneration += 1;403  });404405  function applyAccent(name: AccentColorName) {406    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;407    const hex = color.hex;408    document.documentElement.style.setProperty("--peach-accent", hex);409    // Ambient variables derived from the selected accent.410    document.documentElement.style.setProperty("--peach-glow", hex + "2A");411    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");412  }413414  createEffect(() => {415    applyAccent(accentColor());416  });417418  createEffect(() => {419    document.documentElement.classList.toggle("light", theme() === "light");420    document.body.classList.toggle("light", theme() === "light");421  });422423  createEffect(() => {424    document.documentElement.setAttribute("data-text-size", textSize());425  });426427  createEffect(() => {428    if (reduceMotion()) {429      document.documentElement.setAttribute("data-motion", "reduce");430    } else {431      document.documentElement.removeAttribute("data-motion");432    }433  });434435  // Notes are not a destination of the Entry view; selecting a note opens the436  // notes surface instead (handleSelectEntry). Anything that walks or counts437  // the Entry view's neighbours (counter, prev/next, index) must use this438  // filtered list, otherwise the user can arrow into a note and is stuck —439  // the Entry view can't render a note and the prev/next buttons stop moving.440  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));441442  const selectedIndex = createMemo(() => {443    const currentId = selectedEntry()?.id;444    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;445    return nonNoteEntries().findIndex((entry) => entry.id === currentId);446  });447448  async function applyAccentColor(name: AccentColorName) {449    applyAccent(name);450    setAccentColor(name);451    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });452  }453454  async function applyTheme(t: "dark" | "light") {455    setTheme(t);456    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });457  }458459  async function applyTextSize(size: "small" | "medium" | "large") {460    setTextSize(size);461    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });462  }463464  async function applyReduceMotion(enabled: boolean) {465    setReduceMotion(enabled);466    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });467  }468469  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload470  // small and lets future default updates land for actions the user hasn't471  // touched. Settings sends us the full active map; we filter and store.472  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {473    setShortcutBindings(active);474    const overrides: Record<string, Keybinding> = {};475    for (const action of Object.keys(active) as ShortcutAction[]) {476      const a = active[action];477      const d = DEFAULT_BINDINGS[action];478      const same = a.key === d.key479        && Boolean(a.ctrl)  === Boolean(d.ctrl)480        && Boolean(a.meta)  === Boolean(d.meta)481        && Boolean(a.shift) === Boolean(d.shift)482        && Boolean(a.alt)   === Boolean(d.alt);483      if (!same) overrides[action] = a;484    }485    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });486  }487488  async function applyLockOnClose(enabled: boolean) {489    setLockOnClose(enabled);490    await sendMessage({491      type: "UPDATE_SETTINGS",492      payload: { lockOnClose: enabled },493    });494  }495496  async function applyAutoLockMinutes(minutes: number) {497    setAutoLockMinutes(minutes);498    await sendMessage({499      type: "UPDATE_SETTINGS",500      payload: { autoLockMinutes: minutes },501    });502  }503  async function applyLockedSyncRequiresUnlock(enabled: boolean) {504    setLockedSyncRequiresUnlock(enabled);505    await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });506  }507508  async function applyBreachMonitoring(enabled: boolean) {509    setBreachMonitoring(enabled);510    await sendMessage({511      type: "UPDATE_SETTINGS",512      payload: { breachMonitoring: enabled },513    });514  }515516  // Which onboarding exit flow the user is in (create-vault / restore /517  // adopt). Persisted so closing the popup on one of those screens resumes518  // it directly instead of rewinding to the last carousel card.519  function markOnboardingExit(flow: "create" | "restore" | "adopt") {520    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);521    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});522    // Backing out of the flow remounts the carousel at this card; keep the523    // persisted card in sync so a later popup close resumes consistently.524    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});525  }526  function clearOnboardingExit() {527    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});528    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});529    setResumeRestoreSource(null);530  }531532  onMount(async () => {533    // Resolve the active tab in parallel with the unlock/has-vault queries —534    // openVault()'s smart selection picks the entry matching currentTabDomain,535    // so it must be populated before openVault runs. Previously refreshCurrentTab536    // ran after openVault, leaving popups that booted already-unlocked falling537    // back to loadedEntries[0] instead of the domain match.538    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([539      sendMessage({ type: "IS_UNLOCKED" }),540      sendMessage({ type: "HAS_VAULT" }),541      refreshCurrentTab(),542      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),543    ]);544    const unlocked = Boolean(545      (unlockResponse as { ok: boolean; data?: boolean }).data546    );547    const vaultExists = Boolean(548      (vaultResponse as { ok: boolean; data?: boolean }).data549    );550    const shouldCelebrate = Boolean(551      (celebrationResponse as { ok: boolean; data?: boolean }).data552    );553    setHasVault(vaultExists);554    // Onboarding gate — show once on first install, never again. Existing555    // users (vaultExists) are auto-marked onboarded so they skip the flow556    // even on the very first popup open after this feature ships.557    try {558      const onboarded = await storageGet("peach_onboarded");559      const seen = onboarded === "true";560      if (vaultExists && !seen) {561        await storageSet("peach_onboarded", "true");562        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});563        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});564        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});565        setOnboarded(true);566      } else {567        setOnboarded(seen);568        if (!seen) {569          // A popup close while the user is in an exit flow (create-vault /570          // restore / adopt) resumes that flow directly — landing back on the571          // carousel one screen earlier reads as losing progress.572          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);573          if (exit === "create" || exit === "restore" || exit === "adopt") {574            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);575            setOnboarded(true);576            setStartOnboardingRestore(exit === "restore");577            if (exit === "restore") {578              // The OS file picker kills the action popup mid-restore, so the579              // surface resumes on the source the user had already picked.580              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);581              if (source === "symbols" || source === "codex" || source === "csv") {582                setResumeRestoreSource(source);583              }584            }585            if (exit === "adopt") setView("adopting");586          } else {587            // Otherwise resume mid-onboarding where the user left off instead588            // of rewinding to the opening card.589            const stored = Number(590              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),591            );592            if (593              Number.isInteger(stored) &&594              stored > 0 &&595              stored <= ONBOARDING_LAST_CARD596            ) {597              setOnboardingCardAtExit(stored);598            }599          }600        }601      }602    } catch {603      // storage unavailable — default to onboarded so we never block boot604      setOnboarded(true);605    }606607    // Deep-link: ?restore=peachscript|csv — the detached-window half of the608    // file-picker flow. The action popup dismisses when the OS dialog steals609    // focus, so LockView.detachForFilePicker() reopens this URL in a610    // standalone window that owns the dialog. Routing runs before first611    // render so LockView mounts straight onto the right source form.612    try {613      const restoreRoute = new URLSearchParams(window.location.search).get("restore");614      if (restoreRoute === "peachscript" || restoreRoute === "csv") {615        setOnboarded(true);616        setStartOnboardingRestore(true);617        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");618      }619    } catch {620      // location.search unavailable — ignore.621    }622623    await Promise.all([restoreSettings(), refreshProStatus()]);624625    const pendingInitialSyncMarker =626      unlocked && vaultExists627        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)628        : null;629    const pendingInitialSyncDeviceId = pendingInitialSyncMarker630      ? await resolveInitialSyncDeviceId()631      : null;632633    // Mark played before any animation so a crash mid-sequence never replays634    // it. A newly adopted receiver deliberately defers this until its first635    // real sync finishes, preserving initial sync → celebration → vault.636    if (shouldCelebrate && !pendingInitialSyncDeviceId) {637      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });638    }639640    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome641    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.642    if (unlocked && vaultExists) {643      void sendMessage({644        type: "TRIGGER_SYNC",645        payload: { reason: "foreground" },646      });647      void loadSyncIndicator();648      // Rare device-trust alerts only; routine entry sync stays quiet.649      void loadSyncAlerts();650    }651652    // If the user created a vault but hasn't yet confirmed their recovery653    // words, route back to the recovery-words screen instead of opening the654    // entries view. The flag lives in chrome.storage.local so it survives655    // popup close while the background owns the session-scoped confirmation656    // exemption. Cleared when confirmation succeeds.657    let recoveryPending = false;658    try {659      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });660      recoveryPending = out.ok === true && out.data === true;661    } catch { /* best-effort */ }662663    // Content UI can ask the background to open this trusted popup either for664    // unlock or at a specific entry. Consume both session-scoped handoffs once.665    let pendingOpenEntryId: string | undefined;666    if (chrome.storage.session) {667      try {668        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);669        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };670        if (pending.peach_inline_unlock) {671          openedForInlineUnlock = true;672        }673        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;674        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);675      } catch { /* best-effort */ }676    }677678    if (recoveryPending && unlocked) {679      // forceRecoveryWords overrides LockView's hasVault gate so the680      // recovery surface renders even though the vault now exists.681      setForceRecoveryWords(true);682      setView("lock");683    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {684      void beginInitialSyncWait(pendingInitialSyncDeviceId);685    } else if (unlocked && vaultExists) {686      await openVault(pendingOpenEntryId, shouldCelebrate);687      if (shouldCelebrate) {688        setView("pro-celebration");689        window.setTimeout(() => setView("entry"), 2500);690      } else if (await shouldShowWhatsNew()) {691        setView("whats-new");692      }693    } else if (shouldCelebrate) {694      setPendingCelebration(true);695    }696697    await refreshPendingPasskey();698699    // Track tab changes so smart sort stays current700    const handleTabActivated = () => { void refreshCurrentTab(); };701    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {702      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();703    };704    try {705      chrome.tabs.onActivated.addListener(handleTabActivated);706      chrome.tabs.onUpdated.addListener(handleTabUpdated);707      onCleanup(() => {708        chrome.tabs.onActivated.removeListener(handleTabActivated);709        chrome.tabs.onUpdated.removeListener(handleTabUpdated);710      });711    } catch {712      // tabs permission may not be available in all contexts713    }714715    setBootState("ready");716717    // Deep-link: ?codex=pdf opens a detached window straight into the718    // Codex restore flow with auto-picked PDF. The action popup can't host719    // a file dialog without dismissing itself, so this is the only way the720    // PDF import path survives. See CodexRestoreView.openPdfPicker().721    try {722      const route = new URLSearchParams(window.location.search).get("codex");723      if (route === "pdf" || route === "restore") {724        setView("codex-restore");725      }726    } catch {727      // location.search unavailable — ignore.728    }729730    // Check for pending session-storage notices from the background.731    if (chrome.storage.session) {732      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as733        Record<string, { feature?: string } | undefined>;734      if (session.pendingProGate?.feature) {735        await chrome.storage.session.remove("pendingProGate");736        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);737        setSettingsInitialSection("license");738        setView("settings");739      } else if (session.pendingProNotice?.feature) {740        await chrome.storage.session.remove("pendingProNotice");741        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });742      }743      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {744        pendingRememberSite?: { entryId?: unknown; url?: unknown };745      };746      const request = remembered.pendingRememberSite;747      if (748        typeof request?.entryId === "string" &&749        typeof request.url === "string"750      ) {751        const entry = await sendMessage({ type: "GET_ENTRIES" });752        const match = entry.ok753          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(754              (candidate) => candidate.id === request.entryId,755            )756          : undefined;757        if (match) {758          let host = request.url;759          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }760          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });761        } else {762          await chrome.storage.session.remove("pendingRememberSite");763        }764      }765    }766767    // Warm the lazy-module cache so the Suspense fallback never shows for768    // locally-bundled extension views. Without this, the first navigation to769    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which770    // covers the shell and absorbs clicks on the header.771    void Promise.all([772      import("./views/Settings").catch(() => {}),773      import("./views/Import").catch(() => {}),774      import("./views/Export").catch(() => {}),775      import("./views/Health").catch(() => {}),776      import("./views/TrashView").catch(() => {}),777    ]);778779    // Lock-on-close via a persistent port. sendMessage in pagehide is780    // unreliable — the popup context tears down before the message lands.781    // The SW's onConnect listener locks when this port disconnects.782    let popupPort: chrome.runtime.Port | null = null;783    try {784      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect785      // listener accepts the "popup-" prefix; the random suffix is defence in786      // depth against a port named exactly "popup" from anything but our popup.787      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });788    } catch { /* extension context invalidated — ignore */ }789    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });790791    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale792    // entries instead of waiting for the next GET_ENTRIES round-trip.793    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {794      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();795      if (msg?.type === "SYNC_STATE_CHANGED") {796        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);797      }798      // A background sync merged entries the user never asked for (a peer's799      // push, or the periodic dial). Pull the fresh list so the vault view is800      // not stale — loadEntries() preserves the current selection by id.801      if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {802        void loadEntries();803      }804      if (msg?.type === "PRO_GATE_TRIGGERED") {805        setUpgradeHighlight(msg.payload?.feature ?? null);806        setSettingsInitialSection("license");807        setView("settings");808      }809    };810    chrome.runtime.onMessage.addListener(handleRuntimeMessage);811    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));812813    // Action handlers — keyed by ShortcutAction. The dispatcher below tests814    // each binding against the event and runs the matching handler. New815    // shortcuts only need an entry here; the binding is data, not code.816    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {817      copy_password: () => {818        const entry = selectedEntry();819        if (entry?.password) void popupCopy(820          entry.password,821          "Password",822          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,823        ).then((ok) => { if (ok) triggerRing(); });824      },825      copy_username: () => {826        const entry = selectedEntry();827        if (entry?.username) void popupCopy(828          entry.username,829          "Username",830          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,831        ).then((ok) => { if (ok) triggerRing(); });832      },833      copy_totp: () => {834        const entry = selectedEntry();835        if (!entry?.totp) return;836        try {837          const { code } = generateTOTP(838            entry.totp,839            entry.totpAlgorithm,840            entry.totpDigits,841            entry.totpPeriod,842          );843          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });844        } catch { /* invalid TOTP secret — silent */ }845      },846      new_entry: () => handleAdd(),847      edit_entry: () => {848        const entry = selectedEntry();849        // Passkey-only entries have nothing user-editable in the Add view; the850        // passkey credential itself is opaque. Skip the edit action for them.851        if (!entry || isPasskeyOnly(entry)) return;852        setEditEntry(entry);853        setView("add");854      },855      delete_entry: () => {856        if (selectedEntry()) void handleDelete();857      },858      lock_vault: () => { void handleHeaderLock(); },859      search: () => openList(),860      nav_prev: () => navigate(-1),861      nav_next: () => navigate(1),862      jump_vault: () => setView("entry"),863      jump_settings: () => setView("settings"),864      show_help: () => setShowShortcutsHelp(true),865    };866867    // Actions that are MEANINGFUL only on the entry-detail view. Outside868    // that scope the binding is dormant — pressing C while on Settings869    // does nothing rather than awkwardly copying from a now-stale entry.870    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([871      "copy_password", "copy_username", "copy_totp",872      "edit_entry", "delete_entry", "nav_prev", "nav_next",873    ]);874875    const handleGlobalKeydown = (e: KeyboardEvent) => {876      const v = view();877      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"878        || (e.target as HTMLElement)?.tagName === "TEXTAREA"879        || (e.target as HTMLElement)?.isContentEditable;880      const isPrintableSearchKey =881        e.key.length === 1 &&882        !e.ctrlKey &&883        !e.metaKey &&884        !e.altKey;885886      // No shortcuts while a full-screen surface owns the whole viewport:887      // the lock screen, the post-pairing readiness sequence, or the Pro888      // celebration. Without this the lock screen is at the mercy of whatever889      // single-key binding is active — e.g. typing "n" in a password that890      // slips out of focus triggers `new_entry` and escapes the lock screen.891      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;892893      // Help dialog: Escape closes it; eat the keypress so it doesn't894      // also navigate away from the underlying view.895      if (showShortcutsHelp()) {896        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }897        return;898      }899900      // ── Escape — fixed binding (not user-customizable) ───────901      if (e.key === "Escape") {902        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }903        if (v === "list") { setView("entry"); return; }904        if (v === "settings" || v === "add") { handleBack(); return; }905        if (v === "import" || v === "health") { setView("settings"); return; }906        if (v === "export") { handleExportBack(); return; }907        if (v === "pair-device") { setView("settings"); return; }908      }909910      // Bindings-driven dispatch. Single-key letter bindings without911      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is912      // inside an input, so users can type into search/forms normally.913      const bindings = shortcutBindings();914      for (const action of Object.keys(bindings) as ShortcutAction[]) {915        const binding = bindings[action];916        if (!matchKeybinding(e, binding)) continue;917        // Single-key letter bindings need the input-focus guard. Bindings918        // with any modifier are safe — Ctrl+L doesn't conflict with typing.919        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;920        if (!hasMod && binding.key.length === 1 && inInput) return;921        if (ENTRY_SCOPED.has(action) && v !== "entry") return;922        e.preventDefault();923        SHORTCUT_HANDLERS[action]?.();924        return;925      }926927      // Type-to-search: in entry view, any printable key not consumed by a928      // shortcut above primes the list search seed and opens the list.929      // Kept outside the bindings table because it's a behavior, not a930      // discrete action — every printable character can trigger it.931      if (v === "entry" && !inInput && isPrintableSearchKey) {932        e.preventDefault();933        primeListSearch(e.key);934        return;935      }936    };937    window.addEventListener("keydown", handleGlobalKeydown);938    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));939940    const handleStorageChanged = (941      changes: Record<string, chrome.storage.StorageChange>,942      areaName: string943    ) => {944      if (areaName !== "session") return;945      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {946        return;947      }948      void refreshPendingPasskey();949    };950    chrome.storage.onChanged.addListener(handleStorageChanged);951    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));952  });953954  async function refreshCurrentTab() {955    try {956      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });957      const url = tab?.url ?? tab?.pendingUrl ?? "";958      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");959      // Skip extension/internal URLs — adding chrome://, about:, or960      // chrome-extension:// to a vault entry is never what the user wants.961      const isWebUrl = /^https?:\/\//i.test(url);962      setCurrentTabUrl(isWebUrl ? url : "");963    } catch {964      setCurrentTabDomain("");965      setCurrentTabUrl("");966    }967  }968969  async function refreshPendingPasskey() {970    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });971    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);972  }973974  async function refreshProStatus() {975    const res = await sendMessage({ type: "GET_LICENSE" });976    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;977    setProStatus(Boolean(data?.valid && data?.plan === "pro"));978  }979980  async function restoreSettings() {981    const res = await sendMessage({ type: "GET_SETTINGS" });982    const s = (res as { ok: boolean; data?: any }).data ?? {};983    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {984      setAccentColor(s.accentColor as AccentColorName);985    }986    setTheme(s.theme === "light" ? "light" : "dark");987    if (s.textSize === "small" || s.textSize === "large") {988      setTextSize(s.textSize);989    } else {990      setTextSize("medium");991    }992    setReduceMotion(s.reduceMotion === true);993    setLockOnClose(s.lockOnClose === true);994    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);995    setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);996    setBreachMonitoring(s.breachMonitoring !== false);997    setShortcutBindings(getActiveBindings(s.shortcuts));998  }9991000  async function applyTrashRetention(days: number) {1001    setTrashRetentionDays(days);1002    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1003  }10041005  function primeListSearch(seed: string) {1006    setListSearchSeed(seed);1007    setListSearchSeedNonce((value) => value + 1);1008    openList();1009  }10101011  // Returns null when it detected a dead service worker and triggered handleLock().1012  // Callers that need to abort (e.g. openVault) check for null rather than relying1013  // on view() state, which may already be "lock" during normal startup flows.1014  async function loadEntries(): Promise<VaultEntry[] | null> {1015    const response = await sendMessage({ type: "GET_ENTRIES" });1016    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10171018    // If the service worker was killed and restarted (MV3 workers sleep), the1019    // vault will be locked again — or the worker may not respond at all.1020    // In either case, send the user to the lock screen rather than silently1021    // showing an empty vault (which looks like data loss).1022    if (!res.ok) {1023      if (1024        res.error === "vault_locked" ||1025        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1026        (typeof res.error === "string" && res.error.includes("locked"))1027      ) {1028        handleLock();1029        return null; // signal emergency lock to callers1030      }1031      return [];1032    }10331034    const loadedEntries = res.data ?? [];1035    setEntries(loadedEntries);10361037    const currentId = selectedEntry()?.id;1038    if (currentId) {1039      setSelectedEntry(1040        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1041      );1042    }10431044    return loadedEntries;1045  }10461047  async function openVault(preferredEntryId?: string, skipViewSet = false) {1048    // Purge expired trash entries on each vault open1049    const retDays = trashRetentionDays();1050    if (retDays > 0) {1051      void sendMessage({1052        type: "PURGE_EXPIRED_TRASH",1053        payload: { retentionMs: retDays * 86_400_000 },1054      });1055    }10561057    const loadedEntries = await loadEntries();10581059    // null means loadEntries() triggered an emergency lock (service worker died).1060    // Don't override it by setting view back to "entry" with an empty vault.1061    if (loadedEntries === null) return false;10621063    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1064    let nextSelectedEntry: VaultEntry | null = null;1065    if (preferredEntryId) {1066      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1067    }1068    if (!nextSelectedEntry) {1069      const tabDomain = currentTabDomain();1070      if (tabDomain) {1071        nextSelectedEntry = loadedEntries.find((e) => {1072          if (e.type === "login") {1073            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1074            for (const extra of e.additionalUrls ?? []) {1075              if ((extractDomain(extra) ?? "") === tabDomain) return true;1076            }1077            return false;1078          }1079          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1080          return false;1081        }) ?? null;1082      }1083    }1084    nextSelectedEntry ??= pickNavigable(loadedEntries);10851086    setSelectedEntry(nextSelectedEntry);1087    setEditEntry(undefined);1088    setContentPhase("enter");1089    queueContentIdle();1090    if (!skipViewSet) setView("entry");1091    return true;1092  }10931094  async function handleUnlock() {1095    // If the user previously created a vault but hadn't yet confirmed their1096    // recovery words (and the browser was closed before they could), the1097    // the background-owned recovery-pending state is still set. Re-route1098    // back to the recovery surface so they finish the confirmation step1099    // before reaching the entries view — the words can be re-derived now1100    // that the vault is unlocked again.1101    let recoveryPending = false;1102    try {1103      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1104      recoveryPending = out.ok === true && out.data === true;1105    } catch { /* best-effort */ }1106    if (recoveryPending) {1107      setForceRecoveryWords(true);1108      setView("lock");1109      triggerPipPulse();1110      await restoreSettings();1111      return;1112    }1113    setHasVault(true);1114    triggerPipPulse();1115    await restoreSettings();1116    const celebrating = pendingCelebration();1117    if (celebrating) {1118      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1119      setPendingCelebration(false);1120    }1121    await openVault(undefined, celebrating);1122    if (celebrating) {1123      setView("pro-celebration");1124      window.setTimeout(() => setView("entry"), 2500);1125    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1126      setView("whats-new");1127    }1128    // Popup was opened from the inline "Open Peach to unlock" button — close it1129    // now so the user lands back on the page with the autofill selector ready.1130    if (openedForInlineUnlock) {1131      openedForInlineUnlock = false;1132      window.close();1133    }1134  }11351136  function handleSelectEntry(entry: VaultEntry) {1137    if (entry.type === "note") {1138      setOpenNoteId(entry.id);1139      setShowNotes(true);1140      return;1141    }1142    setSelectedEntry(entry);1143    setContentPhase("enter");1144    queueContentIdle();1145    setView("entry");1146  }11471148  function handleAdd() {1149    setEditEntry(undefined);1150    setView("add");1151  }11521153  async function handleDelete() {1154    const entry = selectedEntry();1155    if (!entry) return;1156    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1157    const loadedEntries = await loadEntries() ?? [];1158    setSelectedEntry(pickNavigable(loadedEntries));1159    setView("entry");1160  }11611162  async function handleDeleteMany(ids: string[]) {1163    for (const id of ids) {1164      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1165    }1166    const loadedEntries = await loadEntries() ?? [];1167    // If the currently selected entry was among the deleted, move to first1168    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1169    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1170  }11711172  async function handleSave(savedEntry?: VaultEntry) {1173    await openVault(savedEntry?.id);1174  }11751176  function handleBack() {1177    const dest = editBackView();1178    setEditBackView("entry");1179    setEditEntry(undefined);1180    setView(dest);1181  }11821183  function handleExportBack() {1184    if (returnToPanic()) {1185      setReturnToPanic(false);1186      setView("panic");1187      return;1188    }1189    setView("settings");1190  }11911192  function handleLock() {1193    setEntries([]);1194    setSelectedEntry(null);1195    setEditEntry(undefined);1196    setContentPhase("idle");1197    setView("lock");1198  }11991200  function handleHeaderLock() {1201    // Lock the UI immediately — do not wait for the service worker. If the SW1202    // is dead or slow, the 5s sendMessage timeout would create a dead-click1203    // window. The SW will lock on its next wake regardless.1204    handleLock();1205    void sendMessage({ type: "LOCK" });1206  }12071208  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1209  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1210  // the trigger's own reply.1211  async function handleHeaderSync(): Promise<void> {1212    if (syncBusy()) return;1213    setSyncBusy(true);1214    setSyncFlash(null);1215    try {1216      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1217      let state: SyncOutcomeSummary = { status: "syncing" };1218      for (let i = 0; i < 360 && isManualSyncPending(state); i++) {1219        await new Promise((r) => setTimeout(r, 250));1220        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1221          ok: boolean;1222          data?: SyncOutcomeSummary;1223        };1224        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1225      }1226      setSyncSummary(state);1227      setSyncFlash(state.status === "error" ? "error" : "ok");1228      await loadSyncAlerts();1229      await loadEntries();1230      await loadSyncIndicator();1231    } catch {1232      setSyncSummary({ status: "error", outcome: "run_failed" });1233      setSyncFlash("error");1234    } finally {1235      setSyncBusy(false);1236      setTimeout(() => setSyncFlash(null), 2_000);1237    }1238  }12391240  function navigate(dir: number) {1241    const navigable = nonNoteEntries();1242    if (navigable.length === 0) return;1243    const currentIndex = selectedIndex();1244    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1245    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1246    setSelectedEntry(navigable[nextIndex]);1247    setContentPhase("enter");1248    queueContentIdle();1249  }12501251  /**1252   * The Entry view's selection invariant: never select a note. Notes are a1253   * separate surface (handleSelectEntry opens the notes view), so any code path1254   * that picks a default — vault open, post-delete, refresh — must skip them1255   * and return null if the only entries are notes.1256   */1257  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1258    return list.find((e) => e.type !== "note") ?? null;1259  }12601261  function openList() {1262    setView("list");1263  }12641265  function openHealth() {1266    setEditEntry(undefined);1267    setView("health");1268  }12691270  function handleOpenFileImport() {1271    setRestoreView("file");1272  }12731274  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12751276  function handleFixEntry(entry: VaultEntry) {1277    setSelectedEntry(entry);1278    // Passkey-only entries have no editable surface — drop into view mode1279    // instead of opening the Add editor.1280    if (isPasskeyOnly(entry)) {1281      setView("entry");1282      return;1283    }1284    setEditBackView(view()); // preserve caller view so back returns here1285    setEditEntry(entry);1286    setView("add");1287  }12881289  const isLockedView = () => view() === "lock" || view() === "adopting";12901291  async function handleAdoptionDone() {1292    await storageSet("peach_onboarded", "true").catch(() => {});1293    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1294    clearOnboardingExit();1295    setOnboarded(true);1296    setHasVault(true);1297    setView("initial-sync");1298    // The background applies a Pro license and persists the source-device1299    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1300    // celebration pending until that exact peer records a completed sync.1301    await refreshProStatus();1302    const deviceId = await resolveInitialSyncDeviceId();1303    if (deviceId) {1304      void beginInitialSyncWait(deviceId);1305    } else {1306      // Pairing completion without a durable peer row should be impossible.1307      // Stay on the honest waiting surface and expose retry instead of falling1308      // through to an empty vault.1309      setInitialSyncSlow(true);1310    }1311  }13121313  async function handleApprovePendingPasskey() {1314    const pending = pendingPasskey();1315    if (!pending) return;1316    if (pending.browser === "firefox" && pending.type === "create") {1317      // Firefox path: popup itself runs navigator.credentials.create. The1318      // background never approved/rejected anything — it just stashed1319      // intent. After the popup-driven flow resolves (success or cancel),1320      // clear the pending key so the popup doesn't re-show this on next open.1321      const { createFirefoxPasskey } = await import("./firefox-passkey");1322      const result = await createFirefoxPasskey(pending);1323      if (!result.ok) {1324        console.warn("Peach Firefox passkey create failed:", result.error);1325      }1326      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1327      setPendingPasskey(null);1328      void loadEntries();1329      window.close();1330      return;1331    }1332    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1333    window.close();1334  }13351336  async function handleDenyPendingPasskey() {1337    const pending = pendingPasskey();1338    if (!pending) return;1339    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1340    window.close();1341  }13421343  function queueContentIdle(after?: () => void) {1344    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1345    contentPhaseTimer = window.setTimeout(() => {1346      setContentPhase("idle");1347      after?.();1348    }, ENTRY_TRANSITION_SETTLE_MS);1349  }13501351  return (1352    <div class="popup-root peach-theme">1353      <Show when={bootState() === "ready"}>1354        <Show1355          when={!pendingPasskey()}1356          fallback={1357            <PasskeyApprovalView1358              type={pendingPasskey()!.type}1359              rpName={pendingPasskey()!.rpName}1360              rpId={pendingPasskey()!.rpId}1361              userName={pendingPasskey()!.userName}1362              userDisplayName={pendingPasskey()!.userDisplayName}1363              onApprove={handleApprovePendingPasskey}1364              onDeny={handleDenyPendingPasskey}1365            />1366          }1367        >1368          <Show1369            when={!isLockedView()}1370            fallback={1371              <Show1372                when={view() !== "adopting"}1373                fallback={1374                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1375                    <AdoptVaultView1376                      onCancel={() => {1377                        clearOnboardingExit();1378                        setOnboarded(false);1379                        setView("lock");1380                      }}1381                      onDone={handleAdoptionDone}1382                    />1383                  </Suspense>1384                }1385              >1386                <Show1387                  when={onboarded() || hasVault()}1388                  fallback={1389                    <Onboarding1390                      initialCard={onboardingCardAtExit()}1391                      onComplete={() => {1392                        markOnboardingExit("create");1393                        setStartOnboardingRestore(false);1394                        setOnboarded(true);1395                      }}1396                      onRestore={() => {1397                        markOnboardingExit("restore");1398                        setStartOnboardingRestore(true);1399                        setOnboarded(true);1400                      }}1401                      onAdopt={() => {1402                        markOnboardingExit("adopt");1403                        setStartOnboardingRestore(false);1404                        setOnboarded(true);1405                        setView("adopting");1406                      }}1407                    />1408                  }1409                >1410                  <LockView1411                    hasVault={hasVault()}1412                    ready={bootState() === "ready"}1413                    isPro={proStatus()}1414                    forceRecoveryWords={forceRecoveryWords()}1415                    initialRestore={startOnboardingRestore()}1416                    initialRestoreSource={resumeRestoreSource() ?? undefined}1417                    onUnlock={() => {1418                      // RecoveryWordDisplay clears the storage flag before1419                      // calling onDone, so handleUnlock will see no pending1420                      // confirmation and proceed through the normal path.1421                      if (forceRecoveryWords()) setForceRecoveryWords(false);1422                      // The create/restore flow is done — a lock later in this1423                      // window must show the unlock screen, not re-enter the1424                      // onboarding restore surface.1425                      setStartOnboardingRestore(false);1426                      setResumeRestoreSource(null);1427                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1428                      return handleUnlock();1429                    }}1430                    onBackToOnboarding={1431                      !hasVault()1432                        ? () => {1433                            // Deliberately rewinding to the carousel — the exit1434                            // flow must not reclaim the next popup open.1435                            clearOnboardingExit();1436                            setStartOnboardingRestore(false);1437                            setOnboarded(false);1438                          }1439                        : undefined1440                    }1441                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1442                  />1443                </Show>1444              </Show>1445            }1446          >1447            <Show1448              when={1449                view() !== "pro-celebration" &&1450                view() !== "initial-sync" &&1451                view() !== "whats-new"1452              }1453              fallback={1454                <Show1455                  when={view() === "initial-sync"}1456                  fallback={1457                    <Show1458                      when={view() === "whats-new"}1459                      fallback={<ProCelebration />}1460                    >1461                      <WhatsNewView onContinue={() => setView("entry")} />1462                    </Show>1463                  }1464                >1465                  <InitialSyncView1466                    slow={initialSyncSlow()}1467                    onRetry={() => void retryInitialSync()}1468                  />1469                </Show>1470              }1471            >1472            <section1473              id="hd-shell"1474              class="peach-shell peach-theme"1475              data-surface="extension"1476            >1477            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1478              <PeachWordmark unlocked />1479              <div class="peach-chrome-actions">1480                <button1481                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1482                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1483                  aria-label="Notes"1484                  title="Notes"1485                >1486                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1487                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1488                    <line x1="4" y1="5" x2="10" y2="5" />1489                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1490                    <line x1="4" y1="10" x2="7" y2="10" />1491                  </svg>1492                </button>1493                <button1494                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1495                    classList={{1496                      "is-syncing": syncBusy(),1497                      "is-ok": syncFlash() === "ok",1498                      "is-error": syncFlash() === "error",1499                    }}1500                    onClick={() => void handleHeaderSync()}1501                    disabled={syncBusy()}1502                    aria-label={`Sync now. ${syncLabel()}`}1503                    title={syncLabel()}1504                  >1505                    <svg class="peach-sync-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1506                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1507                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1508                      <path d="M2.6 1v2.4h2.4" />1509                      <path d="M11.4 13v-2.4H9" />1510                    </svg>1511                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1512                  </button>1513                <button1514                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1515                  onClick={() => setView("settings")}1516                  aria-label="Settings"1517                >1518                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1519                    <path1520                      d="M5.91 1.11C6.38-.02 7.62-.02 8.09 1.11L8.31 1.64C8.48 2.05 8.91 2.27 9.34 2.17L9.9 2.04C11.1 1.76 11.98 2.64 11.7 3.84L11.57 4.4C11.47 4.83 11.69 5.26 12.1 5.43L12.63 5.65C13.76 6.12 13.76 7.36 12.63 7.83L12.1 8.05C11.69 8.22 11.47 8.65 11.57 9.08L11.7 9.64C11.98 10.84 11.1 11.72 9.9 11.44L9.34 11.31C8.91 11.21 8.48 11.43 8.31 11.84L8.09 12.37C7.62 13.5 6.38 13.5 5.91 12.37L5.69 11.84C5.52 11.43 5.09 11.21 4.66 11.31L4.1 11.44C2.9 11.72 2.02 10.84 2.3 9.64L2.43 9.08C2.53 8.65 2.31 8.22 1.9 8.05L1.37 7.83C.24 7.36.24 6.12 1.37 5.65L1.9 5.43C2.31 5.26 2.53 4.83 2.43 4.4L2.3 3.84C2.02 2.64 2.9 1.76 4.1 2.04L4.66 2.17C5.09 2.27 5.52 2.05 5.69 1.64L5.91 1.11Z"1521                      stroke="currentColor"1522                      stroke-width="0.9"1523                    />1524                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1525                  </svg>1526                </button>1527                <button1528                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1529                  onClick={handleAdd}1530                  aria-label="New entry"1531                >1532                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1533                    <path d="M5.5 1v9M1 5.5h9" />1534                  </svg>1535                </button>1536                <button1537                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1538                  onClick={() => void handleHeaderLock()}1539                  aria-label="Lock vault"1540                >1541                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1542                    <path1543                      d="M3.75 6V4.75C3.75 2.96 5.21 1.5 7 1.5C8.79 1.5 10.25 2.96 10.25 4.75V6"1544                      stroke="currentColor"1545                      stroke-width="1"1546                      stroke-linecap="round"1547                    />1548                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1549                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1550                  </svg>1551                </button>1552              </div>1553            </header>15541555            <Show when={pendingProNotice() !== null}>1556              <div class="peach-pro-notice-banner">1557                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1558                <button1559                  class="peach-pro-notice-link"1560                  onClick={() => {1561                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1562                    setSettingsInitialSection("license");1563                    setView("settings");1564                    setPendingProNotice(null);1565                  }}1566                >1567                  Learn more →1568                </button>1569                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1570              </div>1571            </Show>15721573            <Show when={pendingRememberSite()}>1574              {(request) => {1575                const origin = getSecurityOriginDisplay(request().url);1576                const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1577                return (1578                  <div class="peach-pro-notice-banner">1579                    <span>Also use “{request().title}” on {originLabel}?</span>1580                    <button1581                      class="peach-pro-notice-link"1582                      onClick={async () => {1583                        await sendMessage({1584                          type: "ADD_ADDITIONAL_URL",1585                          payload: { id: request().entryId, url: request().url },1586                        });1587                        await chrome.storage.session.remove("pendingRememberSite");1588                        setPendingRememberSite(null);1589                      }}1590                    >1591                      Add1592                    </button>1593                    <button1594                      class="peach-pro-notice-close"1595                      onClick={async () => {1596                        await chrome.storage.session.remove("pendingRememberSite");1597                        setPendingRememberSite(null);1598                      }}1599                      aria-label="Dismiss"1600                    >1601                      ✕1602                    </button>1603                  </div>1604                );1605              }}1606            </Show>16071608            <div class="peach-shell-body">1609              <div class="peach-shell-veil"></div>1610              <div class="peach-content-frame" data-content-phase={contentPhase()}>1611                <EntryView1612                  keyboardEnabled={view() === "entry"}1613                  entry={selectedEntry()}1614                  currentTabUrl={currentTabUrl()}1615                  entries={entries()}1616                  isPro={proStatus()}1617                  onDelete={handleDelete}1618                  onFieldSaved={() => void loadEntries()}1619                  onEscape={() => { setView("list"); }}1620                  onProGate={(feature) => {1621                    setUpgradeHighlight(feature);1622                    setSettingsInitialSection("license");1623                    setView("settings");1624                  }}1625                />1626              </div>16271628              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1629                <div class="peach-search-bar">1630                  <span class="peach-search-icon">1631                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1632                      <circle cx="5.5" cy="5.5" r="3.75" />1633                      <path d="M8.75 8.75L11.5 11.5" />1634                    </svg>1635                  </span>1636                  <input1637                    class="peach-search-input"1638                    placeholder="search vault..."1639                    autocomplete="off"1640                    aria-label="Search vault entries"1641                    onClick={openList}1642                    onFocus={openList}1643                    readOnly1644                  />1645                  <div class="peach-search-meta">1646                    <span class="peach-search-counter">1647                      {nonNoteEntries().length > 01648                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1649                        : "0 entries"}1650                    </span>1651                    <span class="peach-divider"></span>1652                    <button1653                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1654                      onClick={() => navigate(-1)}1655                      aria-label="Previous entry"1656                      disabled={nonNoteEntries().length === 0}1657                    >1658                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1659                        <path d="M5 1L1 5.5L5 10" />1660                      </svg>1661                    </button>1662                    <button1663                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1664                      onClick={() => navigate(1)}1665                      aria-label="Next entry"1666                      disabled={nonNoteEntries().length === 0}1667                    >1668                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1669                        <path d="M1 1L5 5.5L1 10" />1670                      </svg>1671                    </button>1672                  </div>1673                </div>1674              </section>1675            </div>16761677            {/* Rare device-trust alerts; normal sync changes are silent. */}1678            <Show when={alertGroups().length > 0}>1679              <div class="peach-sync-alerts">1680                <For each={alertGroups()}>1681                  {([type, group]) => {1682                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1683                    return (1684                      <div class="peach-sync-alert">1685                        <Show when={group.length > 1}>1686                          <button1687                            class="peach-sync-alert-flip"1688                            type="button"1689                            aria-label="Previous"1690                            onClick={() => flipAlert(type, -1)}1691                          >‹</button>1692                        </Show>1693                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1694                        <Show when={group.length > 1}>1695                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1696                          <button1697                            class="peach-sync-alert-flip"1698                            type="button"1699                            aria-label="Next"1700                            onClick={() => flipAlert(type, 1)}1701                          >›</button>1702                        </Show>1703                        <button1704                          class="peach-sync-alert-dismiss"1705                          type="button"1706                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1707                          onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1708                        >×</button>1709                      </div>1710                    );1711                  }}1712                </For>1713              </div>1714            </Show>17151716            <Show when={view() === "list"}>1717              <VaultView1718                entries={entries()}1719                selectedEntryId={selectedEntry()?.id}1720                currentTabDomain={currentTabDomain()}1721                sortOrder={sortOrder()}1722                searchSeed={listSearchSeed()}1723                searchSeedNonce={listSearchSeedNonce()}1724                onSelectEntry={handleSelectEntry}1725                onSortOrderChange={setSortOrder}1726                onAdd={handleAdd}1727                onClose={() => setView("entry")}1728                onOpenTrash={() => setView("trash")}1729                onDeleteMany={handleDeleteMany}1730              />1731            </Show>1732            <Show when={view() === "settings" || settingsExiting()}>1733              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1734                <SettingsView1735                  entriesCount={entries().length}1736                  breachMonitoring={breachMonitoring()}1737                  lockOnClose={lockOnClose()}1738                  autoLockMinutes={autoLockMinutes()}1739                  trashRetentionDays={trashRetentionDays()}1740                  lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1741                  theme={theme()}1742                  accentColor={accentColor()}1743                  textSize={textSize()}1744                  reduceMotion={reduceMotion()}1745                  isPro={proStatus()}1746                  initialSection={settingsInitialSection()}1747                  upgradeHighlight={upgradeHighlight()}1748                  isExiting={settingsExiting()}1749                  onBreachMonitoringChange={applyBreachMonitoring}1750                  onLockOnCloseChange={applyLockOnClose}1751                  onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1752                  onAutoLockMinutesChange={applyAutoLockMinutes}1753                  onTrashRetentionChange={applyTrashRetention}1754                  onThemeChange={applyTheme}1755                  onAccentChange={applyAccentColor}1756                  onTextSizeChange={applyTextSize}1757                  onReduceMotionChange={applyReduceMotion}1758                  shortcuts={shortcutBindings()}1759                  onShortcutsChange={applyShortcuts}1760                  onLicenseChange={async () => {1761                    // After activation/deactivation: refresh Pro status,1762                    // re-load entries (defensive — signal state can drift if1763                    // the SW briefly restarts during the verify+save round-trip),1764                    // and replay the Pro celebration if this is the first time1765                    // a valid Pro license is seen on this device.1766                    await refreshProStatus();1767                    void loadEntries();1768                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1769                    if ((should as { ok: boolean; data?: boolean }).data === true) {1770                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1771                      setView("pro-celebration");1772                      window.setTimeout(() => setView("settings"), 2500);1773                    }1774                  }}1775                  onBack={() => {1776                    setSettingsExiting(true);1777                    window.setTimeout(() => {1778                      setSettingsExiting(false);1779                      setSettingsInitialSection(null);1780                      setUpgradeHighlight(null);1781                      handleBack();1782                    }, 200);1783                  }}1784                  onImport={() => setView("import")}1785                  onExport={() => setView("export")}1786                  onBreachCheckDone={loadEntries}1787                  onOpenHealth={openHealth}1788                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1789                  onOpenFileImport={handleOpenFileImport}1790                  onOpenPanic={() => setView("panic")}1791                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1792                  onOpenCodexRestore={() => setView("codex-restore")}1793                  onCardChange={setSettingsActiveCard}1794                  onProGate={(feature) => {1795                    setUpgradeHighlight(feature);1796                    setSettingsInitialSection("license");1797                  }}1798                />1799              </Suspense>1800            </Show>1801            <Show when={view() === "codex-generate"}>1802              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1803                <CodexGenerateView1804                  isPro={proStatus()}1805                  onBack={() => setView("settings")}1806                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1807                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1808                  onVaultLocked={() => handleLock()}1809                />1810              </Suspense>1811            </Show>1812            <Show when={view() === "codex-restore"}>1813              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1814                <CodexRestoreView1815                  onBack={() => setView("lock")}1816                  onRestoreSuccess={async () => {1817                    // After restore the vault is unlocked; refresh entries1818                    // and route to the main vault list. handleUnlock is the1819                    // canonical post-unlock setup path.1820                    await handleUnlock();1821                  }}1822                />1823              </Suspense>1824            </Show>1825            <Show when={view() === "pair-device"}>1826              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1827                <PairDeviceView1828                  isPro={proStatus()}1829                  onProChanged={() => { void refreshProStatus(); }}1830                  onBack={() => setView("settings")}1831                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1832                />1833              </Suspense>1834            </Show>1835            <Show when={view() === "add"}>1836              <AddView1837                entry={editEntry()}1838                entries={entries()}1839                onBack={handleBack}1840                onSave={handleSave}1841              />1842            </Show>1843            <Show when={view() === "import"}>1844              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1845                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1846              </Suspense>1847            </Show>1848            <Show when={view() === "export"}>1849              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1850                <ExportView onBack={handleExportBack} />1851              </Suspense>1852            </Show>1853            <Show when={view() === "panic"}>1854              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1855                <PanicView1856                  onCancel={() => setView("settings")}1857                  onOpenExport={() => {1858                    setReturnToPanic(true);1859                    setView("export");1860                  }}1861                  onDestroyed={async () => {1862                    // Fresh onboarding start after a destroy — don't let the1863                    // resume markers land the user mid-carousel.1864                    setOnboardingCardAtExit(0);1865                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1866                    clearOnboardingExit();1867                    setEntries([]);1868                    setSelectedEntry(null);1869                    setEditEntry(undefined);1870                    setHasVault(false);1871                    setOnboarded(false);1872                    setProStatus(false);1873                    setPendingPasskey(null);1874                    setUpgradeHighlight(null);1875                    setSettingsInitialSection(null);1876                    setPendingProNotice(null);1877                    setPendingCelebration(false);1878                    setReturnToPanic(false);1879                    setContentPhase("idle");1880                    setView("lock");1881                  }}1882                />1883              </Suspense>1884            </Show>1885            {/* Health stays mounted while editing an entry from it so1886                computeReused() doesn't rerun on every cancel/back. */}1887            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1888              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1889                <HealthView1890                  entries={entries()}1891                  isPro={proStatus()}1892                  onFixEntry={handleFixEntry}1893                  onBack={() => setView("settings")}1894                  onRefresh={loadEntries}1895                  hidden={view() !== "health"}1896                />1897              </Suspense>1898            </Show>1899            <Show when={view() === "trash"}>1900              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1901                <TrashView1902                  onBack={() => setView("list")}1903                  onRestored={() => void loadEntries()}1904                />1905              </Suspense>1906            </Show>1907            <Show when={showNotes()}>1908              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1909                <NotesMode1910                  notes={entries().filter((e) => e.type === "note")}1911                  initialNoteId={openNoteId()}1912                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1913                  onNoteListChanged={() => void loadEntries()}1914                />1915              </div>1916            </Show>1917            <Show when={restoreView() === "file"}>1918              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1919                <ImportView1920                  mode="onboarding"1921                  replacesExistingVault={true}1922                  onBack={() => setRestoreView(null)}1923                  onImport={async () => {1924                    setRestoreView(null);1925                    await handleUnlock();1926                  }}1927                />1928              </Suspense>1929            </Show>1930            </section>1931            </Show>1932          </Show>1933        </Show>1934      </Show>1935      <Show when={showShortcutsHelp()}>1936        <ShortcutsHelpOverlay1937          bindings={shortcutBindings()}1938          onClose={() => setShowShortcutsHelp(false)}1939        />1940      </Show>1941    </div>1942  );1943}19441945function ShortcutsHelpOverlay(props: {1946  bindings: Record<ShortcutAction, Keybinding>;1947  onClose: () => void;1948}) {1949  return (1950    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1951      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1952        <div class="peach-shortcuts-help-head">1953          <h3>Keyboard shortcuts</h3>1954          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1955        </div>1956        <div class="peach-shortcuts-help-body">1957          <p class="peach-settings-sub-label">ENTRY VIEW</p>1958          <ul>1959            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1960              <li>1961                <span>{d.label}</span>1962                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1963              </li>1964            ))}1965          </ul>1966          <p class="peach-settings-sub-label">ANYWHERE</p>1967          <ul>1968            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1969              <li>1970                <span>{d.label}</span>1971                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1972              </li>1973            ))}1974          </ul>1975        </div>1976      </div>1977    </div>1978  );1979}