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        const now = Date.now();353        if (now - lastBackExitAt < 1600) {354          void CapacitorApp.exitApp();355          return;356        }357        lastBackExitAt = now;358        setBackExitNotice(true);359        window.setTimeout(() => setBackExitNotice(false), 1400);360        return;361      }362      default:363        void CapacitorApp.exitApp();364    }365  }366367  onMount(async () => {368    if (Capacitor.isNativePlatform()) {369      document.body.classList.add("peach-mobile");370      document.documentElement.classList.add("peach-mobile-root");371      void applyMobileStatusBar(theme());372373      // Window Size Class detection — compact/medium/expanded matches Android's374      // breakpoints. Drives two-pane layout on Z Fold inner display + tablets.375      const updateLayoutClass = () => {376        const w = window.innerWidth;377        const cls = document.body.classList;378        cls.remove("layout-compact", "layout-medium", "layout-expanded");379        const next: "compact" | "medium" | "expanded" =380          w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";381        cls.add(`layout-${next}`);382        setLayoutClass(next);383      };384      updateLayoutClass();385      window.addEventListener("resize", updateLayoutClass);386387      // Foldable posture (Z Fold tabletop / book) — set data-posture and a388      // CSS variable for hinge height so layouts can pad away from the crease.389      const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {390        document.body.setAttribute("data-posture", e.posture);391        document.documentElement.style.setProperty(392          "--peach-hinge-height",393          `${e.hingeHeight}px`,394        );395      });396397      const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {398        document.body.style.paddingBottom = `${info.keyboardHeight}px`;399      });400      const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {401        document.body.style.paddingBottom = "0";402      });403      const backButton = await CapacitorApp.addListener("backButton", () => {404        handleBackNavigation();405      });406407      let touchStartX = 0;408      let touchStartY = 0;409      const handleTouchStart = (event: TouchEvent) => {410        const touch = event.touches[0];411        if (!touch) return;412        touchStartX = touch.clientX;413        touchStartY = touch.clientY;414      };415      const handleTouchEnd = (event: TouchEvent) => {416        const touch = event.changedTouches[0];417        if (!touch) return;418        const dx = touch.clientX - touchStartX;419        const dy = Math.abs(touch.clientY - touchStartY);420        if (touchStartX < 30 && dx > 60 && dy < 40) {421          handleBackNavigation();422        }423      };424      document.addEventListener("touchstart", handleTouchStart, { passive: true });425      document.addEventListener("touchend", handleTouchEnd, { passive: true });426427      onCleanup(() => {428        void keyboardShow.remove();429        void keyboardHide.remove();430        void backButton.remove();431        stopFold();432        window.removeEventListener("resize", updateLayoutClass);433        document.removeEventListener("touchstart", handleTouchStart);434        document.removeEventListener("touchend", handleTouchEnd);435        document.body.style.paddingBottom = "0";436      });437    }438439    // Resolve the active tab in parallel with the unlock/has-vault queries —440    // openVault()'s smart selection picks the entry matching currentTabDomain,441    // so it must be populated before openVault runs. Previously refreshCurrentTab442    // ran after openVault, leaving popups that booted already-unlocked falling443    // back to loadedEntries[0] instead of the domain match.444    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([445      sendMessage({ type: "IS_UNLOCKED" }),446      sendMessage({ type: "HAS_VAULT" }),447      refreshCurrentTab(),448      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),449    ]);450    const unlocked = Boolean(451      (unlockResponse as { ok: boolean; data?: boolean }).data452    );453    const vaultExists = Boolean(454      (vaultResponse as { ok: boolean; data?: boolean }).data455    );456    const shouldCelebrate = Boolean(457      (celebrationResponse as { ok: boolean; data?: boolean }).data458    );459    setHasVault(vaultExists);460    // Onboarding gate — show once on first install, never again. Existing461    // users (vaultExists) are auto-marked onboarded so they skip the flow462    // even on the very first popup open after this feature ships.463    try {464      const onboarded = await storageGet("peach_onboarded");465      const seen = onboarded === "true";466      if (vaultExists && !seen) {467        await storageSet("peach_onboarded", "true");468        setOnboarded(true);469      } else {470        setOnboarded(seen);471      }472    } catch {473      // storage unavailable — default to onboarded so we never block boot474      setOnboarded(true);475    }476    await Promise.all([restoreSettings(), refreshProStatus()]);477478    // Mark played before any animation so a crash mid-sequence never replays it479    if (shouldCelebrate) {480      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });481    }482483    if (unlocked && vaultExists) {484      await openVault(undefined, shouldCelebrate);485      if (shouldCelebrate) {486        setView("pro-celebration");487        window.setTimeout(() => setView("entry"), 2500);488      }489    } else if (shouldCelebrate) {490      setPendingCelebration(true);491    }492493    await refreshPendingPasskey();494    await refreshIncomingPairing();495    await consumeRecentPairing();496497    // Track tab changes so smart sort stays current498    if (!Capacitor.isNativePlatform()) {499      const handleTabActivated = () => { void refreshCurrentTab(); };500      const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {501        if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();502      };503      try {504        chrome.tabs.onActivated.addListener(handleTabActivated);505        chrome.tabs.onUpdated.addListener(handleTabUpdated);506        onCleanup(() => {507          chrome.tabs.onActivated.removeListener(handleTabActivated);508          chrome.tabs.onUpdated.removeListener(handleTabUpdated);509        });510      } catch {511        // tabs permission may not be available in all contexts512      }513    }514515    setBootState("ready");516    document.getElementById("pre-lock")?.setAttribute("hidden", "true");517518    // Check for pending session-storage notices from the background.519    if (!Capacitor.isNativePlatform() && chrome.storage.session) {520      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as521        Record<string, { feature?: string } | undefined>;522      if (session.pendingProGate?.feature) {523        await chrome.storage.session.remove("pendingProGate");524        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);525        setSettingsInitialSection("license");526        setView("settings");527      } else if (session.pendingProNotice?.feature) {528        await chrome.storage.session.remove("pendingProNotice");529        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });530      }531    }532533    if (Capacitor.getPlatform() === "android") {534      try {535        const { pending } = await Autofill.checkPendingSave();536        if (pending) {537          const parsed = JSON.parse(pending) as {538            domain?: string;539            username?: string;540            password?: string;541          };542          if (parsed.username && parsed.password) {543            setPendingAutofillSave({544              domain: parsed.domain ?? "",545              username: parsed.username,546              password: parsed.password,547            });548          }549        }550      } catch {551        // Pending autofill saves are opportunistic; boot must continue.552      }553    }554555    // Warm the lazy-module cache so the Suspense fallback never shows for556    // locally-bundled extension views. Without this, the first navigation to557    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which558    // covers the shell and absorbs clicks on the header.559    void Promise.all([560      import("./views/Settings").catch(() => {}),561      import("./views/Import").catch(() => {}),562      import("./views/Export").catch(() => {}),563      import("./views/Health").catch(() => {}),564      import("./views/TrashView").catch(() => {}),565    ]);566567    // Lock-on-close via a persistent port. sendMessage in pagehide is568    // unreliable — the popup context tears down before the message lands.569    // The SW's onConnect listener locks when this port disconnects.570    if (!Capacitor.isNativePlatform()) {571      let popupPort: chrome.runtime.Port | null = null;572      try {573        popupPort = chrome.runtime.connect({ name: "popup" });574      } catch { /* extension context invalidated — ignore */ }575      onCleanup(() => { try { popupPort?.disconnect(); } catch {} });576577      // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale578      // entries instead of waiting for the next GET_ENTRIES round-trip.579      const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {580        if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();581        if (msg?.type === "PRO_GATE_TRIGGERED") {582          setUpgradeHighlight(msg.payload?.feature ?? null);583          setSettingsInitialSection("license");584          setView("settings");585        }586        if (msg?.type === "INCOMING_PAIRING_REQUEST") {587          void refreshIncomingPairing();588        }589      };590      chrome.runtime.onMessage.addListener(handleRuntimeMessage);591      onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));592    }593594    // Action handlers — keyed by ShortcutAction. The dispatcher below tests595    // each binding against the event and runs the matching handler. New596    // shortcuts only need an entry here; the binding is data, not code.597    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {598      copy_password: () => {599        const entry = selectedEntry();600        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });601      },602      copy_username: () => {603        const entry = selectedEntry();604        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });605      },606      copy_totp: () => {607        const entry = selectedEntry();608        if (!entry?.totp) return;609        try {610          const { code } = generateTOTP(entry.totp);611          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });612        } catch { /* invalid TOTP secret — silent */ }613      },614      new_entry: () => handleAdd(),615      edit_entry: () => {616        const entry = selectedEntry();617        // Passkey-only entries have nothing user-editable in the Add view; the618        // passkey credential itself is opaque. Skip the edit action for them.619        if (!entry || isPasskeyOnly(entry)) return;620        setEditEntry(entry);621        setView("add");622      },623      delete_entry: () => {624        if (selectedEntry()) void handleDelete();625      },626      lock_vault: () => { void handleHeaderLock(); },627      search: () => openList(),628      nav_prev: () => navigate(-1),629      nav_next: () => navigate(1),630      jump_vault: () => setView("entry"),631      jump_settings: () => setView("settings"),632      show_help: () => setShowShortcutsHelp(true),633    };634635    // Actions that are MEANINGFUL only on the entry-detail view. Outside636    // that scope the binding is dormant — pressing C while on Settings637    // does nothing rather than awkwardly copying from a now-stale entry.638    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([639      "copy_password", "copy_username", "copy_totp",640      "edit_entry", "delete_entry", "nav_prev", "nav_next",641    ]);642643    const handleGlobalKeydown = (e: KeyboardEvent) => {644      const v = view();645      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"646        || (e.target as HTMLElement)?.tagName === "TEXTAREA"647        || (e.target as HTMLElement)?.isContentEditable;648      const isPrintableSearchKey =649        e.key.length === 1 &&650        !e.ctrlKey &&651        !e.metaKey &&652        !e.altKey;653654      // No input during the pro celebration sequence655      if (v === "pro-celebration") return;656657      // Help dialog: Escape closes it; eat the keypress so it doesn't658      // also navigate away from the underlying view.659      if (showShortcutsHelp()) {660        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }661        return;662      }663664      // ── Escape — fixed binding (not user-customizable) ───────665      if (e.key === "Escape") {666        if (v === "list") { setView("entry"); return; }667        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }668        if (v === "sync-pairing") { setView("settings"); return; }669      }670671      // Bindings-driven dispatch. Single-key letter bindings without672      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is673      // inside an input, so users can type into search/forms normally.674      const bindings = shortcutBindings();675      for (const action of Object.keys(bindings) as ShortcutAction[]) {676        const binding = bindings[action];677        if (!matchKeybinding(e, binding)) continue;678        // Single-key letter bindings need the input-focus guard. Bindings679        // with any modifier are safe — Ctrl+L doesn't conflict with typing.680        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;681        if (!hasMod && binding.key.length === 1 && inInput) return;682        if (ENTRY_SCOPED.has(action) && v !== "entry") return;683        e.preventDefault();684        SHORTCUT_HANDLERS[action]?.();685        return;686      }687688      // Type-to-search: in entry view, any printable key not consumed by a689      // shortcut above primes the list search seed and opens the list.690      // Kept outside the bindings table because it's a behavior, not a691      // discrete action — every printable character can trigger it.692      if (v === "entry" && !inInput && isPrintableSearchKey) {693        e.preventDefault();694        primeListSearch(e.key);695        return;696      }697    };698    window.addEventListener("keydown", handleGlobalKeydown);699    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));700701    if (!Capacitor.isNativePlatform()) {702      const handleStorageChanged = (703        changes: Record<string, chrome.storage.StorageChange>,704        areaName: string705      ) => {706        if (areaName !== "session") return;707        if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {708          return;709        }710        void refreshPendingPasskey();711      };712      chrome.storage.onChanged.addListener(handleStorageChanged);713      onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));714    }715  });716717  async function refreshCurrentTab() {718    if (Capacitor.isNativePlatform()) return;719    try {720      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });721      const url = tab?.url ?? tab?.pendingUrl ?? "";722      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");723      // Skip extension/internal URLs — adding chrome://, about:, or724      // chrome-extension:// to a vault entry is never what the user wants.725      const isWebUrl = /^https?:\/\//i.test(url);726      setCurrentTabUrl(isWebUrl ? url : "");727    } catch {728      setCurrentTabDomain("");729      setCurrentTabUrl("");730    }731  }732733  async function refreshPendingPasskey() {734    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });735    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);736  }737738  async function refreshIncomingPairing() {739    const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });740    const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;741    setIncomingPair(data);742  }743744  // Drains a single one-shot record left by the background when 6-digit745  // pairing succeeded while the popup was closed (or the SyncPairing view was746  // navigated away from before it could show its own success card).747  async function consumeRecentPairing() {748    const res = await sendMessage({ type: "GET_RECENT_PAIRING" });749    const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;750    if (data?.deviceName) {751      setRecentPairToast(data.deviceName);752      window.setTimeout(() => setRecentPairToast(null), 4000);753    }754  }755756  async function respondIncomingPairing(accept: boolean) {757    if (incomingPairResponding()) return;758    setIncomingPairResponding(true);759    await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });760    setIncomingPair(null);761    setIncomingPairResponding(false);762  }763764  // 60-second expiry ticker for the incoming pairing banner. Re-runs when a765  // request appears, ticks once per second, and clears the banner if the766  // request expires while the popup is open.767  createEffect(() => {768    const req = incomingPair();769    if (!req) return;770    const handle = window.setInterval(() => {771      setIncomingPairTick((tick) => tick + 1);772      if (Date.now() >= req.expiresAt) {773        setIncomingPair(null);774        window.clearInterval(handle);775      }776    }, 1000);777    onCleanup(() => window.clearInterval(handle));778  });779780  async function refreshProStatus() {781    const res = await sendMessage({ type: "GET_LICENSE" });782    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;783    setProStatus(Boolean(data?.valid && data?.plan === "pro"));784  }785786  async function restoreSettings() {787    const res = await sendMessage({ type: "GET_SETTINGS" });788    const s = (res as { ok: boolean; data?: any }).data ?? {};789    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {790      setAccentColor(s.accentColor as AccentColorName);791    }792    setTheme(s.theme === "light" ? "light" : "dark");793    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {794      setDisplayFont(s.displayFont);795    } else {796      setDisplayFont("cormorant-italic");797    }798    if (s.textSize === "small" || s.textSize === "large") {799      setTextSize(s.textSize);800    } else {801      setTextSize("medium");802    }803    setReduceMotion(s.reduceMotion === true);804    setLockOnClose(s.lockOnClose !== false);805    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);806    setBreachMonitoring(s.breachMonitoring !== false);807    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);808    setShortcutBindings(getActiveBindings(s.shortcuts));809  }810811  async function applyTrashRetention(days: number) {812    setTrashRetentionDays(days);813    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });814  }815816  function primeListSearch(seed: string) {817    setListSearchSeed(seed);818    setListSearchSeedNonce((value) => value + 1);819    openList();820  }821822  // Returns null when it detected a dead service worker and triggered handleLock().823  // Callers that need to abort (e.g. openVault) check for null rather than relying824  // on view() state, which may already be "lock" during normal startup flows.825  async function loadEntries(): Promise<VaultEntry[] | null> {826    const response = await sendMessage({ type: "GET_ENTRIES" });827    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };828829    // If the service worker was killed and restarted (MV3 workers sleep), the830    // vault will be locked again — or the worker may not respond at all.831    // In either case, send the user to the lock screen rather than silently832    // showing an empty vault (which looks like data loss).833    if (!res.ok) {834      if (835        res.error === "vault locked" ||836        res.error === "SERVICE_WORKER_UNAVAILABLE" ||837        (typeof res.error === "string" && res.error.includes("locked"))838      ) {839        handleLock();840        return null; // signal emergency lock to callers841      }842      return [];843    }844845    const loadedEntries = res.data ?? [];846    setEntries(loadedEntries);847    void publishInMemoryVault(loadedEntries);848849    const currentId = selectedEntry()?.id;850    if (currentId) {851      setSelectedEntry(852        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null853      );854    }855856    return loadedEntries;857  }858859  async function openVault(preferredEntryId?: string, skipViewSet = false) {860    // Purge expired trash entries on each vault open861    const retDays = trashRetentionDays();862    if (retDays > 0) {863      void sendMessage({864        type: "PURGE_EXPIRED_TRASH",865        payload: { retentionMs: retDays * 86_400_000 },866      });867    }868869    const loadedEntries = await loadEntries();870871    // null means loadEntries() triggered an emergency lock (service worker died).872    // Don't override it by setting view back to "entry" with an empty vault.873    if (loadedEntries === null) return;874875    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry876    let nextSelectedEntry: VaultEntry | null = null;877    if (preferredEntryId) {878      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;879    }880    if (!nextSelectedEntry) {881      const tabDomain = currentTabDomain();882      if (tabDomain) {883        nextSelectedEntry = loadedEntries.find((e) => {884          if (e.type === "login") {885            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;886            for (const extra of e.additionalUrls ?? []) {887              if ((extractDomain(extra) ?? "") === tabDomain) return true;888            }889            return false;890          }891          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;892          return false;893        }) ?? null;894      }895    }896    nextSelectedEntry ??= loadedEntries[0] ?? null;897898    setSelectedEntry(nextSelectedEntry);899    setEditEntry(undefined);900    setContentTransitioning(false);901    setContentPhase("enter");902    queueContentIdle();903    if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");904  }905906  async function handleUnlock() {907    setHasVault(true);908    triggerPipPulse();909    await restoreSettings();910    const celebrating = pendingCelebration();911    if (celebrating) {912      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });913      setPendingCelebration(false);914    }915    await openVault(undefined, celebrating);916    if (celebrating) {917      setView("pro-celebration");918      window.setTimeout(() => setView("entry"), 2500);919    }920  }921922  function handleSelectEntry(entry: VaultEntry) {923    setSelectedEntry(entry);924    setContentTransitioning(false);925    setContentPhase("enter");926    queueContentIdle();927    // Two-pane layouts (Z Fold inner display, tablets, DeX) keep the list928    // mounted on the left and only update the detail pane on selection.929    if (!isTwoPaneLayout()) setView("entry");930  }931932  function handleAdd() {933    setEditEntry(undefined);934    setView("add");935  }936937  async function handleDelete() {938    const entry = selectedEntry();939    if (!entry) return;940    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });941    const loadedEntries = await loadEntries() ?? [];942    setSelectedEntry(loadedEntries[0] ?? null);943    setView("entry");944  }945946  async function handleDeleteMany(ids: string[]) {947    for (const id of ids) {948      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });949    }950    const loadedEntries = await loadEntries() ?? [];951    // If the currently selected entry was among the deleted, move to first952    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);953    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);954  }955956  async function handleSave(savedEntry?: VaultEntry) {957    await openVault(savedEntry?.id);958  }959960  async function savePendingAutofill() {961    const pending = pendingAutofillSave();962    if (!pending) return;963    const domain = pending.domain.trim();964    const response = await sendMessage({965      type: "ADD_ENTRY",966      payload: {967        type: "login",968        title: domain || "Saved login",969        url: domain ? `https://${domain}` : "",970        username: pending.username,971        password: pending.password,972      },973    });974    if ((response as { ok: boolean }).ok) {975      setPendingAutofillSave(null);976      await openVault((response as { data?: VaultEntry }).data?.id);977    }978  }979980  function handleBack() {981    setEditEntry(undefined);982    setView("entry");983  }984985  function handleLock() {986    clearCanarySuppressions();987    setEntries([]);988    setSelectedEntry(null);989    setEditEntry(undefined);990    setContentPhase("idle");991    setView("lock");992    void clearInMemoryVault();993  }994995  function handleHeaderLock() {996    // Lock the UI immediately — do not wait for the service worker. If the SW997    // is dead or slow, the 5s sendMessage timeout would create a dead-click998    // window. The SW will lock on its next wake regardless.999    handleLock();1000    void sendMessage({ type: "LOCK" });1001  }10021003  function navigate(dir: number) {1004    const allEntries = entries();1005    if (allEntries.length === 0) return;1006    const currentIndex = selectedIndex();1007    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1008    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1009    setSelectedEntry(allEntries[nextIndex]);1010    setContentPhase("enter");1011    queueContentIdle();1012  }10131014  function openList() {1015    setView("list");1016  }10171018  function openHealth() {1019    setEditEntry(undefined);1020    setView("health");1021  }10221023  function handleOpenFileImport() {1024    setRestoreView("file");1025  }10261027  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10281029  function handleFixEntry(entry: VaultEntry) {1030    // Passkey-only entries have no editable surface — drop into view mode1031    // instead of opening the Add editor.1032    if (isPasskeyOnly(entry)) {1033      setSelectedEntry(entry);1034      setView("entry");1035      return;1036    }1037    setEditEntry(entry);1038    setView("add");1039  }10401041  const isLockedView = () => view() === "lock";10421043  async function handleApprovePendingPasskey() {1044    const pending = pendingPasskey();1045    if (!pending) return;1046    if (pending.browser === "firefox" && pending.type === "create") {1047      // Firefox path: popup itself runs navigator.credentials.create. The1048      // background never approved/rejected anything — it just stashed1049      // intent. After the popup-driven flow resolves (success or cancel),1050      // clear the pending key so the popup doesn't re-show this on next open.1051      const { createFirefoxPasskey } = await import("./firefox-passkey");1052      const result = await createFirefoxPasskey(pending);1053      if (!result.ok) {1054        console.warn("Peach Firefox passkey create failed:", result.error);1055      }1056      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1057      setPendingPasskey(null);1058      void loadEntries();1059      return;1060    }1061    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1062    setPendingPasskey(null);1063  }10641065  async function handleDenyPendingPasskey() {1066    const pending = pendingPasskey();1067    if (!pending) return;1068    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1069    setPendingPasskey(null);1070  }10711072  function queueContentIdle(after?: () => void) {1073    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1074    contentPhaseTimer = window.setTimeout(() => {1075      setContentPhase("idle");1076      after?.();1077    }, ENTRY_TRANSITION_SETTLE_MS);1078  }10791080  return (1081    <div class="popup-root peach-theme">1082      <Show when={bootState() === "ready"}>1083        <Show1084          when={!pendingPasskey()}1085          fallback={1086            <PasskeyApprovalView1087              type={pendingPasskey()!.type}1088              rpName={pendingPasskey()!.rpName}1089              rpId={pendingPasskey()!.rpId}1090              userName={pendingPasskey()!.userName}1091              userDisplayName={pendingPasskey()!.userDisplayName}1092              onApprove={handleApprovePendingPasskey}1093              onDeny={handleDenyPendingPasskey}1094            />1095          }1096        >1097          <Show1098            when={!isLockedView()}1099            fallback={1100              <Show1101                when={onboarded() || hasVault()}1102                fallback={1103                  <Onboarding1104                    onComplete={() => { setOnboarded(true); }}1105                  />1106                }1107              >1108                <LockView1109                  hasVault={hasVault()}1110                  ready={bootState() === "ready"}1111                  isPro={proStatus()}1112                  onUnlock={handleUnlock}1113                  onBackToOnboarding={1114                    !hasVault()1115                      ? () => setOnboarded(false)1116                      : undefined1117                  }1118                />1119              </Show>1120            }1121          >1122            <Show1123              when={view() !== "pro-celebration"}1124              fallback={<ProCelebration />}1125            >1126            <section1127              id="hd-shell"1128              class="peach-shell peach-theme"1129              data-surface="extension"1130            >1131            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1132              <PeachWordmark unlocked />1133              <div class="peach-chrome-actions">1134                <button1135                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1136                  onClick={() => setView("settings")}1137                  aria-label="Settings"1138                >1139                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1140                    <path1141                      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"1142                      stroke="currentColor"1143                      stroke-width="0.9"1144                    />1145                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1146                  </svg>1147                </button>1148                <button1149                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1150                  onClick={handleAdd}1151                  aria-label="New entry"1152                >1153                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1154                    <path d="M5.5 1v9M1 5.5h9" />1155                  </svg>1156                </button>1157                <button1158                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1159                  onClick={() => void handleHeaderLock()}1160                  aria-label="Lock vault"1161                >1162                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1163                    <path1164                      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"1165                      stroke="currentColor"1166                      stroke-width="1"1167                      stroke-linecap="round"1168                    />1169                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1170                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1171                  </svg>1172                </button>1173              </div>1174            </header>11751176            <Show when={recentPairToast()}>1177              <div class="peach-pair-toast" role="status" aria-live="polite">1178                Paired with <strong>{recentPairToast()}</strong>1179              </div>1180            </Show>11811182            <Show when={incomingPair() !== null}>1183              <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1184                <div class="peach-pair-request-header">1185                  <span class="peach-pair-request-mark">🍑</span>1186                  <span class="peach-pair-request-title">Pairing request</span>1187                  <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1188                </div>1189                <p class="peach-pair-request-body">1190                  <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1191                </p>1192                <div class="peach-pair-request-actions">1193                  <button1194                    class="peach-btn peach-btn--ghost peach-btn--sm"1195                    onClick={() => void respondIncomingPairing(false)}1196                    disabled={incomingPairResponding()}1197                  >1198                    Reject1199                  </button>1200                  <button1201                    class="peach-btn peach-btn--accent peach-btn--sm"1202                    onClick={() => void respondIncomingPairing(true)}1203                    disabled={incomingPairResponding()}1204                  >1205                    Accept1206                  </button>1207                </div>1208              </div>1209            </Show>12101211            <Show when={pendingProNotice() !== null}>1212              <div class="peach-pro-notice-banner">1213                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1214                <button1215                  class="peach-pro-notice-link"1216                  onClick={() => {1217                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1218                    setSettingsInitialSection("license");1219                    setView("settings");1220                    setPendingProNotice(null);1221                  }}1222                >1223                  Learn more →1224                </button>1225                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1226              </div>1227            </Show>12281229            <Show when={pendingAutofillSave() !== null && !isLockedView()}>1230              <div class="peach-pro-notice-banner peach-autofill-save-banner">1231                <span>1232                  Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1233                </span>1234                <button1235                  class="peach-pro-notice-link"1236                  onClick={() => void savePendingAutofill()}1237                >1238                  Save1239                </button>1240                <button1241                  class="peach-pro-notice-close"1242                  onClick={() => setPendingAutofillSave(null)}1243                  aria-label="Dismiss"1244                >1245                  x1246                </button>1247              </div>1248            </Show>12491250            <Show when={backExitNotice()}>1251              <div class="peach-pro-notice-banner peach-back-exit-banner">1252                <span>Tap back again to exit Peach</span>1253              </div>1254            </Show>12551256            <div class="peach-shell-body">1257              <div class="peach-shell-veil"></div>1258              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1259                <EntryView1260                  entry={selectedEntry()}1261                  currentTabUrl={currentTabUrl()}1262                  onDelete={handleDelete}1263                  onFieldSaved={() => void loadEntries()}1264                  onEscape={() => { setView("list"); }}1265                />1266              </div>12671268              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1269                <div class="peach-search-bar">1270                  <span class="peach-search-icon">1271                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1272                      <circle cx="5.5" cy="5.5" r="3.75" />1273                      <path d="M8.75 8.75L11.5 11.5" />1274                    </svg>1275                  </span>1276                  <input1277                    class="peach-search-input"1278                    placeholder="search vault..."1279                    autocomplete="off"1280                    aria-label="Search vault entries"1281                    onClick={openList}1282                    onFocus={openList}1283                    readOnly1284                  />1285                  <div class="peach-search-meta">1286                    <span class="peach-search-counter">1287                      {entries().length > 01288                        ? `${selectedIndex() + 1} / ${entries().length}`1289                        : "0 entries"}1290                    </span>1291                    <span class="peach-divider"></span>1292                    <button1293                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1294                      onClick={() => navigate(-1)}1295                      aria-label="Previous entry"1296                      disabled={entries().length === 0}1297                    >1298                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1299                        <path d="M5 1L1 5.5L5 10" />1300                      </svg>1301                    </button>1302                    <button1303                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1304                      onClick={() => navigate(1)}1305                      aria-label="Next entry"1306                      disabled={entries().length === 0}1307                    >1308                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1309                        <path d="M1 1L5 5.5L1 10" />1310                      </svg>1311                    </button>1312                  </div>1313                </div>1314              </section>1315            </div>13161317            <Show when={view() === "list"}>1318              <VaultView1319                entries={entries()}1320                selectedEntryId={selectedEntry()?.id}1321                currentTabDomain={currentTabDomain()}1322                sortOrder={sortOrder()}1323                searchSeed={listSearchSeed()}1324                searchSeedNonce={listSearchSeedNonce()}1325                onSelectEntry={handleSelectEntry}1326                onSortOrderChange={setSortOrder}1327                onAdd={handleAdd}1328                onClose={() => setView("entry")}1329                onOpenTrash={() => setView("trash")}1330                onDeleteMany={handleDeleteMany}1331              />1332            </Show>1333            <Show when={view() === "settings"}>1334              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1335                <SettingsView1336                  entriesCount={entries().length}1337                  breachMonitoring={breachMonitoring()}1338                  lockOnClose={lockOnClose()}1339                  autoLockMinutes={autoLockMinutes()}1340                  trashRetentionDays={trashRetentionDays()}1341                  theme={theme()}1342                  accentColor={accentColor()}1343                  displayFont={displayFont()}1344                  textSize={textSize()}1345                  reduceMotion={reduceMotion()}1346                  isPro={proStatus()}1347                  initialSection={settingsInitialSection()}1348                  upgradeHighlight={upgradeHighlight()}1349                  onBreachMonitoringChange={applyBreachMonitoring}1350                  onLockOnCloseChange={applyLockOnClose}1351                  onAutoLockMinutesChange={applyAutoLockMinutes}1352                  onTrashRetentionChange={applyTrashRetention}1353                  onThemeChange={applyTheme}1354                  onAccentChange={applyAccentColor}1355                  onDisplayFontChange={applyDisplayFont}1356                  onTextSizeChange={applyTextSize}1357                  onReduceMotionChange={applyReduceMotion}1358                  shortcuts={shortcutBindings()}1359                  onShortcutsChange={applyShortcuts}1360                  onLicenseChange={async () => {1361                    // After activation/deactivation: refresh Pro status,1362                    // re-load entries (defensive — signal state can drift if1363                    // the SW briefly restarts during the verify+save round-trip),1364                    // and replay the Pro celebration if this is the first time1365                    // a valid Pro license is seen on this device.1366                    await refreshProStatus();1367                    void loadEntries();1368                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1369                    if ((should as { ok: boolean; data?: boolean }).data === true) {1370                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1371                      setView("pro-celebration");1372                      window.setTimeout(() => setView("settings"), 2500);1373                    }1374                  }}1375                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1376                  onImport={() => setView("import")}1377                  onExport={() => setView("export")}1378                  onBreachCheckDone={loadEntries}1379                  onOpenHealth={openHealth}1380                  onOpenSyncPairing={() => setView("sync-pairing")}1381                  onOpenFileImport={handleOpenFileImport}1382                  onOpenPanic={() => setView("panic")}1383                />1384              </Suspense>1385            </Show>1386            <Show when={view() === "sync-pairing"}>1387              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1388                <SyncPairingView1389                  onClose={() => setView("settings")}1390                  onPaired={() => { /* sync state refreshes when popup returns to Settings */ }}1391                />1392              </Suspense>1393            </Show>1394            <Show when={view() === "add"}>1395              <AddView1396                entry={editEntry()}1397                entries={entries()}1398                onBack={handleBack}1399                onSave={handleSave}1400              />1401            </Show>1402            <Show when={view() === "import"}>1403              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1404                <ImportView onBack={handleBack} onImport={handleSave} />1405              </Suspense>1406            </Show>1407            <Show when={view() === "export"}>1408              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1409                <ExportView1410                  onBack={() => {1411                    if (returnToPanic()) {1412                      setReturnToPanic(false);1413                      setView("panic");1414                    } else {1415                      handleBack();1416                    }1417                  }}1418                />1419              </Suspense>1420            </Show>1421            <Show when={view() === "panic"}>1422              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1423                <PanicView1424                  onCancel={() => setView("settings")}1425                  onOpenExport={() => {1426                    setReturnToPanic(true);1427                    setView("export");1428                  }}1429                  onDestroyed={async () => {1430                    clearCanarySuppressions();1431                    setEntries([]);1432                    setSelectedEntry(null);1433                    setEditEntry(undefined);1434                    setHasVault(false);1435                    setOnboarded(false);1436                    setProStatus(false);1437                    setPendingPasskey(null);1438                    setUpgradeHighlight(null);1439                    setSettingsInitialSection(null);1440                    setPendingProNotice(null);1441                    setPendingCelebration(false);1442                    setReturnToPanic(false);1443                    setContentPhase("idle");1444                    setView("lock");1445                    void clearInMemoryVault();1446                  }}1447                />1448              </Suspense>1449            </Show>1450            <Show when={view() === "health"}>1451              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1452                <HealthView1453                  entries={entries()}1454                  isPro={proStatus()}1455                  onFixEntry={handleFixEntry}1456                  onBack={() => setView("settings")}1457                  onRefresh={loadEntries}1458                />1459              </Suspense>1460            </Show>1461            <Show when={view() === "trash"}>1462              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1463                <TrashView1464                  onBack={() => setView("list")}1465                  onRestored={() => void loadEntries()}1466                />1467              </Suspense>1468            </Show>1469            <Show when={restoreView() === "file"}>1470              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1471                <ImportView1472                  mode="onboarding"1473                  replacesExistingVault={true}1474                  onBack={() => setRestoreView(null)}1475                  onImport={async () => {1476                    setRestoreView(null);1477                    await handleUnlock();1478                  }}1479                />1480              </Suspense>1481            </Show>1482            </section>1483            </Show>1484          </Show>1485        </Show>1486      </Show>1487      <Show when={showShortcutsHelp()}>1488        <ShortcutsHelpOverlay1489          bindings={shortcutBindings()}1490          onClose={() => setShowShortcutsHelp(false)}1491        />1492      </Show>1493    </div>1494  );1495}14961497function ShortcutsHelpOverlay(props: {1498  bindings: Record<ShortcutAction, Keybinding>;1499  onClose: () => void;1500}) {1501  return (1502    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1503      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1504        <div class="peach-shortcuts-help-head">1505          <h3>Keyboard shortcuts</h3>1506          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1507        </div>1508        <div class="peach-shortcuts-help-body">1509          <p class="peach-settings-sub-label">ENTRY VIEW</p>1510          <ul>1511            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1512              <li>1513                <span>{d.label}</span>1514                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1515              </li>1516            ))}1517          </ul>1518          <p class="peach-settings-sub-label">ANYWHERE</p>1519          <ul>1520            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1521              <li>1522                <span>{d.label}</span>1523                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1524              </li>1525            ))}1526          </ul>1527        </div>1528      </div>1529    </div>1530  );1531}