App.tsx

83.6 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;132133export default function App() {134  const [bootState, setBootState] = createSignal<BootState>("loading");135  const [view, setView] = createSignal<View>("lock");136  const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");137  const [hasVault, setHasVault] = createSignal(false);138  const [onboarded, setOnboarded] = createSignal(true);139  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);140  // The restore source picked before the popup closed (the OS file picker141  // kills it), so reopening resumes the same source form instead of the142  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.143  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);144  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes145  // back. Those flows all exit from the final card, so resume there — a fresh146  // install's first mount stays at 0.147  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);148  const [entries, setEntries] = createSignal<VaultEntry[]>([]);149  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);150  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);151152  // Rare device-trust alerts stay visible; routine entry sync is silent.153  interface SyncAlert {154    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";155    entryId: string;156    entryTitle: string;157    deviceName: string;158    timestamp: number;159  }160  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);161  // Per-group flip index: which alert within a type-group is currently shown.162  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});163  async function loadSyncAlerts() {164    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {165      ok: boolean;166      data?: SyncAlert[];167    };168    if (r.ok) setSyncAlerts(r.data ?? []);169  }170  async function dismissAlertGroup(type: SyncAlert["type"]) {171    const result = (await sendMessage({172      type: "DISMISS_SYNC_ALERTS",173      payload: { type },174    })) as { ok: boolean };175    if (!result.ok) return;176    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));177    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });178  }179  function flipAlert(type: string, dir: 1 | -1) {180    const group = syncAlerts().filter((a) => a.type === type);181    if (group.length <= 1) return;182    setAlertFlipIdx((m) => {183      const cur = m[type] ?? 0;184      return { ...m, [type]: (cur + dir + group.length) % group.length };185    });186  }187  /** Grouped alert types in display order, each with its alerts. */188  const alertGroups = createMemo(() => {189    const map = new Map<string, SyncAlert[]>();190    for (const a of syncAlerts()) {191      const g = map.get(a.type);192      if (g) g.push(a); else map.set(a.type, [a]);193    }194    return [...map.entries()];195  });196  function syncAlertText(a: SyncAlert): string {197    if (a.type === "DEVICE_REVOKED") {198      return `${a.deviceName} revoked a paired device — review your devices in Settings`;199    }200    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;201  }202  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");203  const [theme, setTheme] = createSignal<"dark" | "light">("dark");204  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");205  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);206  const [lockOnClose, setLockOnClose] = createSignal(false);207  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);208  const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);209  const [breachMonitoring, setBreachMonitoring] = createSignal(true);210  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);211  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);212  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);213  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);214  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);215  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");216  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");217  // Cached Pro status — synchronous for render paths. Background's218  // GET_LICENSE is async, but render-time reads of isPro() must not block.219  // Refreshed at boot, after activate/deactivate, and whenever the popup220  // re-opens onto a stored license that has changed via another window.221  const [proStatus, setProStatus] = createSignal(false);222  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);223  // When the sync card's pairing button opens the pairing view, deep-link224  // straight into that pairing path instead of the rest screen.225  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");226  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);227  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");228  const [listSearchSeed, setListSearchSeed] = createSignal("");229  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);230  const [pendingCelebration, setPendingCelebration] = createSignal(false);231  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);232  // A page asked to be remembered for an entry it did not match. Pages cannot233  // grow an entry's domain set themselves (PB-26-106), so the background queues234  // the request here and this banner is the confirmation gate.235  const [pendingRememberSite, setPendingRememberSite] = createSignal<{236    entryId: string;237    url: string;238    title: string;239  } | null>(null);240  // True while Panic Step 1 has bounced the user out to Export. When Export's241  // onBack fires we return to Panic instead of Settings so the user keeps the242  // destruction context they came from.243  const [returnToPanic, setReturnToPanic] = createSignal(false);244  // The view to return to when the add/edit view is dismissed. Normally "entry"245  // but "health" when the user arrives via the fix-entry path from Vault Health.246  const [editBackView, setEditBackView] = createSignal<View>("entry");247  const [settingsExiting, setSettingsExiting] = createSignal(false);248  const [showNotes, setShowNotes] = createSignal(false);249  // Topbar sync affordance. Always present: gating it on having a paired device250  // meant it never appeared for the person trying to set pairing up, which is251  // exactly when you reach for it.252  const [syncBusy, setSyncBusy] = createSignal(false);253  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);254  // The indicator light + its spoken-word equivalent. Colour alone is not the255  // signal; the title/aria-label always carries the same claim in English.256  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});257  const [pairedCount, setPairedCount] = createSignal(0);258  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);259  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);260  let initialSyncWaitGeneration = 0;261  const syncLight = () =>262    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());263  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());264  async function loadSyncIndicator() {265    const [stateRes, devicesRes] = await Promise.all([266      sendMessage({ type: "GET_SYNC_STATE" }),267      sendMessage({ type: "LIST_PAIRED_DEVICES" }),268    ]);269    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };270    if (s.ok && s.data) setSyncSummary(s.data);271    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };272    if (d.ok) {273      setPairedCount(d.data?.devices?.length ?? 0);274    } else if (d.error === "trust_data_corrupted") {275      setPairedCount(0);276      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });277    }278  }279280  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {281    const response = (await sendMessage({282      type: "LIST_PAIRED_DEVICES",283    })) as {284      ok: boolean;285      error?: string;286      data?: { devices?: InitialSyncPairedDevice[] };287    };288    if (response.ok) return response.data?.devices ?? [];289    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");290    throw new Error(response.error ?? "list_paired_devices_failed");291  }292  interface InitialSyncPairedDevice extends InitialSyncDevice {293    pairedAt?: number;294  }295296297  async function resolveInitialSyncDeviceId(): Promise<string | null> {298    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(299      () => null,300    );301    const devices = await listInitialSyncDevices().catch(() => []);302    if (stored && devices.some((device) => device.deviceId === stored)) {303      return stored;304    }305306    // Fresh adoption has exactly one direct source, but choosing the newest row307    // also repairs the marker if storage failed after the pair itself committed.308    const newest = [...devices].sort(309      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),310    )[0];311    if (!newest) return stored;312    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(313      () => undefined,314    );315    return newest.deviceId;316  }317318  async function finishInitialSync(generation: number): Promise<void> {319    if (generation !== initialSyncWaitGeneration) return;320    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);321    setInitialSyncDeviceId(null);322    setInitialSyncSlow(false);323    await refreshProStatus();324325    // A license transferred during pairing intentionally leaves the one-shot326    // celebration pending. Consume it only now: sync readiness → celebration327    // → populated vault, never celebration → temporarily empty vault.328    const celebration = (await sendMessage({329      type: "SHOULD_PLAY_CELEBRATION",330    })) as { ok: boolean; data?: boolean };331    const shouldCelebrate = celebration.ok && celebration.data === true;332    if (shouldCelebrate) {333      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });334    }335336    const opened = await openVault(undefined, shouldCelebrate);337    if (!opened || generation !== initialSyncWaitGeneration) return;338    if (shouldCelebrate) {339      setView("pro-celebration");340      window.setTimeout(() => {341        if (generation === initialSyncWaitGeneration) setView("entry");342      }, 2500);343    }344  }345346  async function beginInitialSyncWait(deviceId: string): Promise<void> {347    const generation = ++initialSyncWaitGeneration;348    setInitialSyncDeviceId(deviceId);349    setInitialSyncSlow(false);350    setView("initial-sync");351    const startedAt = Date.now();352353    // Best effort: the pairing write already wakes the mesh listener, while354    // this trigger covers the key-order case where this receiver is the dialer.355    void sendMessage({356      type: "TRIGGER_SYNC",357      payload: { reason: "foreground" },358    }).catch(() => undefined);359360    while (361      generation === initialSyncWaitGeneration &&362      view() === "initial-sync"363    ) {364      try {365        const devices = await listInitialSyncDevices();366        if (hasCompletedInitialSync(devices, deviceId)) {367          await finishInitialSync(generation);368          return;369        }370      } catch {371        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.372      }373      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {374        setInitialSyncSlow(true);375      }376      await new Promise((resolve) =>377        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),378      );379    }380  }381382  async function retryInitialSync(): Promise<void> {383    const deviceId =384      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());385    if (deviceId) {386      await beginInitialSyncWait(deviceId);387    } else {388      setInitialSyncSlow(true);389    }390  }391392  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);393  // Set on popup boot when peach_recovery_pending flag is found in session394  // storage — instructs LockView to skip the unlock UI and open the recovery395  // words confirmation surface instead. Cleared via handleUnlock once the396  // user has typed the 3 confirmation words.397  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);398399  let contentPhaseTimer: number | undefined;400  // Set when the popup was opened from the inline "Open Peach to unlock" button.401  // handleUnlock() closes the popup after unlock so the user returns to the page.402  let openedForInlineUnlock = false;403404  onCleanup(() => {405    initialSyncWaitGeneration += 1;406  });407408  function applyAccent(name: AccentColorName) {409    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;410    const hex = color.hex;411    document.documentElement.style.setProperty("--peach-accent", hex);412    // Ambient variables derived from the selected accent.413    document.documentElement.style.setProperty("--peach-glow", hex + "2A");414    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");415  }416417  createEffect(() => {418    applyAccent(accentColor());419  });420421  createEffect(() => {422    document.documentElement.classList.toggle("light", theme() === "light");423    document.body.classList.toggle("light", theme() === "light");424  });425426  createEffect(() => {427    document.documentElement.setAttribute("data-text-size", textSize());428  });429430  createEffect(() => {431    if (reduceMotion()) {432      document.documentElement.setAttribute("data-motion", "reduce");433    } else {434      document.documentElement.removeAttribute("data-motion");435    }436  });437438  // Notes are not a destination of the Entry view; selecting a note opens the439  // notes surface instead (handleSelectEntry). Anything that walks or counts440  // the Entry view's neighbours (counter, prev/next, index) must use this441  // filtered list, otherwise the user can arrow into a note and is stuck —442  // the Entry view can't render a note and the prev/next buttons stop moving.443  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));444445  const selectedIndex = createMemo(() => {446    const currentId = selectedEntry()?.id;447    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;448    return nonNoteEntries().findIndex((entry) => entry.id === currentId);449  });450451  async function applyAccentColor(name: AccentColorName) {452    applyAccent(name);453    setAccentColor(name);454    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });455  }456457  async function applyTheme(t: "dark" | "light") {458    setTheme(t);459    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });460  }461462  async function applyTextSize(size: "small" | "medium" | "large") {463    setTextSize(size);464    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });465  }466467  async function applyReduceMotion(enabled: boolean) {468    setReduceMotion(enabled);469    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });470  }471472  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload473  // small and lets future default updates land for actions the user hasn't474  // touched. Settings sends us the full active map; we filter and store.475  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {476    setShortcutBindings(active);477    const overrides: Record<string, Keybinding> = {};478    for (const action of Object.keys(active) as ShortcutAction[]) {479      const a = active[action];480      const d = DEFAULT_BINDINGS[action];481      const same = a.key === d.key482        && Boolean(a.ctrl)  === Boolean(d.ctrl)483        && Boolean(a.meta)  === Boolean(d.meta)484        && Boolean(a.shift) === Boolean(d.shift)485        && Boolean(a.alt)   === Boolean(d.alt);486      if (!same) overrides[action] = a;487    }488    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });489  }490491  async function applyLockOnClose(enabled: boolean) {492    setLockOnClose(enabled);493    await sendMessage({494      type: "UPDATE_SETTINGS",495      payload: { lockOnClose: enabled },496    });497  }498499  async function applyAutoLockMinutes(minutes: number) {500    setAutoLockMinutes(minutes);501    await sendMessage({502      type: "UPDATE_SETTINGS",503      payload: { autoLockMinutes: minutes },504    });505  }506  async function applyLockedSyncRequiresUnlock(enabled: boolean) {507    setLockedSyncRequiresUnlock(enabled);508    await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });509  }510511  async function applyBreachMonitoring(enabled: boolean) {512    setBreachMonitoring(enabled);513    await sendMessage({514      type: "UPDATE_SETTINGS",515      payload: { breachMonitoring: enabled },516    });517  }518519  // Which onboarding exit flow the user is in (create-vault / restore /520  // adopt). Persisted so closing the popup on one of those screens resumes521  // it directly instead of rewinding to the last carousel card.522  function markOnboardingExit(flow: "create" | "restore" | "adopt") {523    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);524    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});525    // Backing out of the flow remounts the carousel at this card; keep the526    // persisted card in sync so a later popup close resumes consistently.527    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});528  }529  function clearOnboardingExit() {530    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});531    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});532    setResumeRestoreSource(null);533  }534535  onMount(async () => {536    // Resolve the active tab in parallel with the unlock/has-vault queries —537    // openVault()'s smart selection picks the entry matching currentTabDomain,538    // so it must be populated before openVault runs. Previously refreshCurrentTab539    // ran after openVault, leaving popups that booted already-unlocked falling540    // back to loadedEntries[0] instead of the domain match.541    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([542      sendMessage({ type: "IS_UNLOCKED" }),543      sendMessage({ type: "HAS_VAULT" }),544      refreshCurrentTab(),545      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),546    ]);547    const unlocked = Boolean(548      (unlockResponse as { ok: boolean; data?: boolean }).data549    );550    const vaultExists = Boolean(551      (vaultResponse as { ok: boolean; data?: boolean }).data552    );553    const shouldCelebrate = Boolean(554      (celebrationResponse as { ok: boolean; data?: boolean }).data555    );556    setHasVault(vaultExists);557    // Onboarding gate — show once on first install, never again. Existing558    // users (vaultExists) are auto-marked onboarded so they skip the flow559    // even on the very first popup open after this feature ships.560    try {561      const onboarded = await storageGet("peach_onboarded");562      const seen = onboarded === "true";563      if (vaultExists && !seen) {564        await storageSet("peach_onboarded", "true");565        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});566        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});567        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});568        setOnboarded(true);569      } else {570        setOnboarded(seen);571        if (!seen) {572          // A popup close while the user is in an exit flow (create-vault /573          // restore / adopt) resumes that flow directly — landing back on the574          // carousel one screen earlier reads as losing progress.575          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);576          if (exit === "create" || exit === "restore" || exit === "adopt") {577            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);578            setOnboarded(true);579            setStartOnboardingRestore(exit === "restore");580            if (exit === "restore") {581              // The OS file picker kills the action popup mid-restore, so the582              // surface resumes on the source the user had already picked.583              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);584              if (source === "symbols" || source === "codex" || source === "csv") {585                setResumeRestoreSource(source);586              }587            }588            if (exit === "adopt") setView("adopting");589          } else {590            // Otherwise resume mid-onboarding where the user left off instead591            // of rewinding to the opening card.592            const stored = Number(593              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),594            );595            if (596              Number.isInteger(stored) &&597              stored > 0 &&598              stored <= ONBOARDING_LAST_CARD599            ) {600              setOnboardingCardAtExit(stored);601            }602          }603        }604      }605    } catch {606      // storage unavailable — default to onboarded so we never block boot607      setOnboarded(true);608    }609610    // Deep-link: ?restore=peachscript|csv — the detached-window half of the611    // file-picker flow. The action popup dismisses when the OS dialog steals612    // focus, so LockView.detachForFilePicker() reopens this URL in a613    // standalone window that owns the dialog. Routing runs before first614    // render so LockView mounts straight onto the right source form.615    try {616      const restoreRoute = new URLSearchParams(window.location.search).get("restore");617      if (restoreRoute === "peachscript" || restoreRoute === "csv") {618        setOnboarded(true);619        setStartOnboardingRestore(true);620        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");621      }622    } catch {623      // location.search unavailable — ignore.624    }625626    await Promise.all([restoreSettings(), refreshProStatus()]);627628    const pendingInitialSyncMarker =629      unlocked && vaultExists630        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)631        : null;632    const pendingInitialSyncDeviceId = pendingInitialSyncMarker633      ? await resolveInitialSyncDeviceId()634      : null;635636    // Mark played before any animation so a crash mid-sequence never replays637    // it. A newly adopted receiver deliberately defers this until its first638    // real sync finishes, preserving initial sync → celebration → vault.639    if (shouldCelebrate && !pendingInitialSyncDeviceId) {640      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });641    }642643    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome644    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.645    if (unlocked && vaultExists) {646      void sendMessage({647        type: "TRIGGER_SYNC",648        payload: { reason: "foreground" },649      });650      void loadSyncIndicator();651      // Rare device-trust alerts only; routine entry sync stays quiet.652      void loadSyncAlerts();653    }654655    // If the user created a vault but hasn't yet confirmed their recovery656    // words, route back to the recovery-words screen instead of opening the657    // entries view. The flag lives in chrome.storage.local so it survives658    // popup close while the background owns the session-scoped confirmation659    // exemption. Cleared when confirmation succeeds.660    let recoveryPending = false;661    try {662      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });663      recoveryPending = out.ok === true && out.data === true;664    } catch { /* best-effort */ }665666    // Content UI can ask the background to open this trusted popup either for667    // unlock or at a specific entry. Consume both session-scoped handoffs once.668    let pendingOpenEntryId: string | undefined;669    if (chrome.storage.session) {670      try {671        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);672        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };673        if (pending.peach_inline_unlock) {674          openedForInlineUnlock = true;675        }676        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;677        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);678      } catch { /* best-effort */ }679    }680681    if (recoveryPending && unlocked) {682      // forceRecoveryWords overrides LockView's hasVault gate so the683      // recovery surface renders even though the vault now exists.684      setForceRecoveryWords(true);685      setView("lock");686    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {687      void beginInitialSyncWait(pendingInitialSyncDeviceId);688    } else if (unlocked && vaultExists) {689      await openVault(pendingOpenEntryId, shouldCelebrate);690      if (shouldCelebrate) {691        setView("pro-celebration");692        window.setTimeout(() => setView("entry"), 2500);693      } else if (await shouldShowWhatsNew()) {694        setView("whats-new");695      } else if (!proStatus() && await claimProNudge()) {696        setView("pro-nudge");697      }698    } else if (shouldCelebrate) {699      setPendingCelebration(true);700    }701702    await refreshPendingPasskey();703704    // Track tab changes so smart sort stays current705    const handleTabActivated = () => { void refreshCurrentTab(); };706    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {707      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();708    };709    try {710      chrome.tabs.onActivated.addListener(handleTabActivated);711      chrome.tabs.onUpdated.addListener(handleTabUpdated);712      onCleanup(() => {713        chrome.tabs.onActivated.removeListener(handleTabActivated);714        chrome.tabs.onUpdated.removeListener(handleTabUpdated);715      });716    } catch {717      // tabs permission may not be available in all contexts718    }719720    setBootState("ready");721722    // Deep-link: ?codex=pdf opens a detached window straight into the723    // Codex restore flow with auto-picked PDF. The action popup can't host724    // a file dialog without dismissing itself, so this is the only way the725    // PDF import path survives. See CodexRestoreView.openPdfPicker().726    try {727      const route = new URLSearchParams(window.location.search).get("codex");728      if (route === "pdf" || route === "restore") {729        setView("codex-restore");730      }731    } catch {732      // location.search unavailable — ignore.733    }734735    // Check for pending session-storage notices from the background.736    if (chrome.storage.session) {737      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as738        Record<string, { feature?: string } | undefined>;739      if (session.pendingProGate?.feature) {740        await chrome.storage.session.remove("pendingProGate");741        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);742        setSettingsInitialSection("license");743        setView("settings");744      } else if (session.pendingProNotice?.feature) {745        await chrome.storage.session.remove("pendingProNotice");746        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });747      }748      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {749        pendingRememberSite?: { entryId?: unknown; url?: unknown };750      };751      const request = remembered.pendingRememberSite;752      if (753        typeof request?.entryId === "string" &&754        typeof request.url === "string"755      ) {756        const entry = await sendMessage({ type: "GET_ENTRIES" });757        const match = entry.ok758          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(759              (candidate) => candidate.id === request.entryId,760            )761          : undefined;762        if (match) {763          let host = request.url;764          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }765          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });766        } else {767          await chrome.storage.session.remove("pendingRememberSite");768        }769      }770    }771772    // Warm the lazy-module cache so the Suspense fallback never shows for773    // locally-bundled extension views. Without this, the first navigation to774    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which775    // covers the shell and absorbs clicks on the header.776    void Promise.all([777      import("./views/Settings").catch(() => {}),778      import("./views/Import").catch(() => {}),779      import("./views/Export").catch(() => {}),780      import("./views/Health").catch(() => {}),781      import("./views/TrashView").catch(() => {}),782    ]);783784    // Lock-on-close via a persistent port. sendMessage in pagehide is785    // unreliable — the popup context tears down before the message lands.786    // The SW's onConnect listener locks when this port disconnects.787    let popupPort: chrome.runtime.Port | null = null;788    try {789      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect790      // listener accepts the "popup-" prefix; the random suffix is defence in791      // depth against a port named exactly "popup" from anything but our popup.792      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });793    } catch { /* extension context invalidated — ignore */ }794    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });795796    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale797    // entries instead of waiting for the next GET_ENTRIES round-trip.798    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {799      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();800      if (msg?.type === "SYNC_STATE_CHANGED") {801        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);802      }803      // A background sync merged entries the user never asked for (a peer's804      // push, or the periodic dial). Pull the fresh list so the vault view is805      // not stale — loadEntries() preserves the current selection by id.806      if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {807        void loadEntries();808      }809      if (msg?.type === "PRO_GATE_TRIGGERED") {810        setUpgradeHighlight(msg.payload?.feature ?? null);811        setSettingsInitialSection("license");812        setView("settings");813      }814    };815    chrome.runtime.onMessage.addListener(handleRuntimeMessage);816    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));817818    // Action handlers — keyed by ShortcutAction. The dispatcher below tests819    // each binding against the event and runs the matching handler. New820    // shortcuts only need an entry here; the binding is data, not code.821    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {822      copy_password: () => {823        const entry = selectedEntry();824        if (entry?.password) void popupCopy(825          entry.password,826          "Password",827          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,828        ).then((ok) => { if (ok) triggerRing(); });829      },830      copy_username: () => {831        const entry = selectedEntry();832        if (entry?.username) void popupCopy(833          entry.username,834          "Username",835          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,836        ).then((ok) => { if (ok) triggerRing(); });837      },838      copy_totp: () => {839        const entry = selectedEntry();840        if (!entry?.totp) return;841        try {842          const { code } = generateTOTP(843            entry.totp,844            entry.totpAlgorithm,845            entry.totpDigits,846            entry.totpPeriod,847          );848          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });849        } catch { /* invalid TOTP secret — silent */ }850      },851      new_entry: () => handleAdd(),852      edit_entry: () => {853        const entry = selectedEntry();854        // Passkey-only entries have nothing user-editable in the Add view; the855        // passkey credential itself is opaque. Skip the edit action for them.856        if (!entry || isPasskeyOnly(entry)) return;857        setEditEntry(entry);858        setView("add");859      },860      delete_entry: () => {861        if (selectedEntry()) void handleDelete();862      },863      lock_vault: () => { void handleHeaderLock(); },864      search: () => openList(),865      nav_prev: () => navigate(-1),866      nav_next: () => navigate(1),867      jump_vault: () => setView("entry"),868      jump_settings: () => setView("settings"),869      show_help: () => setShowShortcutsHelp(true),870    };871872    // Actions that are MEANINGFUL only on the entry-detail view. Outside873    // that scope the binding is dormant — pressing C while on Settings874    // does nothing rather than awkwardly copying from a now-stale entry.875    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([876      "copy_password", "copy_username", "copy_totp",877      "edit_entry", "delete_entry", "nav_prev", "nav_next",878    ]);879880    const handleGlobalKeydown = (e: KeyboardEvent) => {881      const v = view();882      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"883        || (e.target as HTMLElement)?.tagName === "TEXTAREA"884        || (e.target as HTMLElement)?.isContentEditable;885      const isPrintableSearchKey =886        e.key.length === 1 &&887        !e.ctrlKey &&888        !e.metaKey &&889        !e.altKey;890891      // No shortcuts while a full-screen surface owns the whole viewport:892      // the lock screen, the post-pairing readiness sequence, or the Pro893      // celebration. Without this the lock screen is at the mercy of whatever894      // single-key binding is active — e.g. typing "n" in a password that895      // slips out of focus triggers `new_entry` and escapes the lock screen.896      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;897898      // Help dialog: Escape closes it; eat the keypress so it doesn't899      // also navigate away from the underlying view.900      if (showShortcutsHelp()) {901        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }902        return;903      }904905      // ── Escape — fixed binding (not user-customizable) ───────906      if (e.key === "Escape") {907        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }908        if (v === "list") { setView("entry"); return; }909        if (v === "settings" || v === "add") { handleBack(); return; }910        if (v === "import" || v === "health") { setView("settings"); return; }911        if (v === "export") { handleExportBack(); return; }912        if (v === "pair-device") { setView("settings"); return; }913      }914915      // Bindings-driven dispatch. Single-key letter bindings without916      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is917      // inside an input, so users can type into search/forms normally.918      const bindings = shortcutBindings();919      for (const action of Object.keys(bindings) as ShortcutAction[]) {920        const binding = bindings[action];921        if (!matchKeybinding(e, binding)) continue;922        // Single-key letter bindings need the input-focus guard. Bindings923        // with any modifier are safe — Ctrl+L doesn't conflict with typing.924        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;925        if (!hasMod && binding.key.length === 1 && inInput) return;926        if (ENTRY_SCOPED.has(action) && v !== "entry") return;927        e.preventDefault();928        SHORTCUT_HANDLERS[action]?.();929        return;930      }931932      // Type-to-search: in entry view, any printable key not consumed by a933      // shortcut above primes the list search seed and opens the list.934      // Kept outside the bindings table because it's a behavior, not a935      // discrete action — every printable character can trigger it.936      if (v === "entry" && !inInput && isPrintableSearchKey) {937        e.preventDefault();938        primeListSearch(e.key);939        return;940      }941    };942    window.addEventListener("keydown", handleGlobalKeydown);943    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));944945    const handleStorageChanged = (946      changes: Record<string, chrome.storage.StorageChange>,947      areaName: string948    ) => {949      if (areaName !== "session") return;950      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {951        return;952      }953      void refreshPendingPasskey();954    };955    chrome.storage.onChanged.addListener(handleStorageChanged);956    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));957  });958959  async function refreshCurrentTab() {960    try {961      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });962      const url = tab?.url ?? tab?.pendingUrl ?? "";963      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");964      // Skip extension/internal URLs — adding chrome://, about:, or965      // chrome-extension:// to a vault entry is never what the user wants.966      const isWebUrl = /^https?:\/\//i.test(url);967      setCurrentTabUrl(isWebUrl ? url : "");968    } catch {969      setCurrentTabDomain("");970      setCurrentTabUrl("");971    }972  }973974  async function refreshPendingPasskey() {975    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });976    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);977  }978979  async function refreshProStatus() {980    const res = await sendMessage({ type: "GET_LICENSE" });981    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;982    setProStatus(Boolean(data?.valid && data?.plan === "pro"));983  }984985  async function restoreSettings() {986    const res = await sendMessage({ type: "GET_SETTINGS" });987    const s = (res as { ok: boolean; data?: any }).data ?? {};988    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {989      setAccentColor(s.accentColor as AccentColorName);990    }991    setTheme(s.theme === "light" ? "light" : "dark");992    if (s.textSize === "small" || s.textSize === "large") {993      setTextSize(s.textSize);994    } else {995      setTextSize("medium");996    }997    setReduceMotion(s.reduceMotion === true);998    setLockOnClose(s.lockOnClose === true);999    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);1000    setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);1001    setBreachMonitoring(s.breachMonitoring !== false);1002    setShortcutBindings(getActiveBindings(s.shortcuts));1003  }10041005  async function applyTrashRetention(days: number) {1006    setTrashRetentionDays(days);1007    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1008  }10091010  function primeListSearch(seed: string) {1011    setListSearchSeed(seed);1012    setListSearchSeedNonce((value) => value + 1);1013    openList();1014  }10151016  // Returns null when it detected a dead service worker and triggered handleLock().1017  // Callers that need to abort (e.g. openVault) check for null rather than relying1018  // on view() state, which may already be "lock" during normal startup flows.1019  async function loadEntries(): Promise<VaultEntry[] | null> {1020    const response = await sendMessage({ type: "GET_ENTRIES" });1021    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10221023    // If the service worker was killed and restarted (MV3 workers sleep), the1024    // vault will be locked again — or the worker may not respond at all.1025    // In either case, send the user to the lock screen rather than silently1026    // showing an empty vault (which looks like data loss).1027    if (!res.ok) {1028      if (1029        res.error === "vault_locked" ||1030        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1031        (typeof res.error === "string" && res.error.includes("locked"))1032      ) {1033        handleLock();1034        return null; // signal emergency lock to callers1035      }1036      return [];1037    }10381039    const loadedEntries = res.data ?? [];1040    setEntries(loadedEntries);10411042    const currentId = selectedEntry()?.id;1043    if (currentId) {1044      setSelectedEntry(1045        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1046      );1047    }10481049    return loadedEntries;1050  }10511052  async function openVault(preferredEntryId?: string, skipViewSet = false) {1053    // Purge expired trash entries on each vault open1054    const retDays = trashRetentionDays();1055    if (retDays > 0) {1056      void sendMessage({1057        type: "PURGE_EXPIRED_TRASH",1058        payload: { retentionMs: retDays * 86_400_000 },1059      });1060    }10611062    const loadedEntries = await loadEntries();10631064    // null means loadEntries() triggered an emergency lock (service worker died).1065    // Don't override it by setting view back to "entry" with an empty vault.1066    if (loadedEntries === null) return false;10671068    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1069    let nextSelectedEntry: VaultEntry | null = null;1070    if (preferredEntryId) {1071      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1072    }1073    if (!nextSelectedEntry) {1074      const tabDomain = currentTabDomain();1075      if (tabDomain) {1076        nextSelectedEntry = loadedEntries.find((e) => {1077          if (e.type === "login") {1078            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1079            for (const extra of e.additionalUrls ?? []) {1080              if ((extractDomain(extra) ?? "") === tabDomain) return true;1081            }1082            return false;1083          }1084          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1085          return false;1086        }) ?? null;1087      }1088    }1089    nextSelectedEntry ??= pickNavigable(loadedEntries);10901091    setSelectedEntry(nextSelectedEntry);1092    setEditEntry(undefined);1093    setContentPhase("enter");1094    queueContentIdle();1095    if (!skipViewSet) setView("entry");1096    return true;1097  }10981099  async function handleUnlock() {1100    // If the user previously created a vault but hadn't yet confirmed their1101    // recovery words (and the browser was closed before they could), the1102    // the background-owned recovery-pending state is still set. Re-route1103    // back to the recovery surface so they finish the confirmation step1104    // before reaching the entries view — the words can be re-derived now1105    // that the vault is unlocked again.1106    let recoveryPending = false;1107    try {1108      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1109      recoveryPending = out.ok === true && out.data === true;1110    } catch { /* best-effort */ }1111    if (recoveryPending) {1112      setForceRecoveryWords(true);1113      setView("lock");1114      triggerPipPulse();1115      await restoreSettings();1116      return;1117    }1118    setHasVault(true);1119    triggerPipPulse();1120    await restoreSettings();1121    const celebrating = pendingCelebration();1122    if (celebrating) {1123      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1124      setPendingCelebration(false);1125    }1126    await openVault(undefined, celebrating);1127    if (celebrating) {1128      setView("pro-celebration");1129      window.setTimeout(() => setView("entry"), 2500);1130    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1131      setView("whats-new");1132    } else if (!openedForInlineUnlock && !proStatus() && await claimProNudge()) {1133      setView("pro-nudge");1134    }1135    // Popup was opened from the inline "Open Peach to unlock" button — close it1136    // now so the user lands back on the page with the autofill selector ready.1137    if (openedForInlineUnlock) {1138      openedForInlineUnlock = false;1139      window.close();1140    }1141  }11421143  function handleSelectEntry(entry: VaultEntry) {1144    if (entry.type === "note") {1145      setOpenNoteId(entry.id);1146      setShowNotes(true);1147      return;1148    }1149    setSelectedEntry(entry);1150    setContentPhase("enter");1151    queueContentIdle();1152    setView("entry");1153  }11541155  function handleAdd() {1156    setEditEntry(undefined);1157    setView("add");1158  }11591160  async function handleDelete() {1161    const entry = selectedEntry();1162    if (!entry) return;1163    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1164    const loadedEntries = await loadEntries() ?? [];1165    setSelectedEntry(pickNavigable(loadedEntries));1166    setView("entry");1167  }11681169  async function handleDeleteMany(ids: string[]) {1170    for (const id of ids) {1171      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1172    }1173    const loadedEntries = await loadEntries() ?? [];1174    // If the currently selected entry was among the deleted, move to first1175    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1176    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1177  }11781179  async function handleSave(savedEntry?: VaultEntry) {1180    await openVault(savedEntry?.id);1181  }11821183  function handleBack() {1184    const dest = editBackView();1185    setEditBackView("entry");1186    setEditEntry(undefined);1187    setView(dest);1188  }11891190  function handleExportBack() {1191    if (returnToPanic()) {1192      setReturnToPanic(false);1193      setView("panic");1194      return;1195    }1196    setView("settings");1197  }11981199  function handleLock() {1200    setEntries([]);1201    setSelectedEntry(null);1202    setEditEntry(undefined);1203    setContentPhase("idle");1204    setView("lock");1205  }12061207  function handleHeaderLock() {1208    // Lock the UI immediately — do not wait for the service worker. If the SW1209    // is dead or slow, the 5s sendMessage timeout would create a dead-click1210    // window. The SW will lock on its next wake regardless.1211    handleLock();1212    void sendMessage({ type: "LOCK" });1213  }12141215  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1216  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1217  // the trigger's own reply.1218  async function handleHeaderSync(): Promise<void> {1219    if (syncBusy()) return;1220    setSyncBusy(true);1221    setSyncFlash(null);1222    try {1223      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1224      let state: SyncOutcomeSummary = { status: "syncing" };1225      for (let i = 0; i < 360 && isManualSyncPending(state); i++) {1226        await new Promise((r) => setTimeout(r, 250));1227        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1228          ok: boolean;1229          data?: SyncOutcomeSummary;1230        };1231        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1232      }1233      setSyncSummary(state);1234      setSyncFlash(state.status === "error" ? "error" : "ok");1235      await loadSyncAlerts();1236      await loadEntries();1237      await loadSyncIndicator();1238    } catch {1239      setSyncSummary({ status: "error", outcome: "run_failed" });1240      setSyncFlash("error");1241    } finally {1242      setSyncBusy(false);1243      setTimeout(() => setSyncFlash(null), 2_000);1244    }1245  }12461247  function navigate(dir: number) {1248    const navigable = nonNoteEntries();1249    if (navigable.length === 0) return;1250    const currentIndex = selectedIndex();1251    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1252    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1253    setSelectedEntry(navigable[nextIndex]);1254    setContentPhase("enter");1255    queueContentIdle();1256  }12571258  /**1259   * The Entry view's selection invariant: never select a note. Notes are a1260   * separate surface (handleSelectEntry opens the notes view), so any code path1261   * that picks a default — vault open, post-delete, refresh — must skip them1262   * and return null if the only entries are notes.1263   */1264  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1265    return list.find((e) => e.type !== "note") ?? null;1266  }12671268  function openList() {1269    setView("list");1270  }12711272  function openHealth() {1273    setEditEntry(undefined);1274    setView("health");1275  }12761277  function handleOpenFileImport() {1278    setRestoreView("file");1279  }12801281  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12821283  function handleFixEntry(entry: VaultEntry) {1284    setSelectedEntry(entry);1285    // Passkey-only entries have no editable surface — drop into view mode1286    // instead of opening the Add editor.1287    if (isPasskeyOnly(entry)) {1288      setView("entry");1289      return;1290    }1291    setEditBackView(view()); // preserve caller view so back returns here1292    setEditEntry(entry);1293    setView("add");1294  }12951296  const isLockedView = () => view() === "lock" || view() === "adopting";12971298  async function handleAdoptionDone() {1299    await storageSet("peach_onboarded", "true").catch(() => {});1300    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1301    clearOnboardingExit();1302    setOnboarded(true);1303    setHasVault(true);1304    setView("initial-sync");1305    // The background applies a Pro license and persists the source-device1306    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1307    // celebration pending until that exact peer records a completed sync.1308    await refreshProStatus();1309    const deviceId = await resolveInitialSyncDeviceId();1310    if (deviceId) {1311      void beginInitialSyncWait(deviceId);1312    } else {1313      // Pairing completion without a durable peer row should be impossible.1314      // Stay on the honest waiting surface and expose retry instead of falling1315      // through to an empty vault.1316      setInitialSyncSlow(true);1317    }1318  }13191320  async function handleApprovePendingPasskey() {1321    const pending = pendingPasskey();1322    if (!pending) return;1323    if (pending.browser === "firefox" && pending.type === "create") {1324      // Firefox path: popup itself runs navigator.credentials.create. The1325      // background never approved/rejected anything — it just stashed1326      // intent. After the popup-driven flow resolves (success or cancel),1327      // clear the pending key so the popup doesn't re-show this on next open.1328      const { createFirefoxPasskey } = await import("./firefox-passkey");1329      const result = await createFirefoxPasskey(pending);1330      if (!result.ok) {1331        console.warn("Peach Firefox passkey create failed:", result.error);1332      }1333      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1334      setPendingPasskey(null);1335      void loadEntries();1336      window.close();1337      return;1338    }1339    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1340    window.close();1341  }13421343  async function handleDenyPendingPasskey() {1344    const pending = pendingPasskey();1345    if (!pending) return;1346    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1347    window.close();1348  }13491350  function queueContentIdle(after?: () => void) {1351    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1352    contentPhaseTimer = window.setTimeout(() => {1353      setContentPhase("idle");1354      after?.();1355    }, ENTRY_TRANSITION_SETTLE_MS);1356  }13571358  return (1359    <div class="popup-root peach-theme">1360      <Show when={bootState() === "ready"}>1361        <Show1362          when={!pendingPasskey()}1363          fallback={1364            <PasskeyApprovalView1365              type={pendingPasskey()!.type}1366              rpName={pendingPasskey()!.rpName}1367              rpId={pendingPasskey()!.rpId}1368              userName={pendingPasskey()!.userName}1369              userDisplayName={pendingPasskey()!.userDisplayName}1370              onApprove={handleApprovePendingPasskey}1371              onDeny={handleDenyPendingPasskey}1372            />1373          }1374        >1375          <Show1376            when={!isLockedView()}1377            fallback={1378              <Show1379                when={view() !== "adopting"}1380                fallback={1381                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1382                    <AdoptVaultView1383                      onCancel={() => {1384                        clearOnboardingExit();1385                        setOnboarded(false);1386                        setView("lock");1387                      }}1388                      onDone={handleAdoptionDone}1389                    />1390                  </Suspense>1391                }1392              >1393                <Show1394                  when={onboarded() || hasVault()}1395                  fallback={1396                    <Onboarding1397                      initialCard={onboardingCardAtExit()}1398                      onComplete={() => {1399                        markOnboardingExit("create");1400                        setStartOnboardingRestore(false);1401                        setOnboarded(true);1402                      }}1403                      onRestore={() => {1404                        markOnboardingExit("restore");1405                        setStartOnboardingRestore(true);1406                        setOnboarded(true);1407                      }}1408                      onAdopt={() => {1409                        markOnboardingExit("adopt");1410                        setStartOnboardingRestore(false);1411                        setOnboarded(true);1412                        setView("adopting");1413                      }}1414                    />1415                  }1416                >1417                  <LockView1418                    hasVault={hasVault()}1419                    ready={bootState() === "ready"}1420                    isPro={proStatus()}1421                    forceRecoveryWords={forceRecoveryWords()}1422                    initialRestore={startOnboardingRestore()}1423                    initialRestoreSource={resumeRestoreSource() ?? undefined}1424                    onUnlock={() => {1425                      // RecoveryWordDisplay clears the storage flag before1426                      // calling onDone, so handleUnlock will see no pending1427                      // confirmation and proceed through the normal path.1428                      if (forceRecoveryWords()) setForceRecoveryWords(false);1429                      // The create/restore flow is done — a lock later in this1430                      // window must show the unlock screen, not re-enter the1431                      // onboarding restore surface.1432                      setStartOnboardingRestore(false);1433                      setResumeRestoreSource(null);1434                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1435                      return handleUnlock();1436                    }}1437                    onBackToOnboarding={1438                      !hasVault()1439                        ? () => {1440                            // Deliberately rewinding to the carousel — the exit1441                            // flow must not reclaim the next popup open.1442                            clearOnboardingExit();1443                            setStartOnboardingRestore(false);1444                            setOnboarded(false);1445                          }1446                        : undefined1447                    }1448                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1449                  />1450                </Show>1451              </Show>1452            }1453          >1454            <Show1455              when={1456                view() !== "pro-celebration" &&1457                view() !== "initial-sync" &&1458                view() !== "whats-new" &&1459                view() !== "pro-nudge"1460              }1461              fallback={1462                <Show1463                  when={view() === "initial-sync"}1464                  fallback={1465                    <Show1466                      when={view() === "whats-new"}1467                      fallback={1468                        <Show when={view() === "pro-nudge"} fallback={<ProCelebration />}>1469                          <ProNudgeView1470                            onSeePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1471                            onDismiss={() => setView("entry")}1472                          />1473                        </Show>1474                      }1475                    >1476                      <WhatsNewView onContinue={() => setView("entry")} />1477                    </Show>1478                  }1479                >1480                  <InitialSyncView1481                    slow={initialSyncSlow()}1482                    onRetry={() => void retryInitialSync()}1483                  />1484                </Show>1485              }1486            >1487            <section1488              id="hd-shell"1489              class="peach-shell peach-theme"1490              data-surface="extension"1491            >1492            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1493              <PeachWordmark unlocked />1494              <div class="peach-chrome-actions">1495                <button1496                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1497                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1498                  aria-label="Notes"1499                  title="Notes"1500                >1501                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1502                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1503                    <line x1="4" y1="5" x2="10" y2="5" />1504                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1505                    <line x1="4" y1="10" x2="7" y2="10" />1506                  </svg>1507                </button>1508                <button1509                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1510                    classList={{1511                      "is-syncing": syncBusy(),1512                      "is-ok": syncFlash() === "ok",1513                      "is-error": syncFlash() === "error",1514                    }}1515                    onClick={() => void handleHeaderSync()}1516                    disabled={syncBusy()}1517                    aria-label={`Sync now. ${syncLabel()}`}1518                    title={syncLabel()}1519                  >1520                    <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">1521                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1522                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1523                      <path d="M2.6 1v2.4h2.4" />1524                      <path d="M11.4 13v-2.4H9" />1525                    </svg>1526                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1527                  </button>1528                <button1529                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1530                  onClick={() => setView("settings")}1531                  aria-label="Settings"1532                >1533                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1534                    <path1535                      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"1536                      stroke="currentColor"1537                      stroke-width="0.9"1538                    />1539                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1540                  </svg>1541                </button>1542                <button1543                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1544                  onClick={handleAdd}1545                  aria-label="New entry"1546                >1547                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1548                    <path d="M5.5 1v9M1 5.5h9" />1549                  </svg>1550                </button>1551                <button1552                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1553                  onClick={() => void handleHeaderLock()}1554                  aria-label="Lock vault"1555                >1556                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1557                    <path1558                      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"1559                      stroke="currentColor"1560                      stroke-width="1"1561                      stroke-linecap="round"1562                    />1563                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1564                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1565                  </svg>1566                </button>1567              </div>1568            </header>15691570            <Show when={pendingProNotice() !== null}>1571              <div class="peach-pro-notice-banner">1572                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1573                <button1574                  class="peach-pro-notice-link"1575                  onClick={() => {1576                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1577                    setSettingsInitialSection("license");1578                    setView("settings");1579                    setPendingProNotice(null);1580                  }}1581                >1582                  Learn more →1583                </button>1584                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1585              </div>1586            </Show>15871588            <Show when={pendingRememberSite()}>1589              {(request) => {1590                const origin = getSecurityOriginDisplay(request().url);1591                const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1592                return (1593                  <div class="peach-pro-notice-banner">1594                    <span>Also use “{request().title}” on {originLabel}?</span>1595                    <button1596                      class="peach-pro-notice-link"1597                      onClick={async () => {1598                        await sendMessage({1599                          type: "ADD_ADDITIONAL_URL",1600                          payload: { id: request().entryId, url: request().url },1601                        });1602                        await chrome.storage.session.remove("pendingRememberSite");1603                        setPendingRememberSite(null);1604                      }}1605                    >1606                      Add1607                    </button>1608                    <button1609                      class="peach-pro-notice-close"1610                      onClick={async () => {1611                        await chrome.storage.session.remove("pendingRememberSite");1612                        setPendingRememberSite(null);1613                      }}1614                      aria-label="Dismiss"1615                    >1616                      ✕1617                    </button>1618                  </div>1619                );1620              }}1621            </Show>16221623            <div class="peach-shell-body">1624              <div class="peach-shell-veil"></div>1625              <div class="peach-content-frame" data-content-phase={contentPhase()}>1626                <EntryView1627                  keyboardEnabled={view() === "entry"}1628                  entry={selectedEntry()}1629                  currentTabUrl={currentTabUrl()}1630                  entries={entries()}1631                  isPro={proStatus()}1632                  onDelete={handleDelete}1633                  onFieldSaved={() => void loadEntries()}1634                  onEscape={() => { setView("list"); }}1635                  onProGate={(feature) => {1636                    setUpgradeHighlight(feature);1637                    setSettingsInitialSection("license");1638                    setView("settings");1639                  }}1640                />1641              </div>16421643              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1644                <div class="peach-search-bar">1645                  <span class="peach-search-icon">1646                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1647                      <circle cx="5.5" cy="5.5" r="3.75" />1648                      <path d="M8.75 8.75L11.5 11.5" />1649                    </svg>1650                  </span>1651                  <input1652                    class="peach-search-input"1653                    placeholder="search vault..."1654                    autocomplete="off"1655                    aria-label="Search vault entries"1656                    onClick={openList}1657                    onFocus={openList}1658                    readOnly1659                  />1660                  <div class="peach-search-meta">1661                    <span class="peach-search-counter">1662                      {nonNoteEntries().length > 01663                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1664                        : "0 entries"}1665                    </span>1666                    <span class="peach-divider"></span>1667                    <button1668                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1669                      onClick={() => navigate(-1)}1670                      aria-label="Previous entry"1671                      disabled={nonNoteEntries().length === 0}1672                    >1673                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1674                        <path d="M5 1L1 5.5L5 10" />1675                      </svg>1676                    </button>1677                    <button1678                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1679                      onClick={() => navigate(1)}1680                      aria-label="Next entry"1681                      disabled={nonNoteEntries().length === 0}1682                    >1683                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1684                        <path d="M1 1L5 5.5L1 10" />1685                      </svg>1686                    </button>1687                  </div>1688                </div>1689              </section>1690            </div>16911692            {/* Rare device-trust alerts; normal sync changes are silent. */}1693            <Show when={alertGroups().length > 0}>1694              <div class="peach-sync-alerts">1695                <For each={alertGroups()}>1696                  {([type, group]) => {1697                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1698                    return (1699                      <div class="peach-sync-alert">1700                        <Show when={group.length > 1}>1701                          <button1702                            class="peach-sync-alert-flip"1703                            type="button"1704                            aria-label="Previous"1705                            onClick={() => flipAlert(type, -1)}1706                          >‹</button>1707                        </Show>1708                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1709                        <Show when={group.length > 1}>1710                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1711                          <button1712                            class="peach-sync-alert-flip"1713                            type="button"1714                            aria-label="Next"1715                            onClick={() => flipAlert(type, 1)}1716                          >›</button>1717                        </Show>1718                        <button1719                          class="peach-sync-alert-dismiss"1720                          type="button"1721                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1722                          onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1723                        >×</button>1724                      </div>1725                    );1726                  }}1727                </For>1728              </div>1729            </Show>17301731            <Show when={view() === "list"}>1732              <VaultView1733                entries={entries()}1734                selectedEntryId={selectedEntry()?.id}1735                currentTabDomain={currentTabDomain()}1736                sortOrder={sortOrder()}1737                searchSeed={listSearchSeed()}1738                searchSeedNonce={listSearchSeedNonce()}1739                onSelectEntry={handleSelectEntry}1740                onSortOrderChange={setSortOrder}1741                onAdd={handleAdd}1742                onClose={() => setView("entry")}1743                onOpenTrash={() => setView("trash")}1744                onDeleteMany={handleDeleteMany}1745              />1746            </Show>1747            <Show when={view() === "settings" || settingsExiting()}>1748              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1749                <SettingsView1750                  entriesCount={entries().length}1751                  breachMonitoring={breachMonitoring()}1752                  lockOnClose={lockOnClose()}1753                  autoLockMinutes={autoLockMinutes()}1754                  trashRetentionDays={trashRetentionDays()}1755                  lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1756                  theme={theme()}1757                  accentColor={accentColor()}1758                  textSize={textSize()}1759                  reduceMotion={reduceMotion()}1760                  isPro={proStatus()}1761                  initialSection={settingsInitialSection()}1762                  upgradeHighlight={upgradeHighlight()}1763                  isExiting={settingsExiting()}1764                  onBreachMonitoringChange={applyBreachMonitoring}1765                  onLockOnCloseChange={applyLockOnClose}1766                  onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1767                  onAutoLockMinutesChange={applyAutoLockMinutes}1768                  onTrashRetentionChange={applyTrashRetention}1769                  onThemeChange={applyTheme}1770                  onAccentChange={applyAccentColor}1771                  onTextSizeChange={applyTextSize}1772                  onReduceMotionChange={applyReduceMotion}1773                  shortcuts={shortcutBindings()}1774                  onShortcutsChange={applyShortcuts}1775                  onLicenseChange={async () => {1776                    // After activation/deactivation: refresh Pro status,1777                    // re-load entries (defensive — signal state can drift if1778                    // the SW briefly restarts during the verify+save round-trip),1779                    // and replay the Pro celebration if this is the first time1780                    // a valid Pro license is seen on this device.1781                    await refreshProStatus();1782                    void loadEntries();1783                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1784                    if ((should as { ok: boolean; data?: boolean }).data === true) {1785                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1786                      setView("pro-celebration");1787                      window.setTimeout(() => setView("settings"), 2500);1788                    }1789                  }}1790                  onBack={() => {1791                    setSettingsExiting(true);1792                    window.setTimeout(() => {1793                      setSettingsExiting(false);1794                      setSettingsInitialSection(null);1795                      setUpgradeHighlight(null);1796                      handleBack();1797                    }, 200);1798                  }}1799                  onImport={() => setView("import")}1800                  onExport={() => setView("export")}1801                  onBreachCheckDone={loadEntries}1802                  onOpenHealth={openHealth}1803                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1804                  onOpenFileImport={handleOpenFileImport}1805                  onOpenPanic={() => setView("panic")}1806                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1807                  onOpenCodexRestore={() => setView("codex-restore")}1808                  onCardChange={setSettingsActiveCard}1809                  onProGate={(feature) => {1810                    setUpgradeHighlight(feature);1811                    setSettingsInitialSection("license");1812                  }}1813                />1814              </Suspense>1815            </Show>1816            <Show when={view() === "codex-generate"}>1817              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1818                <CodexGenerateView1819                  isPro={proStatus()}1820                  onBack={() => setView("settings")}1821                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1822                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1823                  onVaultLocked={() => handleLock()}1824                />1825              </Suspense>1826            </Show>1827            <Show when={view() === "codex-restore"}>1828              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1829                <CodexRestoreView1830                  onBack={() => setView("lock")}1831                  onRestoreSuccess={async () => {1832                    // After restore the vault is unlocked; refresh entries1833                    // and route to the main vault list. handleUnlock is the1834                    // canonical post-unlock setup path.1835                    await handleUnlock();1836                  }}1837                />1838              </Suspense>1839            </Show>1840            <Show when={view() === "pair-device"}>1841              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1842                <PairDeviceView1843                  isPro={proStatus()}1844                  onProChanged={() => { void refreshProStatus(); }}1845                  onBack={() => setView("settings")}1846                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1847                />1848              </Suspense>1849            </Show>1850            <Show when={view() === "add"}>1851              <AddView1852                entry={editEntry()}1853                entries={entries()}1854                onBack={handleBack}1855                onSave={handleSave}1856              />1857            </Show>1858            <Show when={view() === "import"}>1859              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1860                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1861              </Suspense>1862            </Show>1863            <Show when={view() === "export"}>1864              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1865                <ExportView onBack={handleExportBack} />1866              </Suspense>1867            </Show>1868            <Show when={view() === "panic"}>1869              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1870                <PanicView1871                  onCancel={() => setView("settings")}1872                  onOpenExport={() => {1873                    setReturnToPanic(true);1874                    setView("export");1875                  }}1876                  onDestroyed={async () => {1877                    // Fresh onboarding start after a destroy — don't let the1878                    // resume markers land the user mid-carousel.1879                    setOnboardingCardAtExit(0);1880                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1881                    clearOnboardingExit();1882                    setEntries([]);1883                    setSelectedEntry(null);1884                    setEditEntry(undefined);1885                    setHasVault(false);1886                    setOnboarded(false);1887                    setProStatus(false);1888                    setPendingPasskey(null);1889                    setUpgradeHighlight(null);1890                    setSettingsInitialSection(null);1891                    setPendingProNotice(null);1892                    setPendingCelebration(false);1893                    setReturnToPanic(false);1894                    setContentPhase("idle");1895                    setView("lock");1896                  }}1897                />1898              </Suspense>1899            </Show>1900            {/* Health stays mounted while editing an entry from it so1901                computeReused() doesn't rerun on every cancel/back. */}1902            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1903              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1904                <HealthView1905                  entries={entries()}1906                  isPro={proStatus()}1907                  onFixEntry={handleFixEntry}1908                  onBack={() => setView("settings")}1909                  onRefresh={loadEntries}1910                  hidden={view() !== "health"}1911                />1912              </Suspense>1913            </Show>1914            <Show when={view() === "trash"}>1915              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1916                <TrashView1917                  onBack={() => setView("list")}1918                  onRestored={() => void loadEntries()}1919                />1920              </Suspense>1921            </Show>1922            <Show when={showNotes()}>1923              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1924                <NotesMode1925                  notes={entries().filter((e) => e.type === "note")}1926                  initialNoteId={openNoteId()}1927                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1928                  onNoteListChanged={() => void loadEntries()}1929                />1930              </div>1931            </Show>1932            <Show when={restoreView() === "file"}>1933              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1934                <ImportView1935                  mode="onboarding"1936                  replacesExistingVault={true}1937                  onBack={() => setRestoreView(null)}1938                  onImport={async () => {1939                    setRestoreView(null);1940                    await handleUnlock();1941                  }}1942                />1943              </Suspense>1944            </Show>1945            </section>1946            </Show>1947          </Show>1948        </Show>1949      </Show>1950      <Show when={showShortcutsHelp()}>1951        <ShortcutsHelpOverlay1952          bindings={shortcutBindings()}1953          onClose={() => setShowShortcutsHelp(false)}1954        />1955      </Show>1956    </div>1957  );1958}19591960function ShortcutsHelpOverlay(props: {1961  bindings: Record<ShortcutAction, Keybinding>;1962  onClose: () => void;1963}) {1964  return (1965    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1966      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1967        <div class="peach-shortcuts-help-head">1968          <h3>Keyboard shortcuts</h3>1969          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1970        </div>1971        <div class="peach-shortcuts-help-body">1972          <p class="peach-settings-sub-label">ENTRY VIEW</p>1973          <ul>1974            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1975              <li>1976                <span>{d.label}</span>1977                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1978              </li>1979            ))}1980          </ul>1981          <p class="peach-settings-sub-label">ANYWHERE</p>1982          <ul>1983            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1984              <li>1985                <span>{d.label}</span>1986                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1987              </li>1988            ))}1989          </ul>1990        </div>1991      </div>1992    </div>1993  );1994}