App.tsx

63.8 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  consumePendingAutofillRequest(): Promise<{50    pending: { domain: string; package: string } | null;51  }>;52  completeAutofillAuthentication(options: { domain: string }): Promise<void>;53}>("Autofill");5455type View =56  | "lock"57  | "pro-celebration"58  | "entry"59  | "list"60  | "add"61  | "settings"62  | "sync-pairing"63  | "import"64  | "export"65  | "health"66  | "trash"67  | "panic";68type BootState = "loading" | "ready";6970const canarySuppressMap = new Map<string, boolean>();71let shellRingAnimation: Animation | null = null;7273export function isCanarySuppressed(entryId: string): boolean {74  return canarySuppressMap.get(entryId) ?? false;75}7677export function setCanarySuppressed(entryId: string, suppressed: boolean): void {78  canarySuppressMap.set(entryId, suppressed);79}8081export function clearCanarySuppressions(): void {82  canarySuppressMap.clear();83}8485export function triggerRing() {86  const shell = document.getElementById("hd-shell");87  if (!shell) return;8889  shellRingAnimation?.cancel();90  shell.dataset.glowing = "true";91  const animation = shell.animate(92    [93      { offset: 0, boxShadow: "var(--copy-shadow)" },94      {95        offset: 0.3,96        boxShadow:97          "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)",98      },99      { offset: 1, boxShadow: "var(--copy-shadow)" },100    ],101    {102      duration: 720,103      easing: "cubic-bezier(0.4, 0, 0.2, 1)",104      fill: "none",105    }106  );107  shellRingAnimation = animation;108  const clearGlow = () => {109    if (shellRingAnimation !== animation) return;110    shellRingAnimation = null;111    delete shell.dataset.glowing;112  };113  animation.onfinish = clearGlow;114  animation.oncancel = clearGlow;115}116117118export function triggerPipPulse() {119  const pip = document.querySelector(".peach-status-pip, .pip");120  if (pip) {121    pip.classList.remove("pulse");122    void (pip as HTMLElement).offsetWidth;123    pip.classList.add("pulse");124  }125}126127const ENTRY_TRANSITION_EXIT_MS = 95;128const ENTRY_TRANSITION_SETTLE_MS = 135;129130export default function App() {131  const [bootState, setBootState] = createSignal<BootState>("loading");132  const [view, setView] = createSignal<View>("lock");133  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(134    "enter"135  );136  const [contentTransitioning, setContentTransitioning] = createSignal(false);137  const [layoutClass, setLayoutClass] = createSignal<"compact" | "medium" | "expanded">("compact");138  const isTwoPaneLayout = () => layoutClass() === "medium" || layoutClass() === "expanded";139  const [hasVault, setHasVault] = createSignal(false);140  const [onboarded, setOnboarded] = createSignal(true);141  const [entries, setEntries] = createSignal<VaultEntry[]>([]);142  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);143  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);144  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");145  const [theme, setTheme] = createSignal<"dark" | "light">("dark");146  const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");147  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");148  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);149  const [lockOnClose, setLockOnClose] = createSignal(true);150  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);151  const [breachMonitoring, setBreachMonitoring] = createSignal(true);152  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);153  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);154  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);155  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);156  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);157  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");158  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");159  // Cached Pro status — synchronous for render paths. Background's160  // GET_LICENSE is async, but render-time reads of isPro() must not block.161  // Refreshed at boot, after activate/deactivate, and whenever the popup162  // re-opens onto a stored license that has changed via another window.163  const [proStatus, setProStatus] = createSignal(false);164  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);165  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);166  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");167  const [listSearchSeed, setListSearchSeed] = createSignal("");168  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);169  const [pendingCelebration, setPendingCelebration] = createSignal(false);170  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);171  const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{172    domain: string;173    username: string;174    password: string;175  } | null>(null);176  const [pendingAutofillRequest, setPendingAutofillRequest] = createSignal<{177    domain: string;178    pkg: string;179  } | null>(null);180  const [backExitNotice, setBackExitNotice] = createSignal(false);181  const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);182  const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);183  const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);184  const [incomingPairTick, setIncomingPairTick] = createSignal(0);185  const incomingPairSecondsLeft = () => {186    void incomingPairTick();187    const req = incomingPair();188    if (!req) return 0;189    return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));190  };191  // True while Panic Step 1 has bounced the user out to Export. When Export's192  // onBack fires we return to Panic instead of Settings so the user keeps the193  // destruction context they came from.194  const [returnToPanic, setReturnToPanic] = createSignal(false);195196  let contentPhaseTimer: number | undefined;197  let lastBackExitAt = 0;198199  function applyAccent(name: AccentColorName) {200    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;201    const hex = color.hex;202    document.documentElement.style.setProperty("--peach-accent", hex);203    // Ambient surface variables — derived from the accent so surfaces shift204    // subtly toward the accent hue rather than staying fixed warm-brown.205    document.documentElement.style.setProperty("--peach-glow", hex + "2A");206    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");207    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");208  }209210  createEffect(() => {211    applyAccent(accentColor());212  });213214  createEffect(() => {215    document.documentElement.classList.toggle("light", theme() === "light");216    document.body.classList.toggle("light", theme() === "light");217    if (Capacitor.isNativePlatform()) {218      void applyMobileStatusBar(theme());219    }220  });221222  createEffect(() => {223    document.documentElement.setAttribute("data-display-font", displayFont());224  });225226  createEffect(() => {227    document.documentElement.setAttribute("data-text-size", textSize());228  });229230  createEffect(() => {231    if (reduceMotion()) {232      document.documentElement.setAttribute("data-motion", "reduce");233    } else {234      document.documentElement.removeAttribute("data-motion");235    }236  });237238  const selectedIndex = createMemo(() => {239    const currentId = selectedEntry()?.id;240    if (!currentId) return entries().length > 0 ? 0 : -1;241    return entries().findIndex((entry) => entry.id === currentId);242  });243244  async function applyAccentColor(name: AccentColorName) {245    applyAccent(name);246    setAccentColor(name);247    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });248  }249250  async function applyTheme(t: "dark" | "light") {251    setTheme(t);252    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });253  }254255  async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {256    setDisplayFont(f);257    await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });258  }259260  async function applyTextSize(size: "small" | "medium" | "large") {261    setTextSize(size);262    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });263  }264265  async function applyReduceMotion(enabled: boolean) {266    setReduceMotion(enabled);267    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });268  }269270  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload271  // small and lets future default updates land for actions the user hasn't272  // touched. Settings sends us the full active map; we filter and store.273  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {274    setShortcutBindings(active);275    const overrides: Record<string, Keybinding> = {};276    for (const action of Object.keys(active) as ShortcutAction[]) {277      const a = active[action];278      const d = DEFAULT_BINDINGS[action];279      const same = a.key === d.key280        && Boolean(a.ctrl)  === Boolean(d.ctrl)281        && Boolean(a.meta)  === Boolean(d.meta)282        && Boolean(a.shift) === Boolean(d.shift)283        && Boolean(a.alt)   === Boolean(d.alt);284      if (!same) overrides[action] = a;285    }286    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });287  }288289  async function applyLockOnClose(enabled: boolean) {290    setLockOnClose(enabled);291    await sendMessage({292      type: "UPDATE_SETTINGS",293      payload: { lockOnClose: enabled },294    });295  }296297  async function applyAutoLockMinutes(minutes: number) {298    setAutoLockMinutes(minutes);299    await sendMessage({300      type: "UPDATE_SETTINGS",301      payload: { autoLockMinutes: minutes },302    });303  }304305  async function applyBreachMonitoring(enabled: boolean) {306    setBreachMonitoring(enabled);307    await sendMessage({308      type: "UPDATE_SETTINGS",309      payload: { breachMonitoring: enabled },310    });311  }312313  async function applyMobileStatusBar(nextTheme: "dark" | "light") {314    try {315      await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });316      await StatusBar.setBackgroundColor({317        color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",318      });319    } catch {320      // Status bar APIs are available only in the native shell.321    }322  }323324  function handleBackNavigation() {325    if (showShortcutsHelp()) {326      setShowShortcutsHelp(false);327      return;328    }329    if (restoreView() !== null) {330      setRestoreView(null);331      return;332    }333334    switch (view()) {335      case "list":336        setView("entry");337        return;338      case "settings":339      case "add":340      case "import":341        handleBack();342        return;343      case "export":344        if (returnToPanic()) {345          setReturnToPanic(false);346          setView("panic");347        } else {348          handleBack();349        }350        return;351      case "health":352      case "panic":353      case "sync-pairing":354        setView("settings");355        return;356      case "trash":357        setView("list");358        return;359      case "entry":360      case "lock": {361        const now = Date.now();362        if (now - lastBackExitAt < 1600) {363          void CapacitorApp.exitApp();364          return;365        }366        lastBackExitAt = now;367        setBackExitNotice(true);368        window.setTimeout(() => setBackExitNotice(false), 1400);369        return;370      }371      default:372        void CapacitorApp.exitApp();373    }374  }375376  onMount(async () => {377    if (Capacitor.isNativePlatform()) {378      document.body.classList.add("peach-mobile");379      document.documentElement.classList.add("peach-mobile-root");380      void applyMobileStatusBar(theme());381382      // Window Size Class detection — compact/medium/expanded matches Android's383      // breakpoints. Drives two-pane layout on Z Fold inner display + tablets.384      const updateLayoutClass = () => {385        const w = window.innerWidth;386        const cls = document.body.classList;387        cls.remove("layout-compact", "layout-medium", "layout-expanded");388        const next: "compact" | "medium" | "expanded" =389          w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";390        cls.add(`layout-${next}`);391        setLayoutClass(next);392      };393      updateLayoutClass();394      window.addEventListener("resize", updateLayoutClass);395396      // launchMode=singleTask: a re-fired autofill auth intent triggers397      // onNewIntent in MainActivity, which dispatches this window event.398      // Re-consume so the lock screen surfaces the pending request.399      const onAutofillRequested = () => {400        void (async () => {401          try {402            const { pending } = await Autofill.consumePendingAutofillRequest();403            if (pending && (pending.domain || pending.package)) {404              setPendingAutofillRequest({405                domain: pending.domain || pending.package,406                pkg: pending.package || "",407              });408            }409          } catch { /* best effort */ }410        })();411      };412      window.addEventListener("peach:autofill-requested", onAutofillRequested);413414      // When Peach foregrounds, re-check for a pending autofill save (the user415      // may have submitted a form in another app while we were backgrounded)416      // and re-consume any new autofill request extras.417      const appState = await CapacitorApp.addListener("appStateChange", (state) => {418        if (!state.isActive) return;419        void (async () => {420          try {421            const { pending } = await Autofill.checkPendingSave();422            if (pending) {423              const parsed = JSON.parse(pending) as {424                domain?: string;425                username?: string;426                password?: string;427              };428              if (parsed.username && parsed.password) {429                setPendingAutofillSave({430                  domain: parsed.domain ?? "",431                  username: parsed.username,432                  password: parsed.password,433                });434              }435            }436          } catch { /* best effort */ }437          onAutofillRequested();438        })();439      });440441      // Foldable posture (Z Fold tabletop / book) — set data-posture and a442      // CSS variable for hinge height so layouts can pad away from the crease.443      const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {444        document.body.setAttribute("data-posture", e.posture);445        document.documentElement.style.setProperty(446          "--peach-hinge-height",447          `${e.hingeHeight}px`,448        );449      });450451      const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {452        document.body.style.paddingBottom = `${info.keyboardHeight}px`;453      });454      const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {455        document.body.style.paddingBottom = "0";456      });457      const backButton = await CapacitorApp.addListener("backButton", () => {458        handleBackNavigation();459      });460461      let touchStartX = 0;462      let touchStartY = 0;463      const handleTouchStart = (event: TouchEvent) => {464        const touch = event.touches[0];465        if (!touch) return;466        touchStartX = touch.clientX;467        touchStartY = touch.clientY;468      };469      const handleTouchEnd = (event: TouchEvent) => {470        const touch = event.changedTouches[0];471        if (!touch) return;472        const dx = touch.clientX - touchStartX;473        const dy = Math.abs(touch.clientY - touchStartY);474        if (touchStartX < 30 && dx > 60 && dy < 40) {475          handleBackNavigation();476        }477      };478      document.addEventListener("touchstart", handleTouchStart, { passive: true });479      document.addEventListener("touchend", handleTouchEnd, { passive: true });480481      onCleanup(() => {482        void keyboardShow.remove();483        void keyboardHide.remove();484        void backButton.remove();485        void appState.remove();486        stopFold();487        window.removeEventListener("peach:autofill-requested", onAutofillRequested);488        window.removeEventListener("resize", updateLayoutClass);489        document.removeEventListener("touchstart", handleTouchStart);490        document.removeEventListener("touchend", handleTouchEnd);491        document.body.style.paddingBottom = "0";492      });493    }494495    // Resolve the active tab in parallel with the unlock/has-vault queries —496    // openVault()'s smart selection picks the entry matching currentTabDomain,497    // so it must be populated before openVault runs. Previously refreshCurrentTab498    // ran after openVault, leaving popups that booted already-unlocked falling499    // back to loadedEntries[0] instead of the domain match.500    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([501      sendMessage({ type: "IS_UNLOCKED" }),502      sendMessage({ type: "HAS_VAULT" }),503      refreshCurrentTab(),504      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),505    ]);506    const unlocked = Boolean(507      (unlockResponse as { ok: boolean; data?: boolean }).data508    );509    const vaultExists = Boolean(510      (vaultResponse as { ok: boolean; data?: boolean }).data511    );512    const shouldCelebrate = Boolean(513      (celebrationResponse as { ok: boolean; data?: boolean }).data514    );515    setHasVault(vaultExists);516    // Onboarding gate — show once on first install, never again. Existing517    // users (vaultExists) are auto-marked onboarded so they skip the flow518    // even on the very first popup open after this feature ships.519    try {520      const onboarded = await storageGet("peach_onboarded");521      const seen = onboarded === "true";522      if (vaultExists && !seen) {523        await storageSet("peach_onboarded", "true");524        setOnboarded(true);525      } else {526        setOnboarded(seen);527      }528    } catch {529      // storage unavailable — default to onboarded so we never block boot530      setOnboarded(true);531    }532    await Promise.all([restoreSettings(), refreshProStatus()]);533534    // Mark played before any animation so a crash mid-sequence never replays it535    if (shouldCelebrate) {536      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });537    }538539    if (unlocked && vaultExists) {540      await openVault(undefined, shouldCelebrate);541      if (shouldCelebrate) {542        setView("pro-celebration");543        window.setTimeout(() => setView("entry"), 2500);544      }545    } else if (shouldCelebrate) {546      setPendingCelebration(true);547    }548549    await refreshPendingPasskey();550    await refreshIncomingPairing();551    await consumeRecentPairing();552553    // Track tab changes so smart sort stays current554    if (!Capacitor.isNativePlatform()) {555      const handleTabActivated = () => { void refreshCurrentTab(); };556      const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {557        if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();558      };559      try {560        chrome.tabs.onActivated.addListener(handleTabActivated);561        chrome.tabs.onUpdated.addListener(handleTabUpdated);562        onCleanup(() => {563          chrome.tabs.onActivated.removeListener(handleTabActivated);564          chrome.tabs.onUpdated.removeListener(handleTabUpdated);565        });566      } catch {567        // tabs permission may not be available in all contexts568      }569    }570571    setBootState("ready");572    document.getElementById("pre-lock")?.setAttribute("hidden", "true");573574    // Check for pending session-storage notices from the background.575    if (!Capacitor.isNativePlatform() && chrome.storage.session) {576      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as577        Record<string, { feature?: string } | undefined>;578      if (session.pendingProGate?.feature) {579        await chrome.storage.session.remove("pendingProGate");580        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);581        setSettingsInitialSection("license");582        setView("settings");583      } else if (session.pendingProNotice?.feature) {584        await chrome.storage.session.remove("pendingProNotice");585        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });586      }587    }588589    if (Capacitor.getPlatform() === "android") {590      try {591        const { pending } = await Autofill.checkPendingSave();592        if (pending) {593          const parsed = JSON.parse(pending) as {594            domain?: string;595            username?: string;596            password?: string;597          };598          if (parsed.username && parsed.password) {599            setPendingAutofillSave({600              domain: parsed.domain ?? "",601              username: parsed.username,602              password: parsed.password,603            });604          }605        }606      } catch {607        // Pending autofill saves are opportunistic; boot must continue.608      }609610      // Locked-vault autofill flow: was the activity opened via the autofill611      // auth intent? If so, surface "Unlock to fill X" and complete the auth612      // dataset once the vault unlocks.613      try {614        const { pending } = await Autofill.consumePendingAutofillRequest();615        if (pending && (pending.domain || pending.package)) {616          setPendingAutofillRequest({617            domain: pending.domain || pending.package,618            pkg: pending.package || "",619          });620        }621      } catch { /* best effort */ }622    }623624    // Warm the lazy-module cache so the Suspense fallback never shows for625    // locally-bundled extension views. Without this, the first navigation to626    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which627    // covers the shell and absorbs clicks on the header.628    void Promise.all([629      import("./views/Settings").catch(() => {}),630      import("./views/Import").catch(() => {}),631      import("./views/Export").catch(() => {}),632      import("./views/Health").catch(() => {}),633      import("./views/TrashView").catch(() => {}),634    ]);635636    // Lock-on-close via a persistent port. sendMessage in pagehide is637    // unreliable — the popup context tears down before the message lands.638    // The SW's onConnect listener locks when this port disconnects.639    if (!Capacitor.isNativePlatform()) {640      let popupPort: chrome.runtime.Port | null = null;641      try {642        popupPort = chrome.runtime.connect({ name: "popup" });643      } catch { /* extension context invalidated — ignore */ }644      onCleanup(() => { try { popupPort?.disconnect(); } catch {} });645646      // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale647      // entries instead of waiting for the next GET_ENTRIES round-trip.648      const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {649        if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();650        if (msg?.type === "PRO_GATE_TRIGGERED") {651          setUpgradeHighlight(msg.payload?.feature ?? null);652          setSettingsInitialSection("license");653          setView("settings");654        }655        if (msg?.type === "INCOMING_PAIRING_REQUEST") {656          void refreshIncomingPairing();657        }658      };659      chrome.runtime.onMessage.addListener(handleRuntimeMessage);660      onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));661    }662663    // Action handlers — keyed by ShortcutAction. The dispatcher below tests664    // each binding against the event and runs the matching handler. New665    // shortcuts only need an entry here; the binding is data, not code.666    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {667      copy_password: () => {668        const entry = selectedEntry();669        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });670      },671      copy_username: () => {672        const entry = selectedEntry();673        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });674      },675      copy_totp: () => {676        const entry = selectedEntry();677        if (!entry?.totp) return;678        try {679          const { code } = generateTOTP(entry.totp);680          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });681        } catch { /* invalid TOTP secret — silent */ }682      },683      new_entry: () => handleAdd(),684      edit_entry: () => {685        const entry = selectedEntry();686        // Passkey-only entries have nothing user-editable in the Add view; the687        // passkey credential itself is opaque. Skip the edit action for them.688        if (!entry || isPasskeyOnly(entry)) return;689        setEditEntry(entry);690        setView("add");691      },692      delete_entry: () => {693        if (selectedEntry()) void handleDelete();694      },695      lock_vault: () => { void handleHeaderLock(); },696      search: () => openList(),697      nav_prev: () => navigate(-1),698      nav_next: () => navigate(1),699      jump_vault: () => setView("entry"),700      jump_settings: () => setView("settings"),701      show_help: () => setShowShortcutsHelp(true),702    };703704    // Actions that are MEANINGFUL only on the entry-detail view. Outside705    // that scope the binding is dormant — pressing C while on Settings706    // does nothing rather than awkwardly copying from a now-stale entry.707    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([708      "copy_password", "copy_username", "copy_totp",709      "edit_entry", "delete_entry", "nav_prev", "nav_next",710    ]);711712    const handleGlobalKeydown = (e: KeyboardEvent) => {713      const v = view();714      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"715        || (e.target as HTMLElement)?.tagName === "TEXTAREA"716        || (e.target as HTMLElement)?.isContentEditable;717      const isPrintableSearchKey =718        e.key.length === 1 &&719        !e.ctrlKey &&720        !e.metaKey &&721        !e.altKey;722723      // No input during the pro celebration sequence724      if (v === "pro-celebration") return;725726      // Help dialog: Escape closes it; eat the keypress so it doesn't727      // also navigate away from the underlying view.728      if (showShortcutsHelp()) {729        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }730        return;731      }732733      // ── Escape — fixed binding (not user-customizable) ───────734      if (e.key === "Escape") {735        if (v === "list") { setView("entry"); return; }736        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }737        if (v === "sync-pairing") { setView("settings"); return; }738      }739740      // Bindings-driven dispatch. Single-key letter bindings without741      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is742      // inside an input, so users can type into search/forms normally.743      const bindings = shortcutBindings();744      for (const action of Object.keys(bindings) as ShortcutAction[]) {745        const binding = bindings[action];746        if (!matchKeybinding(e, binding)) continue;747        // Single-key letter bindings need the input-focus guard. Bindings748        // with any modifier are safe — Ctrl+L doesn't conflict with typing.749        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;750        if (!hasMod && binding.key.length === 1 && inInput) return;751        if (ENTRY_SCOPED.has(action) && v !== "entry") return;752        e.preventDefault();753        SHORTCUT_HANDLERS[action]?.();754        return;755      }756757      // Type-to-search: in entry view, any printable key not consumed by a758      // shortcut above primes the list search seed and opens the list.759      // Kept outside the bindings table because it's a behavior, not a760      // discrete action — every printable character can trigger it.761      if (v === "entry" && !inInput && isPrintableSearchKey) {762        e.preventDefault();763        primeListSearch(e.key);764        return;765      }766    };767    window.addEventListener("keydown", handleGlobalKeydown);768    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));769770    if (!Capacitor.isNativePlatform()) {771      const handleStorageChanged = (772        changes: Record<string, chrome.storage.StorageChange>,773        areaName: string774      ) => {775        if (areaName !== "session") return;776        if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {777          return;778        }779        void refreshPendingPasskey();780      };781      chrome.storage.onChanged.addListener(handleStorageChanged);782      onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));783    }784  });785786  async function refreshCurrentTab() {787    if (Capacitor.isNativePlatform()) return;788    try {789      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });790      const url = tab?.url ?? tab?.pendingUrl ?? "";791      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");792      // Skip extension/internal URLs — adding chrome://, about:, or793      // chrome-extension:// to a vault entry is never what the user wants.794      const isWebUrl = /^https?:\/\//i.test(url);795      setCurrentTabUrl(isWebUrl ? url : "");796    } catch {797      setCurrentTabDomain("");798      setCurrentTabUrl("");799    }800  }801802  async function refreshPendingPasskey() {803    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });804    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);805  }806807  async function refreshIncomingPairing() {808    const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });809    const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;810    setIncomingPair(data);811  }812813  // Drains a single one-shot record left by the background when 6-digit814  // pairing succeeded while the popup was closed (or the SyncPairing view was815  // navigated away from before it could show its own success card).816  async function consumeRecentPairing() {817    const res = await sendMessage({ type: "GET_RECENT_PAIRING" });818    const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;819    if (data?.deviceName) {820      setRecentPairToast(data.deviceName);821      window.setTimeout(() => setRecentPairToast(null), 4000);822    }823  }824825  async function respondIncomingPairing(accept: boolean) {826    if (incomingPairResponding()) return;827    setIncomingPairResponding(true);828    await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });829    setIncomingPair(null);830    setIncomingPairResponding(false);831  }832833  // 60-second expiry ticker for the incoming pairing banner. Re-runs when a834  // request appears, ticks once per second, and clears the banner if the835  // request expires while the popup is open.836  createEffect(() => {837    const req = incomingPair();838    if (!req) return;839    const handle = window.setInterval(() => {840      setIncomingPairTick((tick) => tick + 1);841      if (Date.now() >= req.expiresAt) {842        setIncomingPair(null);843        window.clearInterval(handle);844      }845    }, 1000);846    onCleanup(() => window.clearInterval(handle));847  });848849  async function refreshProStatus() {850    const res = await sendMessage({ type: "GET_LICENSE" });851    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;852    setProStatus(Boolean(data?.valid && data?.plan === "pro"));853  }854855  async function restoreSettings() {856    const res = await sendMessage({ type: "GET_SETTINGS" });857    const s = (res as { ok: boolean; data?: any }).data ?? {};858    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {859      setAccentColor(s.accentColor as AccentColorName);860    }861    setTheme(s.theme === "light" ? "light" : "dark");862    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {863      setDisplayFont(s.displayFont);864    } else {865      setDisplayFont("cormorant-italic");866    }867    if (s.textSize === "small" || s.textSize === "large") {868      setTextSize(s.textSize);869    } else {870      setTextSize("medium");871    }872    setReduceMotion(s.reduceMotion === true);873    setLockOnClose(s.lockOnClose !== false);874    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);875    setBreachMonitoring(s.breachMonitoring !== false);876    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);877    setShortcutBindings(getActiveBindings(s.shortcuts));878  }879880  async function applyTrashRetention(days: number) {881    setTrashRetentionDays(days);882    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });883  }884885  function primeListSearch(seed: string) {886    setListSearchSeed(seed);887    setListSearchSeedNonce((value) => value + 1);888    openList();889  }890891  // Returns null when it detected a dead service worker and triggered handleLock().892  // Callers that need to abort (e.g. openVault) check for null rather than relying893  // on view() state, which may already be "lock" during normal startup flows.894  async function loadEntries(): Promise<VaultEntry[] | null> {895    const response = await sendMessage({ type: "GET_ENTRIES" });896    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };897898    // If the service worker was killed and restarted (MV3 workers sleep), the899    // vault will be locked again — or the worker may not respond at all.900    // In either case, send the user to the lock screen rather than silently901    // showing an empty vault (which looks like data loss).902    if (!res.ok) {903      if (904        res.error === "vault locked" ||905        res.error === "SERVICE_WORKER_UNAVAILABLE" ||906        (typeof res.error === "string" && res.error.includes("locked"))907      ) {908        handleLock();909        return null; // signal emergency lock to callers910      }911      return [];912    }913914    const loadedEntries = res.data ?? [];915    setEntries(loadedEntries);916    void publishInMemoryVault(loadedEntries).then(() => {917      // If we were opened by Android's autofill auth intent (vault was locked),918      // entries are now published to the native InMemoryVault — finish the919      // auth dataset so the framework fills the originating app's fields.920      const req = pendingAutofillRequest();921      if (req) {922        setPendingAutofillRequest(null);923        void Autofill.completeAutofillAuthentication({ domain: req.domain });924      }925    });926927    const currentId = selectedEntry()?.id;928    if (currentId) {929      setSelectedEntry(930        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null931      );932    }933934    return loadedEntries;935  }936937  async function openVault(preferredEntryId?: string, skipViewSet = false) {938    // Purge expired trash entries on each vault open939    const retDays = trashRetentionDays();940    if (retDays > 0) {941      void sendMessage({942        type: "PURGE_EXPIRED_TRASH",943        payload: { retentionMs: retDays * 86_400_000 },944      });945    }946947    const loadedEntries = await loadEntries();948949    // null means loadEntries() triggered an emergency lock (service worker died).950    // Don't override it by setting view back to "entry" with an empty vault.951    if (loadedEntries === null) return;952953    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry954    let nextSelectedEntry: VaultEntry | null = null;955    if (preferredEntryId) {956      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;957    }958    if (!nextSelectedEntry) {959      const tabDomain = currentTabDomain();960      if (tabDomain) {961        nextSelectedEntry = loadedEntries.find((e) => {962          if (e.type === "login") {963            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;964            for (const extra of e.additionalUrls ?? []) {965              if ((extractDomain(extra) ?? "") === tabDomain) return true;966            }967            return false;968          }969          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;970          return false;971        }) ?? null;972      }973    }974    nextSelectedEntry ??= loadedEntries[0] ?? null;975976    setSelectedEntry(nextSelectedEntry);977    setEditEntry(undefined);978    setContentTransitioning(false);979    setContentPhase("enter");980    queueContentIdle();981    if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");982  }983984  async function handleUnlock() {985    setHasVault(true);986    triggerPipPulse();987    await restoreSettings();988    const celebrating = pendingCelebration();989    if (celebrating) {990      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });991      setPendingCelebration(false);992    }993    await openVault(undefined, celebrating);994    if (celebrating) {995      setView("pro-celebration");996      window.setTimeout(() => setView("entry"), 2500);997    }998  }9991000  function handleSelectEntry(entry: VaultEntry) {1001    setSelectedEntry(entry);1002    setContentTransitioning(false);1003    setContentPhase("enter");1004    queueContentIdle();1005    // Two-pane layouts (Z Fold inner display, tablets, DeX) keep the list1006    // mounted on the left and only update the detail pane on selection.1007    if (!isTwoPaneLayout()) setView("entry");1008  }10091010  function handleAdd() {1011    setEditEntry(undefined);1012    setView("add");1013  }10141015  async function handleDelete() {1016    const entry = selectedEntry();1017    if (!entry) return;1018    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1019    const loadedEntries = await loadEntries() ?? [];1020    setSelectedEntry(loadedEntries[0] ?? null);1021    setView("entry");1022  }10231024  async function handleDeleteMany(ids: string[]) {1025    for (const id of ids) {1026      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1027    }1028    const loadedEntries = await loadEntries() ?? [];1029    // If the currently selected entry was among the deleted, move to first1030    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1031    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1032  }10331034  async function handleSave(savedEntry?: VaultEntry) {1035    await openVault(savedEntry?.id);1036  }10371038  async function savePendingAutofill() {1039    const pending = pendingAutofillSave();1040    if (!pending) return;1041    const domain = pending.domain.trim();1042    const response = await sendMessage({1043      type: "ADD_ENTRY",1044      payload: {1045        type: "login",1046        title: domain || "Saved login",1047        url: domain ? `https://${domain}` : "",1048        username: pending.username,1049        password: pending.password,1050      },1051    });1052    if ((response as { ok: boolean }).ok) {1053      setPendingAutofillSave(null);1054      await openVault((response as { data?: VaultEntry }).data?.id);1055    }1056  }10571058  function handleBack() {1059    setEditEntry(undefined);1060    setView("entry");1061  }10621063  function handleLock() {1064    clearCanarySuppressions();1065    setEntries([]);1066    setSelectedEntry(null);1067    setEditEntry(undefined);1068    setContentPhase("idle");1069    setView("lock");1070    void clearInMemoryVault();1071  }10721073  function handleHeaderLock() {1074    // Lock the UI immediately — do not wait for the service worker. If the SW1075    // is dead or slow, the 5s sendMessage timeout would create a dead-click1076    // window. The SW will lock on its next wake regardless.1077    handleLock();1078    void sendMessage({ type: "LOCK" });1079  }10801081  function navigate(dir: number) {1082    const allEntries = entries();1083    if (allEntries.length === 0) return;1084    const currentIndex = selectedIndex();1085    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1086    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1087    setSelectedEntry(allEntries[nextIndex]);1088    setContentPhase("enter");1089    queueContentIdle();1090  }10911092  function openList() {1093    setView("list");1094  }10951096  function openHealth() {1097    setEditEntry(undefined);1098    setView("health");1099  }11001101  function handleOpenFileImport() {1102    setRestoreView("file");1103  }11041105  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;11061107  function handleFixEntry(entry: VaultEntry) {1108    // Passkey-only entries have no editable surface — drop into view mode1109    // instead of opening the Add editor.1110    if (isPasskeyOnly(entry)) {1111      setSelectedEntry(entry);1112      setView("entry");1113      return;1114    }1115    setEditEntry(entry);1116    setView("add");1117  }11181119  const isLockedView = () => view() === "lock";11201121  async function handleApprovePendingPasskey() {1122    const pending = pendingPasskey();1123    if (!pending) return;1124    if (pending.browser === "firefox" && pending.type === "create") {1125      // Firefox path: popup itself runs navigator.credentials.create. The1126      // background never approved/rejected anything — it just stashed1127      // intent. After the popup-driven flow resolves (success or cancel),1128      // clear the pending key so the popup doesn't re-show this on next open.1129      const { createFirefoxPasskey } = await import("./firefox-passkey");1130      const result = await createFirefoxPasskey(pending);1131      if (!result.ok) {1132        console.warn("Peach Firefox passkey create failed:", result.error);1133      }1134      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1135      setPendingPasskey(null);1136      void loadEntries();1137      return;1138    }1139    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1140    setPendingPasskey(null);1141  }11421143  async function handleDenyPendingPasskey() {1144    const pending = pendingPasskey();1145    if (!pending) return;1146    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1147    setPendingPasskey(null);1148  }11491150  function queueContentIdle(after?: () => void) {1151    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1152    contentPhaseTimer = window.setTimeout(() => {1153      setContentPhase("idle");1154      after?.();1155    }, ENTRY_TRANSITION_SETTLE_MS);1156  }11571158  return (1159    <div class="popup-root peach-theme">1160      <Show when={bootState() === "ready"}>1161        <Show1162          when={!pendingPasskey()}1163          fallback={1164            <PasskeyApprovalView1165              type={pendingPasskey()!.type}1166              rpName={pendingPasskey()!.rpName}1167              rpId={pendingPasskey()!.rpId}1168              userName={pendingPasskey()!.userName}1169              userDisplayName={pendingPasskey()!.userDisplayName}1170              onApprove={handleApprovePendingPasskey}1171              onDeny={handleDenyPendingPasskey}1172            />1173          }1174        >1175          <Show1176            when={!isLockedView()}1177            fallback={1178              <Show1179                when={onboarded() || hasVault()}1180                fallback={1181                  <Onboarding1182                    onComplete={() => { setOnboarded(true); }}1183                  />1184                }1185              >1186                <LockView1187                  hasVault={hasVault()}1188                  ready={bootState() === "ready"}1189                  isPro={proStatus()}1190                  onUnlock={handleUnlock}1191                  onBackToOnboarding={1192                    !hasVault()1193                      ? () => setOnboarded(false)1194                      : undefined1195                  }1196                />1197              </Show>1198            }1199          >1200            <Show1201              when={view() !== "pro-celebration"}1202              fallback={<ProCelebration />}1203            >1204            <section1205              id="hd-shell"1206              class="peach-shell peach-theme"1207              data-surface="extension"1208            >1209            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1210              <PeachWordmark unlocked />1211              <div class="peach-chrome-actions">1212                <button1213                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1214                  onClick={() => setView("settings")}1215                  aria-label="Settings"1216                >1217                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1218                    <path1219                      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"1220                      stroke="currentColor"1221                      stroke-width="0.9"1222                    />1223                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1224                  </svg>1225                </button>1226                <button1227                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1228                  onClick={handleAdd}1229                  aria-label="New entry"1230                >1231                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1232                    <path d="M5.5 1v9M1 5.5h9" />1233                  </svg>1234                </button>1235                <button1236                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1237                  onClick={() => void handleHeaderLock()}1238                  aria-label="Lock vault"1239                >1240                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1241                    <path1242                      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"1243                      stroke="currentColor"1244                      stroke-width="1"1245                      stroke-linecap="round"1246                    />1247                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1248                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1249                  </svg>1250                </button>1251              </div>1252            </header>12531254            <Show when={recentPairToast()}>1255              <div class="peach-pair-toast" role="status" aria-live="polite">1256                Paired with <strong>{recentPairToast()}</strong>1257              </div>1258            </Show>12591260            <Show when={incomingPair() !== null}>1261              <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1262                <div class="peach-pair-request-header">1263                  <span class="peach-pair-request-mark">🍑</span>1264                  <span class="peach-pair-request-title">Pairing request</span>1265                  <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1266                </div>1267                <p class="peach-pair-request-body">1268                  <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1269                </p>1270                <div class="peach-pair-request-actions">1271                  <button1272                    class="peach-btn peach-btn--ghost peach-btn--sm"1273                    onClick={() => void respondIncomingPairing(false)}1274                    disabled={incomingPairResponding()}1275                  >1276                    Reject1277                  </button>1278                  <button1279                    class="peach-btn peach-btn--accent peach-btn--sm"1280                    onClick={() => void respondIncomingPairing(true)}1281                    disabled={incomingPairResponding()}1282                  >1283                    Accept1284                  </button>1285                </div>1286              </div>1287            </Show>12881289            <Show when={pendingProNotice() !== null}>1290              <div class="peach-pro-notice-banner">1291                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1292                <button1293                  class="peach-pro-notice-link"1294                  onClick={() => {1295                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1296                    setSettingsInitialSection("license");1297                    setView("settings");1298                    setPendingProNotice(null);1299                  }}1300                >1301                  Learn more →1302                </button>1303                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1304              </div>1305            </Show>13061307            <Show when={pendingAutofillSave() !== null && !isLockedView()}>1308              <div class="peach-pro-notice-banner peach-autofill-save-banner">1309                <span>1310                  Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1311                </span>1312                <button1313                  class="peach-pro-notice-link"1314                  onClick={() => void savePendingAutofill()}1315                >1316                  Save1317                </button>1318                <button1319                  class="peach-pro-notice-close"1320                  onClick={() => setPendingAutofillSave(null)}1321                  aria-label="Dismiss"1322                >1323                  x1324                </button>1325              </div>1326            </Show>13271328            <Show when={pendingAutofillRequest() !== null}>1329              <div class="peach-pro-notice-banner peach-autofill-request-banner">1330                <span>1331                  Unlock to autofill{pendingAutofillRequest()?.domain ? ` ${pendingAutofillRequest()!.domain}` : ""}1332                </span>1333                <button1334                  class="peach-pro-notice-close"1335                  onClick={() => setPendingAutofillRequest(null)}1336                  aria-label="Dismiss"1337                >1338                  x1339                </button>1340              </div>1341            </Show>13421343            <Show when={backExitNotice()}>1344              <div class="peach-pro-notice-banner peach-back-exit-banner">1345                <span>Tap back again to exit Peach</span>1346              </div>1347            </Show>13481349            <div class="peach-shell-body">1350              <div class="peach-shell-veil"></div>1351              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1352                <EntryView1353                  entry={selectedEntry()}1354                  currentTabUrl={currentTabUrl()}1355                  onDelete={handleDelete}1356                  onFieldSaved={() => void loadEntries()}1357                  onEscape={() => { setView("list"); }}1358                />1359              </div>13601361              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1362                <div class="peach-search-bar">1363                  <span class="peach-search-icon">1364                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1365                      <circle cx="5.5" cy="5.5" r="3.75" />1366                      <path d="M8.75 8.75L11.5 11.5" />1367                    </svg>1368                  </span>1369                  <input1370                    class="peach-search-input"1371                    placeholder="search vault..."1372                    autocomplete="off"1373                    aria-label="Search vault entries"1374                    onClick={openList}1375                    onFocus={openList}1376                    readOnly1377                  />1378                  <div class="peach-search-meta">1379                    <span class="peach-search-counter">1380                      {entries().length > 01381                        ? `${selectedIndex() + 1} / ${entries().length}`1382                        : "0 entries"}1383                    </span>1384                    <span class="peach-divider"></span>1385                    <button1386                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1387                      onClick={() => navigate(-1)}1388                      aria-label="Previous entry"1389                      disabled={entries().length === 0}1390                    >1391                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1392                        <path d="M5 1L1 5.5L5 10" />1393                      </svg>1394                    </button>1395                    <button1396                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1397                      onClick={() => navigate(1)}1398                      aria-label="Next entry"1399                      disabled={entries().length === 0}1400                    >1401                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1402                        <path d="M1 1L5 5.5L1 10" />1403                      </svg>1404                    </button>1405                  </div>1406                </div>1407              </section>1408            </div>14091410            <Show when={view() === "list"}>1411              <VaultView1412                entries={entries()}1413                selectedEntryId={selectedEntry()?.id}1414                currentTabDomain={currentTabDomain()}1415                sortOrder={sortOrder()}1416                searchSeed={listSearchSeed()}1417                searchSeedNonce={listSearchSeedNonce()}1418                onSelectEntry={handleSelectEntry}1419                onSortOrderChange={setSortOrder}1420                onAdd={handleAdd}1421                onClose={() => setView("entry")}1422                onOpenTrash={() => setView("trash")}1423                onDeleteMany={handleDeleteMany}1424              />1425            </Show>1426            <Show when={view() === "settings"}>1427              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1428                <SettingsView1429                  entriesCount={entries().length}1430                  breachMonitoring={breachMonitoring()}1431                  lockOnClose={lockOnClose()}1432                  autoLockMinutes={autoLockMinutes()}1433                  trashRetentionDays={trashRetentionDays()}1434                  theme={theme()}1435                  accentColor={accentColor()}1436                  displayFont={displayFont()}1437                  textSize={textSize()}1438                  reduceMotion={reduceMotion()}1439                  isPro={proStatus()}1440                  initialSection={settingsInitialSection()}1441                  upgradeHighlight={upgradeHighlight()}1442                  onBreachMonitoringChange={applyBreachMonitoring}1443                  onLockOnCloseChange={applyLockOnClose}1444                  onAutoLockMinutesChange={applyAutoLockMinutes}1445                  onTrashRetentionChange={applyTrashRetention}1446                  onThemeChange={applyTheme}1447                  onAccentChange={applyAccentColor}1448                  onDisplayFontChange={applyDisplayFont}1449                  onTextSizeChange={applyTextSize}1450                  onReduceMotionChange={applyReduceMotion}1451                  shortcuts={shortcutBindings()}1452                  onShortcutsChange={applyShortcuts}1453                  onLicenseChange={async () => {1454                    // After activation/deactivation: refresh Pro status,1455                    // re-load entries (defensive — signal state can drift if1456                    // the SW briefly restarts during the verify+save round-trip),1457                    // and replay the Pro celebration if this is the first time1458                    // a valid Pro license is seen on this device.1459                    await refreshProStatus();1460                    void loadEntries();1461                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1462                    if ((should as { ok: boolean; data?: boolean }).data === true) {1463                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1464                      setView("pro-celebration");1465                      window.setTimeout(() => setView("settings"), 2500);1466                    }1467                  }}1468                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1469                  onImport={() => setView("import")}1470                  onExport={() => setView("export")}1471                  onBreachCheckDone={loadEntries}1472                  onOpenHealth={openHealth}1473                  onOpenSyncPairing={() => setView("sync-pairing")}1474                  onOpenFileImport={handleOpenFileImport}1475                  onOpenPanic={() => setView("panic")}1476                />1477              </Suspense>1478            </Show>1479            <Show when={view() === "sync-pairing"}>1480              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1481                <SyncPairingView1482                  onClose={() => setView("settings")}1483                  onPaired={() => { /* sync state refreshes when popup returns to Settings */ }}1484                />1485              </Suspense>1486            </Show>1487            <Show when={view() === "add"}>1488              <AddView1489                entry={editEntry()}1490                entries={entries()}1491                onBack={handleBack}1492                onSave={handleSave}1493              />1494            </Show>1495            <Show when={view() === "import"}>1496              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1497                <ImportView onBack={handleBack} onImport={handleSave} />1498              </Suspense>1499            </Show>1500            <Show when={view() === "export"}>1501              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1502                <ExportView1503                  onBack={() => {1504                    if (returnToPanic()) {1505                      setReturnToPanic(false);1506                      setView("panic");1507                    } else {1508                      handleBack();1509                    }1510                  }}1511                />1512              </Suspense>1513            </Show>1514            <Show when={view() === "panic"}>1515              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1516                <PanicView1517                  onCancel={() => setView("settings")}1518                  onOpenExport={() => {1519                    setReturnToPanic(true);1520                    setView("export");1521                  }}1522                  onDestroyed={async () => {1523                    clearCanarySuppressions();1524                    setEntries([]);1525                    setSelectedEntry(null);1526                    setEditEntry(undefined);1527                    setHasVault(false);1528                    setOnboarded(false);1529                    setProStatus(false);1530                    setPendingPasskey(null);1531                    setUpgradeHighlight(null);1532                    setSettingsInitialSection(null);1533                    setPendingProNotice(null);1534                    setPendingCelebration(false);1535                    setReturnToPanic(false);1536                    setContentPhase("idle");1537                    setView("lock");1538                    void clearInMemoryVault();1539                  }}1540                />1541              </Suspense>1542            </Show>1543            <Show when={view() === "health"}>1544              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1545                <HealthView1546                  entries={entries()}1547                  isPro={proStatus()}1548                  onFixEntry={handleFixEntry}1549                  onBack={() => setView("settings")}1550                  onRefresh={loadEntries}1551                />1552              </Suspense>1553            </Show>1554            <Show when={view() === "trash"}>1555              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1556                <TrashView1557                  onBack={() => setView("list")}1558                  onRestored={() => void loadEntries()}1559                />1560              </Suspense>1561            </Show>1562            <Show when={restoreView() === "file"}>1563              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1564                <ImportView1565                  mode="onboarding"1566                  replacesExistingVault={true}1567                  onBack={() => setRestoreView(null)}1568                  onImport={async () => {1569                    setRestoreView(null);1570                    await handleUnlock();1571                  }}1572                />1573              </Suspense>1574            </Show>1575            </section>1576            </Show>1577          </Show>1578        </Show>1579      </Show>1580      <Show when={showShortcutsHelp()}>1581        <ShortcutsHelpOverlay1582          bindings={shortcutBindings()}1583          onClose={() => setShowShortcutsHelp(false)}1584        />1585      </Show>1586    </div>1587  );1588}15891590function ShortcutsHelpOverlay(props: {1591  bindings: Record<ShortcutAction, Keybinding>;1592  onClose: () => void;1593}) {1594  return (1595    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1596      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1597        <div class="peach-shortcuts-help-head">1598          <h3>Keyboard shortcuts</h3>1599          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1600        </div>1601        <div class="peach-shortcuts-help-body">1602          <p class="peach-settings-sub-label">ENTRY VIEW</p>1603          <ul>1604            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1605              <li>1606                <span>{d.label}</span>1607                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1608              </li>1609            ))}1610          </ul>1611          <p class="peach-settings-sub-label">ANYWHERE</p>1612          <ul>1613            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1614              <li>1615                <span>{d.label}</span>1616                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1617              </li>1618            ))}1619          </ul>1620        </div>1621      </div>1622    </div>1623  );1624}