App.tsx

84.7 KB · raw

1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { storageGet, storageRemove, storageSet } from "../shared/storage";3import { sendMessage } from "../shared/bridge";4import { ACCENT_COLORS } from "../shared/types";5import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";6import { isPasskeyOnly } from "../shared/types";7import {8  hasCompletedInitialSync,9  INITIAL_SYNC_PENDING_DEVICE_KEY,10  type InitialSyncDevice,11} from "../shared/initial-sync";12import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";13import { WhatsNewView } from "./views/WhatsNew";14import { shouldShowWhatsNew } from "../shared/release-notes";15import { ProNudgeView } from "./views/ProNudge";16import { claimProNudge } from "../shared/pro-nudge";17import {18  ONBOARDING_CARD_STORAGE_KEY,19  ONBOARDING_LAST_CARD,20  Onboarding,21} from "./views/Onboarding";2223const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";24import { NotesMode } from "./views/NotesMode";25import { ProCelebration } from "./views/ProCelebration";26import { InitialSyncView } from "./views/InitialSyncView";27import { VaultView, type SortOrder } from "./views/Vault";28import { EntryView } from "./views/Entry";29import { AddView } from "./views/Add";30import { PeachWordmark } from "./components/PeachMark";31import { PasskeyApprovalView } from "./views/PasskeyApproval";32import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";33import {34  isManualSyncPending,35  syncIndicator,36  syncIndicatorLabel,37  type SyncOutcomeSummary,38} from "../shared/sync-outcome";39import { popupCopy } from "./copy";40import {41  DEFAULT_BINDINGS,42  formatKeybinding,43  getActiveBindings,44  matchKeybinding,45  SHORTCUT_DESCRIPTORS,46  type Keybinding,47  type ShortcutAction,48} from "../shared/shortcuts";49import { generateTOTP } from "../crypto/totp";5051export { sendMessage } from "../shared/bridge";5253const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));54const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));55const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));56const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));57const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));58const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));59const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));60const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));61const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));62const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6364type View =65  | "lock"66  | "pro-celebration"67  | "whats-new"68  | "pro-nudge"69  | "entry"70  | "list"71  | "add"72  | "settings"73  | "pair-device"74  | "import"75  | "export"76  | "health"77  | "trash"78  | "panic"79  | "adopting"80  | "initial-sync"81  | "codex-generate"82  | "codex-restore";83type BootState = "loading" | "ready";8485const INITIAL_SYNC_POLL_MS = 500;86const INITIAL_SYNC_SLOW_MS = 15_000;8788let shellRingAnimation: Animation | null = null;8990export function triggerRing() {91  const shell = document.getElementById("hd-shell");92  if (!shell) return;9394  shellRingAnimation?.cancel();95  shell.dataset.glowing = "true";96  const animation = shell.animate(97    [98      { offset: 0, boxShadow: "var(--copy-shadow)" },99      {100        offset: 0.3,101        boxShadow:102          "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)",103      },104      { offset: 1, boxShadow: "var(--copy-shadow)" },105    ],106    {107      duration: 720,108      easing: "cubic-bezier(0.4, 0, 0.2, 1)",109      fill: "none",110    }111  );112  shellRingAnimation = animation;113  const clearGlow = () => {114    if (shellRingAnimation !== animation) return;115    shellRingAnimation = null;116    delete shell.dataset.glowing;117  };118  animation.onfinish = clearGlow;119  animation.oncancel = clearGlow;120}121122export function triggerPipPulse() {123  const pip = document.querySelector(".peach-status-pip, .pip");124  if (pip) {125    pip.classList.remove("pulse");126    void (pip as HTMLElement).offsetWidth;127    pip.classList.add("pulse");128  }129}130131const ENTRY_TRANSITION_SETTLE_MS = 135;132const ALL_SITES: chrome.permissions.Permissions = { origins: ["<all_urls>"] };133134export default function App() {135  const [bootState, setBootState] = createSignal<BootState>("loading");136  const [view, setView] = createSignal<View>("lock");137  const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");138  const [hasVault, setHasVault] = createSignal(false);139  const [onboarded, setOnboarded] = createSignal(true);140  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);141  // The restore source picked before the popup closed (the OS file picker142  // kills it), so reopening resumes the same source form instead of the143  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.144  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);145  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes146  // back. Those flows all exit from the final card, so resume there — a fresh147  // install's first mount stays at 0.148  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);149  const [entries, setEntries] = createSignal<VaultEntry[]>([]);150  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);151  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);152153  // Rare device-trust alerts stay visible; routine entry sync is silent.154  interface SyncAlert {155    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";156    entryId: string;157    entryTitle: string;158    deviceName: string;159    timestamp: number;160  }161  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);162  // Per-group flip index: which alert within a type-group is currently shown.163  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});164  async function loadSyncAlerts() {165    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {166      ok: boolean;167      data?: SyncAlert[];168    };169    if (r.ok) setSyncAlerts(r.data ?? []);170  }171  async function dismissAlertGroup(type: SyncAlert["type"]) {172    const result = (await sendMessage({173      type: "DISMISS_SYNC_ALERTS",174      payload: { type },175    })) as { ok: boolean };176    if (!result.ok) return;177    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));178    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });179  }180  function flipAlert(type: string, dir: 1 | -1) {181    const group = syncAlerts().filter((a) => a.type === type);182    if (group.length <= 1) return;183    setAlertFlipIdx((m) => {184      const cur = m[type] ?? 0;185      return { ...m, [type]: (cur + dir + group.length) % group.length };186    });187  }188  /** Grouped alert types in display order, each with its alerts. */189  const alertGroups = createMemo(() => {190    const map = new Map<string, SyncAlert[]>();191    for (const a of syncAlerts()) {192      const g = map.get(a.type);193      if (g) g.push(a); else map.set(a.type, [a]);194    }195    return [...map.entries()];196  });197  function syncAlertText(a: SyncAlert): string {198    if (a.type === "DEVICE_REVOKED") {199      return `${a.deviceName} revoked a paired device — review your devices in Settings`;200    }201    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;202  }203  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");204  const [theme, setTheme] = createSignal<"dark" | "light">("dark");205  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");206  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);207  const [lockOnClose, setLockOnClose] = createSignal(false);208  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);209  const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);210  const [breachMonitoring, setBreachMonitoring] = createSignal(true);211  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);212  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);213  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);214  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);215  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);216  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");217  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");218  // Cached Pro status — synchronous for render paths. Background's219  // GET_LICENSE is async, but render-time reads of isPro() must not block.220  // Refreshed at boot, after activate/deactivate, and whenever the popup221  // re-opens onto a stored license that has changed via another window.222  const [proStatus, setProStatus] = createSignal(false);223  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);224  // When the sync card's pairing button opens the pairing view, deep-link225  // straight into that pairing path instead of the rest screen.226  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");227  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);228  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");229  const [listSearchSeed, setListSearchSeed] = createSignal("");230  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);231  const [pendingCelebration, setPendingCelebration] = createSignal(false);232  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);233  // A page asked to be remembered for an entry it did not match. Pages cannot234  // grow an entry's domain set themselves (PB-26-106), so the background queues235  // the request here and this banner is the confirmation gate.236  const [pendingRememberSite, setPendingRememberSite] = createSignal<{237    entryId: string;238    url: string;239    title: string;240  } | null>(null);241  // Firefox MV3 lets users withhold <all_urls>; without it no content script242  // loads until a toolbar click grants activeTab for that one tab.243  const [siteAccessMissing, setSiteAccessMissing] = createSignal(false);244  // True while Panic Step 1 has bounced the user out to Export. When Export's245  // onBack fires we return to Panic instead of Settings so the user keeps the246  // destruction context they came from.247  const [returnToPanic, setReturnToPanic] = createSignal(false);248  // The view to return to when the add/edit view is dismissed. Normally "entry"249  // but "health" when the user arrives via the fix-entry path from Vault Health.250  const [editBackView, setEditBackView] = createSignal<View>("entry");251  const [settingsExiting, setSettingsExiting] = createSignal(false);252  const [showNotes, setShowNotes] = createSignal(false);253  // Topbar sync affordance. Always present: gating it on having a paired device254  // meant it never appeared for the person trying to set pairing up, which is255  // exactly when you reach for it.256  const [syncBusy, setSyncBusy] = createSignal(false);257  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);258  // The indicator light + its spoken-word equivalent. Colour alone is not the259  // signal; the title/aria-label always carries the same claim in English.260  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});261  const [pairedCount, setPairedCount] = createSignal(0);262  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);263  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);264  let initialSyncWaitGeneration = 0;265  const syncLight = () =>266    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());267  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());268  async function loadSyncIndicator() {269    const [stateRes, devicesRes] = await Promise.all([270      sendMessage({ type: "GET_SYNC_STATE" }),271      sendMessage({ type: "LIST_PAIRED_DEVICES" }),272    ]);273    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };274    if (s.ok && s.data) setSyncSummary(s.data);275    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };276    if (d.ok) {277      setPairedCount(d.data?.devices?.length ?? 0);278    } else if (d.error === "trust_data_corrupted") {279      setPairedCount(0);280      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });281    }282  }283284  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {285    const response = (await sendMessage({286      type: "LIST_PAIRED_DEVICES",287    })) as {288      ok: boolean;289      error?: string;290      data?: { devices?: InitialSyncPairedDevice[] };291    };292    if (response.ok) return response.data?.devices ?? [];293    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");294    throw new Error(response.error ?? "list_paired_devices_failed");295  }296  interface InitialSyncPairedDevice extends InitialSyncDevice {297    pairedAt?: number;298  }299300301  async function resolveInitialSyncDeviceId(): Promise<string | null> {302    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(303      () => null,304    );305    const devices = await listInitialSyncDevices().catch(() => []);306    if (stored && devices.some((device) => device.deviceId === stored)) {307      return stored;308    }309310    // Fresh adoption has exactly one direct source, but choosing the newest row311    // also repairs the marker if storage failed after the pair itself committed.312    const newest = [...devices].sort(313      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),314    )[0];315    if (!newest) return stored;316    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(317      () => undefined,318    );319    return newest.deviceId;320  }321322  async function finishInitialSync(generation: number): Promise<void> {323    if (generation !== initialSyncWaitGeneration) return;324    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);325    setInitialSyncDeviceId(null);326    setInitialSyncSlow(false);327    await refreshProStatus();328329    // A license transferred during pairing intentionally leaves the one-shot330    // celebration pending. Consume it only now: sync readiness → celebration331    // → populated vault, never celebration → temporarily empty vault.332    const celebration = (await sendMessage({333      type: "SHOULD_PLAY_CELEBRATION",334    })) as { ok: boolean; data?: boolean };335    const shouldCelebrate = celebration.ok && celebration.data === true;336    if (shouldCelebrate) {337      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });338    }339340    const opened = await openVault(undefined, shouldCelebrate);341    if (!opened || generation !== initialSyncWaitGeneration) return;342    if (shouldCelebrate) {343      setView("pro-celebration");344      window.setTimeout(() => {345        if (generation === initialSyncWaitGeneration) setView("entry");346      }, 2500);347    }348  }349350  async function beginInitialSyncWait(deviceId: string): Promise<void> {351    const generation = ++initialSyncWaitGeneration;352    setInitialSyncDeviceId(deviceId);353    setInitialSyncSlow(false);354    setView("initial-sync");355    const startedAt = Date.now();356357    // Best effort: the pairing write already wakes the mesh listener, while358    // this trigger covers the key-order case where this receiver is the dialer.359    void sendMessage({360      type: "TRIGGER_SYNC",361      payload: { reason: "foreground" },362    }).catch(() => undefined);363364    while (365      generation === initialSyncWaitGeneration &&366      view() === "initial-sync"367    ) {368      try {369        const devices = await listInitialSyncDevices();370        if (hasCompletedInitialSync(devices, deviceId)) {371          await finishInitialSync(generation);372          return;373        }374      } catch {375        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.376      }377      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {378        setInitialSyncSlow(true);379      }380      await new Promise((resolve) =>381        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),382      );383    }384  }385386  async function retryInitialSync(): Promise<void> {387    const deviceId =388      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());389    if (deviceId) {390      await beginInitialSyncWait(deviceId);391    } else {392      setInitialSyncSlow(true);393    }394  }395396  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);397  // Set on popup boot when peach_recovery_pending flag is found in session398  // storage — instructs LockView to skip the unlock UI and open the recovery399  // words confirmation surface instead. Cleared via handleUnlock once the400  // user has typed the 3 confirmation words.401  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);402403  let contentPhaseTimer: number | undefined;404  // Set when the popup was opened from the inline "Open Peach to unlock" button.405  // handleUnlock() closes the popup after unlock so the user returns to the page.406  let openedForInlineUnlock = false;407408  onCleanup(() => {409    initialSyncWaitGeneration += 1;410  });411412  function applyAccent(name: AccentColorName) {413    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;414    const hex = color.hex;415    document.documentElement.style.setProperty("--peach-accent", hex);416    // Ambient variables derived from the selected accent.417    document.documentElement.style.setProperty("--peach-glow", hex + "2A");418    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");419  }420421  createEffect(() => {422    applyAccent(accentColor());423  });424425  createEffect(() => {426    document.documentElement.classList.toggle("light", theme() === "light");427    document.body.classList.toggle("light", theme() === "light");428  });429430  createEffect(() => {431    document.documentElement.setAttribute("data-text-size", textSize());432  });433434  createEffect(() => {435    if (reduceMotion()) {436      document.documentElement.setAttribute("data-motion", "reduce");437    } else {438      document.documentElement.removeAttribute("data-motion");439    }440  });441442  // Notes are not a destination of the Entry view; selecting a note opens the443  // notes surface instead (handleSelectEntry). Anything that walks or counts444  // the Entry view's neighbours (counter, prev/next, index) must use this445  // filtered list, otherwise the user can arrow into a note and is stuck —446  // the Entry view can't render a note and the prev/next buttons stop moving.447  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));448449  const selectedIndex = createMemo(() => {450    const currentId = selectedEntry()?.id;451    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;452    return nonNoteEntries().findIndex((entry) => entry.id === currentId);453  });454455  async function applyAccentColor(name: AccentColorName) {456    applyAccent(name);457    setAccentColor(name);458    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });459  }460461  async function applyTheme(t: "dark" | "light") {462    setTheme(t);463    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });464  }465466  async function applyTextSize(size: "small" | "medium" | "large") {467    setTextSize(size);468    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });469  }470471  async function applyReduceMotion(enabled: boolean) {472    setReduceMotion(enabled);473    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });474  }475476  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload477  // small and lets future default updates land for actions the user hasn't478  // touched. Settings sends us the full active map; we filter and store.479  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {480    setShortcutBindings(active);481    const overrides: Record<string, Keybinding> = {};482    for (const action of Object.keys(active) as ShortcutAction[]) {483      const a = active[action];484      const d = DEFAULT_BINDINGS[action];485      const same = a.key === d.key486        && Boolean(a.ctrl)  === Boolean(d.ctrl)487        && Boolean(a.meta)  === Boolean(d.meta)488        && Boolean(a.shift) === Boolean(d.shift)489        && Boolean(a.alt)   === Boolean(d.alt);490      if (!same) overrides[action] = a;491    }492    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });493  }494495  async function applyLockOnClose(enabled: boolean) {496    setLockOnClose(enabled);497    await sendMessage({498      type: "UPDATE_SETTINGS",499      payload: { lockOnClose: enabled },500    });501  }502503  async function applyAutoLockMinutes(minutes: number) {504    setAutoLockMinutes(minutes);505    await sendMessage({506      type: "UPDATE_SETTINGS",507      payload: { autoLockMinutes: minutes },508    });509  }510  async function applyLockedSyncRequiresUnlock(enabled: boolean) {511    setLockedSyncRequiresUnlock(enabled);512    await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });513  }514515  async function applyBreachMonitoring(enabled: boolean) {516    setBreachMonitoring(enabled);517    await sendMessage({518      type: "UPDATE_SETTINGS",519      payload: { breachMonitoring: enabled },520    });521  }522523  // Which onboarding exit flow the user is in (create-vault / restore /524  // adopt). Persisted so closing the popup on one of those screens resumes525  // it directly instead of rewinding to the last carousel card.526  function markOnboardingExit(flow: "create" | "restore" | "adopt") {527    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);528    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});529    // Backing out of the flow remounts the carousel at this card; keep the530    // persisted card in sync so a later popup close resumes consistently.531    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});532  }533  function clearOnboardingExit() {534    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});535    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});536    setResumeRestoreSource(null);537  }538539  onMount(() => {540    void chrome.permissions.contains(ALL_SITES).then((granted) => setSiteAccessMissing(!granted));541  });542543  onMount(async () => {544    // Resolve the active tab in parallel with the unlock/has-vault queries —545    // openVault()'s smart selection picks the entry matching currentTabDomain,546    // so it must be populated before openVault runs. Previously refreshCurrentTab547    // ran after openVault, leaving popups that booted already-unlocked falling548    // back to loadedEntries[0] instead of the domain match.549    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([550      sendMessage({ type: "IS_UNLOCKED" }),551      sendMessage({ type: "HAS_VAULT" }),552      refreshCurrentTab(),553      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),554    ]);555    const unlocked = Boolean(556      (unlockResponse as { ok: boolean; data?: boolean }).data557    );558    const vaultExists = Boolean(559      (vaultResponse as { ok: boolean; data?: boolean }).data560    );561    const shouldCelebrate = Boolean(562      (celebrationResponse as { ok: boolean; data?: boolean }).data563    );564    setHasVault(vaultExists);565    // Onboarding gate — show once on first install, never again. Existing566    // users (vaultExists) are auto-marked onboarded so they skip the flow567    // even on the very first popup open after this feature ships.568    try {569      const onboarded = await storageGet("peach_onboarded");570      const seen = onboarded === "true";571      if (vaultExists && !seen) {572        await storageSet("peach_onboarded", "true");573        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});574        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});575        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});576        setOnboarded(true);577      } else {578        setOnboarded(seen);579        if (!seen) {580          // A popup close while the user is in an exit flow (create-vault /581          // restore / adopt) resumes that flow directly — landing back on the582          // carousel one screen earlier reads as losing progress.583          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);584          if (exit === "create" || exit === "restore" || exit === "adopt") {585            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);586            setOnboarded(true);587            setStartOnboardingRestore(exit === "restore");588            if (exit === "restore") {589              // The OS file picker kills the action popup mid-restore, so the590              // surface resumes on the source the user had already picked.591              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);592              if (source === "symbols" || source === "codex" || source === "csv") {593                setResumeRestoreSource(source);594              }595            }596            if (exit === "adopt") setView("adopting");597          } else {598            // Otherwise resume mid-onboarding where the user left off instead599            // of rewinding to the opening card.600            const stored = Number(601              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),602            );603            if (604              Number.isInteger(stored) &&605              stored > 0 &&606              stored <= ONBOARDING_LAST_CARD607            ) {608              setOnboardingCardAtExit(stored);609            }610          }611        }612      }613    } catch {614      // storage unavailable — default to onboarded so we never block boot615      setOnboarded(true);616    }617618    // Deep-link: ?restore=peachscript|csv — the detached-window half of the619    // file-picker flow. The action popup dismisses when the OS dialog steals620    // focus, so LockView.detachForFilePicker() reopens this URL in a621    // standalone window that owns the dialog. Routing runs before first622    // render so LockView mounts straight onto the right source form.623    try {624      const restoreRoute = new URLSearchParams(window.location.search).get("restore");625      if (restoreRoute === "peachscript" || restoreRoute === "csv") {626        setOnboarded(true);627        setStartOnboardingRestore(true);628        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");629      }630    } catch {631      // location.search unavailable — ignore.632    }633634    await Promise.all([restoreSettings(), refreshProStatus()]);635636    const pendingInitialSyncMarker =637      unlocked && vaultExists638        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)639        : null;640    const pendingInitialSyncDeviceId = pendingInitialSyncMarker641      ? await resolveInitialSyncDeviceId()642      : null;643644    // Mark played before any animation so a crash mid-sequence never replays645    // it. A newly adopted receiver deliberately defers this until its first646    // real sync finishes, preserving initial sync → celebration → vault.647    if (shouldCelebrate && !pendingInitialSyncDeviceId) {648      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });649    }650651    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome652    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.653    if (unlocked && vaultExists) {654      void sendMessage({655        type: "TRIGGER_SYNC",656        payload: { reason: "foreground" },657      });658      void loadSyncIndicator();659      // Rare device-trust alerts only; routine entry sync stays quiet.660      void loadSyncAlerts();661    }662663    // If the user created a vault but hasn't yet confirmed their recovery664    // words, route back to the recovery-words screen instead of opening the665    // entries view. The flag lives in chrome.storage.local so it survives666    // popup close while the background owns the session-scoped confirmation667    // exemption. Cleared when confirmation succeeds.668    let recoveryPending = false;669    try {670      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });671      recoveryPending = out.ok === true && out.data === true;672    } catch { /* best-effort */ }673674    // Content UI can ask the background to open this trusted popup either for675    // unlock or at a specific entry. Consume both session-scoped handoffs once.676    let pendingOpenEntryId: string | undefined;677    if (chrome.storage.session) {678      try {679        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);680        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };681        if (pending.peach_inline_unlock) {682          openedForInlineUnlock = true;683        }684        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;685        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);686      } catch { /* best-effort */ }687    }688689    if (recoveryPending && unlocked) {690      // forceRecoveryWords overrides LockView's hasVault gate so the691      // recovery surface renders even though the vault now exists.692      setForceRecoveryWords(true);693      setView("lock");694    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {695      void beginInitialSyncWait(pendingInitialSyncDeviceId);696    } else if (unlocked && vaultExists) {697      await openVault(pendingOpenEntryId, shouldCelebrate);698      if (shouldCelebrate) {699        setView("pro-celebration");700        window.setTimeout(() => setView("entry"), 2500);701      } else if (await shouldShowWhatsNew()) {702        setView("whats-new");703      } else if (!proStatus() && await claimProNudge()) {704        setView("pro-nudge");705      }706    } else if (shouldCelebrate) {707      setPendingCelebration(true);708    }709710    await refreshPendingPasskey();711712    // Track tab changes so smart sort stays current713    const handleTabActivated = () => { void refreshCurrentTab(); };714    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {715      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();716    };717    try {718      chrome.tabs.onActivated.addListener(handleTabActivated);719      chrome.tabs.onUpdated.addListener(handleTabUpdated);720      onCleanup(() => {721        chrome.tabs.onActivated.removeListener(handleTabActivated);722        chrome.tabs.onUpdated.removeListener(handleTabUpdated);723      });724    } catch {725      // tabs permission may not be available in all contexts726    }727728    setBootState("ready");729730    // Deep-link: ?codex=pdf opens a detached window straight into the731    // Codex restore flow with auto-picked PDF. The action popup can't host732    // a file dialog without dismissing itself, so this is the only way the733    // PDF import path survives. See CodexRestoreView.openPdfPicker().734    try {735      const route = new URLSearchParams(window.location.search).get("codex");736      if (route === "pdf" || route === "restore") {737        setView("codex-restore");738      }739    } catch {740      // location.search unavailable — ignore.741    }742743    // Check for pending session-storage notices from the background.744    if (chrome.storage.session) {745      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as746        Record<string, { feature?: string } | undefined>;747      if (session.pendingProGate?.feature) {748        await chrome.storage.session.remove("pendingProGate");749        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);750        setSettingsInitialSection("license");751        setView("settings");752      } else if (session.pendingProNotice?.feature) {753        await chrome.storage.session.remove("pendingProNotice");754        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });755      }756      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {757        pendingRememberSite?: { entryId?: unknown; url?: unknown };758      };759      const request = remembered.pendingRememberSite;760      if (761        typeof request?.entryId === "string" &&762        typeof request.url === "string"763      ) {764        const entry = await sendMessage({ type: "GET_ENTRIES" });765        const match = entry.ok766          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(767              (candidate) => candidate.id === request.entryId,768            )769          : undefined;770        if (match) {771          let host = request.url;772          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }773          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });774        } else {775          await chrome.storage.session.remove("pendingRememberSite");776        }777      }778    }779780    // Warm the lazy-module cache so the Suspense fallback never shows for781    // locally-bundled extension views. Without this, the first navigation to782    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which783    // covers the shell and absorbs clicks on the header.784    void Promise.all([785      import("./views/Settings").catch(() => {}),786      import("./views/Import").catch(() => {}),787      import("./views/Export").catch(() => {}),788      import("./views/Health").catch(() => {}),789      import("./views/TrashView").catch(() => {}),790    ]);791792    // Lock-on-close via a persistent port. sendMessage in pagehide is793    // unreliable — the popup context tears down before the message lands.794    // The SW's onConnect listener locks when this port disconnects.795    let popupPort: chrome.runtime.Port | null = null;796    try {797      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect798      // listener accepts the "popup-" prefix; the random suffix is defence in799      // depth against a port named exactly "popup" from anything but our popup.800      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });801    } catch { /* extension context invalidated — ignore */ }802    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });803804    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale805    // entries instead of waiting for the next GET_ENTRIES round-trip.806    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {807      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();808      if (msg?.type === "SYNC_STATE_CHANGED") {809        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);810      }811      // A background sync merged entries the user never asked for (a peer's812      // push, or the periodic dial). Pull the fresh list so the vault view is813      // not stale — loadEntries() preserves the current selection by id.814      if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {815        void loadEntries();816      }817      if (msg?.type === "PRO_GATE_TRIGGERED") {818        setUpgradeHighlight(msg.payload?.feature ?? null);819        setSettingsInitialSection("license");820        setView("settings");821      }822    };823    chrome.runtime.onMessage.addListener(handleRuntimeMessage);824    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));825826    // Action handlers — keyed by ShortcutAction. The dispatcher below tests827    // each binding against the event and runs the matching handler. New828    // shortcuts only need an entry here; the binding is data, not code.829    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {830      copy_password: () => {831        const entry = selectedEntry();832        if (entry?.password) void popupCopy(833          entry.password,834          "Password",835          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,836        ).then((ok) => { if (ok) triggerRing(); });837      },838      copy_username: () => {839        const entry = selectedEntry();840        if (entry?.username) void popupCopy(841          entry.username,842          "Username",843          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,844        ).then((ok) => { if (ok) triggerRing(); });845      },846      copy_totp: () => {847        const entry = selectedEntry();848        if (!entry?.totp) return;849        try {850          const { code } = generateTOTP(851            entry.totp,852            entry.totpAlgorithm,853            entry.totpDigits,854            entry.totpPeriod,855          );856          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });857        } catch { /* invalid TOTP secret — silent */ }858      },859      new_entry: () => handleAdd(),860      edit_entry: () => {861        const entry = selectedEntry();862        // Passkey-only entries have nothing user-editable in the Add view; the863        // passkey credential itself is opaque. Skip the edit action for them.864        if (!entry || isPasskeyOnly(entry)) return;865        setEditEntry(entry);866        setView("add");867      },868      delete_entry: () => {869        if (selectedEntry()) void handleDelete();870      },871      lock_vault: () => { void handleHeaderLock(); },872      search: () => openList(),873      nav_prev: () => navigate(-1),874      nav_next: () => navigate(1),875      jump_vault: () => setView("entry"),876      jump_settings: () => setView("settings"),877      show_help: () => setShowShortcutsHelp(true),878    };879880    // Actions that are MEANINGFUL only on the entry-detail view. Outside881    // that scope the binding is dormant — pressing C while on Settings882    // does nothing rather than awkwardly copying from a now-stale entry.883    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([884      "copy_password", "copy_username", "copy_totp",885      "edit_entry", "delete_entry", "nav_prev", "nav_next",886    ]);887888    const handleGlobalKeydown = (e: KeyboardEvent) => {889      const v = view();890      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"891        || (e.target as HTMLElement)?.tagName === "TEXTAREA"892        || (e.target as HTMLElement)?.isContentEditable;893      const isPrintableSearchKey =894        e.key.length === 1 &&895        !e.ctrlKey &&896        !e.metaKey &&897        !e.altKey;898899      // No shortcuts while a full-screen surface owns the whole viewport:900      // the lock screen, the post-pairing readiness sequence, or the Pro901      // celebration. Without this the lock screen is at the mercy of whatever902      // single-key binding is active — e.g. typing "n" in a password that903      // slips out of focus triggers `new_entry` and escapes the lock screen.904      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;905906      // Help dialog: Escape closes it; eat the keypress so it doesn't907      // also navigate away from the underlying view.908      if (showShortcutsHelp()) {909        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }910        return;911      }912913      // ── Escape — fixed binding (not user-customizable) ───────914      if (e.key === "Escape") {915        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }916        if (v === "list") { setView("entry"); return; }917        if (v === "settings" || v === "add") { handleBack(); return; }918        if (v === "import" || v === "health") { setView("settings"); return; }919        if (v === "export") { handleExportBack(); return; }920        if (v === "pair-device") { setView("settings"); return; }921      }922923      // Bindings-driven dispatch. Single-key letter bindings without924      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is925      // inside an input, so users can type into search/forms normally.926      const bindings = shortcutBindings();927      for (const action of Object.keys(bindings) as ShortcutAction[]) {928        const binding = bindings[action];929        if (!matchKeybinding(e, binding)) continue;930        // Single-key letter bindings need the input-focus guard. Bindings931        // with any modifier are safe — Ctrl+L doesn't conflict with typing.932        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;933        if (!hasMod && binding.key.length === 1 && inInput) return;934        if (ENTRY_SCOPED.has(action) && v !== "entry") return;935        e.preventDefault();936        SHORTCUT_HANDLERS[action]?.();937        return;938      }939940      // Type-to-search: in entry view, any printable key not consumed by a941      // shortcut above primes the list search seed and opens the list.942      // Kept outside the bindings table because it's a behavior, not a943      // discrete action — every printable character can trigger it.944      if (v === "entry" && !inInput && isPrintableSearchKey) {945        e.preventDefault();946        primeListSearch(e.key);947        return;948      }949    };950    window.addEventListener("keydown", handleGlobalKeydown);951    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));952953    const handleStorageChanged = (954      changes: Record<string, chrome.storage.StorageChange>,955      areaName: string956    ) => {957      if (areaName !== "session") return;958      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {959        return;960      }961      void refreshPendingPasskey();962    };963    chrome.storage.onChanged.addListener(handleStorageChanged);964    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));965  });966967  async function refreshCurrentTab() {968    try {969      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });970      const url = tab?.url ?? tab?.pendingUrl ?? "";971      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");972      // Skip extension/internal URLs — adding chrome://, about:, or973      // chrome-extension:// to a vault entry is never what the user wants.974      const isWebUrl = /^https?:\/\//i.test(url);975      setCurrentTabUrl(isWebUrl ? url : "");976    } catch {977      setCurrentTabDomain("");978      setCurrentTabUrl("");979    }980  }981982  async function refreshPendingPasskey() {983    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });984    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);985  }986987  async function refreshProStatus() {988    const res = await sendMessage({ type: "GET_LICENSE" });989    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;990    setProStatus(Boolean(data?.valid && data?.plan === "pro"));991  }992993  async function restoreSettings() {994    const res = await sendMessage({ type: "GET_SETTINGS" });995    const s = (res as { ok: boolean; data?: any }).data ?? {};996    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {997      setAccentColor(s.accentColor as AccentColorName);998    }999    setTheme(s.theme === "light" ? "light" : "dark");1000    if (s.textSize === "small" || s.textSize === "large") {1001      setTextSize(s.textSize);1002    } else {1003      setTextSize("medium");1004    }1005    setReduceMotion(s.reduceMotion === true);1006    setLockOnClose(s.lockOnClose === true);1007    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);1008    setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);1009    setBreachMonitoring(s.breachMonitoring !== false);1010    setShortcutBindings(getActiveBindings(s.shortcuts));1011  }10121013  async function applyTrashRetention(days: number) {1014    setTrashRetentionDays(days);1015    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1016  }10171018  function primeListSearch(seed: string) {1019    setListSearchSeed(seed);1020    setListSearchSeedNonce((value) => value + 1);1021    openList();1022  }10231024  // Returns null when it detected a dead service worker and triggered handleLock().1025  // Callers that need to abort (e.g. openVault) check for null rather than relying1026  // on view() state, which may already be "lock" during normal startup flows.1027  async function loadEntries(): Promise<VaultEntry[] | null> {1028    const response = await sendMessage({ type: "GET_ENTRIES" });1029    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10301031    // If the service worker was killed and restarted (MV3 workers sleep), the1032    // vault will be locked again — or the worker may not respond at all.1033    // In either case, send the user to the lock screen rather than silently1034    // showing an empty vault (which looks like data loss).1035    if (!res.ok) {1036      if (1037        res.error === "vault_locked" ||1038        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1039        (typeof res.error === "string" && res.error.includes("locked"))1040      ) {1041        handleLock();1042        return null; // signal emergency lock to callers1043      }1044      return [];1045    }10461047    const loadedEntries = res.data ?? [];1048    setEntries(loadedEntries);10491050    const currentId = selectedEntry()?.id;1051    if (currentId) {1052      setSelectedEntry(1053        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1054      );1055    }10561057    return loadedEntries;1058  }10591060  async function openVault(preferredEntryId?: string, skipViewSet = false) {1061    // Purge expired trash entries on each vault open1062    const retDays = trashRetentionDays();1063    if (retDays > 0) {1064      void sendMessage({1065        type: "PURGE_EXPIRED_TRASH",1066        payload: { retentionMs: retDays * 86_400_000 },1067      });1068    }10691070    const loadedEntries = await loadEntries();10711072    // null means loadEntries() triggered an emergency lock (service worker died).1073    // Don't override it by setting view back to "entry" with an empty vault.1074    if (loadedEntries === null) return false;10751076    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1077    let nextSelectedEntry: VaultEntry | null = null;1078    if (preferredEntryId) {1079      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1080    }1081    if (!nextSelectedEntry) {1082      const tabDomain = currentTabDomain();1083      if (tabDomain) {1084        nextSelectedEntry = loadedEntries.find((e) => {1085          if (e.type === "login") {1086            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1087            for (const extra of e.additionalUrls ?? []) {1088              if ((extractDomain(extra) ?? "") === tabDomain) return true;1089            }1090            return false;1091          }1092          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1093          return false;1094        }) ?? null;1095      }1096    }1097    nextSelectedEntry ??= pickNavigable(loadedEntries);10981099    setSelectedEntry(nextSelectedEntry);1100    setEditEntry(undefined);1101    setContentPhase("enter");1102    queueContentIdle();1103    if (!skipViewSet) setView("entry");1104    return true;1105  }11061107  async function handleUnlock() {1108    // If the user previously created a vault but hadn't yet confirmed their1109    // recovery words (and the browser was closed before they could), the1110    // the background-owned recovery-pending state is still set. Re-route1111    // back to the recovery surface so they finish the confirmation step1112    // before reaching the entries view — the words can be re-derived now1113    // that the vault is unlocked again.1114    let recoveryPending = false;1115    try {1116      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1117      recoveryPending = out.ok === true && out.data === true;1118    } catch { /* best-effort */ }1119    if (recoveryPending) {1120      setForceRecoveryWords(true);1121      setView("lock");1122      triggerPipPulse();1123      await restoreSettings();1124      return;1125    }1126    setHasVault(true);1127    triggerPipPulse();1128    await restoreSettings();1129    const celebrating = pendingCelebration();1130    if (celebrating) {1131      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1132      setPendingCelebration(false);1133    }1134    await openVault(undefined, celebrating);1135    if (celebrating) {1136      setView("pro-celebration");1137      window.setTimeout(() => setView("entry"), 2500);1138    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1139      setView("whats-new");1140    } else if (!openedForInlineUnlock && !proStatus() && await claimProNudge()) {1141      setView("pro-nudge");1142    }1143    // Popup was opened from the inline "Open Peach to unlock" button — close it1144    // now so the user lands back on the page with the autofill selector ready.1145    if (openedForInlineUnlock) {1146      openedForInlineUnlock = false;1147      window.close();1148    }1149  }11501151  function handleSelectEntry(entry: VaultEntry) {1152    if (entry.type === "note") {1153      setOpenNoteId(entry.id);1154      setShowNotes(true);1155      return;1156    }1157    setSelectedEntry(entry);1158    setContentPhase("enter");1159    queueContentIdle();1160    setView("entry");1161  }11621163  function handleAdd() {1164    setEditEntry(undefined);1165    setView("add");1166  }11671168  async function handleDelete() {1169    const entry = selectedEntry();1170    if (!entry) return;1171    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1172    const loadedEntries = await loadEntries() ?? [];1173    setSelectedEntry(pickNavigable(loadedEntries));1174    setView("entry");1175  }11761177  async function handleDeleteMany(ids: string[]) {1178    for (const id of ids) {1179      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1180    }1181    const loadedEntries = await loadEntries() ?? [];1182    // If the currently selected entry was among the deleted, move to first1183    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1184    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1185  }11861187  async function handleSave(savedEntry?: VaultEntry) {1188    await openVault(savedEntry?.id);1189  }11901191  function handleBack() {1192    const dest = editBackView();1193    setEditBackView("entry");1194    setEditEntry(undefined);1195    setView(dest);1196  }11971198  function handleExportBack() {1199    if (returnToPanic()) {1200      setReturnToPanic(false);1201      setView("panic");1202      return;1203    }1204    setView("settings");1205  }12061207  function handleLock() {1208    setEntries([]);1209    setSelectedEntry(null);1210    setEditEntry(undefined);1211    setContentPhase("idle");1212    setView("lock");1213  }12141215  function handleHeaderLock() {1216    // Lock the UI immediately — do not wait for the service worker. If the SW1217    // is dead or slow, the 5s sendMessage timeout would create a dead-click1218    // window. The SW will lock on its next wake regardless.1219    handleLock();1220    void sendMessage({ type: "LOCK" });1221  }12221223  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1224  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1225  // the trigger's own reply.1226  async function handleHeaderSync(): Promise<void> {1227    if (syncBusy()) return;1228    setSyncBusy(true);1229    setSyncFlash(null);1230    try {1231      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1232      let state: SyncOutcomeSummary = { status: "syncing" };1233      for (let i = 0; i < 360 && isManualSyncPending(state); i++) {1234        await new Promise((r) => setTimeout(r, 250));1235        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1236          ok: boolean;1237          data?: SyncOutcomeSummary;1238        };1239        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1240      }1241      setSyncSummary(state);1242      setSyncFlash(state.status === "error" ? "error" : "ok");1243      await loadSyncAlerts();1244      await loadEntries();1245      await loadSyncIndicator();1246    } catch {1247      setSyncSummary({ status: "error", outcome: "run_failed" });1248      setSyncFlash("error");1249    } finally {1250      setSyncBusy(false);1251      setTimeout(() => setSyncFlash(null), 2_000);1252    }1253  }12541255  function navigate(dir: number) {1256    const navigable = nonNoteEntries();1257    if (navigable.length === 0) return;1258    const currentIndex = selectedIndex();1259    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1260    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1261    setSelectedEntry(navigable[nextIndex]);1262    setContentPhase("enter");1263    queueContentIdle();1264  }12651266  /**1267   * The Entry view's selection invariant: never select a note. Notes are a1268   * separate surface (handleSelectEntry opens the notes view), so any code path1269   * that picks a default — vault open, post-delete, refresh — must skip them1270   * and return null if the only entries are notes.1271   */1272  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1273    return list.find((e) => e.type !== "note") ?? null;1274  }12751276  function openList() {1277    setView("list");1278  }12791280  function openHealth() {1281    setEditEntry(undefined);1282    setView("health");1283  }12841285  function handleOpenFileImport() {1286    setRestoreView("file");1287  }12881289  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12901291  function handleFixEntry(entry: VaultEntry) {1292    setSelectedEntry(entry);1293    // Passkey-only entries have no editable surface — drop into view mode1294    // instead of opening the Add editor.1295    if (isPasskeyOnly(entry)) {1296      setView("entry");1297      return;1298    }1299    setEditBackView(view()); // preserve caller view so back returns here1300    setEditEntry(entry);1301    setView("add");1302  }13031304  const isLockedView = () => view() === "lock" || view() === "adopting";13051306  async function handleAdoptionDone() {1307    await storageSet("peach_onboarded", "true").catch(() => {});1308    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1309    clearOnboardingExit();1310    setOnboarded(true);1311    setHasVault(true);1312    setView("initial-sync");1313    // The background applies a Pro license and persists the source-device1314    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1315    // celebration pending until that exact peer records a completed sync.1316    await refreshProStatus();1317    const deviceId = await resolveInitialSyncDeviceId();1318    if (deviceId) {1319      void beginInitialSyncWait(deviceId);1320    } else {1321      // Pairing completion without a durable peer row should be impossible.1322      // Stay on the honest waiting surface and expose retry instead of falling1323      // through to an empty vault.1324      setInitialSyncSlow(true);1325    }1326  }13271328  async function handleApprovePendingPasskey() {1329    const pending = pendingPasskey();1330    if (!pending) return;1331    if (pending.browser === "firefox" && pending.type === "create") {1332      // Firefox path: popup itself runs navigator.credentials.create. The1333      // background never approved/rejected anything — it just stashed1334      // intent. After the popup-driven flow resolves (success or cancel),1335      // clear the pending key so the popup doesn't re-show this on next open.1336      const { createFirefoxPasskey } = await import("./firefox-passkey");1337      const result = await createFirefoxPasskey(pending);1338      if (!result.ok) {1339        console.warn("Peach Firefox passkey create failed:", result.error);1340      }1341      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1342      setPendingPasskey(null);1343      void loadEntries();1344      window.close();1345      return;1346    }1347    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1348    window.close();1349  }13501351  async function handleDenyPendingPasskey() {1352    const pending = pendingPasskey();1353    if (!pending) return;1354    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1355    window.close();1356  }13571358  function queueContentIdle(after?: () => void) {1359    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1360    contentPhaseTimer = window.setTimeout(() => {1361      setContentPhase("idle");1362      after?.();1363    }, ENTRY_TRANSITION_SETTLE_MS);1364  }13651366  return (1367    <div class="popup-root peach-theme">1368      <Show when={siteAccessMissing()}>1369        <div class="peach-pro-notice-banner peach-site-access-banner" role="alert">1370          <span>Peach can't fill on websites until you allow site access.</span>1371          <button1372            class="peach-pro-notice-link"1373            onClick={() => {1374              // Called synchronously from the click: Firefox requires a user gesture.1375              chrome.permissions.request(ALL_SITES).then(1376                (granted) => setSiteAccessMissing(!granted),1377                (error: unknown) => console.warn("Peach site access request failed:", error),1378              );1379            }}1380          >1381            Allow1382          </button>1383        </div>1384      </Show>1385      <Show when={bootState() === "ready"}>1386        <Show1387          when={!pendingPasskey()}1388          fallback={1389            <PasskeyApprovalView1390              type={pendingPasskey()!.type}1391              rpName={pendingPasskey()!.rpName}1392              rpId={pendingPasskey()!.rpId}1393              userName={pendingPasskey()!.userName}1394              userDisplayName={pendingPasskey()!.userDisplayName}1395              onApprove={handleApprovePendingPasskey}1396              onDeny={handleDenyPendingPasskey}1397            />1398          }1399        >1400          <Show1401            when={!isLockedView()}1402            fallback={1403              <Show1404                when={view() !== "adopting"}1405                fallback={1406                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1407                    <AdoptVaultView1408                      onCancel={() => {1409                        clearOnboardingExit();1410                        setOnboarded(false);1411                        setView("lock");1412                      }}1413                      onDone={handleAdoptionDone}1414                    />1415                  </Suspense>1416                }1417              >1418                <Show1419                  when={onboarded() || hasVault()}1420                  fallback={1421                    <Onboarding1422                      initialCard={onboardingCardAtExit()}1423                      onComplete={() => {1424                        markOnboardingExit("create");1425                        setStartOnboardingRestore(false);1426                        setOnboarded(true);1427                      }}1428                      onRestore={() => {1429                        markOnboardingExit("restore");1430                        setStartOnboardingRestore(true);1431                        setOnboarded(true);1432                      }}1433                      onAdopt={() => {1434                        markOnboardingExit("adopt");1435                        setStartOnboardingRestore(false);1436                        setOnboarded(true);1437                        setView("adopting");1438                      }}1439                    />1440                  }1441                >1442                  <LockView1443                    hasVault={hasVault()}1444                    ready={bootState() === "ready"}1445                    isPro={proStatus()}1446                    forceRecoveryWords={forceRecoveryWords()}1447                    initialRestore={startOnboardingRestore()}1448                    initialRestoreSource={resumeRestoreSource() ?? undefined}1449                    onUnlock={() => {1450                      // RecoveryWordDisplay clears the storage flag before1451                      // calling onDone, so handleUnlock will see no pending1452                      // confirmation and proceed through the normal path.1453                      if (forceRecoveryWords()) setForceRecoveryWords(false);1454                      // The create/restore flow is done — a lock later in this1455                      // window must show the unlock screen, not re-enter the1456                      // onboarding restore surface.1457                      setStartOnboardingRestore(false);1458                      setResumeRestoreSource(null);1459                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1460                      return handleUnlock();1461                    }}1462                    onBackToOnboarding={1463                      !hasVault()1464                        ? () => {1465                            // Deliberately rewinding to the carousel — the exit1466                            // flow must not reclaim the next popup open.1467                            clearOnboardingExit();1468                            setStartOnboardingRestore(false);1469                            setOnboarded(false);1470                          }1471                        : undefined1472                    }1473                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1474                  />1475                </Show>1476              </Show>1477            }1478          >1479            <Show1480              when={1481                view() !== "pro-celebration" &&1482                view() !== "initial-sync" &&1483                view() !== "whats-new" &&1484                view() !== "pro-nudge"1485              }1486              fallback={1487                <Show1488                  when={view() === "initial-sync"}1489                  fallback={1490                    <Show1491                      when={view() === "whats-new"}1492                      fallback={1493                        <Show when={view() === "pro-nudge"} fallback={<ProCelebration />}>1494                          <ProNudgeView1495                            onSeePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1496                            onDismiss={() => setView("entry")}1497                          />1498                        </Show>1499                      }1500                    >1501                      <WhatsNewView onContinue={() => setView("entry")} />1502                    </Show>1503                  }1504                >1505                  <InitialSyncView1506                    slow={initialSyncSlow()}1507                    onRetry={() => void retryInitialSync()}1508                  />1509                </Show>1510              }1511            >1512            <section1513              id="hd-shell"1514              class="peach-shell peach-theme"1515              data-surface="extension"1516            >1517            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1518              <PeachWordmark unlocked />1519              <div class="peach-chrome-actions">1520                <button1521                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1522                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1523                  aria-label="Notes"1524                  title="Notes"1525                >1526                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1527                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1528                    <line x1="4" y1="5" x2="10" y2="5" />1529                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1530                    <line x1="4" y1="10" x2="7" y2="10" />1531                  </svg>1532                </button>1533                <button1534                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1535                    classList={{1536                      "is-syncing": syncBusy(),1537                      "is-ok": syncFlash() === "ok",1538                      "is-error": syncFlash() === "error",1539                    }}1540                    onClick={() => void handleHeaderSync()}1541                    disabled={syncBusy()}1542                    aria-label={`Sync now. ${syncLabel()}`}1543                    title={syncLabel()}1544                  >1545                    <svg class="peach-sync-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1546                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1547                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1548                      <path d="M2.6 1v2.4h2.4" />1549                      <path d="M11.4 13v-2.4H9" />1550                    </svg>1551                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1552                  </button>1553                <button1554                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1555                  onClick={() => setView("settings")}1556                  aria-label="Settings"1557                >1558                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1559                    <path1560                      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"1561                      stroke="currentColor"1562                      stroke-width="0.9"1563                    />1564                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1565                  </svg>1566                </button>1567                <button1568                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1569                  onClick={handleAdd}1570                  aria-label="New entry"1571                >1572                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1573                    <path d="M5.5 1v9M1 5.5h9" />1574                  </svg>1575                </button>1576                <button1577                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1578                  onClick={() => void handleHeaderLock()}1579                  aria-label="Lock vault"1580                >1581                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1582                    <path1583                      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"1584                      stroke="currentColor"1585                      stroke-width="1"1586                      stroke-linecap="round"1587                    />1588                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1589                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1590                  </svg>1591                </button>1592              </div>1593            </header>15941595            <Show when={pendingProNotice() !== null}>1596              <div class="peach-pro-notice-banner">1597                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1598                <button1599                  class="peach-pro-notice-link"1600                  onClick={() => {1601                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1602                    setSettingsInitialSection("license");1603                    setView("settings");1604                    setPendingProNotice(null);1605                  }}1606                >1607                  Learn more →1608                </button>1609                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1610              </div>1611            </Show>16121613            <Show when={pendingRememberSite()}>1614              {(request) => {1615                const origin = getSecurityOriginDisplay(request().url);1616                const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1617                return (1618                  <div class="peach-pro-notice-banner">1619                    <span>Also use “{request().title}” on {originLabel}?</span>1620                    <button1621                      class="peach-pro-notice-link"1622                      onClick={async () => {1623                        await sendMessage({1624                          type: "ADD_ADDITIONAL_URL",1625                          payload: { id: request().entryId, url: request().url },1626                        });1627                        await chrome.storage.session.remove("pendingRememberSite");1628                        setPendingRememberSite(null);1629                      }}1630                    >1631                      Add1632                    </button>1633                    <button1634                      class="peach-pro-notice-close"1635                      onClick={async () => {1636                        await chrome.storage.session.remove("pendingRememberSite");1637                        setPendingRememberSite(null);1638                      }}1639                      aria-label="Dismiss"1640                    >1641                      ✕1642                    </button>1643                  </div>1644                );1645              }}1646            </Show>16471648            <div class="peach-shell-body">1649              <div class="peach-shell-veil"></div>1650              <div class="peach-content-frame" data-content-phase={contentPhase()}>1651                <EntryView1652                  keyboardEnabled={view() === "entry"}1653                  entry={selectedEntry()}1654                  currentTabUrl={currentTabUrl()}1655                  entries={entries()}1656                  isPro={proStatus()}1657                  onDelete={handleDelete}1658                  onFieldSaved={() => void loadEntries()}1659                  onEscape={() => { setView("list"); }}1660                  onProGate={(feature) => {1661                    setUpgradeHighlight(feature);1662                    setSettingsInitialSection("license");1663                    setView("settings");1664                  }}1665                />1666              </div>16671668              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1669                <div class="peach-search-bar">1670                  <span class="peach-search-icon">1671                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1672                      <circle cx="5.5" cy="5.5" r="3.75" />1673                      <path d="M8.75 8.75L11.5 11.5" />1674                    </svg>1675                  </span>1676                  <input1677                    class="peach-search-input"1678                    placeholder="search vault..."1679                    autocomplete="off"1680                    aria-label="Search vault entries"1681                    onClick={openList}1682                    onFocus={openList}1683                    readOnly1684                  />1685                  <div class="peach-search-meta">1686                    <span class="peach-search-counter">1687                      {nonNoteEntries().length > 01688                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1689                        : "0 entries"}1690                    </span>1691                    <span class="peach-divider"></span>1692                    <button1693                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1694                      onClick={() => navigate(-1)}1695                      aria-label="Previous entry"1696                      disabled={nonNoteEntries().length === 0}1697                    >1698                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1699                        <path d="M5 1L1 5.5L5 10" />1700                      </svg>1701                    </button>1702                    <button1703                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1704                      onClick={() => navigate(1)}1705                      aria-label="Next entry"1706                      disabled={nonNoteEntries().length === 0}1707                    >1708                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1709                        <path d="M1 1L5 5.5L1 10" />1710                      </svg>1711                    </button>1712                  </div>1713                </div>1714              </section>1715            </div>17161717            {/* Rare device-trust alerts; normal sync changes are silent. */}1718            <Show when={alertGroups().length > 0}>1719              <div class="peach-sync-alerts">1720                <For each={alertGroups()}>1721                  {([type, group]) => {1722                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1723                    return (1724                      <div class="peach-sync-alert">1725                        <Show when={group.length > 1}>1726                          <button1727                            class="peach-sync-alert-flip"1728                            type="button"1729                            aria-label="Previous"1730                            onClick={() => flipAlert(type, -1)}1731                          >‹</button>1732                        </Show>1733                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1734                        <Show when={group.length > 1}>1735                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1736                          <button1737                            class="peach-sync-alert-flip"1738                            type="button"1739                            aria-label="Next"1740                            onClick={() => flipAlert(type, 1)}1741                          >›</button>1742                        </Show>1743                        <button1744                          class="peach-sync-alert-dismiss"1745                          type="button"1746                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1747                          onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1748                        >×</button>1749                      </div>1750                    );1751                  }}1752                </For>1753              </div>1754            </Show>17551756            <Show when={view() === "list"}>1757              <VaultView1758                entries={entries()}1759                selectedEntryId={selectedEntry()?.id}1760                currentTabDomain={currentTabDomain()}1761                sortOrder={sortOrder()}1762                searchSeed={listSearchSeed()}1763                searchSeedNonce={listSearchSeedNonce()}1764                onSelectEntry={handleSelectEntry}1765                onSortOrderChange={setSortOrder}1766                onAdd={handleAdd}1767                onClose={() => setView("entry")}1768                onOpenTrash={() => setView("trash")}1769                onDeleteMany={handleDeleteMany}1770              />1771            </Show>1772            <Show when={view() === "settings" || settingsExiting()}>1773              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1774                <SettingsView1775                  entriesCount={entries().length}1776                  breachMonitoring={breachMonitoring()}1777                  lockOnClose={lockOnClose()}1778                  autoLockMinutes={autoLockMinutes()}1779                  trashRetentionDays={trashRetentionDays()}1780                  lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1781                  theme={theme()}1782                  accentColor={accentColor()}1783                  textSize={textSize()}1784                  reduceMotion={reduceMotion()}1785                  isPro={proStatus()}1786                  initialSection={settingsInitialSection()}1787                  upgradeHighlight={upgradeHighlight()}1788                  isExiting={settingsExiting()}1789                  onBreachMonitoringChange={applyBreachMonitoring}1790                  onLockOnCloseChange={applyLockOnClose}1791                  onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1792                  onAutoLockMinutesChange={applyAutoLockMinutes}1793                  onTrashRetentionChange={applyTrashRetention}1794                  onThemeChange={applyTheme}1795                  onAccentChange={applyAccentColor}1796                  onTextSizeChange={applyTextSize}1797                  onReduceMotionChange={applyReduceMotion}1798                  shortcuts={shortcutBindings()}1799                  onShortcutsChange={applyShortcuts}1800                  onLicenseChange={async () => {1801                    // After activation/deactivation: refresh Pro status,1802                    // re-load entries (defensive — signal state can drift if1803                    // the SW briefly restarts during the verify+save round-trip),1804                    // and replay the Pro celebration if this is the first time1805                    // a valid Pro license is seen on this device.1806                    await refreshProStatus();1807                    void loadEntries();1808                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1809                    if ((should as { ok: boolean; data?: boolean }).data === true) {1810                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1811                      setView("pro-celebration");1812                      window.setTimeout(() => setView("settings"), 2500);1813                    }1814                  }}1815                  onBack={() => {1816                    setSettingsExiting(true);1817                    window.setTimeout(() => {1818                      setSettingsExiting(false);1819                      setSettingsInitialSection(null);1820                      setUpgradeHighlight(null);1821                      handleBack();1822                    }, 200);1823                  }}1824                  onImport={() => setView("import")}1825                  onExport={() => setView("export")}1826                  onBreachCheckDone={loadEntries}1827                  onOpenHealth={openHealth}1828                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1829                  onOpenFileImport={handleOpenFileImport}1830                  onOpenPanic={() => setView("panic")}1831                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1832                  onOpenCodexRestore={() => setView("codex-restore")}1833                  onCardChange={setSettingsActiveCard}1834                  onProGate={(feature) => {1835                    setUpgradeHighlight(feature);1836                    setSettingsInitialSection("license");1837                  }}1838                />1839              </Suspense>1840            </Show>1841            <Show when={view() === "codex-generate"}>1842              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1843                <CodexGenerateView1844                  isPro={proStatus()}1845                  onBack={() => setView("settings")}1846                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1847                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1848                  onVaultLocked={() => handleLock()}1849                />1850              </Suspense>1851            </Show>1852            <Show when={view() === "codex-restore"}>1853              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1854                <CodexRestoreView1855                  onBack={() => setView("lock")}1856                  onRestoreSuccess={async () => {1857                    // After restore the vault is unlocked; refresh entries1858                    // and route to the main vault list. handleUnlock is the1859                    // canonical post-unlock setup path.1860                    await handleUnlock();1861                  }}1862                />1863              </Suspense>1864            </Show>1865            <Show when={view() === "pair-device"}>1866              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1867                <PairDeviceView1868                  isPro={proStatus()}1869                  onProChanged={() => { void refreshProStatus(); }}1870                  onBack={() => setView("settings")}1871                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1872                />1873              </Suspense>1874            </Show>1875            <Show when={view() === "add"}>1876              <AddView1877                entry={editEntry()}1878                entries={entries()}1879                onBack={handleBack}1880                onSave={handleSave}1881              />1882            </Show>1883            <Show when={view() === "import"}>1884              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1885                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1886              </Suspense>1887            </Show>1888            <Show when={view() === "export"}>1889              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1890                <ExportView onBack={handleExportBack} />1891              </Suspense>1892            </Show>1893            <Show when={view() === "panic"}>1894              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1895                <PanicView1896                  onCancel={() => setView("settings")}1897                  onOpenExport={() => {1898                    setReturnToPanic(true);1899                    setView("export");1900                  }}1901                  onDestroyed={async () => {1902                    // Fresh onboarding start after a destroy — don't let the1903                    // resume markers land the user mid-carousel.1904                    setOnboardingCardAtExit(0);1905                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1906                    clearOnboardingExit();1907                    setEntries([]);1908                    setSelectedEntry(null);1909                    setEditEntry(undefined);1910                    setHasVault(false);1911                    setOnboarded(false);1912                    setProStatus(false);1913                    setPendingPasskey(null);1914                    setUpgradeHighlight(null);1915                    setSettingsInitialSection(null);1916                    setPendingProNotice(null);1917                    setPendingCelebration(false);1918                    setReturnToPanic(false);1919                    setContentPhase("idle");1920                    setView("lock");1921                  }}1922                />1923              </Suspense>1924            </Show>1925            {/* Health stays mounted while editing an entry from it so1926                computeReused() doesn't rerun on every cancel/back. */}1927            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1928              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1929                <HealthView1930                  entries={entries()}1931                  isPro={proStatus()}1932                  onFixEntry={handleFixEntry}1933                  onBack={() => setView("settings")}1934                  onRefresh={loadEntries}1935                  hidden={view() !== "health"}1936                />1937              </Suspense>1938            </Show>1939            <Show when={view() === "trash"}>1940              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1941                <TrashView1942                  onBack={() => setView("list")}1943                  onRestored={() => void loadEntries()}1944                />1945              </Suspense>1946            </Show>1947            <Show when={showNotes()}>1948              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1949                <NotesMode1950                  notes={entries().filter((e) => e.type === "note")}1951                  initialNoteId={openNoteId()}1952                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1953                  onNoteListChanged={() => void loadEntries()}1954                />1955              </div>1956            </Show>1957            <Show when={restoreView() === "file"}>1958              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1959                <ImportView1960                  mode="onboarding"1961                  replacesExistingVault={true}1962                  onBack={() => setRestoreView(null)}1963                  onImport={async () => {1964                    setRestoreView(null);1965                    await handleUnlock();1966                  }}1967                />1968              </Suspense>1969            </Show>1970            </section>1971            </Show>1972          </Show>1973        </Show>1974      </Show>1975      <Show when={showShortcutsHelp()}>1976        <ShortcutsHelpOverlay1977          bindings={shortcutBindings()}1978          onClose={() => setShowShortcutsHelp(false)}1979        />1980      </Show>1981    </div>1982  );1983}19841985function ShortcutsHelpOverlay(props: {1986  bindings: Record<ShortcutAction, Keybinding>;1987  onClose: () => void;1988}) {1989  return (1990    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1991      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1992        <div class="peach-shortcuts-help-head">1993          <h3>Keyboard shortcuts</h3>1994          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1995        </div>1996        <div class="peach-shortcuts-help-body">1997          <p class="peach-settings-sub-label">ENTRY VIEW</p>1998          <ul>1999            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (2000              <li>2001                <span>{d.label}</span>2002                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2003              </li>2004            ))}2005          </ul>2006          <p class="peach-settings-sub-label">ANYWHERE</p>2007          <ul>2008            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (2009              <li>2010                <span>{d.label}</span>2011                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2012              </li>2013            ))}2014          </ul>2015        </div>2016      </div>2017    </div>2018  );2019}