App.tsx

60.0 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 { startFoldMonitoring, type PostureChangedEvent } from "../shared/fold";10import { ACCENT_COLORS } from "../shared/types";11import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";12import type { IncomingPairingRequest } from "../shared/sync-types";13import { isPasskeyOnly } from "../shared/types";14import { LockView } from "./views/Lock";15import { Onboarding } from "./views/Onboarding";16import { ProCelebration } from "./views/ProCelebration";17import { VaultView, type SortOrder } from "./views/Vault";18import { EntryView } from "./views/Entry";19import { AddView } from "./views/Add";20import { PeachWordmark } from "./components/PeachMark";21import { PasskeyApprovalView } from "./views/PasskeyApproval";22import { extractDomain } from "../shared/domain";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";3435export { sendMessage } from "../shared/bridge";3637const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));38const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));39const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));40const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));41const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));42const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));43const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));4445const Autofill = registerPlugin<{46  checkPendingSave(): Promise<{ pending: string | null }>;47  openAutofillSettings(): Promise<void>;48  isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;49}>("Autofill");5051type View =52  | "lock"53  | "pro-celebration"54  | "entry"55  | "list"56  | "add"57  | "settings"58  | "sync-pairing"59  | "import"60  | "export"61  | "health"62  | "trash"63  | "panic";64type BootState = "loading" | "ready";6566const canarySuppressMap = new Map<string, boolean>();67let shellRingAnimation: Animation | null = null;6869export function isCanarySuppressed(entryId: string): boolean {70  return canarySuppressMap.get(entryId) ?? false;71}7273export function setCanarySuppressed(entryId: string, suppressed: boolean): void {74  canarySuppressMap.set(entryId, suppressed);75}7677export function clearCanarySuppressions(): void {78  canarySuppressMap.clear();79}8081export function triggerRing() {82  const shell = document.getElementById("hd-shell");83  if (!shell) return;8485  shellRingAnimation?.cancel();86  shell.dataset.glowing = "true";87  const animation = shell.animate(88    [89      { offset: 0, boxShadow: "var(--copy-shadow)" },90      {91        offset: 0.3,92        boxShadow:93          "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)",94      },95      { offset: 1, boxShadow: "var(--copy-shadow)" },96    ],97    {98      duration: 720,99      easing: "cubic-bezier(0.4, 0, 0.2, 1)",100      fill: "none",101    }102  );103  shellRingAnimation = animation;104  const clearGlow = () => {105    if (shellRingAnimation !== animation) return;106    shellRingAnimation = null;107    delete shell.dataset.glowing;108  };109  animation.onfinish = clearGlow;110  animation.oncancel = clearGlow;111}112113114export function triggerPipPulse() {115  const pip = document.querySelector(".peach-status-pip, .pip");116  if (pip) {117    pip.classList.remove("pulse");118    void (pip as HTMLElement).offsetWidth;119    pip.classList.add("pulse");120  }121}122123const ENTRY_TRANSITION_EXIT_MS = 95;124const ENTRY_TRANSITION_SETTLE_MS = 135;125126export default function App() {127  const [bootState, setBootState] = createSignal<BootState>("loading");128  const [view, setView] = createSignal<View>("lock");129  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(130    "enter"131  );132  const [contentTransitioning, setContentTransitioning] = createSignal(false);133  const [layoutClass, setLayoutClass] = createSignal<"compact" | "medium" | "expanded">("compact");134  const isTwoPaneLayout = () => layoutClass() === "medium" || layoutClass() === "expanded";135  const [hasVault, setHasVault] = createSignal(false);136  const [onboarded, setOnboarded] = createSignal(true);137  const [entries, setEntries] = createSignal<VaultEntry[]>([]);138  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);139  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);140  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");141  const [theme, setTheme] = createSignal<"dark" | "light">("dark");142  const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");143  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");144  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);145  const [lockOnClose, setLockOnClose] = createSignal(true);146  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);147  const [breachMonitoring, setBreachMonitoring] = createSignal(true);148  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);149  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);150  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);151  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);152  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);153  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");154  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");155  // Cached Pro status — synchronous for render paths. Background's156  // GET_LICENSE is async, but render-time reads of isPro() must not block.157  // Refreshed at boot, after activate/deactivate, and whenever the popup158  // re-opens onto a stored license that has changed via another window.159  const [proStatus, setProStatus] = createSignal(false);160  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);161  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);162  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");163  const [listSearchSeed, setListSearchSeed] = createSignal("");164  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);165  const [pendingCelebration, setPendingCelebration] = createSignal(false);166  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);167  const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{168    domain: string;169    username: string;170    password: string;171  } | null>(null);172  const [backExitNotice, setBackExitNotice] = createSignal(false);173  const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);174  const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);175  const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);176  const [incomingPairTick, setIncomingPairTick] = createSignal(0);177  const incomingPairSecondsLeft = () => {178    void incomingPairTick();179    const req = incomingPair();180    if (!req) return 0;181    return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));182  };183  // True while Panic Step 1 has bounced the user out to Export. When Export's184  // onBack fires we return to Panic instead of Settings so the user keeps the185  // destruction context they came from.186  const [returnToPanic, setReturnToPanic] = createSignal(false);187188  let contentPhaseTimer: number | undefined;189  let lastBackExitAt = 0;190191  function applyAccent(name: AccentColorName) {192    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;193    const hex = color.hex;194    document.documentElement.style.setProperty("--peach-accent", hex);195    // Ambient surface variables — derived from the accent so surfaces shift196    // subtly toward the accent hue rather than staying fixed warm-brown.197    document.documentElement.style.setProperty("--peach-glow", hex + "2A");198    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");199    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");200  }201202  createEffect(() => {203    applyAccent(accentColor());204  });205206  createEffect(() => {207    document.documentElement.classList.toggle("light", theme() === "light");208    document.body.classList.toggle("light", theme() === "light");209    if (Capacitor.isNativePlatform()) {210      void applyMobileStatusBar(theme());211    }212  });213214  createEffect(() => {215    document.documentElement.setAttribute("data-display-font", displayFont());216  });217218  createEffect(() => {219    document.documentElement.setAttribute("data-text-size", textSize());220  });221222  createEffect(() => {223    if (reduceMotion()) {224      document.documentElement.setAttribute("data-motion", "reduce");225    } else {226      document.documentElement.removeAttribute("data-motion");227    }228  });229230  const selectedIndex = createMemo(() => {231    const currentId = selectedEntry()?.id;232    if (!currentId) return entries().length > 0 ? 0 : -1;233    return entries().findIndex((entry) => entry.id === currentId);234  });235236  async function applyAccentColor(name: AccentColorName) {237    applyAccent(name);238    setAccentColor(name);239    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });240  }241242  async function applyTheme(t: "dark" | "light") {243    setTheme(t);244    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });245  }246247  async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {248    setDisplayFont(f);249    await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });250  }251252  async function applyTextSize(size: "small" | "medium" | "large") {253    setTextSize(size);254    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });255  }256257  async function applyReduceMotion(enabled: boolean) {258    setReduceMotion(enabled);259    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });260  }261262  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload263  // small and lets future default updates land for actions the user hasn't264  // touched. Settings sends us the full active map; we filter and store.265  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {266    setShortcutBindings(active);267    const overrides: Record<string, Keybinding> = {};268    for (const action of Object.keys(active) as ShortcutAction[]) {269      const a = active[action];270      const d = DEFAULT_BINDINGS[action];271      const same = a.key === d.key272        && Boolean(a.ctrl)  === Boolean(d.ctrl)273        && Boolean(a.meta)  === Boolean(d.meta)274        && Boolean(a.shift) === Boolean(d.shift)275        && Boolean(a.alt)   === Boolean(d.alt);276      if (!same) overrides[action] = a;277    }278    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });279  }280281  async function applyLockOnClose(enabled: boolean) {282    setLockOnClose(enabled);283    await sendMessage({284      type: "UPDATE_SETTINGS",285      payload: { lockOnClose: enabled },286    });287  }288289  async function applyAutoLockMinutes(minutes: number) {290    setAutoLockMinutes(minutes);291    await sendMessage({292      type: "UPDATE_SETTINGS",293      payload: { autoLockMinutes: minutes },294    });295  }296297  async function applyBreachMonitoring(enabled: boolean) {298    setBreachMonitoring(enabled);299    await sendMessage({300      type: "UPDATE_SETTINGS",301      payload: { breachMonitoring: enabled },302    });303  }304305  async function applyMobileStatusBar(nextTheme: "dark" | "light") {306    try {307      await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });308      await StatusBar.setBackgroundColor({309        color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",310      });311    } catch {312      // Status bar APIs are available only in the native shell.313    }314  }315316  function handleBackNavigation() {317    if (showShortcutsHelp()) {318      setShowShortcutsHelp(false);319      return;320    }321    if (restoreView() !== null) {322      setRestoreView(null);323      return;324    }325326    switch (view()) {327      case "list":328        setView("entry");329        return;330      case "settings":331      case "add":332      case "import":333        handleBack();334        return;335      case "export":336        if (returnToPanic()) {337          setReturnToPanic(false);338          setView("panic");339        } else {340          handleBack();341        }342        return;343      case "health":344      case "panic":345      case "sync-pairing":346        setView("settings");347        return;348      case "trash":349        setView("list");350        return;351      case "entry":352      case "lock": {353        const now = Date.now();354        if (now - lastBackExitAt < 1600) {355          void CapacitorApp.exitApp();356          return;357        }358        lastBackExitAt = now;359        setBackExitNotice(true);360        window.setTimeout(() => setBackExitNotice(false), 1400);361        return;362      }363      default:364        void CapacitorApp.exitApp();365    }366  }367368  onMount(async () => {369    if (Capacitor.isNativePlatform()) {370      document.body.classList.add("peach-mobile");371      document.documentElement.classList.add("peach-mobile-root");372      void applyMobileStatusBar(theme());373374      // Window Size Class detection — compact/medium/expanded matches Android's375      // breakpoints. Drives two-pane layout on Z Fold inner display + tablets.376      const updateLayoutClass = () => {377        const w = window.innerWidth;378        const cls = document.body.classList;379        cls.remove("layout-compact", "layout-medium", "layout-expanded");380        const next: "compact" | "medium" | "expanded" =381          w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";382        cls.add(`layout-${next}`);383        setLayoutClass(next);384      };385      updateLayoutClass();386      window.addEventListener("resize", updateLayoutClass);387388      // Foldable posture (Z Fold tabletop / book) — set data-posture and a389      // CSS variable for hinge height so layouts can pad away from the crease.390      const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {391        document.body.setAttribute("data-posture", e.posture);392        document.documentElement.style.setProperty(393          "--peach-hinge-height",394          `${e.hingeHeight}px`,395        );396      });397398      const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {399        document.body.style.paddingBottom = `${info.keyboardHeight}px`;400      });401      const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {402        document.body.style.paddingBottom = "0";403      });404      const backButton = await CapacitorApp.addListener("backButton", () => {405        handleBackNavigation();406      });407408      let touchStartX = 0;409      let touchStartY = 0;410      const handleTouchStart = (event: TouchEvent) => {411        const touch = event.touches[0];412        if (!touch) return;413        touchStartX = touch.clientX;414        touchStartY = touch.clientY;415      };416      const handleTouchEnd = (event: TouchEvent) => {417        const touch = event.changedTouches[0];418        if (!touch) return;419        const dx = touch.clientX - touchStartX;420        const dy = Math.abs(touch.clientY - touchStartY);421        if (touchStartX < 30 && dx > 60 && dy < 40) {422          handleBackNavigation();423        }424      };425      document.addEventListener("touchstart", handleTouchStart, { passive: true });426      document.addEventListener("touchend", handleTouchEnd, { passive: true });427428      onCleanup(() => {429        void keyboardShow.remove();430        void keyboardHide.remove();431        void backButton.remove();432        stopFold();433        window.removeEventListener("resize", updateLayoutClass);434        document.removeEventListener("touchstart", handleTouchStart);435        document.removeEventListener("touchend", handleTouchEnd);436        document.body.style.paddingBottom = "0";437      });438    }439440    // Resolve the active tab in parallel with the unlock/has-vault queries —441    // openVault()'s smart selection picks the entry matching currentTabDomain,442    // so it must be populated before openVault runs. Previously refreshCurrentTab443    // ran after openVault, leaving popups that booted already-unlocked falling444    // back to loadedEntries[0] instead of the domain match.445    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([446      sendMessage({ type: "IS_UNLOCKED" }),447      sendMessage({ type: "HAS_VAULT" }),448      refreshCurrentTab(),449      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),450    ]);451    const unlocked = Boolean(452      (unlockResponse as { ok: boolean; data?: boolean }).data453    );454    const vaultExists = Boolean(455      (vaultResponse as { ok: boolean; data?: boolean }).data456    );457    const shouldCelebrate = Boolean(458      (celebrationResponse as { ok: boolean; data?: boolean }).data459    );460    setHasVault(vaultExists);461    // Onboarding gate — show once on first install, never again. Existing462    // users (vaultExists) are auto-marked onboarded so they skip the flow463    // even on the very first popup open after this feature ships.464    try {465      const onboarded = await storageGet("peach_onboarded");466      const seen = onboarded === "true";467      if (vaultExists && !seen) {468        await storageSet("peach_onboarded", "true");469        setOnboarded(true);470      } else {471        setOnboarded(seen);472      }473    } catch {474      // storage unavailable — default to onboarded so we never block boot475      setOnboarded(true);476    }477    await Promise.all([restoreSettings(), refreshProStatus()]);478479    // Mark played before any animation so a crash mid-sequence never replays it480    if (shouldCelebrate) {481      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });482    }483484    if (unlocked && vaultExists) {485      await openVault(undefined, shouldCelebrate);486      if (shouldCelebrate) {487        setView("pro-celebration");488        window.setTimeout(() => setView("entry"), 2500);489      }490    } else if (shouldCelebrate) {491      setPendingCelebration(true);492    }493494    await refreshPendingPasskey();495    await refreshIncomingPairing();496    await consumeRecentPairing();497498    // Track tab changes so smart sort stays current499    if (!Capacitor.isNativePlatform()) {500      const handleTabActivated = () => { void refreshCurrentTab(); };501      const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {502        if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();503      };504      try {505        chrome.tabs.onActivated.addListener(handleTabActivated);506        chrome.tabs.onUpdated.addListener(handleTabUpdated);507        onCleanup(() => {508          chrome.tabs.onActivated.removeListener(handleTabActivated);509          chrome.tabs.onUpdated.removeListener(handleTabUpdated);510        });511      } catch {512        // tabs permission may not be available in all contexts513      }514    }515516    setBootState("ready");517    document.getElementById("pre-lock")?.setAttribute("hidden", "true");518519    // Check for pending session-storage notices from the background.520    if (!Capacitor.isNativePlatform() && chrome.storage.session) {521      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as522        Record<string, { feature?: string } | undefined>;523      if (session.pendingProGate?.feature) {524        await chrome.storage.session.remove("pendingProGate");525        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);526        setSettingsInitialSection("license");527        setView("settings");528      } else if (session.pendingProNotice?.feature) {529        await chrome.storage.session.remove("pendingProNotice");530        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });531      }532    }533534    if (Capacitor.getPlatform() === "android") {535      try {536        const { pending } = await Autofill.checkPendingSave();537        if (pending) {538          const parsed = JSON.parse(pending) as {539            domain?: string;540            username?: string;541            password?: string;542          };543          if (parsed.username && parsed.password) {544            setPendingAutofillSave({545              domain: parsed.domain ?? "",546              username: parsed.username,547              password: parsed.password,548            });549          }550        }551      } catch {552        // Pending autofill saves are opportunistic; boot must continue.553      }554    }555556    // Warm the lazy-module cache so the Suspense fallback never shows for557    // locally-bundled extension views. Without this, the first navigation to558    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which559    // covers the shell and absorbs clicks on the header.560    void Promise.all([561      import("./views/Settings").catch(() => {}),562      import("./views/Import").catch(() => {}),563      import("./views/Export").catch(() => {}),564      import("./views/Health").catch(() => {}),565      import("./views/TrashView").catch(() => {}),566    ]);567568    // Lock-on-close via a persistent port. sendMessage in pagehide is569    // unreliable — the popup context tears down before the message lands.570    // The SW's onConnect listener locks when this port disconnects.571    if (!Capacitor.isNativePlatform()) {572      let popupPort: chrome.runtime.Port | null = null;573      try {574        popupPort = chrome.runtime.connect({ name: "popup" });575      } catch { /* extension context invalidated — ignore */ }576      onCleanup(() => { try { popupPort?.disconnect(); } catch {} });577578      // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale579      // entries instead of waiting for the next GET_ENTRIES round-trip.580      const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {581        if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();582        if (msg?.type === "PRO_GATE_TRIGGERED") {583          setUpgradeHighlight(msg.payload?.feature ?? null);584          setSettingsInitialSection("license");585          setView("settings");586        }587        if (msg?.type === "INCOMING_PAIRING_REQUEST") {588          void refreshIncomingPairing();589        }590      };591      chrome.runtime.onMessage.addListener(handleRuntimeMessage);592      onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));593    }594595    // Action handlers — keyed by ShortcutAction. The dispatcher below tests596    // each binding against the event and runs the matching handler. New597    // shortcuts only need an entry here; the binding is data, not code.598    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {599      copy_password: () => {600        const entry = selectedEntry();601        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });602      },603      copy_username: () => {604        const entry = selectedEntry();605        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });606      },607      copy_totp: () => {608        const entry = selectedEntry();609        if (!entry?.totp) return;610        try {611          const { code } = generateTOTP(entry.totp);612          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });613        } catch { /* invalid TOTP secret — silent */ }614      },615      new_entry: () => handleAdd(),616      edit_entry: () => {617        const entry = selectedEntry();618        // Passkey-only entries have nothing user-editable in the Add view; the619        // passkey credential itself is opaque. Skip the edit action for them.620        if (!entry || isPasskeyOnly(entry)) return;621        setEditEntry(entry);622        setView("add");623      },624      delete_entry: () => {625        if (selectedEntry()) void handleDelete();626      },627      lock_vault: () => { void handleHeaderLock(); },628      search: () => openList(),629      nav_prev: () => navigate(-1),630      nav_next: () => navigate(1),631      jump_vault: () => setView("entry"),632      jump_settings: () => setView("settings"),633      show_help: () => setShowShortcutsHelp(true),634    };635636    // Actions that are MEANINGFUL only on the entry-detail view. Outside637    // that scope the binding is dormant — pressing C while on Settings638    // does nothing rather than awkwardly copying from a now-stale entry.639    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([640      "copy_password", "copy_username", "copy_totp",641      "edit_entry", "delete_entry", "nav_prev", "nav_next",642    ]);643644    const handleGlobalKeydown = (e: KeyboardEvent) => {645      const v = view();646      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"647        || (e.target as HTMLElement)?.tagName === "TEXTAREA"648        || (e.target as HTMLElement)?.isContentEditable;649      const isPrintableSearchKey =650        e.key.length === 1 &&651        !e.ctrlKey &&652        !e.metaKey &&653        !e.altKey;654655      // No input during the pro celebration sequence656      if (v === "pro-celebration") return;657658      // Help dialog: Escape closes it; eat the keypress so it doesn't659      // also navigate away from the underlying view.660      if (showShortcutsHelp()) {661        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }662        return;663      }664665      // ── Escape — fixed binding (not user-customizable) ───────666      if (e.key === "Escape") {667        if (v === "list") { setView("entry"); return; }668        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }669        if (v === "sync-pairing") { setView("settings"); return; }670      }671672      // Bindings-driven dispatch. Single-key letter bindings without673      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is674      // inside an input, so users can type into search/forms normally.675      const bindings = shortcutBindings();676      for (const action of Object.keys(bindings) as ShortcutAction[]) {677        const binding = bindings[action];678        if (!matchKeybinding(e, binding)) continue;679        // Single-key letter bindings need the input-focus guard. Bindings680        // with any modifier are safe — Ctrl+L doesn't conflict with typing.681        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;682        if (!hasMod && binding.key.length === 1 && inInput) return;683        if (ENTRY_SCOPED.has(action) && v !== "entry") return;684        e.preventDefault();685        SHORTCUT_HANDLERS[action]?.();686        return;687      }688689      // Type-to-search: in entry view, any printable key not consumed by a690      // shortcut above primes the list search seed and opens the list.691      // Kept outside the bindings table because it's a behavior, not a692      // discrete action — every printable character can trigger it.693      if (v === "entry" && !inInput && isPrintableSearchKey) {694        e.preventDefault();695        primeListSearch(e.key);696        return;697      }698    };699    window.addEventListener("keydown", handleGlobalKeydown);700    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));701702    if (!Capacitor.isNativePlatform()) {703      const handleStorageChanged = (704        changes: Record<string, chrome.storage.StorageChange>,705        areaName: string706      ) => {707        if (areaName !== "session") return;708        if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {709          return;710        }711        void refreshPendingPasskey();712      };713      chrome.storage.onChanged.addListener(handleStorageChanged);714      onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));715    }716  });717718  async function refreshCurrentTab() {719    if (Capacitor.isNativePlatform()) return;720    try {721      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });722      const url = tab?.url ?? tab?.pendingUrl ?? "";723      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");724      // Skip extension/internal URLs — adding chrome://, about:, or725      // chrome-extension:// to a vault entry is never what the user wants.726      const isWebUrl = /^https?:\/\//i.test(url);727      setCurrentTabUrl(isWebUrl ? url : "");728    } catch {729      setCurrentTabDomain("");730      setCurrentTabUrl("");731    }732  }733734  async function refreshPendingPasskey() {735    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });736    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);737  }738739  async function refreshIncomingPairing() {740    const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });741    const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;742    setIncomingPair(data);743  }744745  // Drains a single one-shot record left by the background when 6-digit746  // pairing succeeded while the popup was closed (or the SyncPairing view was747  // navigated away from before it could show its own success card).748  async function consumeRecentPairing() {749    const res = await sendMessage({ type: "GET_RECENT_PAIRING" });750    const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;751    if (data?.deviceName) {752      setRecentPairToast(data.deviceName);753      window.setTimeout(() => setRecentPairToast(null), 4000);754    }755  }756757  async function respondIncomingPairing(accept: boolean) {758    if (incomingPairResponding()) return;759    setIncomingPairResponding(true);760    await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });761    setIncomingPair(null);762    setIncomingPairResponding(false);763  }764765  // 60-second expiry ticker for the incoming pairing banner. Re-runs when a766  // request appears, ticks once per second, and clears the banner if the767  // request expires while the popup is open.768  createEffect(() => {769    const req = incomingPair();770    if (!req) return;771    const handle = window.setInterval(() => {772      setIncomingPairTick((tick) => tick + 1);773      if (Date.now() >= req.expiresAt) {774        setIncomingPair(null);775        window.clearInterval(handle);776      }777    }, 1000);778    onCleanup(() => window.clearInterval(handle));779  });780781  async function refreshProStatus() {782    const res = await sendMessage({ type: "GET_LICENSE" });783    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;784    setProStatus(Boolean(data?.valid && data?.plan === "pro"));785  }786787  async function restoreSettings() {788    const res = await sendMessage({ type: "GET_SETTINGS" });789    const s = (res as { ok: boolean; data?: any }).data ?? {};790    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {791      setAccentColor(s.accentColor as AccentColorName);792    }793    setTheme(s.theme === "light" ? "light" : "dark");794    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {795      setDisplayFont(s.displayFont);796    } else {797      setDisplayFont("cormorant-italic");798    }799    if (s.textSize === "small" || s.textSize === "large") {800      setTextSize(s.textSize);801    } else {802      setTextSize("medium");803    }804    setReduceMotion(s.reduceMotion === true);805    setLockOnClose(s.lockOnClose !== false);806    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);807    setBreachMonitoring(s.breachMonitoring !== false);808    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);809    setShortcutBindings(getActiveBindings(s.shortcuts));810  }811812  async function applyTrashRetention(days: number) {813    setTrashRetentionDays(days);814    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });815  }816817  function primeListSearch(seed: string) {818    setListSearchSeed(seed);819    setListSearchSeedNonce((value) => value + 1);820    openList();821  }822823  // Returns null when it detected a dead service worker and triggered handleLock().824  // Callers that need to abort (e.g. openVault) check for null rather than relying825  // on view() state, which may already be "lock" during normal startup flows.826  async function loadEntries(): Promise<VaultEntry[] | null> {827    const response = await sendMessage({ type: "GET_ENTRIES" });828    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };829830    // If the service worker was killed and restarted (MV3 workers sleep), the831    // vault will be locked again — or the worker may not respond at all.832    // In either case, send the user to the lock screen rather than silently833    // showing an empty vault (which looks like data loss).834    if (!res.ok) {835      if (836        res.error === "vault locked" ||837        res.error === "SERVICE_WORKER_UNAVAILABLE" ||838        (typeof res.error === "string" && res.error.includes("locked"))839      ) {840        handleLock();841        return null; // signal emergency lock to callers842      }843      return [];844    }845846    const loadedEntries = res.data ?? [];847    setEntries(loadedEntries);848    void publishInMemoryVault(loadedEntries);849850    const currentId = selectedEntry()?.id;851    if (currentId) {852      setSelectedEntry(853        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null854      );855    }856857    return loadedEntries;858  }859860  async function openVault(preferredEntryId?: string, skipViewSet = false) {861    // Purge expired trash entries on each vault open862    const retDays = trashRetentionDays();863    if (retDays > 0) {864      void sendMessage({865        type: "PURGE_EXPIRED_TRASH",866        payload: { retentionMs: retDays * 86_400_000 },867      });868    }869870    const loadedEntries = await loadEntries();871872    // null means loadEntries() triggered an emergency lock (service worker died).873    // Don't override it by setting view back to "entry" with an empty vault.874    if (loadedEntries === null) return;875876    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry877    let nextSelectedEntry: VaultEntry | null = null;878    if (preferredEntryId) {879      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;880    }881    if (!nextSelectedEntry) {882      const tabDomain = currentTabDomain();883      if (tabDomain) {884        nextSelectedEntry = loadedEntries.find((e) => {885          if (e.type === "login") {886            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;887            for (const extra of e.additionalUrls ?? []) {888              if ((extractDomain(extra) ?? "") === tabDomain) return true;889            }890            return false;891          }892          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;893          return false;894        }) ?? null;895      }896    }897    nextSelectedEntry ??= loadedEntries[0] ?? null;898899    setSelectedEntry(nextSelectedEntry);900    setEditEntry(undefined);901    setContentTransitioning(false);902    setContentPhase("enter");903    queueContentIdle();904    if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");905  }906907  async function handleUnlock() {908    setHasVault(true);909    triggerPipPulse();910    await restoreSettings();911    const celebrating = pendingCelebration();912    if (celebrating) {913      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });914      setPendingCelebration(false);915    }916    await openVault(undefined, celebrating);917    if (celebrating) {918      setView("pro-celebration");919      window.setTimeout(() => setView("entry"), 2500);920    }921  }922923  function handleSelectEntry(entry: VaultEntry) {924    setSelectedEntry(entry);925    setContentTransitioning(false);926    setContentPhase("enter");927    queueContentIdle();928    // Two-pane layouts (Z Fold inner display, tablets, DeX) keep the list929    // mounted on the left and only update the detail pane on selection.930    if (!isTwoPaneLayout()) setView("entry");931  }932933  function handleAdd() {934    setEditEntry(undefined);935    setView("add");936  }937938  async function handleDelete() {939    const entry = selectedEntry();940    if (!entry) return;941    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });942    const loadedEntries = await loadEntries() ?? [];943    setSelectedEntry(loadedEntries[0] ?? null);944    setView("entry");945  }946947  async function handleDeleteMany(ids: string[]) {948    for (const id of ids) {949      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });950    }951    const loadedEntries = await loadEntries() ?? [];952    // If the currently selected entry was among the deleted, move to first953    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);954    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);955  }956957  async function handleSave(savedEntry?: VaultEntry) {958    await openVault(savedEntry?.id);959  }960961  async function savePendingAutofill() {962    const pending = pendingAutofillSave();963    if (!pending) return;964    const domain = pending.domain.trim();965    const response = await sendMessage({966      type: "ADD_ENTRY",967      payload: {968        type: "login",969        title: domain || "Saved login",970        url: domain ? `https://${domain}` : "",971        username: pending.username,972        password: pending.password,973      },974    });975    if ((response as { ok: boolean }).ok) {976      setPendingAutofillSave(null);977      await openVault((response as { data?: VaultEntry }).data?.id);978    }979  }980981  function handleBack() {982    setEditEntry(undefined);983    setView("entry");984  }985986  function handleLock() {987    clearCanarySuppressions();988    setEntries([]);989    setSelectedEntry(null);990    setEditEntry(undefined);991    setContentPhase("idle");992    setView("lock");993    void clearInMemoryVault();994  }995996  function handleHeaderLock() {997    // Lock the UI immediately — do not wait for the service worker. If the SW998    // is dead or slow, the 5s sendMessage timeout would create a dead-click999    // window. The SW will lock on its next wake regardless.1000    handleLock();1001    void sendMessage({ type: "LOCK" });1002  }10031004  function navigate(dir: number) {1005    const allEntries = entries();1006    if (allEntries.length === 0) return;1007    const currentIndex = selectedIndex();1008    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1009    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1010    setSelectedEntry(allEntries[nextIndex]);1011    setContentPhase("enter");1012    queueContentIdle();1013  }10141015  function openList() {1016    setView("list");1017  }10181019  function openHealth() {1020    setEditEntry(undefined);1021    setView("health");1022  }10231024  function handleOpenFileImport() {1025    setRestoreView("file");1026  }10271028  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10291030  function handleFixEntry(entry: VaultEntry) {1031    // Passkey-only entries have no editable surface — drop into view mode1032    // instead of opening the Add editor.1033    if (isPasskeyOnly(entry)) {1034      setSelectedEntry(entry);1035      setView("entry");1036      return;1037    }1038    setEditEntry(entry);1039    setView("add");1040  }10411042  const isLockedView = () => view() === "lock";10431044  async function handleApprovePendingPasskey() {1045    const pending = pendingPasskey();1046    if (!pending) return;1047    if (pending.browser === "firefox" && pending.type === "create") {1048      // Firefox path: popup itself runs navigator.credentials.create. The1049      // background never approved/rejected anything — it just stashed1050      // intent. After the popup-driven flow resolves (success or cancel),1051      // clear the pending key so the popup doesn't re-show this on next open.1052      const { createFirefoxPasskey } = await import("./firefox-passkey");1053      const result = await createFirefoxPasskey(pending);1054      if (!result.ok) {1055        console.warn("Peach Firefox passkey create failed:", result.error);1056      }1057      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1058      setPendingPasskey(null);1059      void loadEntries();1060      return;1061    }1062    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1063    setPendingPasskey(null);1064  }10651066  async function handleDenyPendingPasskey() {1067    const pending = pendingPasskey();1068    if (!pending) return;1069    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1070    setPendingPasskey(null);1071  }10721073  function queueContentIdle(after?: () => void) {1074    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1075    contentPhaseTimer = window.setTimeout(() => {1076      setContentPhase("idle");1077      after?.();1078    }, ENTRY_TRANSITION_SETTLE_MS);1079  }10801081  return (1082    <div class="popup-root peach-theme">1083      <Show when={bootState() === "ready"}>1084        <Show1085          when={!pendingPasskey()}1086          fallback={1087            <PasskeyApprovalView1088              type={pendingPasskey()!.type}1089              rpName={pendingPasskey()!.rpName}1090              rpId={pendingPasskey()!.rpId}1091              userName={pendingPasskey()!.userName}1092              userDisplayName={pendingPasskey()!.userDisplayName}1093              onApprove={handleApprovePendingPasskey}1094              onDeny={handleDenyPendingPasskey}1095            />1096          }1097        >1098          <Show1099            when={!isLockedView()}1100            fallback={1101              <Show1102                when={onboarded() || hasVault()}1103                fallback={1104                  <Onboarding1105                    onComplete={() => { setOnboarded(true); }}1106                  />1107                }1108              >1109                <LockView1110                  hasVault={hasVault()}1111                  ready={bootState() === "ready"}1112                  isPro={proStatus()}1113                  onUnlock={handleUnlock}1114                  onBackToOnboarding={1115                    !hasVault()1116                      ? () => setOnboarded(false)1117                      : undefined1118                  }1119                />1120              </Show>1121            }1122          >1123            <Show1124              when={view() !== "pro-celebration"}1125              fallback={<ProCelebration />}1126            >1127            <section1128              id="hd-shell"1129              class="peach-shell peach-theme"1130              data-surface="extension"1131            >1132            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1133              <PeachWordmark unlocked />1134              <div class="peach-chrome-actions">1135                <button1136                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1137                  onClick={() => setView("settings")}1138                  aria-label="Settings"1139                >1140                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1141                    <path1142                      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"1143                      stroke="currentColor"1144                      stroke-width="0.9"1145                    />1146                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1147                  </svg>1148                </button>1149                <button1150                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1151                  onClick={handleAdd}1152                  aria-label="New entry"1153                >1154                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1155                    <path d="M5.5 1v9M1 5.5h9" />1156                  </svg>1157                </button>1158                <button1159                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1160                  onClick={() => void handleHeaderLock()}1161                  aria-label="Lock vault"1162                >1163                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1164                    <path1165                      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"1166                      stroke="currentColor"1167                      stroke-width="1"1168                      stroke-linecap="round"1169                    />1170                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1171                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1172                  </svg>1173                </button>1174              </div>1175            </header>11761177            <Show when={recentPairToast()}>1178              <div class="peach-pair-toast" role="status" aria-live="polite">1179                Paired with <strong>{recentPairToast()}</strong>1180              </div>1181            </Show>11821183            <Show when={incomingPair() !== null}>1184              <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1185                <div class="peach-pair-request-header">1186                  <span class="peach-pair-request-mark">🍑</span>1187                  <span class="peach-pair-request-title">Pairing request</span>1188                  <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1189                </div>1190                <p class="peach-pair-request-body">1191                  <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1192                </p>1193                <div class="peach-pair-request-actions">1194                  <button1195                    class="peach-btn peach-btn--ghost peach-btn--sm"1196                    onClick={() => void respondIncomingPairing(false)}1197                    disabled={incomingPairResponding()}1198                  >1199                    Reject1200                  </button>1201                  <button1202                    class="peach-btn peach-btn--accent peach-btn--sm"1203                    onClick={() => void respondIncomingPairing(true)}1204                    disabled={incomingPairResponding()}1205                  >1206                    Accept1207                  </button>1208                </div>1209              </div>1210            </Show>12111212            <Show when={pendingProNotice() !== null}>1213              <div class="peach-pro-notice-banner">1214                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1215                <button1216                  class="peach-pro-notice-link"1217                  onClick={() => {1218                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1219                    setSettingsInitialSection("license");1220                    setView("settings");1221                    setPendingProNotice(null);1222                  }}1223                >1224                  Learn more →1225                </button>1226                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1227              </div>1228            </Show>12291230            <Show when={pendingAutofillSave() !== null && !isLockedView()}>1231              <div class="peach-pro-notice-banner peach-autofill-save-banner">1232                <span>1233                  Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1234                </span>1235                <button1236                  class="peach-pro-notice-link"1237                  onClick={() => void savePendingAutofill()}1238                >1239                  Save1240                </button>1241                <button1242                  class="peach-pro-notice-close"1243                  onClick={() => setPendingAutofillSave(null)}1244                  aria-label="Dismiss"1245                >1246                  x1247                </button>1248              </div>1249            </Show>12501251            <Show when={backExitNotice()}>1252              <div class="peach-pro-notice-banner peach-back-exit-banner">1253                <span>Tap back again to exit Peach</span>1254              </div>1255            </Show>12561257            <div class="peach-shell-body">1258              <div class="peach-shell-veil"></div>1259              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1260                <EntryView1261                  entry={selectedEntry()}1262                  currentTabUrl={currentTabUrl()}1263                  onDelete={handleDelete}1264                  onFieldSaved={() => void loadEntries()}1265                  onEscape={() => { setView("list"); }}1266                />1267              </div>12681269              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1270                <div class="peach-search-bar">1271                  <span class="peach-search-icon">1272                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1273                      <circle cx="5.5" cy="5.5" r="3.75" />1274                      <path d="M8.75 8.75L11.5 11.5" />1275                    </svg>1276                  </span>1277                  <input1278                    class="peach-search-input"1279                    placeholder="search vault..."1280                    autocomplete="off"1281                    aria-label="Search vault entries"1282                    onClick={openList}1283                    onFocus={openList}1284                    readOnly1285                  />1286                  <div class="peach-search-meta">1287                    <span class="peach-search-counter">1288                      {entries().length > 01289                        ? `${selectedIndex() + 1} / ${entries().length}`1290                        : "0 entries"}1291                    </span>1292                    <span class="peach-divider"></span>1293                    <button1294                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1295                      onClick={() => navigate(-1)}1296                      aria-label="Previous entry"1297                      disabled={entries().length === 0}1298                    >1299                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1300                        <path d="M5 1L1 5.5L5 10" />1301                      </svg>1302                    </button>1303                    <button1304                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1305                      onClick={() => navigate(1)}1306                      aria-label="Next entry"1307                      disabled={entries().length === 0}1308                    >1309                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1310                        <path d="M1 1L5 5.5L1 10" />1311                      </svg>1312                    </button>1313                  </div>1314                </div>1315              </section>1316            </div>13171318            <Show when={view() === "list"}>1319              <VaultView1320                entries={entries()}1321                selectedEntryId={selectedEntry()?.id}1322                currentTabDomain={currentTabDomain()}1323                sortOrder={sortOrder()}1324                searchSeed={listSearchSeed()}1325                searchSeedNonce={listSearchSeedNonce()}1326                onSelectEntry={handleSelectEntry}1327                onSortOrderChange={setSortOrder}1328                onAdd={handleAdd}1329                onClose={() => setView("entry")}1330                onOpenTrash={() => setView("trash")}1331                onDeleteMany={handleDeleteMany}1332              />1333            </Show>1334            <Show when={view() === "settings"}>1335              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1336                <SettingsView1337                  entriesCount={entries().length}1338                  breachMonitoring={breachMonitoring()}1339                  lockOnClose={lockOnClose()}1340                  autoLockMinutes={autoLockMinutes()}1341                  trashRetentionDays={trashRetentionDays()}1342                  theme={theme()}1343                  accentColor={accentColor()}1344                  displayFont={displayFont()}1345                  textSize={textSize()}1346                  reduceMotion={reduceMotion()}1347                  isPro={proStatus()}1348                  initialSection={settingsInitialSection()}1349                  upgradeHighlight={upgradeHighlight()}1350                  onBreachMonitoringChange={applyBreachMonitoring}1351                  onLockOnCloseChange={applyLockOnClose}1352                  onAutoLockMinutesChange={applyAutoLockMinutes}1353                  onTrashRetentionChange={applyTrashRetention}1354                  onThemeChange={applyTheme}1355                  onAccentChange={applyAccentColor}1356                  onDisplayFontChange={applyDisplayFont}1357                  onTextSizeChange={applyTextSize}1358                  onReduceMotionChange={applyReduceMotion}1359                  shortcuts={shortcutBindings()}1360                  onShortcutsChange={applyShortcuts}1361                  onLicenseChange={async () => {1362                    // After activation/deactivation: refresh Pro status,1363                    // re-load entries (defensive — signal state can drift if1364                    // the SW briefly restarts during the verify+save round-trip),1365                    // and replay the Pro celebration if this is the first time1366                    // a valid Pro license is seen on this device.1367                    await refreshProStatus();1368                    void loadEntries();1369                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1370                    if ((should as { ok: boolean; data?: boolean }).data === true) {1371                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1372                      setView("pro-celebration");1373                      window.setTimeout(() => setView("settings"), 2500);1374                    }1375                  }}1376                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1377                  onImport={() => setView("import")}1378                  onExport={() => setView("export")}1379                  onBreachCheckDone={loadEntries}1380                  onOpenHealth={openHealth}1381                  onOpenSyncPairing={() => setView("sync-pairing")}1382                  onOpenFileImport={handleOpenFileImport}1383                  onOpenPanic={() => setView("panic")}1384                />1385              </Suspense>1386            </Show>1387            <Show when={view() === "sync-pairing"}>1388              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1389                <SyncPairingView1390                  onClose={() => setView("settings")}1391                  onPaired={() => { /* sync state refreshes when popup returns to Settings */ }}1392                />1393              </Suspense>1394            </Show>1395            <Show when={view() === "add"}>1396              <AddView1397                entry={editEntry()}1398                entries={entries()}1399                onBack={handleBack}1400                onSave={handleSave}1401              />1402            </Show>1403            <Show when={view() === "import"}>1404              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1405                <ImportView onBack={handleBack} onImport={handleSave} />1406              </Suspense>1407            </Show>1408            <Show when={view() === "export"}>1409              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1410                <ExportView1411                  onBack={() => {1412                    if (returnToPanic()) {1413                      setReturnToPanic(false);1414                      setView("panic");1415                    } else {1416                      handleBack();1417                    }1418                  }}1419                />1420              </Suspense>1421            </Show>1422            <Show when={view() === "panic"}>1423              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1424                <PanicView1425                  onCancel={() => setView("settings")}1426                  onOpenExport={() => {1427                    setReturnToPanic(true);1428                    setView("export");1429                  }}1430                  onDestroyed={async () => {1431                    clearCanarySuppressions();1432                    setEntries([]);1433                    setSelectedEntry(null);1434                    setEditEntry(undefined);1435                    setHasVault(false);1436                    setOnboarded(false);1437                    setProStatus(false);1438                    setPendingPasskey(null);1439                    setUpgradeHighlight(null);1440                    setSettingsInitialSection(null);1441                    setPendingProNotice(null);1442                    setPendingCelebration(false);1443                    setReturnToPanic(false);1444                    setContentPhase("idle");1445                    setView("lock");1446                    void clearInMemoryVault();1447                  }}1448                />1449              </Suspense>1450            </Show>1451            <Show when={view() === "health"}>1452              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1453                <HealthView1454                  entries={entries()}1455                  isPro={proStatus()}1456                  onFixEntry={handleFixEntry}1457                  onBack={() => setView("settings")}1458                  onRefresh={loadEntries}1459                />1460              </Suspense>1461            </Show>1462            <Show when={view() === "trash"}>1463              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1464                <TrashView1465                  onBack={() => setView("list")}1466                  onRestored={() => void loadEntries()}1467                />1468              </Suspense>1469            </Show>1470            <Show when={restoreView() === "file"}>1471              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1472                <ImportView1473                  mode="onboarding"1474                  replacesExistingVault={true}1475                  onBack={() => setRestoreView(null)}1476                  onImport={async () => {1477                    setRestoreView(null);1478                    await handleUnlock();1479                  }}1480                />1481              </Suspense>1482            </Show>1483            </section>1484            </Show>1485          </Show>1486        </Show>1487      </Show>1488      <Show when={showShortcutsHelp()}>1489        <ShortcutsHelpOverlay1490          bindings={shortcutBindings()}1491          onClose={() => setShowShortcutsHelp(false)}1492        />1493      </Show>1494    </div>1495  );1496}14971498function ShortcutsHelpOverlay(props: {1499  bindings: Record<ShortcutAction, Keybinding>;1500  onClose: () => void;1501}) {1502  return (1503    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1504      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1505        <div class="peach-shortcuts-help-head">1506          <h3>Keyboard shortcuts</h3>1507          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1508        </div>1509        <div class="peach-shortcuts-help-body">1510          <p class="peach-settings-sub-label">ENTRY VIEW</p>1511          <ul>1512            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1513              <li>1514                <span>{d.label}</span>1515                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1516              </li>1517            ))}1518          </ul>1519          <p class="peach-settings-sub-label">ANYWHERE</p>1520          <ul>1521            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1522              <li>1523                <span>{d.label}</span>1524                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1525              </li>1526            ))}1527          </ul>1528        </div>1529      </div>1530    </div>1531  );1532}