App.tsx

82.2 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  syncIndicator,33  syncIndicatorLabel,34  type SyncOutcomeSummary,35} from "../shared/sync-outcome";36import { popupCopy } from "./copy";37import {38  DEFAULT_BINDINGS,39  formatKeybinding,40  getActiveBindings,41  matchKeybinding,42  SHORTCUT_DESCRIPTORS,43  type Keybinding,44  type ShortcutAction,45} from "../shared/shortcuts";46import { generateTOTP } from "../crypto/totp";4748export { sendMessage } from "../shared/bridge";4950const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));51const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));52const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));53const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));54const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));55const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));56const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));57const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));58const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));59const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6061type View =62  | "lock"63  | "pro-celebration"64  | "whats-new"65  | "entry"66  | "list"67  | "add"68  | "settings"69  | "pair-device"70  | "import"71  | "export"72  | "health"73  | "trash"74  | "panic"75  | "adopting"76  | "initial-sync"77  | "codex-generate"78  | "codex-restore";79type BootState = "loading" | "ready";8081const INITIAL_SYNC_POLL_MS = 500;82const INITIAL_SYNC_SLOW_MS = 15_000;8384let shellRingAnimation: Animation | null = null;8586export function triggerRing() {87  const shell = document.getElementById("hd-shell");88  if (!shell) return;8990  shellRingAnimation?.cancel();91  shell.dataset.glowing = "true";92  const animation = shell.animate(93    [94      { offset: 0, boxShadow: "var(--copy-shadow)" },95      {96        offset: 0.3,97        boxShadow:98          "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)",99      },100      { offset: 1, boxShadow: "var(--copy-shadow)" },101    ],102    {103      duration: 720,104      easing: "cubic-bezier(0.4, 0, 0.2, 1)",105      fill: "none",106    }107  );108  shellRingAnimation = animation;109  const clearGlow = () => {110    if (shellRingAnimation !== animation) return;111    shellRingAnimation = null;112    delete shell.dataset.glowing;113  };114  animation.onfinish = clearGlow;115  animation.oncancel = clearGlow;116}117118export function triggerPipPulse() {119  const pip = document.querySelector(".peach-status-pip, .pip");120  if (pip) {121    pip.classList.remove("pulse");122    void (pip as HTMLElement).offsetWidth;123    pip.classList.add("pulse");124  }125}126127const ENTRY_TRANSITION_SETTLE_MS = 135;128129export default function App() {130  const [bootState, setBootState] = createSignal<BootState>("loading");131  const [view, setView] = createSignal<View>("lock");132  const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");133  const [hasVault, setHasVault] = createSignal(false);134  const [onboarded, setOnboarded] = createSignal(true);135  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);136  // The restore source picked before the popup closed (the OS file picker137  // kills it), so reopening resumes the same source form instead of the138  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.139  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);140  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes141  // back. Those flows all exit from the final card, so resume there — a fresh142  // install's first mount stays at 0.143  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);144  const [entries, setEntries] = createSignal<VaultEntry[]>([]);145  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);146  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);147148  // Rare device-trust alerts stay visible; routine entry sync is silent.149  interface SyncAlert {150    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";151    entryId: string;152    entryTitle: string;153    deviceName: string;154    timestamp: number;155  }156  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);157  // Per-group flip index: which alert within a type-group is currently shown.158  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});159  async function loadSyncAlerts() {160    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {161      ok: boolean;162      data?: SyncAlert[];163    };164    if (r.ok && (r.data?.length ?? 0) > 0) {165      setSyncAlerts(r.data ?? []);166      // Acknowledge so a crash between read and render doesn't lose them —167      // they remain on screen until the user dismisses each.168      void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });169    }170  }171  function dismissAlertGroup(type: string) {172    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));173    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });174  }175  function flipAlert(type: string, dir: 1 | -1) {176    const group = syncAlerts().filter((a) => a.type === type);177    if (group.length <= 1) return;178    setAlertFlipIdx((m) => {179      const cur = m[type] ?? 0;180      return { ...m, [type]: (cur + dir + group.length) % group.length };181    });182  }183  /** Grouped alert types in display order, each with its alerts. */184  const alertGroups = createMemo(() => {185    const map = new Map<string, SyncAlert[]>();186    for (const a of syncAlerts()) {187      const g = map.get(a.type);188      if (g) g.push(a); else map.set(a.type, [a]);189    }190    return [...map.entries()];191  });192  function syncAlertText(a: SyncAlert): string {193    if (a.type === "DEVICE_REVOKED") {194      return `${a.deviceName} revoked a paired device — review your devices in Settings`;195    }196    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;197  }198  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");199  const [theme, setTheme] = createSignal<"dark" | "light">("dark");200  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");201  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);202  const [lockOnClose, setLockOnClose] = createSignal(false);203  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);204  const [breachMonitoring, setBreachMonitoring] = createSignal(true);205  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);206  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);207  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);208  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);209  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);210  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");211  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");212  // Cached Pro status — synchronous for render paths. Background's213  // GET_LICENSE is async, but render-time reads of isPro() must not block.214  // Refreshed at boot, after activate/deactivate, and whenever the popup215  // re-opens onto a stored license that has changed via another window.216  const [proStatus, setProStatus] = createSignal(false);217  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);218  // When the sync card's pairing button opens the pairing view, deep-link219  // straight into that pairing path instead of the rest screen.220  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");221  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);222  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");223  const [listSearchSeed, setListSearchSeed] = createSignal("");224  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);225  const [pendingCelebration, setPendingCelebration] = createSignal(false);226  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);227  // A page asked to be remembered for an entry it did not match. Pages cannot228  // grow an entry's domain set themselves (PB-26-106), so the background queues229  // the request here and this banner is the confirmation gate.230  const [pendingRememberSite, setPendingRememberSite] = createSignal<{231    entryId: string;232    url: string;233    title: string;234  } | null>(null);235  // True while Panic Step 1 has bounced the user out to Export. When Export's236  // onBack fires we return to Panic instead of Settings so the user keeps the237  // destruction context they came from.238  const [returnToPanic, setReturnToPanic] = createSignal(false);239  // The view to return to when the add/edit view is dismissed. Normally "entry"240  // but "health" when the user arrives via the fix-entry path from Vault Health.241  const [editBackView, setEditBackView] = createSignal<View>("entry");242  const [settingsExiting, setSettingsExiting] = createSignal(false);243  const [showNotes, setShowNotes] = createSignal(false);244  // Topbar sync affordance. Always present: gating it on having a paired device245  // meant it never appeared for the person trying to set pairing up, which is246  // exactly when you reach for it.247  const [syncBusy, setSyncBusy] = createSignal(false);248  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);249  // The indicator light + its spoken-word equivalent. Colour alone is not the250  // signal; the title/aria-label always carries the same claim in English.251  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});252  const [pairedCount, setPairedCount] = createSignal(0);253  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);254  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);255  let initialSyncWaitGeneration = 0;256  const syncLight = () =>257    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());258  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());259  async function loadSyncIndicator() {260    const [stateRes, devicesRes] = await Promise.all([261      sendMessage({ type: "GET_SYNC_STATE" }),262      sendMessage({ type: "LIST_PAIRED_DEVICES" }),263    ]);264    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };265    if (s.ok && s.data) setSyncSummary(s.data);266    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };267    if (d.ok) {268      setPairedCount(d.data?.devices?.length ?? 0);269    } else if (d.error === "trust_data_corrupted") {270      setPairedCount(0);271      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });272    }273  }274275  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {276    const response = (await sendMessage({277      type: "LIST_PAIRED_DEVICES",278    })) as {279      ok: boolean;280      error?: string;281      data?: { devices?: InitialSyncPairedDevice[] };282    };283    if (response.ok) return response.data?.devices ?? [];284    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");285    throw new Error(response.error ?? "list_paired_devices_failed");286  }287  interface InitialSyncPairedDevice extends InitialSyncDevice {288    pairedAt?: number;289  }290291292  async function resolveInitialSyncDeviceId(): Promise<string | null> {293    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(294      () => null,295    );296    const devices = await listInitialSyncDevices().catch(() => []);297    if (stored && devices.some((device) => device.deviceId === stored)) {298      return stored;299    }300301    // Fresh adoption has exactly one direct source, but choosing the newest row302    // also repairs the marker if storage failed after the pair itself committed.303    const newest = [...devices].sort(304      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),305    )[0];306    if (!newest) return stored;307    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(308      () => undefined,309    );310    return newest.deviceId;311  }312313  async function finishInitialSync(generation: number): Promise<void> {314    if (generation !== initialSyncWaitGeneration) return;315    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);316    setInitialSyncDeviceId(null);317    setInitialSyncSlow(false);318    await refreshProStatus();319320    // A license transferred during pairing intentionally leaves the one-shot321    // celebration pending. Consume it only now: sync readiness → celebration322    // → populated vault, never celebration → temporarily empty vault.323    const celebration = (await sendMessage({324      type: "SHOULD_PLAY_CELEBRATION",325    })) as { ok: boolean; data?: boolean };326    const shouldCelebrate = celebration.ok && celebration.data === true;327    if (shouldCelebrate) {328      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });329    }330331    const opened = await openVault(undefined, shouldCelebrate);332    if (!opened || generation !== initialSyncWaitGeneration) return;333    if (shouldCelebrate) {334      setView("pro-celebration");335      window.setTimeout(() => {336        if (generation === initialSyncWaitGeneration) setView("entry");337      }, 2500);338    }339  }340341  async function beginInitialSyncWait(deviceId: string): Promise<void> {342    const generation = ++initialSyncWaitGeneration;343    setInitialSyncDeviceId(deviceId);344    setInitialSyncSlow(false);345    setView("initial-sync");346    const startedAt = Date.now();347348    // Best effort: the pairing write already wakes the mesh listener, while349    // this trigger covers the key-order case where this receiver is the dialer.350    void sendMessage({351      type: "TRIGGER_SYNC",352      payload: { reason: "foreground" },353    }).catch(() => undefined);354355    while (356      generation === initialSyncWaitGeneration &&357      view() === "initial-sync"358    ) {359      try {360        const devices = await listInitialSyncDevices();361        if (hasCompletedInitialSync(devices, deviceId)) {362          await finishInitialSync(generation);363          return;364        }365      } catch {366        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.367      }368      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {369        setInitialSyncSlow(true);370      }371      await new Promise((resolve) =>372        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),373      );374    }375  }376377  async function retryInitialSync(): Promise<void> {378    const deviceId =379      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());380    if (deviceId) {381      await beginInitialSyncWait(deviceId);382    } else {383      setInitialSyncSlow(true);384    }385  }386387  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);388  // Set on popup boot when peach_recovery_pending flag is found in session389  // storage — instructs LockView to skip the unlock UI and open the recovery390  // words confirmation surface instead. Cleared via handleUnlock once the391  // user has typed the 3 confirmation words.392  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);393394  let contentPhaseTimer: number | undefined;395  // Set when the popup was opened from the inline "Open Peach to unlock" button.396  // handleUnlock() closes the popup after unlock so the user returns to the page.397  let openedForInlineUnlock = false;398399  onCleanup(() => {400    initialSyncWaitGeneration += 1;401  });402403  function applyAccent(name: AccentColorName) {404    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;405    const hex = color.hex;406    document.documentElement.style.setProperty("--peach-accent", hex);407    // Ambient variables derived from the selected accent.408    document.documentElement.style.setProperty("--peach-glow", hex + "2A");409    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");410  }411412  createEffect(() => {413    applyAccent(accentColor());414  });415416  createEffect(() => {417    document.documentElement.classList.toggle("light", theme() === "light");418    document.body.classList.toggle("light", theme() === "light");419  });420421  createEffect(() => {422    document.documentElement.setAttribute("data-text-size", textSize());423  });424425  createEffect(() => {426    if (reduceMotion()) {427      document.documentElement.setAttribute("data-motion", "reduce");428    } else {429      document.documentElement.removeAttribute("data-motion");430    }431  });432433  // Notes are not a destination of the Entry view; selecting a note opens the434  // notes surface instead (handleSelectEntry). Anything that walks or counts435  // the Entry view's neighbours (counter, prev/next, index) must use this436  // filtered list, otherwise the user can arrow into a note and is stuck —437  // the Entry view can't render a note and the prev/next buttons stop moving.438  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));439440  const selectedIndex = createMemo(() => {441    const currentId = selectedEntry()?.id;442    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;443    return nonNoteEntries().findIndex((entry) => entry.id === currentId);444  });445446  async function applyAccentColor(name: AccentColorName) {447    applyAccent(name);448    setAccentColor(name);449    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });450  }451452  async function applyTheme(t: "dark" | "light") {453    setTheme(t);454    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });455  }456457  async function applyTextSize(size: "small" | "medium" | "large") {458    setTextSize(size);459    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });460  }461462  async function applyReduceMotion(enabled: boolean) {463    setReduceMotion(enabled);464    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });465  }466467  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload468  // small and lets future default updates land for actions the user hasn't469  // touched. Settings sends us the full active map; we filter and store.470  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {471    setShortcutBindings(active);472    const overrides: Record<string, Keybinding> = {};473    for (const action of Object.keys(active) as ShortcutAction[]) {474      const a = active[action];475      const d = DEFAULT_BINDINGS[action];476      const same = a.key === d.key477        && Boolean(a.ctrl)  === Boolean(d.ctrl)478        && Boolean(a.meta)  === Boolean(d.meta)479        && Boolean(a.shift) === Boolean(d.shift)480        && Boolean(a.alt)   === Boolean(d.alt);481      if (!same) overrides[action] = a;482    }483    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });484  }485486  async function applyLockOnClose(enabled: boolean) {487    setLockOnClose(enabled);488    await sendMessage({489      type: "UPDATE_SETTINGS",490      payload: { lockOnClose: enabled },491    });492  }493494  async function applyAutoLockMinutes(minutes: number) {495    setAutoLockMinutes(minutes);496    await sendMessage({497      type: "UPDATE_SETTINGS",498      payload: { autoLockMinutes: minutes },499    });500  }501502  async function applyBreachMonitoring(enabled: boolean) {503    setBreachMonitoring(enabled);504    await sendMessage({505      type: "UPDATE_SETTINGS",506      payload: { breachMonitoring: enabled },507    });508  }509510  // Which onboarding exit flow the user is in (create-vault / restore /511  // adopt). Persisted so closing the popup on one of those screens resumes512  // it directly instead of rewinding to the last carousel card.513  function markOnboardingExit(flow: "create" | "restore" | "adopt") {514    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);515    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});516    // Backing out of the flow remounts the carousel at this card; keep the517    // persisted card in sync so a later popup close resumes consistently.518    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});519  }520  function clearOnboardingExit() {521    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});522    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});523    setResumeRestoreSource(null);524  }525526  onMount(async () => {527    // Resolve the active tab in parallel with the unlock/has-vault queries —528    // openVault()'s smart selection picks the entry matching currentTabDomain,529    // so it must be populated before openVault runs. Previously refreshCurrentTab530    // ran after openVault, leaving popups that booted already-unlocked falling531    // back to loadedEntries[0] instead of the domain match.532    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([533      sendMessage({ type: "IS_UNLOCKED" }),534      sendMessage({ type: "HAS_VAULT" }),535      refreshCurrentTab(),536      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),537    ]);538    const unlocked = Boolean(539      (unlockResponse as { ok: boolean; data?: boolean }).data540    );541    const vaultExists = Boolean(542      (vaultResponse as { ok: boolean; data?: boolean }).data543    );544    const shouldCelebrate = Boolean(545      (celebrationResponse as { ok: boolean; data?: boolean }).data546    );547    setHasVault(vaultExists);548    // Onboarding gate — show once on first install, never again. Existing549    // users (vaultExists) are auto-marked onboarded so they skip the flow550    // even on the very first popup open after this feature ships.551    try {552      const onboarded = await storageGet("peach_onboarded");553      const seen = onboarded === "true";554      if (vaultExists && !seen) {555        await storageSet("peach_onboarded", "true");556        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});557        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});558        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});559        setOnboarded(true);560      } else {561        setOnboarded(seen);562        if (!seen) {563          // A popup close while the user is in an exit flow (create-vault /564          // restore / adopt) resumes that flow directly — landing back on the565          // carousel one screen earlier reads as losing progress.566          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);567          if (exit === "create" || exit === "restore" || exit === "adopt") {568            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);569            setOnboarded(true);570            setStartOnboardingRestore(exit === "restore");571            if (exit === "restore") {572              // The OS file picker kills the action popup mid-restore, so the573              // surface resumes on the source the user had already picked.574              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);575              if (source === "symbols" || source === "codex" || source === "csv") {576                setResumeRestoreSource(source);577              }578            }579            if (exit === "adopt") setView("adopting");580          } else {581            // Otherwise resume mid-onboarding where the user left off instead582            // of rewinding to the opening card.583            const stored = Number(584              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),585            );586            if (587              Number.isInteger(stored) &&588              stored > 0 &&589              stored <= ONBOARDING_LAST_CARD590            ) {591              setOnboardingCardAtExit(stored);592            }593          }594        }595      }596    } catch {597      // storage unavailable — default to onboarded so we never block boot598      setOnboarded(true);599    }600601    // Deep-link: ?restore=peachscript|csv — the detached-window half of the602    // file-picker flow. The action popup dismisses when the OS dialog steals603    // focus, so LockView.detachForFilePicker() reopens this URL in a604    // standalone window that owns the dialog. Routing runs before first605    // render so LockView mounts straight onto the right source form.606    try {607      const restoreRoute = new URLSearchParams(window.location.search).get("restore");608      if (restoreRoute === "peachscript" || restoreRoute === "csv") {609        setOnboarded(true);610        setStartOnboardingRestore(true);611        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");612      }613    } catch {614      // location.search unavailable — ignore.615    }616617    await Promise.all([restoreSettings(), refreshProStatus()]);618619    const pendingInitialSyncMarker =620      unlocked && vaultExists621        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)622        : null;623    const pendingInitialSyncDeviceId = pendingInitialSyncMarker624      ? await resolveInitialSyncDeviceId()625      : null;626627    // Mark played before any animation so a crash mid-sequence never replays628    // it. A newly adopted receiver deliberately defers this until its first629    // real sync finishes, preserving initial sync → celebration → vault.630    if (shouldCelebrate && !pendingInitialSyncDeviceId) {631      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });632    }633634    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome635    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.636    if (unlocked && vaultExists) {637      void sendMessage({638        type: "TRIGGER_SYNC",639        payload: { reason: "foreground" },640      });641      void loadSyncIndicator();642      // Rare device-trust alerts only; routine entry sync stays quiet.643      void loadSyncAlerts();644    }645646    // If the user created a vault but hasn't yet confirmed their recovery647    // words, route back to the recovery-words screen instead of opening the648    // entries view. The flag lives in chrome.storage.local so it survives649    // popup close while the background owns the session-scoped confirmation650    // exemption. Cleared when confirmation succeeds.651    let recoveryPending = false;652    try {653      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });654      recoveryPending = out.ok === true && out.data === true;655    } catch { /* best-effort */ }656657    // Content UI can ask the background to open this trusted popup either for658    // unlock or at a specific entry. Consume both session-scoped handoffs once.659    let pendingOpenEntryId: string | undefined;660    if (chrome.storage.session) {661      try {662        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);663        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };664        if (pending.peach_inline_unlock) {665          openedForInlineUnlock = true;666        }667        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;668        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);669      } catch { /* best-effort */ }670    }671672    if (recoveryPending && unlocked) {673      // forceRecoveryWords overrides LockView's hasVault gate so the674      // recovery surface renders even though the vault now exists.675      setForceRecoveryWords(true);676      setView("lock");677    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {678      void beginInitialSyncWait(pendingInitialSyncDeviceId);679    } else if (unlocked && vaultExists) {680      await openVault(pendingOpenEntryId, shouldCelebrate);681      if (shouldCelebrate) {682        setView("pro-celebration");683        window.setTimeout(() => setView("entry"), 2500);684      } else if (await shouldShowWhatsNew()) {685        setView("whats-new");686      }687    } else if (shouldCelebrate) {688      setPendingCelebration(true);689    }690691    await refreshPendingPasskey();692693    // Track tab changes so smart sort stays current694    const handleTabActivated = () => { void refreshCurrentTab(); };695    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {696      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();697    };698    try {699      chrome.tabs.onActivated.addListener(handleTabActivated);700      chrome.tabs.onUpdated.addListener(handleTabUpdated);701      onCleanup(() => {702        chrome.tabs.onActivated.removeListener(handleTabActivated);703        chrome.tabs.onUpdated.removeListener(handleTabUpdated);704      });705    } catch {706      // tabs permission may not be available in all contexts707    }708709    setBootState("ready");710711    // Deep-link: ?codex=pdf opens a detached window straight into the712    // Codex restore flow with auto-picked PDF. The action popup can't host713    // a file dialog without dismissing itself, so this is the only way the714    // PDF import path survives. See CodexRestoreView.openPdfPicker().715    try {716      const route = new URLSearchParams(window.location.search).get("codex");717      if (route === "pdf" || route === "restore") {718        setView("codex-restore");719      }720    } catch {721      // location.search unavailable — ignore.722    }723724    // Check for pending session-storage notices from the background.725    if (chrome.storage.session) {726      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as727        Record<string, { feature?: string } | undefined>;728      if (session.pendingProGate?.feature) {729        await chrome.storage.session.remove("pendingProGate");730        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);731        setSettingsInitialSection("license");732        setView("settings");733      } else if (session.pendingProNotice?.feature) {734        await chrome.storage.session.remove("pendingProNotice");735        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });736      }737      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {738        pendingRememberSite?: { entryId?: unknown; url?: unknown };739      };740      const request = remembered.pendingRememberSite;741      if (742        typeof request?.entryId === "string" &&743        typeof request.url === "string"744      ) {745        const entry = await sendMessage({ type: "GET_ENTRIES" });746        const match = entry.ok747          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(748              (candidate) => candidate.id === request.entryId,749            )750          : undefined;751        if (match) {752          let host = request.url;753          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }754          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });755        } else {756          await chrome.storage.session.remove("pendingRememberSite");757        }758      }759    }760761    // Warm the lazy-module cache so the Suspense fallback never shows for762    // locally-bundled extension views. Without this, the first navigation to763    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which764    // covers the shell and absorbs clicks on the header.765    void Promise.all([766      import("./views/Settings").catch(() => {}),767      import("./views/Import").catch(() => {}),768      import("./views/Export").catch(() => {}),769      import("./views/Health").catch(() => {}),770      import("./views/TrashView").catch(() => {}),771    ]);772773    // Lock-on-close via a persistent port. sendMessage in pagehide is774    // unreliable — the popup context tears down before the message lands.775    // The SW's onConnect listener locks when this port disconnects.776    let popupPort: chrome.runtime.Port | null = null;777    try {778      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect779      // listener accepts the "popup-" prefix; the random suffix is defence in780      // depth against a port named exactly "popup" from anything but our popup.781      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });782    } catch { /* extension context invalidated — ignore */ }783    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });784785    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale786    // entries instead of waiting for the next GET_ENTRIES round-trip.787    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {788      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();789      if (msg?.type === "SYNC_STATE_CHANGED") {790        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);791      }792      if (msg?.type === "PRO_GATE_TRIGGERED") {793        setUpgradeHighlight(msg.payload?.feature ?? null);794        setSettingsInitialSection("license");795        setView("settings");796      }797    };798    chrome.runtime.onMessage.addListener(handleRuntimeMessage);799    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));800801    // Action handlers — keyed by ShortcutAction. The dispatcher below tests802    // each binding against the event and runs the matching handler. New803    // shortcuts only need an entry here; the binding is data, not code.804    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {805      copy_password: () => {806        const entry = selectedEntry();807        if (entry?.password) void popupCopy(808          entry.password,809          "Password",810          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,811        ).then((ok) => { if (ok) triggerRing(); });812      },813      copy_username: () => {814        const entry = selectedEntry();815        if (entry?.username) void popupCopy(816          entry.username,817          "Username",818          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,819        ).then((ok) => { if (ok) triggerRing(); });820      },821      copy_totp: () => {822        const entry = selectedEntry();823        if (!entry?.totp) return;824        try {825          const { code } = generateTOTP(826            entry.totp,827            entry.totpAlgorithm,828            entry.totpDigits,829            entry.totpPeriod,830          );831          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });832        } catch { /* invalid TOTP secret — silent */ }833      },834      new_entry: () => handleAdd(),835      edit_entry: () => {836        const entry = selectedEntry();837        // Passkey-only entries have nothing user-editable in the Add view; the838        // passkey credential itself is opaque. Skip the edit action for them.839        if (!entry || isPasskeyOnly(entry)) return;840        setEditEntry(entry);841        setView("add");842      },843      delete_entry: () => {844        if (selectedEntry()) void handleDelete();845      },846      lock_vault: () => { void handleHeaderLock(); },847      search: () => openList(),848      nav_prev: () => navigate(-1),849      nav_next: () => navigate(1),850      jump_vault: () => setView("entry"),851      jump_settings: () => setView("settings"),852      show_help: () => setShowShortcutsHelp(true),853    };854855    // Actions that are MEANINGFUL only on the entry-detail view. Outside856    // that scope the binding is dormant — pressing C while on Settings857    // does nothing rather than awkwardly copying from a now-stale entry.858    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([859      "copy_password", "copy_username", "copy_totp",860      "edit_entry", "delete_entry", "nav_prev", "nav_next",861    ]);862863    const handleGlobalKeydown = (e: KeyboardEvent) => {864      const v = view();865      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"866        || (e.target as HTMLElement)?.tagName === "TEXTAREA"867        || (e.target as HTMLElement)?.isContentEditable;868      const isPrintableSearchKey =869        e.key.length === 1 &&870        !e.ctrlKey &&871        !e.metaKey &&872        !e.altKey;873874      // No shortcuts while a full-screen surface owns the whole viewport:875      // the lock screen, the post-pairing readiness sequence, or the Pro876      // celebration. Without this the lock screen is at the mercy of whatever877      // single-key binding is active — e.g. typing "n" in a password that878      // slips out of focus triggers `new_entry` and escapes the lock screen.879      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;880881      // Help dialog: Escape closes it; eat the keypress so it doesn't882      // also navigate away from the underlying view.883      if (showShortcutsHelp()) {884        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }885        return;886      }887888      // ── Escape — fixed binding (not user-customizable) ───────889      if (e.key === "Escape") {890        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }891        if (v === "list") { setView("entry"); return; }892        if (v === "settings" || v === "add") { handleBack(); return; }893        if (v === "import" || v === "health") { setView("settings"); return; }894        if (v === "export") { handleExportBack(); return; }895        if (v === "pair-device") { setView("settings"); return; }896      }897898      // Bindings-driven dispatch. Single-key letter bindings without899      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is900      // inside an input, so users can type into search/forms normally.901      const bindings = shortcutBindings();902      for (const action of Object.keys(bindings) as ShortcutAction[]) {903        const binding = bindings[action];904        if (!matchKeybinding(e, binding)) continue;905        // Single-key letter bindings need the input-focus guard. Bindings906        // with any modifier are safe — Ctrl+L doesn't conflict with typing.907        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;908        if (!hasMod && binding.key.length === 1 && inInput) return;909        if (ENTRY_SCOPED.has(action) && v !== "entry") return;910        e.preventDefault();911        SHORTCUT_HANDLERS[action]?.();912        return;913      }914915      // Type-to-search: in entry view, any printable key not consumed by a916      // shortcut above primes the list search seed and opens the list.917      // Kept outside the bindings table because it's a behavior, not a918      // discrete action — every printable character can trigger it.919      if (v === "entry" && !inInput && isPrintableSearchKey) {920        e.preventDefault();921        primeListSearch(e.key);922        return;923      }924    };925    window.addEventListener("keydown", handleGlobalKeydown);926    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));927928    const handleStorageChanged = (929      changes: Record<string, chrome.storage.StorageChange>,930      areaName: string931    ) => {932      if (areaName !== "session") return;933      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {934        return;935      }936      void refreshPendingPasskey();937    };938    chrome.storage.onChanged.addListener(handleStorageChanged);939    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));940  });941942  async function refreshCurrentTab() {943    try {944      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });945      const url = tab?.url ?? tab?.pendingUrl ?? "";946      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");947      // Skip extension/internal URLs — adding chrome://, about:, or948      // chrome-extension:// to a vault entry is never what the user wants.949      const isWebUrl = /^https?:\/\//i.test(url);950      setCurrentTabUrl(isWebUrl ? url : "");951    } catch {952      setCurrentTabDomain("");953      setCurrentTabUrl("");954    }955  }956957  async function refreshPendingPasskey() {958    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });959    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);960  }961962  async function refreshProStatus() {963    const res = await sendMessage({ type: "GET_LICENSE" });964    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;965    setProStatus(Boolean(data?.valid && data?.plan === "pro"));966  }967968  async function restoreSettings() {969    const res = await sendMessage({ type: "GET_SETTINGS" });970    const s = (res as { ok: boolean; data?: any }).data ?? {};971    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {972      setAccentColor(s.accentColor as AccentColorName);973    }974    setTheme(s.theme === "light" ? "light" : "dark");975    if (s.textSize === "small" || s.textSize === "large") {976      setTextSize(s.textSize);977    } else {978      setTextSize("medium");979    }980    setReduceMotion(s.reduceMotion === true);981    setLockOnClose(s.lockOnClose === true);982    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);983    setBreachMonitoring(s.breachMonitoring !== false);984    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);985    setShortcutBindings(getActiveBindings(s.shortcuts));986  }987988  async function applyTrashRetention(days: number) {989    setTrashRetentionDays(days);990    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });991  }992993  function primeListSearch(seed: string) {994    setListSearchSeed(seed);995    setListSearchSeedNonce((value) => value + 1);996    openList();997  }998999  // Returns null when it detected a dead service worker and triggered handleLock().1000  // Callers that need to abort (e.g. openVault) check for null rather than relying1001  // on view() state, which may already be "lock" during normal startup flows.1002  async function loadEntries(): Promise<VaultEntry[] | null> {1003    const response = await sendMessage({ type: "GET_ENTRIES" });1004    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10051006    // If the service worker was killed and restarted (MV3 workers sleep), the1007    // vault will be locked again — or the worker may not respond at all.1008    // In either case, send the user to the lock screen rather than silently1009    // showing an empty vault (which looks like data loss).1010    if (!res.ok) {1011      if (1012        res.error === "vault_locked" ||1013        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1014        (typeof res.error === "string" && res.error.includes("locked"))1015      ) {1016        handleLock();1017        return null; // signal emergency lock to callers1018      }1019      return [];1020    }10211022    const loadedEntries = res.data ?? [];1023    setEntries(loadedEntries);10241025    const currentId = selectedEntry()?.id;1026    if (currentId) {1027      setSelectedEntry(1028        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1029      );1030    }10311032    return loadedEntries;1033  }10341035  async function openVault(preferredEntryId?: string, skipViewSet = false) {1036    // Purge expired trash entries on each vault open1037    const retDays = trashRetentionDays();1038    if (retDays > 0) {1039      void sendMessage({1040        type: "PURGE_EXPIRED_TRASH",1041        payload: { retentionMs: retDays * 86_400_000 },1042      });1043    }10441045    const loadedEntries = await loadEntries();10461047    // null means loadEntries() triggered an emergency lock (service worker died).1048    // Don't override it by setting view back to "entry" with an empty vault.1049    if (loadedEntries === null) return false;10501051    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1052    let nextSelectedEntry: VaultEntry | null = null;1053    if (preferredEntryId) {1054      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1055    }1056    if (!nextSelectedEntry) {1057      const tabDomain = currentTabDomain();1058      if (tabDomain) {1059        nextSelectedEntry = loadedEntries.find((e) => {1060          if (e.type === "login") {1061            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1062            for (const extra of e.additionalUrls ?? []) {1063              if ((extractDomain(extra) ?? "") === tabDomain) return true;1064            }1065            return false;1066          }1067          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1068          return false;1069        }) ?? null;1070      }1071    }1072    nextSelectedEntry ??= pickNavigable(loadedEntries);10731074    setSelectedEntry(nextSelectedEntry);1075    setEditEntry(undefined);1076    setContentPhase("enter");1077    queueContentIdle();1078    if (!skipViewSet) setView("entry");1079    return true;1080  }10811082  async function handleUnlock() {1083    // If the user previously created a vault but hadn't yet confirmed their1084    // recovery words (and the browser was closed before they could), the1085    // the background-owned recovery-pending state is still set. Re-route1086    // back to the recovery surface so they finish the confirmation step1087    // before reaching the entries view — the words can be re-derived now1088    // that the vault is unlocked again.1089    let recoveryPending = false;1090    try {1091      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1092      recoveryPending = out.ok === true && out.data === true;1093    } catch { /* best-effort */ }1094    if (recoveryPending) {1095      setForceRecoveryWords(true);1096      setView("lock");1097      triggerPipPulse();1098      await restoreSettings();1099      return;1100    }1101    setHasVault(true);1102    triggerPipPulse();1103    await restoreSettings();1104    const celebrating = pendingCelebration();1105    if (celebrating) {1106      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1107      setPendingCelebration(false);1108    }1109    await openVault(undefined, celebrating);1110    if (celebrating) {1111      setView("pro-celebration");1112      window.setTimeout(() => setView("entry"), 2500);1113    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1114      setView("whats-new");1115    }1116    // Popup was opened from the inline "Open Peach to unlock" button — close it1117    // now so the user lands back on the page with the autofill selector ready.1118    if (openedForInlineUnlock) {1119      openedForInlineUnlock = false;1120      window.close();1121    }1122  }11231124  function handleSelectEntry(entry: VaultEntry) {1125    if (entry.type === "note") {1126      setOpenNoteId(entry.id);1127      setShowNotes(true);1128      return;1129    }1130    setSelectedEntry(entry);1131    setContentPhase("enter");1132    queueContentIdle();1133    setView("entry");1134  }11351136  function handleAdd() {1137    setEditEntry(undefined);1138    setView("add");1139  }11401141  async function handleDelete() {1142    const entry = selectedEntry();1143    if (!entry) return;1144    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1145    const loadedEntries = await loadEntries() ?? [];1146    setSelectedEntry(pickNavigable(loadedEntries));1147    setView("entry");1148  }11491150  async function handleDeleteMany(ids: string[]) {1151    for (const id of ids) {1152      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1153    }1154    const loadedEntries = await loadEntries() ?? [];1155    // If the currently selected entry was among the deleted, move to first1156    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1157    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1158  }11591160  async function handleSave(savedEntry?: VaultEntry) {1161    await openVault(savedEntry?.id);1162  }11631164  function handleBack() {1165    const dest = editBackView();1166    setEditBackView("entry");1167    setEditEntry(undefined);1168    setView(dest);1169  }11701171  function handleExportBack() {1172    if (returnToPanic()) {1173      setReturnToPanic(false);1174      setView("panic");1175      return;1176    }1177    setView("settings");1178  }11791180  function handleLock() {1181    setEntries([]);1182    setSelectedEntry(null);1183    setEditEntry(undefined);1184    setContentPhase("idle");1185    setView("lock");1186  }11871188  function handleHeaderLock() {1189    // Lock the UI immediately — do not wait for the service worker. If the SW1190    // is dead or slow, the 5s sendMessage timeout would create a dead-click1191    // window. The SW will lock on its next wake regardless.1192    handleLock();1193    void sendMessage({ type: "LOCK" });1194  }11951196  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1197  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1198  // the trigger's own reply.1199  async function handleHeaderSync(): Promise<void> {1200    if (syncBusy()) return;1201    setSyncBusy(true);1202    setSyncFlash(null);1203    try {1204      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1205      let state: SyncOutcomeSummary = { status: "syncing" };1206      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1207        await new Promise((r) => setTimeout(r, 250));1208        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1209          ok: boolean;1210          data?: SyncOutcomeSummary;1211        };1212        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1213      }1214      setSyncSummary(state);1215      setSyncFlash(state.status === "error" ? "error" : "ok");1216      await loadSyncAlerts();1217      await loadEntries();1218      await loadSyncIndicator();1219    } catch {1220      setSyncSummary({ status: "error", outcome: "run_failed" });1221      setSyncFlash("error");1222    } finally {1223      setSyncBusy(false);1224      setTimeout(() => setSyncFlash(null), 2_000);1225    }1226  }12271228  function navigate(dir: number) {1229    const navigable = nonNoteEntries();1230    if (navigable.length === 0) return;1231    const currentIndex = selectedIndex();1232    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1233    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1234    setSelectedEntry(navigable[nextIndex]);1235    setContentPhase("enter");1236    queueContentIdle();1237  }12381239  /**1240   * The Entry view's selection invariant: never select a note. Notes are a1241   * separate surface (handleSelectEntry opens the notes view), so any code path1242   * that picks a default — vault open, post-delete, refresh — must skip them1243   * and return null if the only entries are notes.1244   */1245  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1246    return list.find((e) => e.type !== "note") ?? null;1247  }12481249  function openList() {1250    setView("list");1251  }12521253  function openHealth() {1254    setEditEntry(undefined);1255    setView("health");1256  }12571258  function handleOpenFileImport() {1259    setRestoreView("file");1260  }12611262  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12631264  function handleFixEntry(entry: VaultEntry) {1265    setSelectedEntry(entry);1266    // Passkey-only entries have no editable surface — drop into view mode1267    // instead of opening the Add editor.1268    if (isPasskeyOnly(entry)) {1269      setView("entry");1270      return;1271    }1272    setEditBackView(view()); // preserve caller view so back returns here1273    setEditEntry(entry);1274    setView("add");1275  }12761277  const isLockedView = () => view() === "lock" || view() === "adopting";12781279  async function handleAdoptionDone() {1280    await storageSet("peach_onboarded", "true").catch(() => {});1281    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1282    clearOnboardingExit();1283    setOnboarded(true);1284    setHasVault(true);1285    setView("initial-sync");1286    // The background applies a Pro license and persists the source-device1287    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1288    // celebration pending until that exact peer records a completed sync.1289    await refreshProStatus();1290    const deviceId = await resolveInitialSyncDeviceId();1291    if (deviceId) {1292      void beginInitialSyncWait(deviceId);1293    } else {1294      // Pairing completion without a durable peer row should be impossible.1295      // Stay on the honest waiting surface and expose retry instead of falling1296      // through to an empty vault.1297      setInitialSyncSlow(true);1298    }1299  }13001301  async function handleApprovePendingPasskey() {1302    const pending = pendingPasskey();1303    if (!pending) return;1304    if (pending.browser === "firefox" && pending.type === "create") {1305      // Firefox path: popup itself runs navigator.credentials.create. The1306      // background never approved/rejected anything — it just stashed1307      // intent. After the popup-driven flow resolves (success or cancel),1308      // clear the pending key so the popup doesn't re-show this on next open.1309      const { createFirefoxPasskey } = await import("./firefox-passkey");1310      const result = await createFirefoxPasskey(pending);1311      if (!result.ok) {1312        console.warn("Peach Firefox passkey create failed:", result.error);1313      }1314      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1315      setPendingPasskey(null);1316      void loadEntries();1317      window.close();1318      return;1319    }1320    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1321    window.close();1322  }13231324  async function handleDenyPendingPasskey() {1325    const pending = pendingPasskey();1326    if (!pending) return;1327    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1328    window.close();1329  }13301331  function queueContentIdle(after?: () => void) {1332    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1333    contentPhaseTimer = window.setTimeout(() => {1334      setContentPhase("idle");1335      after?.();1336    }, ENTRY_TRANSITION_SETTLE_MS);1337  }13381339  return (1340    <div class="popup-root peach-theme">1341      <Show when={bootState() === "ready"}>1342        <Show1343          when={!pendingPasskey()}1344          fallback={1345            <PasskeyApprovalView1346              type={pendingPasskey()!.type}1347              rpName={pendingPasskey()!.rpName}1348              rpId={pendingPasskey()!.rpId}1349              userName={pendingPasskey()!.userName}1350              userDisplayName={pendingPasskey()!.userDisplayName}1351              onApprove={handleApprovePendingPasskey}1352              onDeny={handleDenyPendingPasskey}1353            />1354          }1355        >1356          <Show1357            when={!isLockedView()}1358            fallback={1359              <Show1360                when={view() !== "adopting"}1361                fallback={1362                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1363                    <AdoptVaultView1364                      onCancel={() => {1365                        clearOnboardingExit();1366                        setOnboarded(false);1367                        setView("lock");1368                      }}1369                      onDone={handleAdoptionDone}1370                    />1371                  </Suspense>1372                }1373              >1374                <Show1375                  when={onboarded() || hasVault()}1376                  fallback={1377                    <Onboarding1378                      initialCard={onboardingCardAtExit()}1379                      onComplete={() => {1380                        markOnboardingExit("create");1381                        setStartOnboardingRestore(false);1382                        setOnboarded(true);1383                      }}1384                      onRestore={() => {1385                        markOnboardingExit("restore");1386                        setStartOnboardingRestore(true);1387                        setOnboarded(true);1388                      }}1389                      onAdopt={() => {1390                        markOnboardingExit("adopt");1391                        setStartOnboardingRestore(false);1392                        setOnboarded(true);1393                        setView("adopting");1394                      }}1395                    />1396                  }1397                >1398                  <LockView1399                    hasVault={hasVault()}1400                    ready={bootState() === "ready"}1401                    isPro={proStatus()}1402                    forceRecoveryWords={forceRecoveryWords()}1403                    initialRestore={startOnboardingRestore()}1404                    initialRestoreSource={resumeRestoreSource() ?? undefined}1405                    onUnlock={() => {1406                      // RecoveryWordDisplay clears the storage flag before1407                      // calling onDone, so handleUnlock will see no pending1408                      // confirmation and proceed through the normal path.1409                      if (forceRecoveryWords()) setForceRecoveryWords(false);1410                      // The create/restore flow is done — a lock later in this1411                      // window must show the unlock screen, not re-enter the1412                      // onboarding restore surface.1413                      setStartOnboardingRestore(false);1414                      setResumeRestoreSource(null);1415                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1416                      return handleUnlock();1417                    }}1418                    onBackToOnboarding={1419                      !hasVault()1420                        ? () => {1421                            // Deliberately rewinding to the carousel — the exit1422                            // flow must not reclaim the next popup open.1423                            clearOnboardingExit();1424                            setStartOnboardingRestore(false);1425                            setOnboarded(false);1426                          }1427                        : undefined1428                    }1429                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1430                  />1431                </Show>1432              </Show>1433            }1434          >1435            <Show1436              when={1437                view() !== "pro-celebration" &&1438                view() !== "initial-sync" &&1439                view() !== "whats-new"1440              }1441              fallback={1442                <Show1443                  when={view() === "initial-sync"}1444                  fallback={1445                    <Show1446                      when={view() === "whats-new"}1447                      fallback={<ProCelebration />}1448                    >1449                      <WhatsNewView onContinue={() => setView("entry")} />1450                    </Show>1451                  }1452                >1453                  <InitialSyncView1454                    slow={initialSyncSlow()}1455                    onRetry={() => void retryInitialSync()}1456                  />1457                </Show>1458              }1459            >1460            <section1461              id="hd-shell"1462              class="peach-shell peach-theme"1463              data-surface="extension"1464            >1465            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1466              <PeachWordmark unlocked />1467              <div class="peach-chrome-actions">1468                <button1469                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1470                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1471                  aria-label="Notes"1472                  title="Notes"1473                >1474                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1475                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1476                    <line x1="4" y1="5" x2="10" y2="5" />1477                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1478                    <line x1="4" y1="10" x2="7" y2="10" />1479                  </svg>1480                </button>1481                <button1482                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1483                    classList={{1484                      "is-syncing": syncBusy(),1485                      "is-ok": syncFlash() === "ok",1486                      "is-error": syncFlash() === "error",1487                    }}1488                    onClick={() => void handleHeaderSync()}1489                    disabled={syncBusy()}1490                    aria-label={`Sync now. ${syncLabel()}`}1491                    title={syncLabel()}1492                  >1493                    <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">1494                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1495                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1496                      <path d="M2.6 1v2.4h2.4" />1497                      <path d="M11.4 13v-2.4H9" />1498                    </svg>1499                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1500                  </button>1501                <button1502                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1503                  onClick={() => setView("settings")}1504                  aria-label="Settings"1505                >1506                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1507                    <path1508                      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"1509                      stroke="currentColor"1510                      stroke-width="0.9"1511                    />1512                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1513                  </svg>1514                </button>1515                <button1516                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1517                  onClick={handleAdd}1518                  aria-label="New entry"1519                >1520                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1521                    <path d="M5.5 1v9M1 5.5h9" />1522                  </svg>1523                </button>1524                <button1525                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1526                  onClick={() => void handleHeaderLock()}1527                  aria-label="Lock vault"1528                >1529                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1530                    <path1531                      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"1532                      stroke="currentColor"1533                      stroke-width="1"1534                      stroke-linecap="round"1535                    />1536                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1537                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1538                  </svg>1539                </button>1540              </div>1541            </header>15421543            <Show when={pendingProNotice() !== null}>1544              <div class="peach-pro-notice-banner">1545                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1546                <button1547                  class="peach-pro-notice-link"1548                  onClick={() => {1549                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1550                    setSettingsInitialSection("license");1551                    setView("settings");1552                    setPendingProNotice(null);1553                  }}1554                >1555                  Learn more →1556                </button>1557                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1558              </div>1559            </Show>15601561            <Show when={pendingRememberSite()}>1562              {(request) => {1563                const origin = getSecurityOriginDisplay(request().url);1564                const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1565                return (1566                  <div class="peach-pro-notice-banner">1567                    <span>Also use “{request().title}” on {originLabel}?</span>1568                    <button1569                      class="peach-pro-notice-link"1570                      onClick={async () => {1571                        await sendMessage({1572                          type: "ADD_ADDITIONAL_URL",1573                          payload: { id: request().entryId, url: request().url },1574                        });1575                        await chrome.storage.session.remove("pendingRememberSite");1576                        setPendingRememberSite(null);1577                      }}1578                    >1579                      Add1580                    </button>1581                    <button1582                      class="peach-pro-notice-close"1583                      onClick={async () => {1584                        await chrome.storage.session.remove("pendingRememberSite");1585                        setPendingRememberSite(null);1586                      }}1587                      aria-label="Dismiss"1588                    >1589                      ✕1590                    </button>1591                  </div>1592                );1593              }}1594            </Show>15951596            <div class="peach-shell-body">1597              <div class="peach-shell-veil"></div>1598              <div class="peach-content-frame" data-content-phase={contentPhase()}>1599                <EntryView1600                  keyboardEnabled={view() === "entry"}1601                  entry={selectedEntry()}1602                  currentTabUrl={currentTabUrl()}1603                  entries={entries()}1604                  isPro={proStatus()}1605                  onDelete={handleDelete}1606                  onFieldSaved={() => void loadEntries()}1607                  onEscape={() => { setView("list"); }}1608                  onProGate={(feature) => {1609                    setUpgradeHighlight(feature);1610                    setSettingsInitialSection("license");1611                    setView("settings");1612                  }}1613                />1614              </div>16151616              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1617                <div class="peach-search-bar">1618                  <span class="peach-search-icon">1619                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1620                      <circle cx="5.5" cy="5.5" r="3.75" />1621                      <path d="M8.75 8.75L11.5 11.5" />1622                    </svg>1623                  </span>1624                  <input1625                    class="peach-search-input"1626                    placeholder="search vault..."1627                    autocomplete="off"1628                    aria-label="Search vault entries"1629                    onClick={openList}1630                    onFocus={openList}1631                    readOnly1632                  />1633                  <div class="peach-search-meta">1634                    <span class="peach-search-counter">1635                      {nonNoteEntries().length > 01636                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1637                        : "0 entries"}1638                    </span>1639                    <span class="peach-divider"></span>1640                    <button1641                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1642                      onClick={() => navigate(-1)}1643                      aria-label="Previous entry"1644                      disabled={nonNoteEntries().length === 0}1645                    >1646                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1647                        <path d="M5 1L1 5.5L5 10" />1648                      </svg>1649                    </button>1650                    <button1651                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1652                      onClick={() => navigate(1)}1653                      aria-label="Next entry"1654                      disabled={nonNoteEntries().length === 0}1655                    >1656                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1657                        <path d="M1 1L5 5.5L1 10" />1658                      </svg>1659                    </button>1660                  </div>1661                </div>1662              </section>1663            </div>16641665            {/* Rare device-trust alerts; normal sync changes are silent. */}1666            <Show when={alertGroups().length > 0}>1667              <div class="peach-sync-alerts">1668                <For each={alertGroups()}>1669                  {([type, group]) => {1670                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1671                    return (1672                      <div class="peach-sync-alert">1673                        <Show when={group.length > 1}>1674                          <button1675                            class="peach-sync-alert-flip"1676                            type="button"1677                            aria-label="Previous"1678                            onClick={() => flipAlert(type, -1)}1679                          >‹</button>1680                        </Show>1681                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1682                        <Show when={group.length > 1}>1683                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1684                          <button1685                            class="peach-sync-alert-flip"1686                            type="button"1687                            aria-label="Next"1688                            onClick={() => flipAlert(type, 1)}1689                          >›</button>1690                        </Show>1691                        <button1692                          class="peach-sync-alert-dismiss"1693                          type="button"1694                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1695                          onClick={() => dismissAlertGroup(type)}1696                        >×</button>1697                      </div>1698                    );1699                  }}1700                </For>1701              </div>1702            </Show>17031704            <Show when={view() === "list"}>1705              <VaultView1706                entries={entries()}1707                selectedEntryId={selectedEntry()?.id}1708                currentTabDomain={currentTabDomain()}1709                sortOrder={sortOrder()}1710                searchSeed={listSearchSeed()}1711                searchSeedNonce={listSearchSeedNonce()}1712                onSelectEntry={handleSelectEntry}1713                onSortOrderChange={setSortOrder}1714                onAdd={handleAdd}1715                onClose={() => setView("entry")}1716                onOpenTrash={() => setView("trash")}1717                onDeleteMany={handleDeleteMany}1718              />1719            </Show>1720            <Show when={view() === "settings" || settingsExiting()}>1721              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1722                <SettingsView1723                  entriesCount={entries().length}1724                  breachMonitoring={breachMonitoring()}1725                  lockOnClose={lockOnClose()}1726                  autoLockMinutes={autoLockMinutes()}1727                  trashRetentionDays={trashRetentionDays()}1728                  theme={theme()}1729                  accentColor={accentColor()}1730                  textSize={textSize()}1731                  reduceMotion={reduceMotion()}1732                  isPro={proStatus()}1733                  initialSection={settingsInitialSection()}1734                  upgradeHighlight={upgradeHighlight()}1735                  isExiting={settingsExiting()}1736                  onBreachMonitoringChange={applyBreachMonitoring}1737                  onLockOnCloseChange={applyLockOnClose}1738                  onAutoLockMinutesChange={applyAutoLockMinutes}1739                  onTrashRetentionChange={applyTrashRetention}1740                  onThemeChange={applyTheme}1741                  onAccentChange={applyAccentColor}1742                  onTextSizeChange={applyTextSize}1743                  onReduceMotionChange={applyReduceMotion}1744                  shortcuts={shortcutBindings()}1745                  onShortcutsChange={applyShortcuts}1746                  onLicenseChange={async () => {1747                    // After activation/deactivation: refresh Pro status,1748                    // re-load entries (defensive — signal state can drift if1749                    // the SW briefly restarts during the verify+save round-trip),1750                    // and replay the Pro celebration if this is the first time1751                    // a valid Pro license is seen on this device.1752                    await refreshProStatus();1753                    void loadEntries();1754                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1755                    if ((should as { ok: boolean; data?: boolean }).data === true) {1756                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1757                      setView("pro-celebration");1758                      window.setTimeout(() => setView("settings"), 2500);1759                    }1760                  }}1761                  onBack={() => {1762                    setSettingsExiting(true);1763                    window.setTimeout(() => {1764                      setSettingsExiting(false);1765                      setSettingsInitialSection(null);1766                      setUpgradeHighlight(null);1767                      handleBack();1768                    }, 200);1769                  }}1770                  onImport={() => setView("import")}1771                  onExport={() => setView("export")}1772                  onBreachCheckDone={loadEntries}1773                  onOpenHealth={openHealth}1774                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1775                  onOpenFileImport={handleOpenFileImport}1776                  onOpenPanic={() => setView("panic")}1777                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1778                  onOpenCodexRestore={() => setView("codex-restore")}1779                  onCardChange={setSettingsActiveCard}1780                  onProGate={(feature) => {1781                    setUpgradeHighlight(feature);1782                    setSettingsInitialSection("license");1783                  }}1784                />1785              </Suspense>1786            </Show>1787            <Show when={view() === "codex-generate"}>1788              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1789                <CodexGenerateView1790                  isPro={proStatus()}1791                  onBack={() => setView("settings")}1792                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1793                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1794                  onVaultLocked={() => handleLock()}1795                />1796              </Suspense>1797            </Show>1798            <Show when={view() === "codex-restore"}>1799              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1800                <CodexRestoreView1801                  onBack={() => setView("lock")}1802                  onRestoreSuccess={async () => {1803                    // After restore the vault is unlocked; refresh entries1804                    // and route to the main vault list. handleUnlock is the1805                    // canonical post-unlock setup path.1806                    await handleUnlock();1807                  }}1808                />1809              </Suspense>1810            </Show>1811            <Show when={view() === "pair-device"}>1812              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1813                <PairDeviceView1814                  isPro={proStatus()}1815                  onProChanged={() => { void refreshProStatus(); }}1816                  onBack={() => setView("settings")}1817                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1818                />1819              </Suspense>1820            </Show>1821            <Show when={view() === "add"}>1822              <AddView1823                entry={editEntry()}1824                entries={entries()}1825                onBack={handleBack}1826                onSave={handleSave}1827              />1828            </Show>1829            <Show when={view() === "import"}>1830              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1831                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1832              </Suspense>1833            </Show>1834            <Show when={view() === "export"}>1835              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1836                <ExportView onBack={handleExportBack} />1837              </Suspense>1838            </Show>1839            <Show when={view() === "panic"}>1840              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1841                <PanicView1842                  onCancel={() => setView("settings")}1843                  onOpenExport={() => {1844                    setReturnToPanic(true);1845                    setView("export");1846                  }}1847                  onDestroyed={async () => {1848                    // Fresh onboarding start after a destroy — don't let the1849                    // resume markers land the user mid-carousel.1850                    setOnboardingCardAtExit(0);1851                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1852                    clearOnboardingExit();1853                    setEntries([]);1854                    setSelectedEntry(null);1855                    setEditEntry(undefined);1856                    setHasVault(false);1857                    setOnboarded(false);1858                    setProStatus(false);1859                    setPendingPasskey(null);1860                    setUpgradeHighlight(null);1861                    setSettingsInitialSection(null);1862                    setPendingProNotice(null);1863                    setPendingCelebration(false);1864                    setReturnToPanic(false);1865                    setContentPhase("idle");1866                    setView("lock");1867                  }}1868                />1869              </Suspense>1870            </Show>1871            {/* Health stays mounted while editing an entry from it so1872                computeReused() doesn't rerun on every cancel/back. */}1873            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1874              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1875                <HealthView1876                  entries={entries()}1877                  isPro={proStatus()}1878                  onFixEntry={handleFixEntry}1879                  onBack={() => setView("settings")}1880                  onRefresh={loadEntries}1881                  hidden={view() !== "health"}1882                />1883              </Suspense>1884            </Show>1885            <Show when={view() === "trash"}>1886              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1887                <TrashView1888                  onBack={() => setView("list")}1889                  onRestored={() => void loadEntries()}1890                />1891              </Suspense>1892            </Show>1893            <Show when={showNotes()}>1894              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1895                <NotesMode1896                  notes={entries().filter((e) => e.type === "note")}1897                  initialNoteId={openNoteId()}1898                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1899                  onNoteListChanged={() => void loadEntries()}1900                />1901              </div>1902            </Show>1903            <Show when={restoreView() === "file"}>1904              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1905                <ImportView1906                  mode="onboarding"1907                  replacesExistingVault={true}1908                  onBack={() => setRestoreView(null)}1909                  onImport={async () => {1910                    setRestoreView(null);1911                    await handleUnlock();1912                  }}1913                />1914              </Suspense>1915            </Show>1916            </section>1917            </Show>1918          </Show>1919        </Show>1920      </Show>1921      <Show when={showShortcutsHelp()}>1922        <ShortcutsHelpOverlay1923          bindings={shortcutBindings()}1924          onClose={() => setShowShortcutsHelp(false)}1925        />1926      </Show>1927    </div>1928  );1929}19301931function ShortcutsHelpOverlay(props: {1932  bindings: Record<ShortcutAction, Keybinding>;1933  onClose: () => void;1934}) {1935  return (1936    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1937      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1938        <div class="peach-shortcuts-help-head">1939          <h3>Keyboard shortcuts</h3>1940          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1941        </div>1942        <div class="peach-shortcuts-help-body">1943          <p class="peach-settings-sub-label">ENTRY VIEW</p>1944          <ul>1945            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1946              <li>1947                <span>{d.label}</span>1948                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1949              </li>1950            ))}1951          </ul>1952          <p class="peach-settings-sub-label">ANYWHERE</p>1953          <ul>1954            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1955              <li>1956                <span>{d.label}</span>1957                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1958              </li>1959            ))}1960          </ul>1961        </div>1962      </div>1963    </div>1964  );1965}