App.tsx

53.4 KB · raw

1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { App as CapacitorApp } from "@capacitor/app";3import { Capacitor, registerPlugin } from "@capacitor/core";4import { Keyboard } from "@capacitor/keyboard";5import { StatusBar, Style } from "@capacitor/status-bar";6import { storageGet, storageSet } from "../shared/storage";7import { sendMessage } from "../shared/bridge";8import { clearInMemoryVault, publishInMemoryVault } from "../shared/in-memory-vault";9import { ACCENT_COLORS } from "../shared/types";10import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";11import { isPasskeyOnly } from "../shared/types";12import { LockView } from "./views/Lock";13import { Onboarding } from "./views/Onboarding";14import { ProCelebration } from "./views/ProCelebration";15import { VaultView, type SortOrder } from "./views/Vault";16import { EntryView } from "./views/Entry";17import { AddView } from "./views/Add";18import { PeachWordmark } from "./components/PeachMark";19import { PasskeyApprovalView } from "./views/PasskeyApproval";20import { extractDomain } from "../shared/domain";21import { popupCopy } from "./copy";22import {23  DEFAULT_BINDINGS,24  formatKeybinding,25  getActiveBindings,26  matchKeybinding,27  SHORTCUT_DESCRIPTORS,28  type Keybinding,29  type ShortcutAction,30} from "../shared/shortcuts";31import { generateTOTP } from "../crypto/totp";3233export { sendMessage } from "../shared/bridge";3435const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));36const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));37const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));38const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));39const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));40const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));4142const Autofill = registerPlugin<{43  checkPendingSave(): Promise<{ pending: string | null }>;44  openAutofillSettings(): Promise<void>;45  isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;46}>("Autofill");4748type View =49  | "lock"50  | "pro-celebration"51  | "entry"52  | "list"53  | "add"54  | "settings"55  | "import"56  | "export"57  | "health"58  | "trash"59  | "panic";60type BootState = "loading" | "ready";6162const canarySuppressMap = new Map<string, boolean>();63let shellRingAnimation: Animation | null = null;6465export function isCanarySuppressed(entryId: string): boolean {66  return canarySuppressMap.get(entryId) ?? false;67}6869export function setCanarySuppressed(entryId: string, suppressed: boolean): void {70  canarySuppressMap.set(entryId, suppressed);71}7273export function clearCanarySuppressions(): void {74  canarySuppressMap.clear();75}7677export function triggerRing() {78  const shell = document.getElementById("hd-shell");79  if (!shell) return;8081  shellRingAnimation?.cancel();82  shell.dataset.glowing = "true";83  const animation = shell.animate(84    [85      { offset: 0, boxShadow: "var(--copy-shadow)" },86      {87        offset: 0.3,88        boxShadow:89          "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)",90      },91      { offset: 1, boxShadow: "var(--copy-shadow)" },92    ],93    {94      duration: 720,95      easing: "cubic-bezier(0.4, 0, 0.2, 1)",96      fill: "none",97    }98  );99  shellRingAnimation = animation;100  const clearGlow = () => {101    if (shellRingAnimation !== animation) return;102    shellRingAnimation = null;103    delete shell.dataset.glowing;104  };105  animation.onfinish = clearGlow;106  animation.oncancel = clearGlow;107}108109110export function triggerPipPulse() {111  const pip = document.querySelector(".peach-status-pip, .pip");112  if (pip) {113    pip.classList.remove("pulse");114    void (pip as HTMLElement).offsetWidth;115    pip.classList.add("pulse");116  }117}118119const ENTRY_TRANSITION_EXIT_MS = 95;120const ENTRY_TRANSITION_SETTLE_MS = 135;121122export default function App() {123  const [bootState, setBootState] = createSignal<BootState>("loading");124  const [view, setView] = createSignal<View>("lock");125  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(126    "enter"127  );128  const [contentTransitioning, setContentTransitioning] = createSignal(false);129  const [hasVault, setHasVault] = createSignal(false);130  const [onboarded, setOnboarded] = createSignal(true);131  const [entries, setEntries] = createSignal<VaultEntry[]>([]);132  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);133  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);134  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");135  const [theme, setTheme] = createSignal<"dark" | "light">("dark");136  const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");137  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");138  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);139  const [lockOnClose, setLockOnClose] = createSignal(true);140  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);141  const [breachMonitoring, setBreachMonitoring] = createSignal(true);142  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);143  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);144  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);145  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);146  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);147  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");148  // Cached Pro status — synchronous for render paths. Background's149  // GET_LICENSE is async, but render-time reads of isPro() must not block.150  // Refreshed at boot, after activate/deactivate, and whenever the popup151  // re-opens onto a stored license that has changed via another window.152  const [proStatus, setProStatus] = createSignal(false);153  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);154  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);155  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");156  const [listSearchSeed, setListSearchSeed] = createSignal("");157  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);158  const [pendingCelebration, setPendingCelebration] = createSignal(false);159  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);160  const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{161    domain: string;162    username: string;163    password: string;164  } | null>(null);165  const [backExitNotice, setBackExitNotice] = createSignal(false);166  // True while Panic Step 1 has bounced the user out to Export. When Export's167  // onBack fires we return to Panic instead of Settings so the user keeps the168  // destruction context they came from.169  const [returnToPanic, setReturnToPanic] = createSignal(false);170171  let contentPhaseTimer: number | undefined;172  let lastBackExitAt = 0;173174  function applyAccent(name: AccentColorName) {175    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;176    const hex = color.hex;177    document.documentElement.style.setProperty("--peach-accent", hex);178    // Ambient surface variables — derived from the accent so surfaces shift179    // subtly toward the accent hue rather than staying fixed warm-brown.180    document.documentElement.style.setProperty("--peach-glow", hex + "2A");181    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");182    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");183  }184185  createEffect(() => {186    applyAccent(accentColor());187  });188189  createEffect(() => {190    document.documentElement.classList.toggle("light", theme() === "light");191    document.body.classList.toggle("light", theme() === "light");192    if (Capacitor.isNativePlatform()) {193      void applyMobileStatusBar(theme());194    }195  });196197  createEffect(() => {198    document.documentElement.setAttribute("data-display-font", displayFont());199  });200201  createEffect(() => {202    document.documentElement.setAttribute("data-text-size", textSize());203  });204205  createEffect(() => {206    if (reduceMotion()) {207      document.documentElement.setAttribute("data-motion", "reduce");208    } else {209      document.documentElement.removeAttribute("data-motion");210    }211  });212213  const selectedIndex = createMemo(() => {214    const currentId = selectedEntry()?.id;215    if (!currentId) return entries().length > 0 ? 0 : -1;216    return entries().findIndex((entry) => entry.id === currentId);217  });218219  async function applyAccentColor(name: AccentColorName) {220    applyAccent(name);221    setAccentColor(name);222    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });223  }224225  async function applyTheme(t: "dark" | "light") {226    setTheme(t);227    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });228  }229230  async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {231    setDisplayFont(f);232    await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });233  }234235  async function applyTextSize(size: "small" | "medium" | "large") {236    setTextSize(size);237    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });238  }239240  async function applyReduceMotion(enabled: boolean) {241    setReduceMotion(enabled);242    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });243  }244245  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload246  // small and lets future default updates land for actions the user hasn't247  // touched. Settings sends us the full active map; we filter and store.248  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {249    setShortcutBindings(active);250    const overrides: Record<string, Keybinding> = {};251    for (const action of Object.keys(active) as ShortcutAction[]) {252      const a = active[action];253      const d = DEFAULT_BINDINGS[action];254      const same = a.key === d.key255        && Boolean(a.ctrl)  === Boolean(d.ctrl)256        && Boolean(a.meta)  === Boolean(d.meta)257        && Boolean(a.shift) === Boolean(d.shift)258        && Boolean(a.alt)   === Boolean(d.alt);259      if (!same) overrides[action] = a;260    }261    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });262  }263264  async function applyLockOnClose(enabled: boolean) {265    setLockOnClose(enabled);266    await sendMessage({267      type: "UPDATE_SETTINGS",268      payload: { lockOnClose: enabled },269    });270  }271272  async function applyAutoLockMinutes(minutes: number) {273    setAutoLockMinutes(minutes);274    await sendMessage({275      type: "UPDATE_SETTINGS",276      payload: { autoLockMinutes: minutes },277    });278  }279280  async function applyBreachMonitoring(enabled: boolean) {281    setBreachMonitoring(enabled);282    await sendMessage({283      type: "UPDATE_SETTINGS",284      payload: { breachMonitoring: enabled },285    });286  }287288  async function applyMobileStatusBar(nextTheme: "dark" | "light") {289    try {290      await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });291      await StatusBar.setBackgroundColor({292        color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",293      });294    } catch {295      // Status bar APIs are available only in the native shell.296    }297  }298299  function handleBackNavigation() {300    if (showShortcutsHelp()) {301      setShowShortcutsHelp(false);302      return;303    }304    if (restoreView() !== null) {305      setRestoreView(null);306      return;307    }308309    switch (view()) {310      case "list":311        setView("entry");312        return;313      case "settings":314      case "add":315      case "import":316        handleBack();317        return;318      case "export":319        if (returnToPanic()) {320          setReturnToPanic(false);321          setView("panic");322        } else {323          handleBack();324        }325        return;326      case "health":327      case "panic":328        setView("settings");329        return;330      case "trash":331        setView("list");332        return;333      case "entry": {334        const now = Date.now();335        if (now - lastBackExitAt < 1600) {336          void CapacitorApp.exitApp();337          return;338        }339        lastBackExitAt = now;340        setBackExitNotice(true);341        window.setTimeout(() => setBackExitNotice(false), 1400);342        return;343      }344      default:345        void CapacitorApp.exitApp();346    }347  }348349  onMount(async () => {350    if (Capacitor.isNativePlatform()) {351      document.body.classList.add("peach-mobile");352      document.documentElement.classList.add("peach-mobile-root");353      void applyMobileStatusBar(theme());354355      const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {356        document.body.style.paddingBottom = `${info.keyboardHeight}px`;357      });358      const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {359        document.body.style.paddingBottom = "0";360      });361      const backButton = await CapacitorApp.addListener("backButton", () => {362        handleBackNavigation();363      });364365      let touchStartX = 0;366      let touchStartY = 0;367      const handleTouchStart = (event: TouchEvent) => {368        const touch = event.touches[0];369        if (!touch) return;370        touchStartX = touch.clientX;371        touchStartY = touch.clientY;372      };373      const handleTouchEnd = (event: TouchEvent) => {374        const touch = event.changedTouches[0];375        if (!touch) return;376        const dx = touch.clientX - touchStartX;377        const dy = Math.abs(touch.clientY - touchStartY);378        if (touchStartX < 30 && dx > 60 && dy < 40) {379          handleBackNavigation();380        }381      };382      document.addEventListener("touchstart", handleTouchStart, { passive: true });383      document.addEventListener("touchend", handleTouchEnd, { passive: true });384385      onCleanup(() => {386        void keyboardShow.remove();387        void keyboardHide.remove();388        void backButton.remove();389        document.removeEventListener("touchstart", handleTouchStart);390        document.removeEventListener("touchend", handleTouchEnd);391        document.body.style.paddingBottom = "0";392      });393    }394395    // Resolve the active tab in parallel with the unlock/has-vault queries —396    // openVault()'s smart selection picks the entry matching currentTabDomain,397    // so it must be populated before openVault runs. Previously refreshCurrentTab398    // ran after openVault, leaving popups that booted already-unlocked falling399    // back to loadedEntries[0] instead of the domain match.400    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([401      sendMessage({ type: "IS_UNLOCKED" }),402      sendMessage({ type: "HAS_VAULT" }),403      refreshCurrentTab(),404      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),405    ]);406    const unlocked = Boolean(407      (unlockResponse as { ok: boolean; data?: boolean }).data408    );409    const vaultExists = Boolean(410      (vaultResponse as { ok: boolean; data?: boolean }).data411    );412    const shouldCelebrate = Boolean(413      (celebrationResponse as { ok: boolean; data?: boolean }).data414    );415    setHasVault(vaultExists);416    // Onboarding gate — show once on first install, never again. Existing417    // users (vaultExists) are auto-marked onboarded so they skip the flow418    // even on the very first popup open after this feature ships.419    try {420      const onboarded = await storageGet("peach_onboarded");421      const seen = onboarded === "true";422      if (vaultExists && !seen) {423        await storageSet("peach_onboarded", "true");424        setOnboarded(true);425      } else {426        setOnboarded(seen);427      }428    } catch {429      // storage unavailable — default to onboarded so we never block boot430      setOnboarded(true);431    }432    await Promise.all([restoreSettings(), refreshProStatus()]);433434    // Mark played before any animation so a crash mid-sequence never replays it435    if (shouldCelebrate) {436      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });437    }438439    if (unlocked && vaultExists) {440      await openVault(undefined, shouldCelebrate);441      if (shouldCelebrate) {442        setView("pro-celebration");443        window.setTimeout(() => setView("entry"), 2500);444      }445    } else if (shouldCelebrate) {446      setPendingCelebration(true);447    }448449    await refreshPendingPasskey();450451    // Track tab changes so smart sort stays current452    if (!Capacitor.isNativePlatform()) {453      const handleTabActivated = () => { void refreshCurrentTab(); };454      const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {455        if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();456      };457      try {458        chrome.tabs.onActivated.addListener(handleTabActivated);459        chrome.tabs.onUpdated.addListener(handleTabUpdated);460        onCleanup(() => {461          chrome.tabs.onActivated.removeListener(handleTabActivated);462          chrome.tabs.onUpdated.removeListener(handleTabUpdated);463        });464      } catch {465        // tabs permission may not be available in all contexts466      }467    }468469    setBootState("ready");470    document.getElementById("pre-lock")?.setAttribute("hidden", "true");471472    // Check for pending session-storage notices from the background.473    if (!Capacitor.isNativePlatform() && chrome.storage.session) {474      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as475        Record<string, { feature?: string } | undefined>;476      if (session.pendingProGate?.feature) {477        await chrome.storage.session.remove("pendingProGate");478        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);479        setSettingsInitialSection("license");480        setView("settings");481      } else if (session.pendingProNotice?.feature) {482        await chrome.storage.session.remove("pendingProNotice");483        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });484      }485    }486487    if (Capacitor.getPlatform() === "android") {488      try {489        const { pending } = await Autofill.checkPendingSave();490        if (pending) {491          const parsed = JSON.parse(pending) as {492            domain?: string;493            username?: string;494            password?: string;495          };496          if (parsed.username && parsed.password) {497            setPendingAutofillSave({498              domain: parsed.domain ?? "",499              username: parsed.username,500              password: parsed.password,501            });502          }503        }504      } catch {505        // Pending autofill saves are opportunistic; boot must continue.506      }507    }508509    // Warm the lazy-module cache so the Suspense fallback never shows for510    // locally-bundled extension views. Without this, the first navigation to511    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which512    // covers the shell and absorbs clicks on the header.513    void Promise.all([514      import("./views/Settings").catch(() => {}),515      import("./views/Import").catch(() => {}),516      import("./views/Export").catch(() => {}),517      import("./views/Health").catch(() => {}),518      import("./views/TrashView").catch(() => {}),519    ]);520521    // Lock-on-close via a persistent port. sendMessage in pagehide is522    // unreliable — the popup context tears down before the message lands.523    // The SW's onConnect listener locks when this port disconnects.524    if (!Capacitor.isNativePlatform()) {525      let popupPort: chrome.runtime.Port | null = null;526      try {527        popupPort = chrome.runtime.connect({ name: "popup" });528      } catch { /* extension context invalidated — ignore */ }529      onCleanup(() => { try { popupPort?.disconnect(); } catch {} });530531      // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale532      // entries instead of waiting for the next GET_ENTRIES round-trip.533      const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {534        if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();535        if (msg?.type === "PRO_GATE_TRIGGERED") {536          setUpgradeHighlight(msg.payload?.feature ?? null);537          setSettingsInitialSection("license");538          setView("settings");539        }540      };541      chrome.runtime.onMessage.addListener(handleRuntimeMessage);542      onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));543    }544545    // Action handlers — keyed by ShortcutAction. The dispatcher below tests546    // each binding against the event and runs the matching handler. New547    // shortcuts only need an entry here; the binding is data, not code.548    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {549      copy_password: () => {550        const entry = selectedEntry();551        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });552      },553      copy_username: () => {554        const entry = selectedEntry();555        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });556      },557      copy_totp: () => {558        const entry = selectedEntry();559        if (!entry?.totp) return;560        try {561          const { code } = generateTOTP(entry.totp);562          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });563        } catch { /* invalid TOTP secret — silent */ }564      },565      new_entry: () => handleAdd(),566      edit_entry: () => {567        const entry = selectedEntry();568        // Passkey-only entries have nothing user-editable in the Add view; the569        // passkey credential itself is opaque. Skip the edit action for them.570        if (!entry || isPasskeyOnly(entry)) return;571        setEditEntry(entry);572        setView("add");573      },574      delete_entry: () => {575        if (selectedEntry()) void handleDelete();576      },577      lock_vault: () => { void handleHeaderLock(); },578      search: () => openList(),579      nav_prev: () => navigate(-1),580      nav_next: () => navigate(1),581      jump_vault: () => setView("entry"),582      jump_settings: () => setView("settings"),583      show_help: () => setShowShortcutsHelp(true),584    };585586    // Actions that are MEANINGFUL only on the entry-detail view. Outside587    // that scope the binding is dormant — pressing C while on Settings588    // does nothing rather than awkwardly copying from a now-stale entry.589    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([590      "copy_password", "copy_username", "copy_totp",591      "edit_entry", "delete_entry", "nav_prev", "nav_next",592    ]);593594    const handleGlobalKeydown = (e: KeyboardEvent) => {595      const v = view();596      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"597        || (e.target as HTMLElement)?.tagName === "TEXTAREA"598        || (e.target as HTMLElement)?.isContentEditable;599      const isPrintableSearchKey =600        e.key.length === 1 &&601        !e.ctrlKey &&602        !e.metaKey &&603        !e.altKey;604605      // No input during the pro celebration sequence606      if (v === "pro-celebration") return;607608      // Help dialog: Escape closes it; eat the keypress so it doesn't609      // also navigate away from the underlying view.610      if (showShortcutsHelp()) {611        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }612        return;613      }614615      // ── Escape — fixed binding (not user-customizable) ───────616      if (e.key === "Escape") {617        if (v === "list") { setView("entry"); return; }618        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }619      }620621      // Bindings-driven dispatch. Single-key letter bindings without622      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is623      // inside an input, so users can type into search/forms normally.624      const bindings = shortcutBindings();625      for (const action of Object.keys(bindings) as ShortcutAction[]) {626        const binding = bindings[action];627        if (!matchKeybinding(e, binding)) continue;628        // Single-key letter bindings need the input-focus guard. Bindings629        // with any modifier are safe — Ctrl+L doesn't conflict with typing.630        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;631        if (!hasMod && binding.key.length === 1 && inInput) return;632        if (ENTRY_SCOPED.has(action) && v !== "entry") return;633        e.preventDefault();634        SHORTCUT_HANDLERS[action]?.();635        return;636      }637638      // Type-to-search: in entry view, any printable key not consumed by a639      // shortcut above primes the list search seed and opens the list.640      // Kept outside the bindings table because it's a behavior, not a641      // discrete action — every printable character can trigger it.642      if (v === "entry" && !inInput && isPrintableSearchKey) {643        e.preventDefault();644        primeListSearch(e.key);645        return;646      }647    };648    window.addEventListener("keydown", handleGlobalKeydown);649    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));650651    if (!Capacitor.isNativePlatform()) {652      const handleStorageChanged = (653        changes: Record<string, chrome.storage.StorageChange>,654        areaName: string655      ) => {656        if (areaName !== "session") return;657        if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {658          return;659        }660        void refreshPendingPasskey();661      };662      chrome.storage.onChanged.addListener(handleStorageChanged);663      onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));664    }665  });666667  async function refreshCurrentTab() {668    if (Capacitor.isNativePlatform()) return;669    try {670      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });671      const url = tab?.url ?? tab?.pendingUrl ?? "";672      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");673    } catch {674      setCurrentTabDomain("");675    }676  }677678  async function refreshPendingPasskey() {679    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });680    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);681  }682683  async function refreshProStatus() {684    const res = await sendMessage({ type: "GET_LICENSE" });685    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;686    setProStatus(Boolean(data?.valid && data?.plan === "pro"));687  }688689  async function restoreSettings() {690    const res = await sendMessage({ type: "GET_SETTINGS" });691    const s = (res as { ok: boolean; data?: any }).data ?? {};692    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {693      setAccentColor(s.accentColor as AccentColorName);694    }695    setTheme(s.theme === "light" ? "light" : "dark");696    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {697      setDisplayFont(s.displayFont);698    } else {699      setDisplayFont("cormorant-italic");700    }701    if (s.textSize === "small" || s.textSize === "large") {702      setTextSize(s.textSize);703    } else {704      setTextSize("medium");705    }706    setReduceMotion(s.reduceMotion === true);707    setLockOnClose(s.lockOnClose !== false);708    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);709    setBreachMonitoring(s.breachMonitoring !== false);710    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);711    setShortcutBindings(getActiveBindings(s.shortcuts));712  }713714  async function applyTrashRetention(days: number) {715    setTrashRetentionDays(days);716    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });717  }718719  function primeListSearch(seed: string) {720    setListSearchSeed(seed);721    setListSearchSeedNonce((value) => value + 1);722    openList();723  }724725  // Returns null when it detected a dead service worker and triggered handleLock().726  // Callers that need to abort (e.g. openVault) check for null rather than relying727  // on view() state, which may already be "lock" during normal startup flows.728  async function loadEntries(): Promise<VaultEntry[] | null> {729    const response = await sendMessage({ type: "GET_ENTRIES" });730    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };731732    // If the service worker was killed and restarted (MV3 workers sleep), the733    // vault will be locked again — or the worker may not respond at all.734    // In either case, send the user to the lock screen rather than silently735    // showing an empty vault (which looks like data loss).736    if (!res.ok) {737      if (738        res.error === "vault locked" ||739        res.error === "SERVICE_WORKER_UNAVAILABLE" ||740        (typeof res.error === "string" && res.error.includes("locked"))741      ) {742        handleLock();743        return null; // signal emergency lock to callers744      }745      return [];746    }747748    const loadedEntries = res.data ?? [];749    setEntries(loadedEntries);750    void publishInMemoryVault(loadedEntries);751752    const currentId = selectedEntry()?.id;753    if (currentId) {754      setSelectedEntry(755        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null756      );757    }758759    return loadedEntries;760  }761762  async function openVault(preferredEntryId?: string, skipViewSet = false) {763    // Purge expired trash entries on each vault open764    const retDays = trashRetentionDays();765    if (retDays > 0) {766      void sendMessage({767        type: "PURGE_EXPIRED_TRASH",768        payload: { retentionMs: retDays * 86_400_000 },769      });770    }771772    const loadedEntries = await loadEntries();773774    // null means loadEntries() triggered an emergency lock (service worker died).775    // Don't override it by setting view back to "entry" with an empty vault.776    if (loadedEntries === null) return;777778    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry779    let nextSelectedEntry: VaultEntry | null = null;780    if (preferredEntryId) {781      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;782    }783    if (!nextSelectedEntry) {784      const tabDomain = currentTabDomain();785      if (tabDomain) {786        nextSelectedEntry = loadedEntries.find((e) => {787          if (e.type === "login") {788            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;789            for (const extra of e.additionalUrls ?? []) {790              if ((extractDomain(extra) ?? "") === tabDomain) return true;791            }792            return false;793          }794          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;795          return false;796        }) ?? null;797      }798    }799    nextSelectedEntry ??= loadedEntries[0] ?? null;800801    setSelectedEntry(nextSelectedEntry);802    setEditEntry(undefined);803    setContentTransitioning(false);804    setContentPhase("enter");805    queueContentIdle();806    if (!skipViewSet) setView("entry");807  }808809  async function handleUnlock() {810    setHasVault(true);811    triggerPipPulse();812    await restoreSettings();813    const celebrating = pendingCelebration();814    if (celebrating) {815      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });816      setPendingCelebration(false);817    }818    await openVault(undefined, celebrating);819    if (celebrating) {820      setView("pro-celebration");821      window.setTimeout(() => setView("entry"), 2500);822    }823  }824825  function handleSelectEntry(entry: VaultEntry) {826    setSelectedEntry(entry);827    setContentTransitioning(false);828    setContentPhase("enter");829    queueContentIdle();830    setView("entry");831  }832833  function handleAdd() {834    setEditEntry(undefined);835    setView("add");836  }837838  async function handleDelete() {839    const entry = selectedEntry();840    if (!entry) return;841    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });842    const loadedEntries = await loadEntries() ?? [];843    setSelectedEntry(loadedEntries[0] ?? null);844    setView("entry");845  }846847  async function handleDeleteMany(ids: string[]) {848    for (const id of ids) {849      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });850    }851    const loadedEntries = await loadEntries() ?? [];852    // If the currently selected entry was among the deleted, move to first853    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);854    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);855  }856857  async function handleSave(savedEntry?: VaultEntry) {858    await openVault(savedEntry?.id);859  }860861  async function savePendingAutofill() {862    const pending = pendingAutofillSave();863    if (!pending) return;864    const domain = pending.domain.trim();865    const response = await sendMessage({866      type: "ADD_ENTRY",867      payload: {868        type: "login",869        title: domain || "Saved login",870        url: domain ? `https://${domain}` : "",871        username: pending.username,872        password: pending.password,873      },874    });875    if ((response as { ok: boolean }).ok) {876      setPendingAutofillSave(null);877      await openVault((response as { data?: VaultEntry }).data?.id);878    }879  }880881  function handleBack() {882    setEditEntry(undefined);883    setView("entry");884  }885886  function handleLock() {887    clearCanarySuppressions();888    setEntries([]);889    setSelectedEntry(null);890    setEditEntry(undefined);891    setContentPhase("idle");892    setView("lock");893    void clearInMemoryVault();894  }895896  function handleHeaderLock() {897    // Lock the UI immediately — do not wait for the service worker. If the SW898    // is dead or slow, the 5s sendMessage timeout would create a dead-click899    // window. The SW will lock on its next wake regardless.900    handleLock();901    void sendMessage({ type: "LOCK" });902  }903904  function navigate(dir: number) {905    const allEntries = entries();906    if (allEntries.length === 0) return;907    const currentIndex = selectedIndex();908    const baseIndex = currentIndex >= 0 ? currentIndex : 0;909    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;910    setSelectedEntry(allEntries[nextIndex]);911    setContentPhase("enter");912    queueContentIdle();913  }914915  function openList() {916    setView("list");917  }918919  function openHealth() {920    setEditEntry(undefined);921    setView("health");922  }923924  function handleOpenFileImport() {925    setRestoreView("file");926  }927928  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;929930  function handleFixEntry(entry: VaultEntry) {931    // Passkey-only entries have no editable surface — drop into view mode932    // instead of opening the Add editor.933    if (isPasskeyOnly(entry)) {934      setSelectedEntry(entry);935      setView("entry");936      return;937    }938    setEditEntry(entry);939    setView("add");940  }941942  const isLockedView = () => view() === "lock";943944  async function handleApprovePendingPasskey() {945    const pending = pendingPasskey();946    if (!pending) return;947    if (pending.browser === "firefox" && pending.type === "create") {948      // Firefox path: popup itself runs navigator.credentials.create. The949      // background never approved/rejected anything — it just stashed950      // intent. After the popup-driven flow resolves (success or cancel),951      // clear the pending key so the popup doesn't re-show this on next open.952      const { createFirefoxPasskey } = await import("./firefox-passkey");953      const result = await createFirefoxPasskey(pending);954      if (!result.ok) {955        console.warn("Peach Firefox passkey create failed:", result.error);956      }957      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });958      setPendingPasskey(null);959      void loadEntries();960      return;961    }962    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });963    setPendingPasskey(null);964  }965966  async function handleDenyPendingPasskey() {967    const pending = pendingPasskey();968    if (!pending) return;969    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });970    setPendingPasskey(null);971  }972973  function queueContentIdle(after?: () => void) {974    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);975    contentPhaseTimer = window.setTimeout(() => {976      setContentPhase("idle");977      after?.();978    }, ENTRY_TRANSITION_SETTLE_MS);979  }980981  return (982    <div class="popup-root peach-theme">983      <Show when={bootState() === "ready"}>984        <Show985          when={!pendingPasskey()}986          fallback={987            <PasskeyApprovalView988              type={pendingPasskey()!.type}989              rpName={pendingPasskey()!.rpName}990              rpId={pendingPasskey()!.rpId}991              userName={pendingPasskey()!.userName}992              userDisplayName={pendingPasskey()!.userDisplayName}993              onApprove={handleApprovePendingPasskey}994              onDeny={handleDenyPendingPasskey}995            />996          }997        >998          <Show999            when={!isLockedView()}1000            fallback={1001              <Show1002                when={onboarded() || hasVault()}1003                fallback={1004                  <Onboarding1005                    onComplete={() => { setOnboarded(true); }}1006                  />1007                }1008              >1009                <LockView1010                  hasVault={hasVault()}1011                  ready={bootState() === "ready"}1012                  isPro={proStatus()}1013                  onUnlock={handleUnlock}1014                  onBackToOnboarding={1015                    !hasVault()1016                      ? () => setOnboarded(false)1017                      : undefined1018                  }1019                />1020              </Show>1021            }1022          >1023            <Show1024              when={view() !== "pro-celebration"}1025              fallback={<ProCelebration />}1026            >1027            <section1028              id="hd-shell"1029              class="peach-shell peach-theme"1030              data-surface="extension"1031            >1032            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1033              <PeachWordmark unlocked />1034              <div class="peach-chrome-actions">1035                <button1036                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1037                  onClick={() => setView("settings")}1038                  aria-label="Settings"1039                >1040                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1041                    <path1042                      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"1043                      stroke="currentColor"1044                      stroke-width="0.9"1045                    />1046                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1047                  </svg>1048                </button>1049                <button1050                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1051                  onClick={handleAdd}1052                  aria-label="New entry"1053                >1054                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1055                    <path d="M5.5 1v9M1 5.5h9" />1056                  </svg>1057                </button>1058                <button1059                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1060                  onClick={() => void handleHeaderLock()}1061                  aria-label="Lock vault"1062                >1063                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1064                    <path1065                      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"1066                      stroke="currentColor"1067                      stroke-width="1"1068                      stroke-linecap="round"1069                    />1070                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1071                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1072                  </svg>1073                </button>1074              </div>1075            </header>10761077            <Show when={pendingProNotice() !== null}>1078              <div class="peach-pro-notice-banner">1079                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1080                <button1081                  class="peach-pro-notice-link"1082                  onClick={() => {1083                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1084                    setSettingsInitialSection("license");1085                    setView("settings");1086                    setPendingProNotice(null);1087                  }}1088                >1089                  Learn more →1090                </button>1091                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1092              </div>1093            </Show>10941095            <Show when={pendingAutofillSave() !== null && !isLockedView()}>1096              <div class="peach-pro-notice-banner peach-autofill-save-banner">1097                <span>1098                  Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1099                </span>1100                <button1101                  class="peach-pro-notice-link"1102                  onClick={() => void savePendingAutofill()}1103                >1104                  Save1105                </button>1106                <button1107                  class="peach-pro-notice-close"1108                  onClick={() => setPendingAutofillSave(null)}1109                  aria-label="Dismiss"1110                >1111                  x1112                </button>1113              </div>1114            </Show>11151116            <Show when={backExitNotice()}>1117              <div class="peach-pro-notice-banner peach-back-exit-banner">1118                <span>Tap back again to exit Peach</span>1119              </div>1120            </Show>11211122            <div class="peach-shell-body">1123              <div class="peach-shell-veil"></div>1124              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1125                <EntryView1126                  entry={selectedEntry()}1127                  onDelete={handleDelete}1128                  onFieldSaved={() => void loadEntries()}1129                  onEscape={() => { setView("list"); }}1130                />1131              </div>11321133              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1134                <div class="peach-search-bar">1135                  <span class="peach-search-icon">1136                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1137                      <circle cx="5.5" cy="5.5" r="3.75" />1138                      <path d="M8.75 8.75L11.5 11.5" />1139                    </svg>1140                  </span>1141                  <input1142                    class="peach-search-input"1143                    placeholder="search vault..."1144                    autocomplete="off"1145                    aria-label="Search vault entries"1146                    onClick={openList}1147                    onFocus={openList}1148                    readOnly1149                  />1150                  <div class="peach-search-meta">1151                    <span class="peach-search-counter">1152                      {entries().length > 01153                        ? `${selectedIndex() + 1} / ${entries().length}`1154                        : "0 entries"}1155                    </span>1156                    <span class="peach-divider"></span>1157                    <button1158                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1159                      onClick={() => navigate(-1)}1160                      aria-label="Previous entry"1161                      disabled={entries().length === 0}1162                    >1163                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1164                        <path d="M5 1L1 5.5L5 10" />1165                      </svg>1166                    </button>1167                    <button1168                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1169                      onClick={() => navigate(1)}1170                      aria-label="Next entry"1171                      disabled={entries().length === 0}1172                    >1173                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1174                        <path d="M1 1L5 5.5L1 10" />1175                      </svg>1176                    </button>1177                  </div>1178                </div>1179              </section>1180            </div>11811182            <Show when={view() === "list"}>1183              <VaultView1184                entries={entries()}1185                selectedEntryId={selectedEntry()?.id}1186                currentTabDomain={currentTabDomain()}1187                sortOrder={sortOrder()}1188                searchSeed={listSearchSeed()}1189                searchSeedNonce={listSearchSeedNonce()}1190                onSelectEntry={handleSelectEntry}1191                onSortOrderChange={setSortOrder}1192                onAdd={handleAdd}1193                onClose={() => setView("entry")}1194                onOpenTrash={() => setView("trash")}1195                onDeleteMany={handleDeleteMany}1196              />1197            </Show>1198            <Show when={view() === "settings"}>1199              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1200                <SettingsView1201                  entriesCount={entries().length}1202                  breachMonitoring={breachMonitoring()}1203                  lockOnClose={lockOnClose()}1204                  autoLockMinutes={autoLockMinutes()}1205                  trashRetentionDays={trashRetentionDays()}1206                  theme={theme()}1207                  accentColor={accentColor()}1208                  displayFont={displayFont()}1209                  textSize={textSize()}1210                  reduceMotion={reduceMotion()}1211                  isPro={proStatus()}1212                  initialSection={settingsInitialSection()}1213                  upgradeHighlight={upgradeHighlight()}1214                  onBreachMonitoringChange={applyBreachMonitoring}1215                  onLockOnCloseChange={applyLockOnClose}1216                  onAutoLockMinutesChange={applyAutoLockMinutes}1217                  onTrashRetentionChange={applyTrashRetention}1218                  onThemeChange={applyTheme}1219                  onAccentChange={applyAccentColor}1220                  onDisplayFontChange={applyDisplayFont}1221                  onTextSizeChange={applyTextSize}1222                  onReduceMotionChange={applyReduceMotion}1223                  shortcuts={shortcutBindings()}1224                  onShortcutsChange={applyShortcuts}1225                  onLicenseChange={async () => {1226                    // After activation/deactivation: refresh Pro status,1227                    // re-load entries (defensive — signal state can drift if1228                    // the SW briefly restarts during the verify+save round-trip),1229                    // and replay the Pro celebration if this is the first time1230                    // a valid Pro license is seen on this device.1231                    await refreshProStatus();1232                    void loadEntries();1233                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1234                    if ((should as { ok: boolean; data?: boolean }).data === true) {1235                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1236                      setView("pro-celebration");1237                      window.setTimeout(() => setView("settings"), 2500);1238                    }1239                  }}1240                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1241                  onImport={() => setView("import")}1242                  onExport={() => setView("export")}1243                  onBreachCheckDone={loadEntries}1244                  onOpenHealth={openHealth}1245                  onOpenFileImport={handleOpenFileImport}1246                  onOpenPanic={() => setView("panic")}1247                />1248              </Suspense>1249            </Show>1250            <Show when={view() === "add"}>1251              <AddView1252                entry={editEntry()}1253                entries={entries()}1254                onBack={handleBack}1255                onSave={handleSave}1256              />1257            </Show>1258            <Show when={view() === "import"}>1259              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1260                <ImportView onBack={handleBack} onImport={handleSave} />1261              </Suspense>1262            </Show>1263            <Show when={view() === "export"}>1264              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1265                <ExportView1266                  onBack={() => {1267                    if (returnToPanic()) {1268                      setReturnToPanic(false);1269                      setView("panic");1270                    } else {1271                      handleBack();1272                    }1273                  }}1274                />1275              </Suspense>1276            </Show>1277            <Show when={view() === "panic"}>1278              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1279                <PanicView1280                  onCancel={() => setView("settings")}1281                  onOpenExport={() => {1282                    setReturnToPanic(true);1283                    setView("export");1284                  }}1285                  onDestroyed={async () => {1286                    clearCanarySuppressions();1287                    setEntries([]);1288                    setSelectedEntry(null);1289                    setEditEntry(undefined);1290                    setHasVault(false);1291                    setOnboarded(false);1292                    setProStatus(false);1293                    setPendingPasskey(null);1294                    setUpgradeHighlight(null);1295                    setSettingsInitialSection(null);1296                    setPendingProNotice(null);1297                    setPendingCelebration(false);1298                    setReturnToPanic(false);1299                    setContentPhase("idle");1300                    setView("lock");1301                    void clearInMemoryVault();1302                  }}1303                />1304              </Suspense>1305            </Show>1306            <Show when={view() === "health"}>1307              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1308                <HealthView1309                  entries={entries()}1310                  isPro={proStatus()}1311                  onFixEntry={handleFixEntry}1312                  onBack={() => setView("settings")}1313                  onRefresh={loadEntries}1314                />1315              </Suspense>1316            </Show>1317            <Show when={view() === "trash"}>1318              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1319                <TrashView1320                  onBack={() => setView("list")}1321                  onRestored={() => void loadEntries()}1322                />1323              </Suspense>1324            </Show>1325            <Show when={restoreView() === "file"}>1326              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1327                <ImportView1328                  mode="onboarding"1329                  replacesExistingVault={true}1330                  onBack={() => setRestoreView(null)}1331                  onImport={async () => {1332                    setRestoreView(null);1333                    await handleUnlock();1334                  }}1335                />1336              </Suspense>1337            </Show>1338            </section>1339            </Show>1340          </Show>1341        </Show>1342      </Show>1343      <Show when={showShortcutsHelp()}>1344        <ShortcutsHelpOverlay1345          bindings={shortcutBindings()}1346          onClose={() => setShowShortcutsHelp(false)}1347        />1348      </Show>1349    </div>1350  );1351}13521353function ShortcutsHelpOverlay(props: {1354  bindings: Record<ShortcutAction, Keybinding>;1355  onClose: () => void;1356}) {1357  return (1358    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1359      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1360        <div class="peach-shortcuts-help-head">1361          <h3>Keyboard shortcuts</h3>1362          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1363        </div>1364        <div class="peach-shortcuts-help-body">1365          <p class="peach-settings-sub-label">ENTRY VIEW</p>1366          <ul>1367            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1368              <li>1369                <span>{d.label}</span>1370                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1371              </li>1372            ))}1373          </ul>1374          <p class="peach-settings-sub-label">ANYWHERE</p>1375          <ul>1376            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1377              <li>1378                <span>{d.label}</span>1379                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1380              </li>1381            ))}1382          </ul>1383        </div>1384      </div>1385    </div>1386  );1387}