App.tsx

79.3 KB · raw

1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { Portal } from "solid-js/web";3import { storageGet, storageSet } from "../shared/storage";4import { sendMessage } from "../shared/bridge";5import { ACCENT_COLORS } from "../shared/types";6import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7import { isPasskeyOnly } from "../shared/types";8import { LockView } from "./views/Lock";9import { Onboarding } from "./views/Onboarding";10import { NotesMode } from "./views/NotesMode";11import { ProCelebration } from "./views/ProCelebration";12import { VaultView, type SortOrder } from "./views/Vault";13import { EntryView } from "./views/Entry";14import { AddView } from "./views/Add";15import { PeachWordmark } from "./components/PeachMark";16import { PasskeyApprovalView } from "./views/PasskeyApproval";17import { extractDomain } from "../shared/domain";18import {19  syncIndicator,20  syncIndicatorLabel,21  type SyncOutcomeSummary,22} from "../shared/sync-outcome";23import { popupCopy } from "./copy";24import {25  DEFAULT_BINDINGS,26  formatKeybinding,27  getActiveBindings,28  matchKeybinding,29  SHORTCUT_DESCRIPTORS,30  type Keybinding,31  type ShortcutAction,32} from "../shared/shortcuts";33import { generateTOTP } from "../crypto/totp";34import { isNativePlatform, nativePlugin } from "../shared/native";3536export { sendMessage } from "../shared/bridge";3738const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));39const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));40const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));41const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));42const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));43const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));44const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));45const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));46const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));47const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));4849type View =50  | "lock"51  | "pro-celebration"52  | "entry"53  | "list"54  | "add"55  | "settings"56  | "pair-device"57  | "import"58  | "export"59  | "health"60  | "trash"61  | "panic"62  | "adopting"63  | "codex-generate"64  | "codex-restore";65type BootState = "loading" | "ready";6667const canarySuppressMap = new Map<string, boolean>();68let shellRingAnimation: Animation | null = null;6970export function isCanarySuppressed(entryId: string): boolean {71  return canarySuppressMap.get(entryId) ?? false;72}7374export function setCanarySuppressed(entryId: string, suppressed: boolean): void {75  canarySuppressMap.set(entryId, suppressed);76}7778export function clearCanarySuppressions(): void {79  canarySuppressMap.clear();80}8182export function triggerRing() {83  const shell = document.getElementById("hd-shell");84  if (!shell) return;8586  shellRingAnimation?.cancel();87  shell.dataset.glowing = "true";88  const animation = shell.animate(89    [90      { offset: 0, boxShadow: "var(--copy-shadow)" },91      {92        offset: 0.3,93        boxShadow:94          "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)",95      },96      { offset: 1, boxShadow: "var(--copy-shadow)" },97    ],98    {99      duration: 720,100      easing: "cubic-bezier(0.4, 0, 0.2, 1)",101      fill: "none",102    }103  );104  shellRingAnimation = animation;105  const clearGlow = () => {106    if (shellRingAnimation !== animation) return;107    shellRingAnimation = null;108    delete shell.dataset.glowing;109  };110  animation.onfinish = clearGlow;111  animation.oncancel = clearGlow;112}113114115export function triggerPipPulse() {116  const pip = document.querySelector(".peach-status-pip, .pip");117  if (pip) {118    pip.classList.remove("pulse");119    void (pip as HTMLElement).offsetWidth;120    pip.classList.add("pulse");121  }122}123124function defaultAdoptionDeviceName(): string {125  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";126  if (/Firefox/i.test(ua)) return "Firefox";127  if (/Edg\//i.test(ua)) return "Edge";128  if (/Chrome/i.test(ua)) return "Chrome";129  if (/Safari/i.test(ua)) return "Safari";130  return "Peach";131}132133const ENTRY_TRANSITION_SETTLE_MS = 135;134135export default function App() {136  const [bootState, setBootState] = createSignal<BootState>("loading");137  const [view, setView] = createSignal<View>("lock");138  // Mobile bottom-sheet live drag offset in px (null = not actively dragging,139  // CSS transition takes over for snap animation).140  const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);141  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(142    "enter"143  );144  const [contentTransitioning, setContentTransitioning] = createSignal(false);145  const [hasVault, setHasVault] = createSignal(false);146  const [onboarded, setOnboarded] = createSignal(true);147  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);148  const [entries, setEntries] = createSignal<VaultEntry[]>([]);149  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);150  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);151152  // Rare device-trust alerts stay visible; routine entry sync is silent.153  interface SyncAlert {154    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";155    entryId: string;156    entryTitle: string;157    deviceName: string;158    timestamp: number;159  }160  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);161  async function loadSyncAlerts() {162    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {163      ok: boolean;164      data?: SyncAlert[];165    };166    if (r.ok && (r.data?.length ?? 0) > 0) {167      setSyncAlerts(r.data ?? []);168      // Acknowledge so a crash between read and render doesn't lose them —169      // they remain on screen until the user dismisses each.170      void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });171    }172  }173  function dismissSyncAlert(index: number) {174    setSyncAlerts((arr) => arr.filter((_, i) => i !== index));175  }176  function syncAlertText(a: SyncAlert): string {177    if (a.type === "DEVICE_REVOKED") {178      return `${a.deviceName} revoked a paired device — review your devices in Settings`;179    }180    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;181  }182  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");183  const [theme, setTheme] = createSignal<"dark" | "light">("dark");184  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");185  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);186  const [lockOnClose, setLockOnClose] = createSignal(true);187  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);188  const [breachMonitoring, setBreachMonitoring] = createSignal(true);189  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);190  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);191  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);192  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);193  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);194  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");195  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");196  // Cached Pro status — synchronous for render paths. Background's197  // GET_LICENSE is async, but render-time reads of isPro() must not block.198  // Refreshed at boot, after activate/deactivate, and whenever the popup199  // re-opens onto a stored license that has changed via another window.200  const [proStatus, setProStatus] = createSignal(false);201  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);202  // When the sync card's pairing button opens the pairing view, deep-link203  // straight into that pairing path instead of the rest screen.204  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");205  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);206  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");207  const [listSearchSeed, setListSearchSeed] = createSignal("");208  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);209  const [pendingCelebration, setPendingCelebration] = createSignal(false);210  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);211  // True while Panic Step 1 has bounced the user out to Export. When Export's212  // onBack fires we return to Panic instead of Settings so the user keeps the213  // destruction context they came from.214  const [returnToPanic, setReturnToPanic] = createSignal(false);215  // The view to return to when the add/edit view is dismissed. Normally "entry"216  // but "health" when the user arrives via the fix-entry path from Vault Health.217  const [editBackView, setEditBackView] = createSignal<View>("entry");218  const [settingsExiting, setSettingsExiting] = createSignal(false);219  const [showNotes, setShowNotes] = createSignal(false);220  // Topbar sync affordance. Always present: gating it on having a paired device221  // meant it never appeared for the person trying to set pairing up, which is222  // exactly when you reach for it.223  const [syncBusy, setSyncBusy] = createSignal(false);224  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);225  // The indicator light + its spoken-word equivalent. Colour alone is not the226  // signal; the title/aria-label always carries the same claim in English.227  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});228  const [pairedCount, setPairedCount] = createSignal(0);229  const syncLight = () =>230    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());231  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());232  async function loadSyncIndicator() {233    const [stateRes, devicesRes] = await Promise.all([234      sendMessage({ type: "GET_SYNC_STATE" }),235      sendMessage({ type: "LIST_PAIRED_DEVICES" }),236    ]);237    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };238    if (s.ok && s.data) setSyncSummary(s.data);239    const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };240    if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);241  }242  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);243  // Set on popup boot when peach_recovery_pending flag is found in session244  // storage — instructs LockView to skip the unlock UI and open the recovery245  // words confirmation surface instead. Cleared via handleUnlock once the246  // user has typed the 3 confirmation words.247  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);248249  let contentPhaseTimer: number | undefined;250  // Set when the popup was opened from the inline "Open Peach to unlock" button.251  // handleUnlock() closes the popup after unlock so the user returns to the page.252  let openedForInlineUnlock = false;253254  function applyAccent(name: AccentColorName) {255    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;256    const hex = color.hex;257    document.documentElement.style.setProperty("--peach-accent", hex);258    // Ambient surface variables — derived from the accent so surfaces shift259    // subtly toward the accent hue rather than staying fixed warm-brown.260    document.documentElement.style.setProperty("--peach-glow", hex + "2A");261    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");262    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");263  }264265  createEffect(() => {266    applyAccent(accentColor());267  });268269  createEffect(() => {270    document.documentElement.classList.toggle("light", theme() === "light");271    document.body.classList.toggle("light", theme() === "light");272  });273274  createEffect(() => {275    document.documentElement.setAttribute("data-text-size", textSize());276  });277278  createEffect(() => {279    if (reduceMotion()) {280      document.documentElement.setAttribute("data-motion", "reduce");281    } else {282      document.documentElement.removeAttribute("data-motion");283    }284  });285286  const selectedIndex = createMemo(() => {287    const currentId = selectedEntry()?.id;288    if (!currentId) return entries().length > 0 ? 0 : -1;289    return entries().findIndex((entry) => entry.id === currentId);290  });291292  async function applyAccentColor(name: AccentColorName) {293    applyAccent(name);294    setAccentColor(name);295    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });296  }297298  async function applyTheme(t: "dark" | "light") {299    setTheme(t);300    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });301  }302303  async function applyTextSize(size: "small" | "medium" | "large") {304    setTextSize(size);305    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });306  }307308  async function applyReduceMotion(enabled: boolean) {309    setReduceMotion(enabled);310    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });311  }312313  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload314  // small and lets future default updates land for actions the user hasn't315  // touched. Settings sends us the full active map; we filter and store.316  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {317    setShortcutBindings(active);318    const overrides: Record<string, Keybinding> = {};319    for (const action of Object.keys(active) as ShortcutAction[]) {320      const a = active[action];321      const d = DEFAULT_BINDINGS[action];322      const same = a.key === d.key323        && Boolean(a.ctrl)  === Boolean(d.ctrl)324        && Boolean(a.meta)  === Boolean(d.meta)325        && Boolean(a.shift) === Boolean(d.shift)326        && Boolean(a.alt)   === Boolean(d.alt);327      if (!same) overrides[action] = a;328    }329    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });330  }331332  async function applyLockOnClose(enabled: boolean) {333    setLockOnClose(enabled);334    await sendMessage({335      type: "UPDATE_SETTINGS",336      payload: { lockOnClose: enabled },337    });338  }339340  async function applyAutoLockMinutes(minutes: number) {341    setAutoLockMinutes(minutes);342    await sendMessage({343      type: "UPDATE_SETTINGS",344      payload: { autoLockMinutes: minutes },345    });346  }347348  async function applyBreachMonitoring(enabled: boolean) {349    setBreachMonitoring(enabled);350    await sendMessage({351      type: "UPDATE_SETTINGS",352      payload: { breachMonitoring: enabled },353    });354  }355356  onMount(async () => {357    // Resolve the active tab in parallel with the unlock/has-vault queries —358    // openVault()'s smart selection picks the entry matching currentTabDomain,359    // so it must be populated before openVault runs. Previously refreshCurrentTab360    // ran after openVault, leaving popups that booted already-unlocked falling361    // back to loadedEntries[0] instead of the domain match.362    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([363      sendMessage({ type: "IS_UNLOCKED" }),364      sendMessage({ type: "HAS_VAULT" }),365      refreshCurrentTab(),366      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),367    ]);368    const unlocked = Boolean(369      (unlockResponse as { ok: boolean; data?: boolean }).data370    );371    const vaultExists = Boolean(372      (vaultResponse as { ok: boolean; data?: boolean }).data373    );374    const shouldCelebrate = Boolean(375      (celebrationResponse as { ok: boolean; data?: boolean }).data376    );377    setHasVault(vaultExists);378    // Onboarding gate — show once on first install, never again. Existing379    // users (vaultExists) are auto-marked onboarded so they skip the flow380    // even on the very first popup open after this feature ships.381    try {382      const onboarded = await storageGet("peach_onboarded");383      const seen = onboarded === "true";384      if (vaultExists && !seen) {385        await storageSet("peach_onboarded", "true");386        setOnboarded(true);387      } else {388        setOnboarded(seen);389      }390    } catch {391      // storage unavailable — default to onboarded so we never block boot392      setOnboarded(true);393    }394    await Promise.all([restoreSettings(), refreshProStatus()]);395396    // Mark played before any animation so a crash mid-sequence never replays it397    if (shouldCelebrate) {398      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });399    }400401    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome402    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.403    if (unlocked && vaultExists) {404      void sendMessage({405        type: "TRIGGER_SYNC",406        payload: { reason: "foreground" },407      });408      void loadSyncIndicator();409      // Rare device-trust alerts only; routine entry sync stays quiet.410      void loadSyncAlerts();411    }412413    // If the user created a vault but hasn't yet confirmed their recovery414    // words, route back to the recovery-words screen instead of opening the415    // entries view. The flag lives in chrome.storage.local so it survives416    // both popup close (SW suppresses lock-on-close) and full browser417    // restart (handleUnlock checks the same flag after a fresh password418    // unlock). Cleared when confirmation succeeds.419    let recoveryPending = false;420    try {421      const out = await chrome.storage.local?.get("peach_recovery_pending");422      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";423    } catch { /* best-effort */ }424425    // If this popup was opened from the inline "Open Peach to unlock" button,426    // the background set a session flag. Read and clear it so handleUnlock()427    // can close the popup after unlock and return the user to the page.428    if (chrome.storage.session) {429      try {430        const s = await chrome.storage.session.get("peach_inline_unlock");431        if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {432          openedForInlineUnlock = true;433          await chrome.storage.session.remove("peach_inline_unlock");434        }435      } catch { /* best-effort */ }436    }437438    // Native: closing the app before recovery-words confirmation means the439    // user didn't finish vault creation. Throw the partial vault away and440    // restart onboarding rather than asking them to unlock a vault they441    // never finished creating. Desktop keeps the existing "unlock to442    // resume" flow because closing the popup there is much more casual.443    if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {444      try {445        await chrome.storage.local?.remove([446          "peach_vault_a",447          "peach_vault_b",448          "peach_recovery_pending",449          "peach_onboarded",450          "peach_vault_history",451        ]);452      } catch { /* best-effort */ }453      setHasVault(false);454      setOnboarded(false);455      setView("lock");456      setBootState("ready");457      return;458    }459460    if (recoveryPending && unlocked) {461      // forceRecoveryWords overrides LockView's hasVault gate so the462      // recovery surface renders even though the vault now exists.463      setForceRecoveryWords(true);464      setView("lock");465    } else if (unlocked && vaultExists) {466      await openVault(undefined, shouldCelebrate);467      if (shouldCelebrate) {468        setView("pro-celebration");469        window.setTimeout(() => setView("entry"), 2500);470      }471    } else if (shouldCelebrate) {472      setPendingCelebration(true);473    }474475    await refreshPendingPasskey();476477    // Track tab changes so smart sort stays current478    const handleTabActivated = () => { void refreshCurrentTab(); };479    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {480      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();481    };482    try {483      chrome.tabs.onActivated.addListener(handleTabActivated);484      chrome.tabs.onUpdated.addListener(handleTabUpdated);485      onCleanup(() => {486        chrome.tabs.onActivated.removeListener(handleTabActivated);487        chrome.tabs.onUpdated.removeListener(handleTabUpdated);488      });489    } catch {490      // tabs permission may not be available in all contexts491    }492493    setBootState("ready");494495    // Deep-link: ?codex=pdf opens a detached window straight into the496    // Codex restore flow with auto-picked PDF. The action popup can't host497    // a file dialog without dismissing itself, so this is the only way the498    // PDF import path survives. See CodexRestoreView.openPdfPicker().499    try {500      const route = new URLSearchParams(window.location.search).get("codex");501      if (route === "pdf" || route === "restore") {502        setView("codex-restore");503      }504    } catch {505      // location.search unavailable — ignore.506    }507508    // Check for pending session-storage notices from the background.509    if (chrome.storage.session) {510      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as511        Record<string, { feature?: string } | undefined>;512      if (session.pendingProGate?.feature) {513        await chrome.storage.session.remove("pendingProGate");514        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);515        setSettingsInitialSection("license");516        setView("settings");517      } else if (session.pendingProNotice?.feature) {518        await chrome.storage.session.remove("pendingProNotice");519        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });520      }521    }522523    // Warm the lazy-module cache so the Suspense fallback never shows for524    // locally-bundled extension views. Without this, the first navigation to525    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which526    // covers the shell and absorbs clicks on the header.527    void Promise.all([528      import("./views/Settings").catch(() => {}),529      import("./views/Import").catch(() => {}),530      import("./views/Export").catch(() => {}),531      import("./views/Health").catch(() => {}),532      import("./views/TrashView").catch(() => {}),533    ]);534535    // Lock-on-close via a persistent port. sendMessage in pagehide is536    // unreliable — the popup context tears down before the message lands.537    // The SW's onConnect listener locks when this port disconnects.538    let popupPort: chrome.runtime.Port | null = null;539    try {540      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect541      // listener accepts the "popup-" prefix; the random suffix is defence in542      // depth against a port named exactly "popup" from anything but our popup.543      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });544    } catch { /* extension context invalidated — ignore */ }545    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });546547    // Android autolock. chrome.alarms is no-op'd on Capacitor, so the only548    // reliable signal is appStateChange — setTimeout is killed when the OS549    // backgrounds the app. We measure elapsed background time on resume.550    if (isNativePlatform()) {551      let backgroundedAt: number | null = null;552553      const CapApp = nativePlugin<{554        addListener: (555          ev: string,556          cb: (s: { isActive: boolean }) => void,557        ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };558      }>("App");559      if (CapApp?.addListener) {560        const sub = CapApp.addListener(561          "appStateChange",562          ({ isActive }: { isActive: boolean }) => {563            if (!isActive) {564              backgroundedAt = Date.now();565              return;566            }567            if (backgroundedAt !== null && autoLockMinutes() > 0) {568              const elapsed = Date.now() - backgroundedAt;569              if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();570            }571            backgroundedAt = null;572          }573        );574        // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.575        onCleanup(() => {576          Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});577        });578      }579580      // L12/COR-011: foreground idle autolock. appStateChange only fires when581      // the OS backgrounds the app — if the user idles with the screen on,582      // chrome.alarms is no-op'd in Capacitor so the vault would stay open583      // indefinitely. Poll every 30s and lock if elapsed since last user584      // interaction exceeds autoLockMinutes.585      let lastUserActivityAt = Date.now();586      const bumpActivity = () => { lastUserActivityAt = Date.now(); };587      const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;588      for (const ev of ACTIVITY_EVENTS) {589        document.addEventListener(ev, bumpActivity, { passive: true, capture: true });590      }591      const idleLockInterval = setInterval(() => {592        const minutes = autoLockMinutes();593        if (!minutes || minutes <= 0) return;594        const elapsed = Date.now() - lastUserActivityAt;595        if (elapsed >= minutes * 60_000) {596          lastUserActivityAt = Date.now();597          handleHeaderLock();598        }599      }, 30_000);600      onCleanup(() => {601        clearInterval(idleLockInterval);602        for (const ev of ACTIVITY_EVENTS) {603          document.removeEventListener(ev, bumpActivity, { capture: true });604        }605      });606607      // The retired native peer-discovery trigger went with the old Capacitor608      // pairing plugin. Reconnection is address-cache first, subnet probe second609      // (sync-runner.ts); relocating a peer that changed networks is an610      // Android-side discovery concern now.611      if (isNativePlatform()) {612        // Android runs the background/sync dispatcher in-process with the613        // popup, so the production runner has to be registered from the614        // UI bootstrap path instead of the MV3 service-worker entry.615        void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>616          import("../background/sync-runner").then(({ productionSyncRunner }) => {617            setSyncRunner(productionSyncRunner);618            void hydratePersistedSyncState();619          })620        );621      }622    }623624    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale625    // entries instead of waiting for the next GET_ENTRIES round-trip.626    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {627      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();628      if (msg?.type === "SYNC_STATE_CHANGED") {629        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);630      }631      if (msg?.type === "PRO_GATE_TRIGGERED") {632        setUpgradeHighlight(msg.payload?.feature ?? null);633        setSettingsInitialSection("license");634        setView("settings");635      }636    };637    chrome.runtime.onMessage.addListener(handleRuntimeMessage);638    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));639640    // Android hardware/gesture back. MainActivity fires peach:back; the popup641    // decides what "back" means per surface. Lock+no-vault is the common case642    // — return to onboarding rather than letting the OS dismiss the activity.643    const handlePeachBack = () => {644      // Notes mode captures back first645      if (showNotes()) {646        setShowNotes(false);647        setOpenNoteId(undefined);648        return;649      }650      const v = view();651      if (v === "lock" && !hasVault() && onboarded()) {652        setOnboarded(false);653        return;654      }655      if (v === "list") { setView("entry"); return; }656      if (v === "settings") {657        window.dispatchEvent(new Event("peach:settings-back"));658        return;659      }660      if (v === "import" || v === "health") { setView("settings"); return; }661      if (v === "export") { handleExportBack(); return; }662      if (v === "add") { handleBack(); return; }663      if (v === "pair-device") { setView("settings"); return; }664    };665    window.addEventListener("peach:back", handlePeachBack);666    onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));667668    // Mobile bottom-sheet drag tracking. The vault list sits as a sheet that669    // peeks from the bottom (search bar handle visible) and drags up to fill670    // the screen. dragOffset is the LIVE finger offset during a drag, in px,671    // 0 when no drag in progress. The wrapper element computes transform =672    // (rest position) + dragOffset, so the user sees 1:1 finger tracking.673    if (isNativePlatform()) {674      let touchStartY = 0;675      let touchStartX = 0;676      let lastY = 0;677      let lastT = 0;678      let dragging = false;679      let captured = false;680      const HORIZONTAL_TOLERANCE = 16;681      const ACTIVATION_DY = 8;682      const PEEK_PX = 60;683684      const onTouchStart = (e: TouchEvent) => {685        const t = e.touches[0];686        if (!t) return;687        const v = view();688        if (v !== "entry" && v !== "list") return;689        // Entry view: only intercept drags starting in the bottom 40%.690        // List view: intercept drags starting in the top 35% (the search/handle row).691        const inBand = v === "entry"692          ? t.clientY > window.innerHeight * 0.6693          : t.clientY < window.innerHeight * 0.35;694        if (!inBand) return;695        touchStartY = lastY = t.clientY;696        touchStartX = t.clientX;697        lastT = e.timeStamp;698        dragging = true;699        captured = false;700      };701702      const onTouchMove = (e: TouchEvent) => {703        if (!dragging) return;704        const t = e.touches[0];705        if (!t) return;706        const dy = t.clientY - touchStartY;707        const dx = Math.abs(t.clientX - touchStartX);708709        if (!captured) {710          if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {711            // Looks like a horizontal swipe — release.712            dragging = false;713            return;714          }715          if (Math.abs(dy) < ACTIVATION_DY) return;716          captured = true;717        }718719        // Translate the sheet by dy from its rest (peek) position. Peek is720        // (viewport height - PEEK_PX). Open is 0.721        const v = view();722        const restClosed = window.innerHeight - PEEK_PX;723        const baseTranslate = v === "list" ? 0 : restClosed;724        const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));725        setSheetTranslate(next);726        lastY = t.clientY;727        lastT = e.timeStamp;728      };729730      const onTouchEnd = (e: TouchEvent) => {731        if (!dragging) { return; }732        const t = e.changedTouches[0];733        const elapsed = Math.max(1, e.timeStamp - lastT);734        const velocity = t ? (t.clientY - lastY) / elapsed : 0; // px/ms735        const wasCaptured = captured;736        dragging = false;737        captured = false;738        const v = view();739740        if (!wasCaptured) {741          // Tap in the peek strip while in entry view → open the vault list.742          if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");743          setSheetTranslate(null);744          return;745        }746747        const restClosed = window.innerHeight - PEEK_PX;748        const current = sheetTranslate() ?? restClosed;749750        // Snap rules:751        //   - Strong upward velocity → open752        //   - Strong downward velocity → close753        //   - Otherwise snap to whichever rest is closer754        let nextOpen: boolean;755        if (velocity < -0.05) nextOpen = true;756        else if (velocity > 0.05) nextOpen = false;757        else nextOpen = current < restClosed * 0.5;758759        if (nextOpen && v !== "list") setView("list");760        if (!nextOpen && v !== "entry") setView("entry");761        // Release the live offset so CSS transition handles the snap.762        setSheetTranslate(null);763      };764765      document.addEventListener("touchstart", onTouchStart, { passive: true });766      document.addEventListener("touchmove",  onTouchMove,  { passive: true });767      document.addEventListener("touchend",   onTouchEnd,   { passive: true });768      document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });769      onCleanup(() => {770        document.removeEventListener("touchstart", onTouchStart);771        document.removeEventListener("touchmove",  onTouchMove);772        document.removeEventListener("touchend",   onTouchEnd);773      });774    }775776    // Action handlers — keyed by ShortcutAction. The dispatcher below tests777    // each binding against the event and runs the matching handler. New778    // shortcuts only need an entry here; the binding is data, not code.779    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {780      copy_password: () => {781        const entry = selectedEntry();782        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });783      },784      copy_username: () => {785        const entry = selectedEntry();786        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });787      },788      copy_totp: () => {789        const entry = selectedEntry();790        if (!entry?.totp) return;791        try {792          const { code } = generateTOTP(entry.totp);793          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });794        } catch { /* invalid TOTP secret — silent */ }795      },796      new_entry: () => handleAdd(),797      edit_entry: () => {798        const entry = selectedEntry();799        // Passkey-only entries have nothing user-editable in the Add view; the800        // passkey credential itself is opaque. Skip the edit action for them.801        if (!entry || isPasskeyOnly(entry)) return;802        setEditEntry(entry);803        setView("add");804      },805      delete_entry: () => {806        if (selectedEntry()) void handleDelete();807      },808      lock_vault: () => { void handleHeaderLock(); },809      search: () => openList(),810      nav_prev: () => navigate(-1),811      nav_next: () => navigate(1),812      jump_vault: () => setView("entry"),813      jump_settings: () => setView("settings"),814      show_help: () => setShowShortcutsHelp(true),815    };816817    // Actions that are MEANINGFUL only on the entry-detail view. Outside818    // that scope the binding is dormant — pressing C while on Settings819    // does nothing rather than awkwardly copying from a now-stale entry.820    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([821      "copy_password", "copy_username", "copy_totp",822      "edit_entry", "delete_entry", "nav_prev", "nav_next",823    ]);824825    const handleGlobalKeydown = (e: KeyboardEvent) => {826      const v = view();827      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"828        || (e.target as HTMLElement)?.tagName === "TEXTAREA"829        || (e.target as HTMLElement)?.isContentEditable;830      const isPrintableSearchKey =831        e.key.length === 1 &&832        !e.ctrlKey &&833        !e.metaKey &&834        !e.altKey;835836      // No input during the pro celebration sequence837      if (v === "pro-celebration") return;838839      // Help dialog: Escape closes it; eat the keypress so it doesn't840      // also navigate away from the underlying view.841      if (showShortcutsHelp()) {842        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }843        return;844      }845846      // ── Escape — fixed binding (not user-customizable) ───────847      if (e.key === "Escape") {848        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }849        if (v === "list") { setView("entry"); return; }850        if (v === "settings" || v === "add") { handleBack(); return; }851        if (v === "import" || v === "health") { setView("settings"); return; }852        if (v === "export") { handleExportBack(); return; }853        if (v === "pair-device") { setView("settings"); return; }854      }855856      // Bindings-driven dispatch. Single-key letter bindings without857      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is858      // inside an input, so users can type into search/forms normally.859      const bindings = shortcutBindings();860      for (const action of Object.keys(bindings) as ShortcutAction[]) {861        const binding = bindings[action];862        if (!matchKeybinding(e, binding)) continue;863        // Single-key letter bindings need the input-focus guard. Bindings864        // with any modifier are safe — Ctrl+L doesn't conflict with typing.865        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;866        if (!hasMod && binding.key.length === 1 && inInput) return;867        if (ENTRY_SCOPED.has(action) && v !== "entry") return;868        e.preventDefault();869        SHORTCUT_HANDLERS[action]?.();870        return;871      }872873      // Type-to-search: in entry view, any printable key not consumed by a874      // shortcut above primes the list search seed and opens the list.875      // Kept outside the bindings table because it's a behavior, not a876      // discrete action — every printable character can trigger it.877      if (v === "entry" && !inInput && isPrintableSearchKey) {878        e.preventDefault();879        primeListSearch(e.key);880        return;881      }882    };883    window.addEventListener("keydown", handleGlobalKeydown);884    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));885886    const handleStorageChanged = (887      changes: Record<string, chrome.storage.StorageChange>,888      areaName: string889    ) => {890      if (areaName !== "session") return;891      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {892        return;893      }894      void refreshPendingPasskey();895    };896    chrome.storage.onChanged.addListener(handleStorageChanged);897    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));898  });899900  async function refreshCurrentTab() {901    try {902      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });903      const url = tab?.url ?? tab?.pendingUrl ?? "";904      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");905      // Skip extension/internal URLs — adding chrome://, about:, or906      // chrome-extension:// to a vault entry is never what the user wants.907      const isWebUrl = /^https?:\/\//i.test(url);908      setCurrentTabUrl(isWebUrl ? url : "");909    } catch {910      setCurrentTabDomain("");911      setCurrentTabUrl("");912    }913  }914915  async function refreshPendingPasskey() {916    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });917    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);918  }919920  async function refreshProStatus() {921    const res = await sendMessage({ type: "GET_LICENSE" });922    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;923    setProStatus(Boolean(data?.valid && data?.plan === "pro"));924  }925926  async function restoreSettings() {927    const res = await sendMessage({ type: "GET_SETTINGS" });928    const s = (res as { ok: boolean; data?: any }).data ?? {};929    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {930      setAccentColor(s.accentColor as AccentColorName);931    }932    setTheme(s.theme === "light" ? "light" : "dark");933    if (s.textSize === "small" || s.textSize === "large") {934      setTextSize(s.textSize);935    } else {936      setTextSize("medium");937    }938    setReduceMotion(s.reduceMotion === true);939    setLockOnClose(s.lockOnClose !== false);940    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);941    setBreachMonitoring(s.breachMonitoring !== false);942    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);943    setShortcutBindings(getActiveBindings(s.shortcuts));944  }945946  async function applyTrashRetention(days: number) {947    setTrashRetentionDays(days);948    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });949  }950951  function primeListSearch(seed: string) {952    setListSearchSeed(seed);953    setListSearchSeedNonce((value) => value + 1);954    openList();955  }956957  // Returns null when it detected a dead service worker and triggered handleLock().958  // Callers that need to abort (e.g. openVault) check for null rather than relying959  // on view() state, which may already be "lock" during normal startup flows.960  async function loadEntries(): Promise<VaultEntry[] | null> {961    const response = await sendMessage({ type: "GET_ENTRIES" });962    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };963964    // If the service worker was killed and restarted (MV3 workers sleep), the965    // vault will be locked again — or the worker may not respond at all.966    // In either case, send the user to the lock screen rather than silently967    // showing an empty vault (which looks like data loss).968    if (!res.ok) {969      if (970        res.error === "vault_locked" ||971        res.error === "SERVICE_WORKER_UNAVAILABLE" ||972        (typeof res.error === "string" && res.error.includes("locked"))973      ) {974        handleLock();975        return null; // signal emergency lock to callers976      }977      return [];978    }979980    const loadedEntries = res.data ?? [];981    setEntries(loadedEntries);982983    const currentId = selectedEntry()?.id;984    if (currentId) {985      setSelectedEntry(986        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null987      );988    }989990    return loadedEntries;991  }992993  async function openVault(preferredEntryId?: string, skipViewSet = false) {994    // Purge expired trash entries on each vault open995    const retDays = trashRetentionDays();996    if (retDays > 0) {997      void sendMessage({998        type: "PURGE_EXPIRED_TRASH",999        payload: { retentionMs: retDays * 86_400_000 },1000      });1001    }10021003    const loadedEntries = await loadEntries();10041005    // null means loadEntries() triggered an emergency lock (service worker died).1006    // Don't override it by setting view back to "entry" with an empty vault.1007    if (loadedEntries === null) return;10081009    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1010    let nextSelectedEntry: VaultEntry | null = null;1011    if (preferredEntryId) {1012      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1013    }1014    if (!nextSelectedEntry) {1015      const tabDomain = currentTabDomain();1016      if (tabDomain) {1017        nextSelectedEntry = loadedEntries.find((e) => {1018          if (e.type === "login") {1019            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1020            for (const extra of e.additionalUrls ?? []) {1021              if ((extractDomain(extra) ?? "") === tabDomain) return true;1022            }1023            return false;1024          }1025          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1026          return false;1027        }) ?? null;1028      }1029    }1030    nextSelectedEntry ??= loadedEntries[0] ?? null;10311032    setSelectedEntry(nextSelectedEntry);1033    setEditEntry(undefined);1034    setContentTransitioning(false);1035    setContentPhase("enter");1036    queueContentIdle();1037    if (!skipViewSet) setView("entry");1038  }10391040  async function handleUnlock() {1041    // If the user previously created a vault but hadn't yet confirmed their1042    // recovery words (and the browser was closed before they could), the1043    // peach_recovery_pending flag is still set in storage.local. Re-route1044    // back to the recovery surface so they finish the confirmation step1045    // before reaching the entries view — the words can be re-derived now1046    // that the vault is unlocked again.1047    let recoveryPending = false;1048    try {1049      const out = await chrome.storage.local?.get("peach_recovery_pending");1050      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1051    } catch { /* best-effort */ }1052    if (recoveryPending) {1053      setForceRecoveryWords(true);1054      setView("lock");1055      triggerPipPulse();1056      await restoreSettings();1057      return;1058    }1059    setHasVault(true);1060    triggerPipPulse();1061    await restoreSettings();1062    const celebrating = pendingCelebration();1063    if (celebrating) {1064      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1065      setPendingCelebration(false);1066    }1067    await openVault(undefined, celebrating);1068    if (celebrating) {1069      setView("pro-celebration");1070      window.setTimeout(() => setView("entry"), 2500);1071    }1072    // Popup was opened from the inline "Open Peach to unlock" button — close it1073    // now so the user lands back on the page with the autofill selector ready.1074    if (openedForInlineUnlock) {1075      openedForInlineUnlock = false;1076      window.close();1077    }1078  }10791080  function handleSelectEntry(entry: VaultEntry) {1081    if (entry.type === "note") {1082      setOpenNoteId(entry.id);1083      setShowNotes(true);1084      return;1085    }1086    setSelectedEntry(entry);1087    setContentTransitioning(false);1088    setContentPhase("enter");1089    queueContentIdle();1090    setView("entry");1091  }10921093  function handleAdd() {1094    setEditEntry(undefined);1095    setView("add");1096  }10971098  async function handleDelete() {1099    const entry = selectedEntry();1100    if (!entry) return;1101    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1102    const loadedEntries = await loadEntries() ?? [];1103    setSelectedEntry(loadedEntries[0] ?? null);1104    setView("entry");1105  }11061107  async function handleDeleteMany(ids: string[]) {1108    for (const id of ids) {1109      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1110    }1111    const loadedEntries = await loadEntries() ?? [];1112    // If the currently selected entry was among the deleted, move to first1113    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1114    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1115  }11161117  async function handleSave(savedEntry?: VaultEntry) {1118    await openVault(savedEntry?.id);1119  }11201121  function handleBack() {1122    const dest = editBackView();1123    setEditBackView("entry");1124    setEditEntry(undefined);1125    setView(dest);1126  }11271128  function handleExportBack() {1129    if (returnToPanic()) {1130      setReturnToPanic(false);1131      setView("panic");1132      return;1133    }1134    setView("settings");1135  }11361137  function handleLock() {1138    clearCanarySuppressions();1139    setEntries([]);1140    setSelectedEntry(null);1141    setEditEntry(undefined);1142    setContentPhase("idle");1143    setView("lock");1144  }11451146  function handleHeaderLock() {1147    // Lock the UI immediately — do not wait for the service worker. If the SW1148    // is dead or slow, the 5s sendMessage timeout would create a dead-click1149    // window. The SW will lock on its next wake regardless.1150    handleLock();1151    void sendMessage({ type: "LOCK" });1152  }11531154  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1155  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1156  // the trigger's own reply.1157  async function handleHeaderSync(): Promise<void> {1158    if (syncBusy()) return;1159    setSyncBusy(true);1160    setSyncFlash(null);1161    try {1162      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1163      let state: SyncOutcomeSummary = { status: "syncing" };1164      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1165        await new Promise((r) => setTimeout(r, 250));1166        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1167          ok: boolean;1168          data?: SyncOutcomeSummary;1169        };1170        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1171      }1172      setSyncSummary(state);1173      setSyncFlash(state.status === "error" ? "error" : "ok");1174      await loadSyncAlerts();1175      await loadEntries();1176      await loadSyncIndicator();1177    } catch {1178      setSyncSummary({ status: "error", outcome: "run_failed" });1179      setSyncFlash("error");1180    } finally {1181      setSyncBusy(false);1182      setTimeout(() => setSyncFlash(null), 2_000);1183    }1184  }11851186  function navigate(dir: number) {1187    const allEntries = entries();1188    if (allEntries.length === 0) return;1189    const currentIndex = selectedIndex();1190    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1191    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1192    setSelectedEntry(allEntries[nextIndex]);1193    setContentPhase("enter");1194    queueContentIdle();1195  }11961197  function openList() {1198    setView("list");1199  }12001201  function openHealth() {1202    setEditEntry(undefined);1203    setView("health");1204  }12051206  function handleOpenFileImport() {1207    setRestoreView("file");1208  }12091210  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12111212  function handleFixEntry(entry: VaultEntry) {1213    setSelectedEntry(entry);1214    // Passkey-only entries have no editable surface — drop into view mode1215    // instead of opening the Add editor.1216    if (isPasskeyOnly(entry)) {1217      setView("entry");1218      return;1219    }1220    setEditBackView(view()); // preserve caller view so back returns here1221    setEditEntry(entry);1222    setView("add");1223  }12241225  const isLockedView = () => view() === "lock" || view() === "adopting";12261227  async function handleAdoptionDone() {1228    await storageSet("peach_onboarded", "true").catch(() => {});1229    setOnboarded(true);1230    setHasVault(true);1231    await handleUnlock();1232    void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });1233  }12341235  /**1236   * Join an existing vault from a brand-new install.1237   *1238   * This used to require the receiver to LISTEN for the other device (a1239   * Capacitor pairing server), which a browser extension structurally cannot1240   * do — so on every real browser build it threw before doing anything. Over a1241   * pairing code the receiver is the DIALLER instead, which is the only shape1242   * that works here: dial the code, confirm the SAS, then ask for the vault.1243   *1244   * The vault does not exist yet at dial time, so nothing in this path may1245   * touch the vault before `createVaultFromRootKey` runs.1246   */1247  async function startVaultAdoption(args: {1248    password: string;1249    connection: { invitation: string };1250    confirmSAS(sas: string): Promise<boolean>;1251    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1252  }) {1253    const [{ dialRelayPairingChannel, openRelayPairingSocket }, { requestAdoption }] =1254      await Promise.all([1255        import("../shared/pairing-dial"),1256        import("../shared/vault-adopt"),1257      ]);12581259    const dial = await dialRelayPairingChannel(args.connection.invitation, {1260      openSocket: openRelayPairingSocket,1261    });1262    if (!dial.ok) throw new Error(dial.error);1263    const { channel, sas, socket, relayUrl, rendezvousSecretHex } = dial.dialled;12641265    try {1266      const source = await requestAdoption(channel, {1267        deviceName: defaultAdoptionDeviceName(),1268        generateDeviceId: () => crypto.randomUUID(),1269        // requestAdoption writes ADOPT_CONFIRM and refuses to read a root-key1270        // grant until this local SAS decision is true.1271        confirmSas: () => args.confirmSAS(sas),1272        pairState: { rendezvousSecretHex, relayUrl },1273        createVaultFromRootKey: args.createVault,1274        deriveLocalIdentity: async () => {1275          const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });1276          const r = res as { ok: boolean; data?: unknown; error?: string };1277          if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1278          return r.data as Awaited<ReturnType<Parameters<typeof requestAdoption>[1]["deriveLocalIdentity"]>>;1279        },1280        upsertPairedDevice: async (paired) => {1281          const res = await sendMessage({ type: "COMPLETE_PAIRING", payload: { paired } });1282          const r = res as { ok: boolean; error?: string };1283          if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1284        },1285        applyLicense: async (license) => {1286          const res = (await sendMessage({1287            type: "ACTIVATE_LICENSE",1288            payload: { key: license },1289          })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1290          if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1291        },1292        onError(error) {1293          throw new Error(1294            error === "ADOPT_SAS_REJECTED" ? "sas_rejected" : error,1295          );1296        },1297      });1298      if (!source) throw new Error("adoption_rejected");1299      if (relayUrl) {1300        // Adopt the source's relay so this device dials the same rendezvous.1301        const setting = (await sendMessage({1302          type: "SET_SYNC_SETTINGS",1303          payload: { relayUrl },1304        })) as { ok: boolean; error?: string };1305        if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");1306      }1307    } finally {1308      socket.close();1309    }1310  }13111312  async function handleApprovePendingPasskey() {1313    const pending = pendingPasskey();1314    if (!pending) return;1315    if (pending.browser === "firefox" && pending.type === "create") {1316      // Firefox path: popup itself runs navigator.credentials.create. The1317      // background never approved/rejected anything — it just stashed1318      // intent. After the popup-driven flow resolves (success or cancel),1319      // clear the pending key so the popup doesn't re-show this on next open.1320      const { createFirefoxPasskey } = await import("./firefox-passkey");1321      const result = await createFirefoxPasskey(pending);1322      if (!result.ok) {1323        console.warn("Peach Firefox passkey create failed:", result.error);1324      }1325      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1326      setPendingPasskey(null);1327      void loadEntries();1328      window.close();1329      return;1330    }1331    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1332    window.close();1333  }13341335  async function handleDenyPendingPasskey() {1336    const pending = pendingPasskey();1337    if (!pending) return;1338    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1339    window.close();1340  }13411342  function queueContentIdle(after?: () => void) {1343    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1344    contentPhaseTimer = window.setTimeout(() => {1345      setContentPhase("idle");1346      after?.();1347    }, ENTRY_TRANSITION_SETTLE_MS);1348  }13491350  return (1351    <div class="popup-root peach-theme">1352      <Show when={bootState() === "ready"}>1353        <Show1354          when={!pendingPasskey()}1355          fallback={1356            <PasskeyApprovalView1357              type={pendingPasskey()!.type}1358              rpName={pendingPasskey()!.rpName}1359              rpId={pendingPasskey()!.rpId}1360              userName={pendingPasskey()!.userName}1361              userDisplayName={pendingPasskey()!.userDisplayName}1362              onApprove={handleApprovePendingPasskey}1363              onDeny={handleDenyPendingPasskey}1364            />1365          }1366        >1367          <Show1368            when={!isLockedView()}1369            fallback={1370              <Show1371                when={view() !== "adopting"}1372                fallback={1373                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1374                    <AdoptVaultView1375                      onCancel={() => {1376                        setOnboarded(false);1377                        setView("lock");1378                      }}1379                      onDone={handleAdoptionDone}1380                      startAdoption={startVaultAdoption}1381                    />1382                  </Suspense>1383                }1384              >1385                <Show1386                  when={onboarded() || hasVault()}1387                  fallback={1388                    <Onboarding1389                      onComplete={() => {1390                        setStartOnboardingRestore(false);1391                        setOnboarded(true);1392                      }}1393                      onRestore={() => {1394                        setStartOnboardingRestore(true);1395                        setOnboarded(true);1396                      }}1397                      onAdopt={() => {1398                        setStartOnboardingRestore(false);1399                        setOnboarded(true);1400                        setView("adopting");1401                      }}1402                    />1403                  }1404                >1405                  <LockView1406                    hasVault={hasVault()}1407                    ready={bootState() === "ready"}1408                    isPro={proStatus()}1409                    forceRecoveryWords={forceRecoveryWords()}1410                    initialRestore={startOnboardingRestore()}1411                    onUnlock={() => {1412                      // RecoveryWordDisplay clears the storage flag before1413                      // calling onDone, so handleUnlock will see no pending1414                      // confirmation and proceed through the normal path.1415                      if (forceRecoveryWords()) setForceRecoveryWords(false);1416                      return handleUnlock();1417                    }}1418                    onBackToOnboarding={1419                      !hasVault()1420                        ? () => {1421                            setStartOnboardingRestore(false);1422                            setOnboarded(false);1423                          }1424                        : undefined1425                    }1426                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1427                  />1428                </Show>1429              </Show>1430            }1431          >1432            <Show1433              when={view() !== "pro-celebration"}1434              fallback={<ProCelebration />}1435            >1436            <section1437              id="hd-shell"1438              class="peach-shell peach-theme"1439              data-surface="extension"1440            >1441            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1442              <PeachWordmark unlocked />1443              <div class="peach-chrome-actions">1444                <button1445                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1446                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1447                  aria-label="Notes"1448                  title="Notes"1449                >1450                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1451                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1452                    <line x1="4" y1="5" x2="10" y2="5" />1453                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1454                    <line x1="4" y1="10" x2="7" y2="10" />1455                  </svg>1456                </button>1457                <button1458                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1459                    classList={{1460                      "is-syncing": syncBusy(),1461                      "is-ok": syncFlash() === "ok",1462                      "is-error": syncFlash() === "error",1463                    }}1464                    onClick={() => void handleHeaderSync()}1465                    disabled={syncBusy()}1466                    aria-label={`Sync now. ${syncLabel()}`}1467                    title={syncLabel()}1468                  >1469                    <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">1470                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1471                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1472                      <path d="M2.6 1v2.4h2.4" />1473                      <path d="M11.4 13v-2.4H9" />1474                    </svg>1475                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1476                  </button>1477                <button1478                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1479                  onClick={() => setView("settings")}1480                  aria-label="Settings"1481                >1482                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1483                    <path1484                      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"1485                      stroke="currentColor"1486                      stroke-width="0.9"1487                    />1488                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1489                  </svg>1490                </button>1491                <button1492                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1493                  onClick={handleAdd}1494                  aria-label="New entry"1495                >1496                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1497                    <path d="M5.5 1v9M1 5.5h9" />1498                  </svg>1499                </button>1500                <button1501                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1502                  onClick={() => void handleHeaderLock()}1503                  aria-label="Lock vault"1504                >1505                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1506                    <path1507                      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"1508                      stroke="currentColor"1509                      stroke-width="1"1510                      stroke-linecap="round"1511                    />1512                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1513                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1514                  </svg>1515                </button>1516              </div>1517            </header>15181519            <Show when={pendingProNotice() !== null}>1520              <div class="peach-pro-notice-banner">1521                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1522                <button1523                  class="peach-pro-notice-link"1524                  onClick={() => {1525                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1526                    setSettingsInitialSection("license");1527                    setView("settings");1528                    setPendingProNotice(null);1529                  }}1530                >1531                  Learn more →1532                </button>1533                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1534              </div>1535            </Show>15361537            <div class="peach-shell-body">1538              <div class="peach-shell-veil"></div>1539              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1540                <EntryView1541                  entry={selectedEntry()}1542                  currentTabUrl={currentTabUrl()}1543                  entries={entries()}1544                  isPro={proStatus()}1545                  onDelete={handleDelete}1546                  onFieldSaved={() => void loadEntries()}1547                  onEscape={() => { setView("list"); }}1548                  onProGate={(feature) => {1549                    setUpgradeHighlight(feature);1550                    setSettingsInitialSection("license");1551                    setView("settings");1552                  }}1553                />1554              </div>15551556              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1557                <div class="peach-search-bar">1558                  <span class="peach-search-icon">1559                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1560                      <circle cx="5.5" cy="5.5" r="3.75" />1561                      <path d="M8.75 8.75L11.5 11.5" />1562                    </svg>1563                  </span>1564                  <input1565                    class="peach-search-input"1566                    placeholder="search vault..."1567                    autocomplete="off"1568                    aria-label="Search vault entries"1569                    onClick={openList}1570                    onFocus={openList}1571                    readOnly1572                  />1573                  <div class="peach-search-meta">1574                    <span class="peach-search-counter">1575                      {entries().length > 01576                        ? `${selectedIndex() + 1} / ${entries().length}`1577                        : "0 entries"}1578                    </span>1579                    <span class="peach-divider"></span>1580                    <button1581                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1582                      onClick={() => navigate(-1)}1583                      aria-label="Previous entry"1584                      disabled={entries().length === 0}1585                    >1586                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1587                        <path d="M5 1L1 5.5L5 10" />1588                      </svg>1589                    </button>1590                    <button1591                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1592                      onClick={() => navigate(1)}1593                      aria-label="Next entry"1594                      disabled={entries().length === 0}1595                    >1596                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1597                        <path d="M1 1L5 5.5L1 10" />1598                      </svg>1599                    </button>1600                  </div>1601                </div>1602              </section>1603            </div>16041605            {/* Desktop: shown on demand via Show. Native sheet is rendered1606                via Portal below at the document root so its position:fixed1607                isn't trapped by .peach-shell's transform-based animations. */}1608            {/* Rare device-trust alerts; normal sync changes are silent. */}1609            <Show when={syncAlerts().length > 0}>1610              <div class="peach-sync-alerts">1611                <For each={syncAlerts()}>1612                  {(alert, i) => (1613                    <div class="peach-sync-alert">1614                      <span class="peach-sync-alert-text">⚠ {syncAlertText(alert)}</span>1615                      <button1616                        class="peach-sync-alert-dismiss"1617                        type="button"1618                        aria-label="Dismiss"1619                        onClick={() => dismissSyncAlert(i())}1620                      >×</button>1621                    </div>1622                  )}1623                </For>1624              </div>1625            </Show>16261627            <Show when={!isNativePlatform() && view() === "list"}>1628              <VaultView1629                entries={entries()}1630                selectedEntryId={selectedEntry()?.id}1631                currentTabDomain={currentTabDomain()}1632                sortOrder={sortOrder()}1633                searchSeed={listSearchSeed()}1634                searchSeedNonce={listSearchSeedNonce()}1635                onSelectEntry={handleSelectEntry}1636                onSortOrderChange={setSortOrder}1637                onAdd={handleAdd}1638                onClose={() => setView("entry")}1639                onOpenTrash={() => setView("trash")}1640                onDeleteMany={handleDeleteMany}1641              />1642            </Show>1643            <Show when={isNativePlatform() && hasVault()}>1644              <Portal>1645                <div1646                  class="peach-vault-sheet"1647                  classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1648                  style={(() => {1649                    const live = sheetTranslate();1650                    if (live === null) return undefined;1651                    return { transform: `translateY(${live}px)` };1652                  })()}1653                >1654                  <VaultView1655                    entries={entries()}1656                    selectedEntryId={selectedEntry()?.id}1657                    currentTabDomain={currentTabDomain()}1658                    sortOrder={sortOrder()}1659                    searchSeed={listSearchSeed()}1660                    searchSeedNonce={listSearchSeedNonce()}1661                    isNative={true}1662                    onSelectEntry={handleSelectEntry}1663                    onSortOrderChange={setSortOrder}1664                    onAdd={handleAdd}1665                    onClose={() => setView("entry")}1666                    onOpenTrash={() => setView("trash")}1667                    onDeleteMany={handleDeleteMany}1668                  />1669                </div>1670              </Portal>1671            </Show>1672            <Show when={view() === "settings" || settingsExiting()}>1673              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1674                <SettingsView1675                  entriesCount={entries().length}1676                  breachMonitoring={breachMonitoring()}1677                  lockOnClose={lockOnClose()}1678                  autoLockMinutes={autoLockMinutes()}1679                  trashRetentionDays={trashRetentionDays()}1680                  theme={theme()}1681                  accentColor={accentColor()}1682                  textSize={textSize()}1683                  reduceMotion={reduceMotion()}1684                  isPro={proStatus()}1685                  initialSection={settingsInitialSection()}1686                  upgradeHighlight={upgradeHighlight()}1687                  isExiting={settingsExiting()}1688                  onBreachMonitoringChange={applyBreachMonitoring}1689                  onLockOnCloseChange={applyLockOnClose}1690                  onAutoLockMinutesChange={applyAutoLockMinutes}1691                  onTrashRetentionChange={applyTrashRetention}1692                  onThemeChange={applyTheme}1693                  onAccentChange={applyAccentColor}1694                  onTextSizeChange={applyTextSize}1695                  onReduceMotionChange={applyReduceMotion}1696                  shortcuts={shortcutBindings()}1697                  onShortcutsChange={applyShortcuts}1698                  onLicenseChange={async () => {1699                    // After activation/deactivation: refresh Pro status,1700                    // re-load entries (defensive — signal state can drift if1701                    // the SW briefly restarts during the verify+save round-trip),1702                    // and replay the Pro celebration if this is the first time1703                    // a valid Pro license is seen on this device.1704                    await refreshProStatus();1705                    void loadEntries();1706                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1707                    if ((should as { ok: boolean; data?: boolean }).data === true) {1708                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1709                      setView("pro-celebration");1710                      window.setTimeout(() => setView("settings"), 2500);1711                    }1712                  }}1713                  onBack={() => {1714                    setSettingsExiting(true);1715                    window.setTimeout(() => {1716                      setSettingsExiting(false);1717                      setSettingsInitialSection(null);1718                      setUpgradeHighlight(null);1719                      handleBack();1720                    }, 200);1721                  }}1722                  onImport={() => setView("import")}1723                  onExport={() => setView("export")}1724                  onBreachCheckDone={loadEntries}1725                  onOpenHealth={openHealth}1726                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1727                  onOpenFileImport={handleOpenFileImport}1728                  onOpenPanic={() => setView("panic")}1729                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1730                  onOpenCodexRestore={() => setView("codex-restore")}1731                  onCardChange={setSettingsActiveCard}1732                  onProGate={(feature) => {1733                    setUpgradeHighlight(feature);1734                    setSettingsInitialSection("license");1735                  }}1736                />1737              </Suspense>1738            </Show>1739            <Show when={view() === "codex-generate"}>1740              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1741                <CodexGenerateView1742                  isPro={proStatus()}1743                  onBack={() => setView("settings")}1744                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1745                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1746                  onVaultLocked={() => handleLock()}1747                />1748              </Suspense>1749            </Show>1750            <Show when={view() === "codex-restore"}>1751              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1752                <CodexRestoreView1753                  onBack={() => setView("lock")}1754                  onRestoreSuccess={async () => {1755                    // After restore the vault is unlocked; refresh entries1756                    // and route to the main vault list. handleUnlock is the1757                    // canonical post-unlock setup path.1758                    await handleUnlock();1759                  }}1760                />1761              </Suspense>1762            </Show>1763            <Show when={view() === "pair-device"}>1764              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1765                <PairDeviceView1766                  isPro={proStatus()}1767                  onProChanged={() => { void refreshProStatus(); }}1768                  onBack={() => setView("settings")}1769                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1770                />1771              </Suspense>1772            </Show>1773            <Show when={view() === "add"}>1774              <AddView1775                entry={editEntry()}1776                entries={entries()}1777                onBack={handleBack}1778                onSave={handleSave}1779              />1780            </Show>1781            <Show when={view() === "import"}>1782              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1783                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1784              </Suspense>1785            </Show>1786            <Show when={view() === "export"}>1787              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1788                <ExportView onBack={handleExportBack} />1789              </Suspense>1790            </Show>1791            <Show when={view() === "panic"}>1792              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1793                <PanicView1794                  onCancel={() => setView("settings")}1795                  onOpenExport={() => {1796                    setReturnToPanic(true);1797                    setView("export");1798                  }}1799                  onDestroyed={async () => {1800                    clearCanarySuppressions();1801                    setEntries([]);1802                    setSelectedEntry(null);1803                    setEditEntry(undefined);1804                    setHasVault(false);1805                    setOnboarded(false);1806                    setProStatus(false);1807                    setPendingPasskey(null);1808                    setUpgradeHighlight(null);1809                    setSettingsInitialSection(null);1810                    setPendingProNotice(null);1811                    setPendingCelebration(false);1812                    setReturnToPanic(false);1813                    setContentPhase("idle");1814                    setView("lock");1815                  }}1816                />1817              </Suspense>1818            </Show>1819            {/* Health stays mounted while editing an entry from it so1820                computeReused() doesn't rerun on every cancel/back. */}1821            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1822              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1823                <HealthView1824                  entries={entries()}1825                  isPro={proStatus()}1826                  onFixEntry={handleFixEntry}1827                  onBack={() => setView("settings")}1828                  onRefresh={loadEntries}1829                  hidden={view() !== "health"}1830                />1831              </Suspense>1832            </Show>1833            <Show when={view() === "trash"}>1834              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1835                <TrashView1836                  onBack={() => setView("list")}1837                  onRestored={() => void loadEntries()}1838                />1839              </Suspense>1840            </Show>1841            <Show when={showNotes()}>1842              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1843                <NotesMode1844                  notes={entries().filter((e) => e.type === "note")}1845                  initialNoteId={openNoteId()}1846                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1847                  onNoteListChanged={() => void loadEntries()}1848                />1849              </div>1850            </Show>1851            <Show when={restoreView() === "file"}>1852              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1853                <ImportView1854                  mode="onboarding"1855                  replacesExistingVault={true}1856                  onBack={() => setRestoreView(null)}1857                  onImport={async () => {1858                    setRestoreView(null);1859                    await handleUnlock();1860                  }}1861                />1862              </Suspense>1863            </Show>1864            </section>1865            </Show>1866          </Show>1867        </Show>1868      </Show>1869      <Show when={showShortcutsHelp()}>1870        <ShortcutsHelpOverlay1871          bindings={shortcutBindings()}1872          onClose={() => setShowShortcutsHelp(false)}1873        />1874      </Show>1875    </div>1876  );1877}18781879function ShortcutsHelpOverlay(props: {1880  bindings: Record<ShortcutAction, Keybinding>;1881  onClose: () => void;1882}) {1883  return (1884    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1885      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1886        <div class="peach-shortcuts-help-head">1887          <h3>Keyboard shortcuts</h3>1888          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1889        </div>1890        <div class="peach-shortcuts-help-body">1891          <p class="peach-settings-sub-label">ENTRY VIEW</p>1892          <ul>1893            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1894              <li>1895                <span>{d.label}</span>1896                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1897              </li>1898            ))}1899          </ul>1900          <p class="peach-settings-sub-label">ANYWHERE</p>1901          <ul>1902            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1903              <li>1904                <span>{d.label}</span>1905                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1906              </li>1907            ))}1908          </ul>1909        </div>1910      </div>1911    </div>1912  );1913}