App.tsx

82.8 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 {16  ONBOARDING_CARD_STORAGE_KEY,17  ONBOARDING_LAST_CARD,18  Onboarding,19} from "./views/Onboarding";2021const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";22import { NotesMode } from "./views/NotesMode";23import { ProCelebration } from "./views/ProCelebration";24import { InitialSyncView } from "./views/InitialSyncView";25import { VaultView, type SortOrder } from "./views/Vault";26import { EntryView } from "./views/Entry";27import { AddView } from "./views/Add";28import { PeachWordmark } from "./components/PeachMark";29import { PasskeyApprovalView } from "./views/PasskeyApproval";30import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";31import {32  syncIndicator,33  syncIndicatorLabel,34  type SyncOutcomeSummary,35} from "../shared/sync-outcome";36import { popupCopy } from "./copy";37import {38  DEFAULT_BINDINGS,39  formatKeybinding,40  getActiveBindings,41  matchKeybinding,42  SHORTCUT_DESCRIPTORS,43  type Keybinding,44  type ShortcutAction,45} from "../shared/shortcuts";46import { generateTOTP } from "../crypto/totp";4748export { sendMessage } from "../shared/bridge";4950const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));51const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));52const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));53const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));54const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));55const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));56const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));57const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));58const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));59const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6061type View =62  | "lock"63  | "pro-celebration"64  | "whats-new"65  | "entry"66  | "list"67  | "add"68  | "settings"69  | "pair-device"70  | "import"71  | "export"72  | "health"73  | "trash"74  | "panic"75  | "adopting"76  | "initial-sync"77  | "codex-generate"78  | "codex-restore";79type BootState = "loading" | "ready";8081const INITIAL_SYNC_POLL_MS = 500;82const INITIAL_SYNC_SLOW_MS = 15_000;8384let shellRingAnimation: Animation | null = null;8586export function triggerRing() {87  const shell = document.getElementById("hd-shell");88  if (!shell) return;8990  shellRingAnimation?.cancel();91  shell.dataset.glowing = "true";92  const animation = shell.animate(93    [94      { offset: 0, boxShadow: "var(--copy-shadow)" },95      {96        offset: 0.3,97        boxShadow:98          "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)",99      },100      { offset: 1, boxShadow: "var(--copy-shadow)" },101    ],102    {103      duration: 720,104      easing: "cubic-bezier(0.4, 0, 0.2, 1)",105      fill: "none",106    }107  );108  shellRingAnimation = animation;109  const clearGlow = () => {110    if (shellRingAnimation !== animation) return;111    shellRingAnimation = null;112    delete shell.dataset.glowing;113  };114  animation.onfinish = clearGlow;115  animation.oncancel = clearGlow;116}117118export function triggerPipPulse() {119  const pip = document.querySelector(".peach-status-pip, .pip");120  if (pip) {121    pip.classList.remove("pulse");122    void (pip as HTMLElement).offsetWidth;123    pip.classList.add("pulse");124  }125}126127const ENTRY_TRANSITION_SETTLE_MS = 135;128129export default function App() {130  const [bootState, setBootState] = createSignal<BootState>("loading");131  const [view, setView] = createSignal<View>("lock");132  const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");133  const [hasVault, setHasVault] = createSignal(false);134  const [onboarded, setOnboarded] = createSignal(true);135  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);136  // The restore source picked before the popup closed (the OS file picker137  // kills it), so reopening resumes the same source form instead of the138  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.139  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);140  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes141  // back. Those flows all exit from the final card, so resume there — a fresh142  // install's first mount stays at 0.143  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);144  const [entries, setEntries] = createSignal<VaultEntry[]>([]);145  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);146  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);147148  // Rare device-trust alerts stay visible; routine entry sync is silent.149  interface SyncAlert {150    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";151    entryId: string;152    entryTitle: string;153    deviceName: string;154    timestamp: number;155  }156  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);157  // Per-group flip index: which alert within a type-group is currently shown.158  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});159  async function loadSyncAlerts() {160    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {161      ok: boolean;162      data?: SyncAlert[];163    };164    if (r.ok) setSyncAlerts(r.data ?? []);165  }166  async function dismissAlertGroup(type: SyncAlert["type"]) {167    const result = (await sendMessage({168      type: "DISMISS_SYNC_ALERTS",169      payload: { type },170    })) as { ok: boolean };171    if (!result.ok) return;172    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));173    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });174  }175  function flipAlert(type: string, dir: 1 | -1) {176    const group = syncAlerts().filter((a) => a.type === type);177    if (group.length <= 1) return;178    setAlertFlipIdx((m) => {179      const cur = m[type] ?? 0;180      return { ...m, [type]: (cur + dir + group.length) % group.length };181    });182  }183  /** Grouped alert types in display order, each with its alerts. */184  const alertGroups = createMemo(() => {185    const map = new Map<string, SyncAlert[]>();186    for (const a of syncAlerts()) {187      const g = map.get(a.type);188      if (g) g.push(a); else map.set(a.type, [a]);189    }190    return [...map.entries()];191  });192  function syncAlertText(a: SyncAlert): string {193    if (a.type === "DEVICE_REVOKED") {194      return `${a.deviceName} revoked a paired device — review your devices in Settings`;195    }196    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;197  }198  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");199  const [theme, setTheme] = createSignal<"dark" | "light">("dark");200  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");201  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);202  const [lockOnClose, setLockOnClose] = createSignal(false);203  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);204  const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);205  const [breachMonitoring, setBreachMonitoring] = createSignal(true);206  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);207  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);208  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);209  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);210  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);211  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");212  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");213  // Cached Pro status — synchronous for render paths. Background's214  // GET_LICENSE is async, but render-time reads of isPro() must not block.215  // Refreshed at boot, after activate/deactivate, and whenever the popup216  // re-opens onto a stored license that has changed via another window.217  const [proStatus, setProStatus] = createSignal(false);218  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);219  // When the sync card's pairing button opens the pairing view, deep-link220  // straight into that pairing path instead of the rest screen.221  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");222  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);223  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");224  const [listSearchSeed, setListSearchSeed] = createSignal("");225  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);226  const [pendingCelebration, setPendingCelebration] = createSignal(false);227  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);228  // A page asked to be remembered for an entry it did not match. Pages cannot229  // grow an entry's domain set themselves (PB-26-106), so the background queues230  // the request here and this banner is the confirmation gate.231  const [pendingRememberSite, setPendingRememberSite] = createSignal<{232    entryId: string;233    url: string;234    title: string;235  } | null>(null);236  // True while Panic Step 1 has bounced the user out to Export. When Export's237  // onBack fires we return to Panic instead of Settings so the user keeps the238  // destruction context they came from.239  const [returnToPanic, setReturnToPanic] = createSignal(false);240  // The view to return to when the add/edit view is dismissed. Normally "entry"241  // but "health" when the user arrives via the fix-entry path from Vault Health.242  const [editBackView, setEditBackView] = createSignal<View>("entry");243  const [settingsExiting, setSettingsExiting] = createSignal(false);244  const [showNotes, setShowNotes] = createSignal(false);245  // Topbar sync affordance. Always present: gating it on having a paired device246  // meant it never appeared for the person trying to set pairing up, which is247  // exactly when you reach for it.248  const [syncBusy, setSyncBusy] = createSignal(false);249  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);250  // The indicator light + its spoken-word equivalent. Colour alone is not the251  // signal; the title/aria-label always carries the same claim in English.252  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});253  const [pairedCount, setPairedCount] = createSignal(0);254  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);255  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);256  let initialSyncWaitGeneration = 0;257  const syncLight = () =>258    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());259  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());260  async function loadSyncIndicator() {261    const [stateRes, devicesRes] = await Promise.all([262      sendMessage({ type: "GET_SYNC_STATE" }),263      sendMessage({ type: "LIST_PAIRED_DEVICES" }),264    ]);265    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };266    if (s.ok && s.data) setSyncSummary(s.data);267    const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };268    if (d.ok) {269      setPairedCount(d.data?.devices?.length ?? 0);270    } else if (d.error === "trust_data_corrupted") {271      setPairedCount(0);272      setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });273    }274  }275276  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {277    const response = (await sendMessage({278      type: "LIST_PAIRED_DEVICES",279    })) as {280      ok: boolean;281      error?: string;282      data?: { devices?: InitialSyncPairedDevice[] };283    };284    if (response.ok) return response.data?.devices ?? [];285    if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");286    throw new Error(response.error ?? "list_paired_devices_failed");287  }288  interface InitialSyncPairedDevice extends InitialSyncDevice {289    pairedAt?: number;290  }291292293  async function resolveInitialSyncDeviceId(): Promise<string | null> {294    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(295      () => null,296    );297    const devices = await listInitialSyncDevices().catch(() => []);298    if (stored && devices.some((device) => device.deviceId === stored)) {299      return stored;300    }301302    // Fresh adoption has exactly one direct source, but choosing the newest row303    // also repairs the marker if storage failed after the pair itself committed.304    const newest = [...devices].sort(305      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),306    )[0];307    if (!newest) return stored;308    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(309      () => undefined,310    );311    return newest.deviceId;312  }313314  async function finishInitialSync(generation: number): Promise<void> {315    if (generation !== initialSyncWaitGeneration) return;316    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);317    setInitialSyncDeviceId(null);318    setInitialSyncSlow(false);319    await refreshProStatus();320321    // A license transferred during pairing intentionally leaves the one-shot322    // celebration pending. Consume it only now: sync readiness → celebration323    // → populated vault, never celebration → temporarily empty vault.324    const celebration = (await sendMessage({325      type: "SHOULD_PLAY_CELEBRATION",326    })) as { ok: boolean; data?: boolean };327    const shouldCelebrate = celebration.ok && celebration.data === true;328    if (shouldCelebrate) {329      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });330    }331332    const opened = await openVault(undefined, shouldCelebrate);333    if (!opened || generation !== initialSyncWaitGeneration) return;334    if (shouldCelebrate) {335      setView("pro-celebration");336      window.setTimeout(() => {337        if (generation === initialSyncWaitGeneration) setView("entry");338      }, 2500);339    }340  }341342  async function beginInitialSyncWait(deviceId: string): Promise<void> {343    const generation = ++initialSyncWaitGeneration;344    setInitialSyncDeviceId(deviceId);345    setInitialSyncSlow(false);346    setView("initial-sync");347    const startedAt = Date.now();348349    // Best effort: the pairing write already wakes the mesh listener, while350    // this trigger covers the key-order case where this receiver is the dialer.351    void sendMessage({352      type: "TRIGGER_SYNC",353      payload: { reason: "foreground" },354    }).catch(() => undefined);355356    while (357      generation === initialSyncWaitGeneration &&358      view() === "initial-sync"359    ) {360      try {361        const devices = await listInitialSyncDevices();362        if (hasCompletedInitialSync(devices, deviceId)) {363          await finishInitialSync(generation);364          return;365        }366      } catch {367        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.368      }369      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {370        setInitialSyncSlow(true);371      }372      await new Promise((resolve) =>373        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),374      );375    }376  }377378  async function retryInitialSync(): Promise<void> {379    const deviceId =380      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());381    if (deviceId) {382      await beginInitialSyncWait(deviceId);383    } else {384      setInitialSyncSlow(true);385    }386  }387388  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);389  // Set on popup boot when peach_recovery_pending flag is found in session390  // storage — instructs LockView to skip the unlock UI and open the recovery391  // words confirmation surface instead. Cleared via handleUnlock once the392  // user has typed the 3 confirmation words.393  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);394395  let contentPhaseTimer: number | undefined;396  // Set when the popup was opened from the inline "Open Peach to unlock" button.397  // handleUnlock() closes the popup after unlock so the user returns to the page.398  let openedForInlineUnlock = false;399400  onCleanup(() => {401    initialSyncWaitGeneration += 1;402  });403404  function applyAccent(name: AccentColorName) {405    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;406    const hex = color.hex;407    document.documentElement.style.setProperty("--peach-accent", hex);408    // Ambient variables derived from the selected accent.409    document.documentElement.style.setProperty("--peach-glow", hex + "2A");410    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");411  }412413  createEffect(() => {414    applyAccent(accentColor());415  });416417  createEffect(() => {418    document.documentElement.classList.toggle("light", theme() === "light");419    document.body.classList.toggle("light", theme() === "light");420  });421422  createEffect(() => {423    document.documentElement.setAttribute("data-text-size", textSize());424  });425426  createEffect(() => {427    if (reduceMotion()) {428      document.documentElement.setAttribute("data-motion", "reduce");429    } else {430      document.documentElement.removeAttribute("data-motion");431    }432  });433434  // Notes are not a destination of the Entry view; selecting a note opens the435  // notes surface instead (handleSelectEntry). Anything that walks or counts436  // the Entry view's neighbours (counter, prev/next, index) must use this437  // filtered list, otherwise the user can arrow into a note and is stuck —438  // the Entry view can't render a note and the prev/next buttons stop moving.439  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));440441  const selectedIndex = createMemo(() => {442    const currentId = selectedEntry()?.id;443    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;444    return nonNoteEntries().findIndex((entry) => entry.id === currentId);445  });446447  async function applyAccentColor(name: AccentColorName) {448    applyAccent(name);449    setAccentColor(name);450    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });451  }452453  async function applyTheme(t: "dark" | "light") {454    setTheme(t);455    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });456  }457458  async function applyTextSize(size: "small" | "medium" | "large") {459    setTextSize(size);460    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });461  }462463  async function applyReduceMotion(enabled: boolean) {464    setReduceMotion(enabled);465    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });466  }467468  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload469  // small and lets future default updates land for actions the user hasn't470  // touched. Settings sends us the full active map; we filter and store.471  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {472    setShortcutBindings(active);473    const overrides: Record<string, Keybinding> = {};474    for (const action of Object.keys(active) as ShortcutAction[]) {475      const a = active[action];476      const d = DEFAULT_BINDINGS[action];477      const same = a.key === d.key478        && Boolean(a.ctrl)  === Boolean(d.ctrl)479        && Boolean(a.meta)  === Boolean(d.meta)480        && Boolean(a.shift) === Boolean(d.shift)481        && Boolean(a.alt)   === Boolean(d.alt);482      if (!same) overrides[action] = a;483    }484    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });485  }486487  async function applyLockOnClose(enabled: boolean) {488    setLockOnClose(enabled);489    await sendMessage({490      type: "UPDATE_SETTINGS",491      payload: { lockOnClose: enabled },492    });493  }494495  async function applyAutoLockMinutes(minutes: number) {496    setAutoLockMinutes(minutes);497    await sendMessage({498      type: "UPDATE_SETTINGS",499      payload: { autoLockMinutes: minutes },500    });501  }502  async function applyLockedSyncRequiresUnlock(enabled: boolean) {503    setLockedSyncRequiresUnlock(enabled);504    await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });505  }506507  async function applyBreachMonitoring(enabled: boolean) {508    setBreachMonitoring(enabled);509    await sendMessage({510      type: "UPDATE_SETTINGS",511      payload: { breachMonitoring: enabled },512    });513  }514515  // Which onboarding exit flow the user is in (create-vault / restore /516  // adopt). Persisted so closing the popup on one of those screens resumes517  // it directly instead of rewinding to the last carousel card.518  function markOnboardingExit(flow: "create" | "restore" | "adopt") {519    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);520    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});521    // Backing out of the flow remounts the carousel at this card; keep the522    // persisted card in sync so a later popup close resumes consistently.523    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});524  }525  function clearOnboardingExit() {526    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});527    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});528    setResumeRestoreSource(null);529  }530531  onMount(async () => {532    // Resolve the active tab in parallel with the unlock/has-vault queries —533    // openVault()'s smart selection picks the entry matching currentTabDomain,534    // so it must be populated before openVault runs. Previously refreshCurrentTab535    // ran after openVault, leaving popups that booted already-unlocked falling536    // back to loadedEntries[0] instead of the domain match.537    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([538      sendMessage({ type: "IS_UNLOCKED" }),539      sendMessage({ type: "HAS_VAULT" }),540      refreshCurrentTab(),541      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),542    ]);543    const unlocked = Boolean(544      (unlockResponse as { ok: boolean; data?: boolean }).data545    );546    const vaultExists = Boolean(547      (vaultResponse as { ok: boolean; data?: boolean }).data548    );549    const shouldCelebrate = Boolean(550      (celebrationResponse as { ok: boolean; data?: boolean }).data551    );552    setHasVault(vaultExists);553    // Onboarding gate — show once on first install, never again. Existing554    // users (vaultExists) are auto-marked onboarded so they skip the flow555    // even on the very first popup open after this feature ships.556    try {557      const onboarded = await storageGet("peach_onboarded");558      const seen = onboarded === "true";559      if (vaultExists && !seen) {560        await storageSet("peach_onboarded", "true");561        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});562        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});563        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});564        setOnboarded(true);565      } else {566        setOnboarded(seen);567        if (!seen) {568          // A popup close while the user is in an exit flow (create-vault /569          // restore / adopt) resumes that flow directly — landing back on the570          // carousel one screen earlier reads as losing progress.571          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);572          if (exit === "create" || exit === "restore" || exit === "adopt") {573            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);574            setOnboarded(true);575            setStartOnboardingRestore(exit === "restore");576            if (exit === "restore") {577              // The OS file picker kills the action popup mid-restore, so the578              // surface resumes on the source the user had already picked.579              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);580              if (source === "symbols" || source === "codex" || source === "csv") {581                setResumeRestoreSource(source);582              }583            }584            if (exit === "adopt") setView("adopting");585          } else {586            // Otherwise resume mid-onboarding where the user left off instead587            // of rewinding to the opening card.588            const stored = Number(589              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),590            );591            if (592              Number.isInteger(stored) &&593              stored > 0 &&594              stored <= ONBOARDING_LAST_CARD595            ) {596              setOnboardingCardAtExit(stored);597            }598          }599        }600      }601    } catch {602      // storage unavailable — default to onboarded so we never block boot603      setOnboarded(true);604    }605606    // Deep-link: ?restore=peachscript|csv — the detached-window half of the607    // file-picker flow. The action popup dismisses when the OS dialog steals608    // focus, so LockView.detachForFilePicker() reopens this URL in a609    // standalone window that owns the dialog. Routing runs before first610    // render so LockView mounts straight onto the right source form.611    try {612      const restoreRoute = new URLSearchParams(window.location.search).get("restore");613      if (restoreRoute === "peachscript" || restoreRoute === "csv") {614        setOnboarded(true);615        setStartOnboardingRestore(true);616        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");617      }618    } catch {619      // location.search unavailable — ignore.620    }621622    await Promise.all([restoreSettings(), refreshProStatus()]);623624    const pendingInitialSyncMarker =625      unlocked && vaultExists626        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)627        : null;628    const pendingInitialSyncDeviceId = pendingInitialSyncMarker629      ? await resolveInitialSyncDeviceId()630      : null;631632    // Mark played before any animation so a crash mid-sequence never replays633    // it. A newly adopted receiver deliberately defers this until its first634    // real sync finishes, preserving initial sync → celebration → vault.635    if (shouldCelebrate && !pendingInitialSyncDeviceId) {636      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });637    }638639    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome640    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.641    if (unlocked && vaultExists) {642      void sendMessage({643        type: "TRIGGER_SYNC",644        payload: { reason: "foreground" },645      });646      void loadSyncIndicator();647      // Rare device-trust alerts only; routine entry sync stays quiet.648      void loadSyncAlerts();649    }650651    // If the user created a vault but hasn't yet confirmed their recovery652    // words, route back to the recovery-words screen instead of opening the653    // entries view. The flag lives in chrome.storage.local so it survives654    // popup close while the background owns the session-scoped confirmation655    // exemption. Cleared when confirmation succeeds.656    let recoveryPending = false;657    try {658      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });659      recoveryPending = out.ok === true && out.data === true;660    } catch { /* best-effort */ }661662    // Content UI can ask the background to open this trusted popup either for663    // unlock or at a specific entry. Consume both session-scoped handoffs once.664    let pendingOpenEntryId: string | undefined;665    if (chrome.storage.session) {666      try {667        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);668        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };669        if (pending.peach_inline_unlock) {670          openedForInlineUnlock = true;671        }672        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;673        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);674      } catch { /* best-effort */ }675    }676677    if (recoveryPending && unlocked) {678      // forceRecoveryWords overrides LockView's hasVault gate so the679      // recovery surface renders even though the vault now exists.680      setForceRecoveryWords(true);681      setView("lock");682    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {683      void beginInitialSyncWait(pendingInitialSyncDeviceId);684    } else if (unlocked && vaultExists) {685      await openVault(pendingOpenEntryId, shouldCelebrate);686      if (shouldCelebrate) {687        setView("pro-celebration");688        window.setTimeout(() => setView("entry"), 2500);689      } else if (await shouldShowWhatsNew()) {690        setView("whats-new");691      }692    } else if (shouldCelebrate) {693      setPendingCelebration(true);694    }695696    await refreshPendingPasskey();697698    // Track tab changes so smart sort stays current699    const handleTabActivated = () => { void refreshCurrentTab(); };700    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {701      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();702    };703    try {704      chrome.tabs.onActivated.addListener(handleTabActivated);705      chrome.tabs.onUpdated.addListener(handleTabUpdated);706      onCleanup(() => {707        chrome.tabs.onActivated.removeListener(handleTabActivated);708        chrome.tabs.onUpdated.removeListener(handleTabUpdated);709      });710    } catch {711      // tabs permission may not be available in all contexts712    }713714    setBootState("ready");715716    // Deep-link: ?codex=pdf opens a detached window straight into the717    // Codex restore flow with auto-picked PDF. The action popup can't host718    // a file dialog without dismissing itself, so this is the only way the719    // PDF import path survives. See CodexRestoreView.openPdfPicker().720    try {721      const route = new URLSearchParams(window.location.search).get("codex");722      if (route === "pdf" || route === "restore") {723        setView("codex-restore");724      }725    } catch {726      // location.search unavailable — ignore.727    }728729    // Check for pending session-storage notices from the background.730    if (chrome.storage.session) {731      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as732        Record<string, { feature?: string } | undefined>;733      if (session.pendingProGate?.feature) {734        await chrome.storage.session.remove("pendingProGate");735        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);736        setSettingsInitialSection("license");737        setView("settings");738      } else if (session.pendingProNotice?.feature) {739        await chrome.storage.session.remove("pendingProNotice");740        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });741      }742      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {743        pendingRememberSite?: { entryId?: unknown; url?: unknown };744      };745      const request = remembered.pendingRememberSite;746      if (747        typeof request?.entryId === "string" &&748        typeof request.url === "string"749      ) {750        const entry = await sendMessage({ type: "GET_ENTRIES" });751        const match = entry.ok752          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(753              (candidate) => candidate.id === request.entryId,754            )755          : undefined;756        if (match) {757          let host = request.url;758          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }759          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });760        } else {761          await chrome.storage.session.remove("pendingRememberSite");762        }763      }764    }765766    // Warm the lazy-module cache so the Suspense fallback never shows for767    // locally-bundled extension views. Without this, the first navigation to768    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which769    // covers the shell and absorbs clicks on the header.770    void Promise.all([771      import("./views/Settings").catch(() => {}),772      import("./views/Import").catch(() => {}),773      import("./views/Export").catch(() => {}),774      import("./views/Health").catch(() => {}),775      import("./views/TrashView").catch(() => {}),776    ]);777778    // Lock-on-close via a persistent port. sendMessage in pagehide is779    // unreliable — the popup context tears down before the message lands.780    // The SW's onConnect listener locks when this port disconnects.781    let popupPort: chrome.runtime.Port | null = null;782    try {783      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect784      // listener accepts the "popup-" prefix; the random suffix is defence in785      // depth against a port named exactly "popup" from anything but our popup.786      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });787    } catch { /* extension context invalidated — ignore */ }788    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });789790    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale791    // entries instead of waiting for the next GET_ENTRIES round-trip.792    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {793      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();794      if (msg?.type === "SYNC_STATE_CHANGED") {795        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);796      }797      // A background sync merged entries the user never asked for (a peer's798      // push, or the periodic dial). Pull the fresh list so the vault view is799      // not stale — loadEntries() preserves the current selection by id.800      if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {801        void loadEntries();802      }803      if (msg?.type === "PRO_GATE_TRIGGERED") {804        setUpgradeHighlight(msg.payload?.feature ?? null);805        setSettingsInitialSection("license");806        setView("settings");807      }808    };809    chrome.runtime.onMessage.addListener(handleRuntimeMessage);810    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));811812    // Action handlers — keyed by ShortcutAction. The dispatcher below tests813    // each binding against the event and runs the matching handler. New814    // shortcuts only need an entry here; the binding is data, not code.815    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {816      copy_password: () => {817        const entry = selectedEntry();818        if (entry?.password) void popupCopy(819          entry.password,820          "Password",821          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,822        ).then((ok) => { if (ok) triggerRing(); });823      },824      copy_username: () => {825        const entry = selectedEntry();826        if (entry?.username) void popupCopy(827          entry.username,828          "Username",829          entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,830        ).then((ok) => { if (ok) triggerRing(); });831      },832      copy_totp: () => {833        const entry = selectedEntry();834        if (!entry?.totp) return;835        try {836          const { code } = generateTOTP(837            entry.totp,838            entry.totpAlgorithm,839            entry.totpDigits,840            entry.totpPeriod,841          );842          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });843        } catch { /* invalid TOTP secret — silent */ }844      },845      new_entry: () => handleAdd(),846      edit_entry: () => {847        const entry = selectedEntry();848        // Passkey-only entries have nothing user-editable in the Add view; the849        // passkey credential itself is opaque. Skip the edit action for them.850        if (!entry || isPasskeyOnly(entry)) return;851        setEditEntry(entry);852        setView("add");853      },854      delete_entry: () => {855        if (selectedEntry()) void handleDelete();856      },857      lock_vault: () => { void handleHeaderLock(); },858      search: () => openList(),859      nav_prev: () => navigate(-1),860      nav_next: () => navigate(1),861      jump_vault: () => setView("entry"),862      jump_settings: () => setView("settings"),863      show_help: () => setShowShortcutsHelp(true),864    };865866    // Actions that are MEANINGFUL only on the entry-detail view. Outside867    // that scope the binding is dormant — pressing C while on Settings868    // does nothing rather than awkwardly copying from a now-stale entry.869    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([870      "copy_password", "copy_username", "copy_totp",871      "edit_entry", "delete_entry", "nav_prev", "nav_next",872    ]);873874    const handleGlobalKeydown = (e: KeyboardEvent) => {875      const v = view();876      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"877        || (e.target as HTMLElement)?.tagName === "TEXTAREA"878        || (e.target as HTMLElement)?.isContentEditable;879      const isPrintableSearchKey =880        e.key.length === 1 &&881        !e.ctrlKey &&882        !e.metaKey &&883        !e.altKey;884885      // No shortcuts while a full-screen surface owns the whole viewport:886      // the lock screen, the post-pairing readiness sequence, or the Pro887      // celebration. Without this the lock screen is at the mercy of whatever888      // single-key binding is active — e.g. typing "n" in a password that889      // slips out of focus triggers `new_entry` and escapes the lock screen.890      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;891892      // Help dialog: Escape closes it; eat the keypress so it doesn't893      // also navigate away from the underlying view.894      if (showShortcutsHelp()) {895        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }896        return;897      }898899      // ── Escape — fixed binding (not user-customizable) ───────900      if (e.key === "Escape") {901        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }902        if (v === "list") { setView("entry"); return; }903        if (v === "settings" || v === "add") { handleBack(); return; }904        if (v === "import" || v === "health") { setView("settings"); return; }905        if (v === "export") { handleExportBack(); return; }906        if (v === "pair-device") { setView("settings"); return; }907      }908909      // Bindings-driven dispatch. Single-key letter bindings without910      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is911      // inside an input, so users can type into search/forms normally.912      const bindings = shortcutBindings();913      for (const action of Object.keys(bindings) as ShortcutAction[]) {914        const binding = bindings[action];915        if (!matchKeybinding(e, binding)) continue;916        // Single-key letter bindings need the input-focus guard. Bindings917        // with any modifier are safe — Ctrl+L doesn't conflict with typing.918        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;919        if (!hasMod && binding.key.length === 1 && inInput) return;920        if (ENTRY_SCOPED.has(action) && v !== "entry") return;921        e.preventDefault();922        SHORTCUT_HANDLERS[action]?.();923        return;924      }925926      // Type-to-search: in entry view, any printable key not consumed by a927      // shortcut above primes the list search seed and opens the list.928      // Kept outside the bindings table because it's a behavior, not a929      // discrete action — every printable character can trigger it.930      if (v === "entry" && !inInput && isPrintableSearchKey) {931        e.preventDefault();932        primeListSearch(e.key);933        return;934      }935    };936    window.addEventListener("keydown", handleGlobalKeydown);937    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));938939    const handleStorageChanged = (940      changes: Record<string, chrome.storage.StorageChange>,941      areaName: string942    ) => {943      if (areaName !== "session") return;944      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {945        return;946      }947      void refreshPendingPasskey();948    };949    chrome.storage.onChanged.addListener(handleStorageChanged);950    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));951  });952953  async function refreshCurrentTab() {954    try {955      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });956      const url = tab?.url ?? tab?.pendingUrl ?? "";957      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");958      // Skip extension/internal URLs — adding chrome://, about:, or959      // chrome-extension:// to a vault entry is never what the user wants.960      const isWebUrl = /^https?:\/\//i.test(url);961      setCurrentTabUrl(isWebUrl ? url : "");962    } catch {963      setCurrentTabDomain("");964      setCurrentTabUrl("");965    }966  }967968  async function refreshPendingPasskey() {969    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });970    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);971  }972973  async function refreshProStatus() {974    const res = await sendMessage({ type: "GET_LICENSE" });975    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;976    setProStatus(Boolean(data?.valid && data?.plan === "pro"));977  }978979  async function restoreSettings() {980    const res = await sendMessage({ type: "GET_SETTINGS" });981    const s = (res as { ok: boolean; data?: any }).data ?? {};982    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {983      setAccentColor(s.accentColor as AccentColorName);984    }985    setTheme(s.theme === "light" ? "light" : "dark");986    if (s.textSize === "small" || s.textSize === "large") {987      setTextSize(s.textSize);988    } else {989      setTextSize("medium");990    }991    setReduceMotion(s.reduceMotion === true);992    setLockOnClose(s.lockOnClose === true);993    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);994    setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);995    setBreachMonitoring(s.breachMonitoring !== false);996    setShortcutBindings(getActiveBindings(s.shortcuts));997  }998999  async function applyTrashRetention(days: number) {1000    setTrashRetentionDays(days);1001    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1002  }10031004  function primeListSearch(seed: string) {1005    setListSearchSeed(seed);1006    setListSearchSeedNonce((value) => value + 1);1007    openList();1008  }10091010  // Returns null when it detected a dead service worker and triggered handleLock().1011  // Callers that need to abort (e.g. openVault) check for null rather than relying1012  // on view() state, which may already be "lock" during normal startup flows.1013  async function loadEntries(): Promise<VaultEntry[] | null> {1014    const response = await sendMessage({ type: "GET_ENTRIES" });1015    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10161017    // If the service worker was killed and restarted (MV3 workers sleep), the1018    // vault will be locked again — or the worker may not respond at all.1019    // In either case, send the user to the lock screen rather than silently1020    // showing an empty vault (which looks like data loss).1021    if (!res.ok) {1022      if (1023        res.error === "vault_locked" ||1024        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1025        (typeof res.error === "string" && res.error.includes("locked"))1026      ) {1027        handleLock();1028        return null; // signal emergency lock to callers1029      }1030      return [];1031    }10321033    const loadedEntries = res.data ?? [];1034    setEntries(loadedEntries);10351036    const currentId = selectedEntry()?.id;1037    if (currentId) {1038      setSelectedEntry(1039        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1040      );1041    }10421043    return loadedEntries;1044  }10451046  async function openVault(preferredEntryId?: string, skipViewSet = false) {1047    // Purge expired trash entries on each vault open1048    const retDays = trashRetentionDays();1049    if (retDays > 0) {1050      void sendMessage({1051        type: "PURGE_EXPIRED_TRASH",1052        payload: { retentionMs: retDays * 86_400_000 },1053      });1054    }10551056    const loadedEntries = await loadEntries();10571058    // null means loadEntries() triggered an emergency lock (service worker died).1059    // Don't override it by setting view back to "entry" with an empty vault.1060    if (loadedEntries === null) return false;10611062    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1063    let nextSelectedEntry: VaultEntry | null = null;1064    if (preferredEntryId) {1065      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1066    }1067    if (!nextSelectedEntry) {1068      const tabDomain = currentTabDomain();1069      if (tabDomain) {1070        nextSelectedEntry = loadedEntries.find((e) => {1071          if (e.type === "login") {1072            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1073            for (const extra of e.additionalUrls ?? []) {1074              if ((extractDomain(extra) ?? "") === tabDomain) return true;1075            }1076            return false;1077          }1078          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1079          return false;1080        }) ?? null;1081      }1082    }1083    nextSelectedEntry ??= pickNavigable(loadedEntries);10841085    setSelectedEntry(nextSelectedEntry);1086    setEditEntry(undefined);1087    setContentPhase("enter");1088    queueContentIdle();1089    if (!skipViewSet) setView("entry");1090    return true;1091  }10921093  async function handleUnlock() {1094    // If the user previously created a vault but hadn't yet confirmed their1095    // recovery words (and the browser was closed before they could), the1096    // the background-owned recovery-pending state is still set. Re-route1097    // back to the recovery surface so they finish the confirmation step1098    // before reaching the entries view — the words can be re-derived now1099    // that the vault is unlocked again.1100    let recoveryPending = false;1101    try {1102      const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1103      recoveryPending = out.ok === true && out.data === true;1104    } catch { /* best-effort */ }1105    if (recoveryPending) {1106      setForceRecoveryWords(true);1107      setView("lock");1108      triggerPipPulse();1109      await restoreSettings();1110      return;1111    }1112    setHasVault(true);1113    triggerPipPulse();1114    await restoreSettings();1115    const celebrating = pendingCelebration();1116    if (celebrating) {1117      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1118      setPendingCelebration(false);1119    }1120    await openVault(undefined, celebrating);1121    if (celebrating) {1122      setView("pro-celebration");1123      window.setTimeout(() => setView("entry"), 2500);1124    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1125      setView("whats-new");1126    }1127    // Popup was opened from the inline "Open Peach to unlock" button — close it1128    // now so the user lands back on the page with the autofill selector ready.1129    if (openedForInlineUnlock) {1130      openedForInlineUnlock = false;1131      window.close();1132    }1133  }11341135  function handleSelectEntry(entry: VaultEntry) {1136    if (entry.type === "note") {1137      setOpenNoteId(entry.id);1138      setShowNotes(true);1139      return;1140    }1141    setSelectedEntry(entry);1142    setContentPhase("enter");1143    queueContentIdle();1144    setView("entry");1145  }11461147  function handleAdd() {1148    setEditEntry(undefined);1149    setView("add");1150  }11511152  async function handleDelete() {1153    const entry = selectedEntry();1154    if (!entry) return;1155    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1156    const loadedEntries = await loadEntries() ?? [];1157    setSelectedEntry(pickNavigable(loadedEntries));1158    setView("entry");1159  }11601161  async function handleDeleteMany(ids: string[]) {1162    for (const id of ids) {1163      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1164    }1165    const loadedEntries = await loadEntries() ?? [];1166    // If the currently selected entry was among the deleted, move to first1167    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1168    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1169  }11701171  async function handleSave(savedEntry?: VaultEntry) {1172    await openVault(savedEntry?.id);1173  }11741175  function handleBack() {1176    const dest = editBackView();1177    setEditBackView("entry");1178    setEditEntry(undefined);1179    setView(dest);1180  }11811182  function handleExportBack() {1183    if (returnToPanic()) {1184      setReturnToPanic(false);1185      setView("panic");1186      return;1187    }1188    setView("settings");1189  }11901191  function handleLock() {1192    setEntries([]);1193    setSelectedEntry(null);1194    setEditEntry(undefined);1195    setContentPhase("idle");1196    setView("lock");1197  }11981199  function handleHeaderLock() {1200    // Lock the UI immediately — do not wait for the service worker. If the SW1201    // is dead or slow, the 5s sendMessage timeout would create a dead-click1202    // window. The SW will lock on its next wake regardless.1203    handleLock();1204    void sendMessage({ type: "LOCK" });1205  }12061207  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1208  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1209  // the trigger's own reply.1210  async function handleHeaderSync(): Promise<void> {1211    if (syncBusy()) return;1212    setSyncBusy(true);1213    setSyncFlash(null);1214    try {1215      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1216      let state: SyncOutcomeSummary = { status: "syncing" };1217      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1218        await new Promise((r) => setTimeout(r, 250));1219        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1220          ok: boolean;1221          data?: SyncOutcomeSummary;1222        };1223        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1224      }1225      setSyncSummary(state);1226      setSyncFlash(state.status === "error" ? "error" : "ok");1227      await loadSyncAlerts();1228      await loadEntries();1229      await loadSyncIndicator();1230    } catch {1231      setSyncSummary({ status: "error", outcome: "run_failed" });1232      setSyncFlash("error");1233    } finally {1234      setSyncBusy(false);1235      setTimeout(() => setSyncFlash(null), 2_000);1236    }1237  }12381239  function navigate(dir: number) {1240    const navigable = nonNoteEntries();1241    if (navigable.length === 0) return;1242    const currentIndex = selectedIndex();1243    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1244    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1245    setSelectedEntry(navigable[nextIndex]);1246    setContentPhase("enter");1247    queueContentIdle();1248  }12491250  /**1251   * The Entry view's selection invariant: never select a note. Notes are a1252   * separate surface (handleSelectEntry opens the notes view), so any code path1253   * that picks a default — vault open, post-delete, refresh — must skip them1254   * and return null if the only entries are notes.1255   */1256  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1257    return list.find((e) => e.type !== "note") ?? null;1258  }12591260  function openList() {1261    setView("list");1262  }12631264  function openHealth() {1265    setEditEntry(undefined);1266    setView("health");1267  }12681269  function handleOpenFileImport() {1270    setRestoreView("file");1271  }12721273  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12741275  function handleFixEntry(entry: VaultEntry) {1276    setSelectedEntry(entry);1277    // Passkey-only entries have no editable surface — drop into view mode1278    // instead of opening the Add editor.1279    if (isPasskeyOnly(entry)) {1280      setView("entry");1281      return;1282    }1283    setEditBackView(view()); // preserve caller view so back returns here1284    setEditEntry(entry);1285    setView("add");1286  }12871288  const isLockedView = () => view() === "lock" || view() === "adopting";12891290  async function handleAdoptionDone() {1291    await storageSet("peach_onboarded", "true").catch(() => {});1292    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1293    clearOnboardingExit();1294    setOnboarded(true);1295    setHasVault(true);1296    setView("initial-sync");1297    // The background applies a Pro license and persists the source-device1298    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1299    // celebration pending until that exact peer records a completed sync.1300    await refreshProStatus();1301    const deviceId = await resolveInitialSyncDeviceId();1302    if (deviceId) {1303      void beginInitialSyncWait(deviceId);1304    } else {1305      // Pairing completion without a durable peer row should be impossible.1306      // Stay on the honest waiting surface and expose retry instead of falling1307      // through to an empty vault.1308      setInitialSyncSlow(true);1309    }1310  }13111312  async function handleApprovePendingPasskey() {1313    const pending = pendingPasskey();1314    if (!pending) return;1315    if (pending.browser === "firefox" && pending.type === "create") {1316      // Firefox path: popup itself runs navigator.credentials.create. The1317      // background never approved/rejected anything — it just stashed1318      // intent. After the popup-driven flow resolves (success or cancel),1319      // clear the pending key so the popup doesn't re-show this on next open.1320      const { createFirefoxPasskey } = await import("./firefox-passkey");1321      const result = await createFirefoxPasskey(pending);1322      if (!result.ok) {1323        console.warn("Peach Firefox passkey create failed:", result.error);1324      }1325      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1326      setPendingPasskey(null);1327      void loadEntries();1328      window.close();1329      return;1330    }1331    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1332    window.close();1333  }13341335  async function handleDenyPendingPasskey() {1336    const pending = pendingPasskey();1337    if (!pending) return;1338    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1339    window.close();1340  }13411342  function queueContentIdle(after?: () => void) {1343    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1344    contentPhaseTimer = window.setTimeout(() => {1345      setContentPhase("idle");1346      after?.();1347    }, ENTRY_TRANSITION_SETTLE_MS);1348  }13491350  return (1351    <div class="popup-root peach-theme">1352      <Show when={bootState() === "ready"}>1353        <Show1354          when={!pendingPasskey()}1355          fallback={1356            <PasskeyApprovalView1357              type={pendingPasskey()!.type}1358              rpName={pendingPasskey()!.rpName}1359              rpId={pendingPasskey()!.rpId}1360              userName={pendingPasskey()!.userName}1361              userDisplayName={pendingPasskey()!.userDisplayName}1362              onApprove={handleApprovePendingPasskey}1363              onDeny={handleDenyPendingPasskey}1364            />1365          }1366        >1367          <Show1368            when={!isLockedView()}1369            fallback={1370              <Show1371                when={view() !== "adopting"}1372                fallback={1373                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1374                    <AdoptVaultView1375                      onCancel={() => {1376                        clearOnboardingExit();1377                        setOnboarded(false);1378                        setView("lock");1379                      }}1380                      onDone={handleAdoptionDone}1381                    />1382                  </Suspense>1383                }1384              >1385                <Show1386                  when={onboarded() || hasVault()}1387                  fallback={1388                    <Onboarding1389                      initialCard={onboardingCardAtExit()}1390                      onComplete={() => {1391                        markOnboardingExit("create");1392                        setStartOnboardingRestore(false);1393                        setOnboarded(true);1394                      }}1395                      onRestore={() => {1396                        markOnboardingExit("restore");1397                        setStartOnboardingRestore(true);1398                        setOnboarded(true);1399                      }}1400                      onAdopt={() => {1401                        markOnboardingExit("adopt");1402                        setStartOnboardingRestore(false);1403                        setOnboarded(true);1404                        setView("adopting");1405                      }}1406                    />1407                  }1408                >1409                  <LockView1410                    hasVault={hasVault()}1411                    ready={bootState() === "ready"}1412                    isPro={proStatus()}1413                    forceRecoveryWords={forceRecoveryWords()}1414                    initialRestore={startOnboardingRestore()}1415                    initialRestoreSource={resumeRestoreSource() ?? undefined}1416                    onUnlock={() => {1417                      // RecoveryWordDisplay clears the storage flag before1418                      // calling onDone, so handleUnlock will see no pending1419                      // confirmation and proceed through the normal path.1420                      if (forceRecoveryWords()) setForceRecoveryWords(false);1421                      // The create/restore flow is done — a lock later in this1422                      // window must show the unlock screen, not re-enter the1423                      // onboarding restore surface.1424                      setStartOnboardingRestore(false);1425                      setResumeRestoreSource(null);1426                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1427                      return handleUnlock();1428                    }}1429                    onBackToOnboarding={1430                      !hasVault()1431                        ? () => {1432                            // Deliberately rewinding to the carousel — the exit1433                            // flow must not reclaim the next popup open.1434                            clearOnboardingExit();1435                            setStartOnboardingRestore(false);1436                            setOnboarded(false);1437                          }1438                        : undefined1439                    }1440                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1441                  />1442                </Show>1443              </Show>1444            }1445          >1446            <Show1447              when={1448                view() !== "pro-celebration" &&1449                view() !== "initial-sync" &&1450                view() !== "whats-new"1451              }1452              fallback={1453                <Show1454                  when={view() === "initial-sync"}1455                  fallback={1456                    <Show1457                      when={view() === "whats-new"}1458                      fallback={<ProCelebration />}1459                    >1460                      <WhatsNewView onContinue={() => setView("entry")} />1461                    </Show>1462                  }1463                >1464                  <InitialSyncView1465                    slow={initialSyncSlow()}1466                    onRetry={() => void retryInitialSync()}1467                  />1468                </Show>1469              }1470            >1471            <section1472              id="hd-shell"1473              class="peach-shell peach-theme"1474              data-surface="extension"1475            >1476            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1477              <PeachWordmark unlocked />1478              <div class="peach-chrome-actions">1479                <button1480                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1481                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1482                  aria-label="Notes"1483                  title="Notes"1484                >1485                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1486                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1487                    <line x1="4" y1="5" x2="10" y2="5" />1488                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1489                    <line x1="4" y1="10" x2="7" y2="10" />1490                  </svg>1491                </button>1492                <button1493                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1494                    classList={{1495                      "is-syncing": syncBusy(),1496                      "is-ok": syncFlash() === "ok",1497                      "is-error": syncFlash() === "error",1498                    }}1499                    onClick={() => void handleHeaderSync()}1500                    disabled={syncBusy()}1501                    aria-label={`Sync now. ${syncLabel()}`}1502                    title={syncLabel()}1503                  >1504                    <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">1505                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1506                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1507                      <path d="M2.6 1v2.4h2.4" />1508                      <path d="M11.4 13v-2.4H9" />1509                    </svg>1510                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1511                  </button>1512                <button1513                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1514                  onClick={() => setView("settings")}1515                  aria-label="Settings"1516                >1517                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1518                    <path1519                      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"1520                      stroke="currentColor"1521                      stroke-width="0.9"1522                    />1523                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1524                  </svg>1525                </button>1526                <button1527                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1528                  onClick={handleAdd}1529                  aria-label="New entry"1530                >1531                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1532                    <path d="M5.5 1v9M1 5.5h9" />1533                  </svg>1534                </button>1535                <button1536                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1537                  onClick={() => void handleHeaderLock()}1538                  aria-label="Lock vault"1539                >1540                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1541                    <path1542                      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"1543                      stroke="currentColor"1544                      stroke-width="1"1545                      stroke-linecap="round"1546                    />1547                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1548                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1549                  </svg>1550                </button>1551              </div>1552            </header>15531554            <Show when={pendingProNotice() !== null}>1555              <div class="peach-pro-notice-banner">1556                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1557                <button1558                  class="peach-pro-notice-link"1559                  onClick={() => {1560                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1561                    setSettingsInitialSection("license");1562                    setView("settings");1563                    setPendingProNotice(null);1564                  }}1565                >1566                  Learn more →1567                </button>1568                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1569              </div>1570            </Show>15711572            <Show when={pendingRememberSite()}>1573              {(request) => {1574                const origin = getSecurityOriginDisplay(request().url);1575                const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1576                return (1577                  <div class="peach-pro-notice-banner">1578                    <span>Also use “{request().title}” on {originLabel}?</span>1579                    <button1580                      class="peach-pro-notice-link"1581                      onClick={async () => {1582                        await sendMessage({1583                          type: "ADD_ADDITIONAL_URL",1584                          payload: { id: request().entryId, url: request().url },1585                        });1586                        await chrome.storage.session.remove("pendingRememberSite");1587                        setPendingRememberSite(null);1588                      }}1589                    >1590                      Add1591                    </button>1592                    <button1593                      class="peach-pro-notice-close"1594                      onClick={async () => {1595                        await chrome.storage.session.remove("pendingRememberSite");1596                        setPendingRememberSite(null);1597                      }}1598                      aria-label="Dismiss"1599                    >1600                      ✕1601                    </button>1602                  </div>1603                );1604              }}1605            </Show>16061607            <div class="peach-shell-body">1608              <div class="peach-shell-veil"></div>1609              <div class="peach-content-frame" data-content-phase={contentPhase()}>1610                <EntryView1611                  keyboardEnabled={view() === "entry"}1612                  entry={selectedEntry()}1613                  currentTabUrl={currentTabUrl()}1614                  entries={entries()}1615                  isPro={proStatus()}1616                  onDelete={handleDelete}1617                  onFieldSaved={() => void loadEntries()}1618                  onEscape={() => { setView("list"); }}1619                  onProGate={(feature) => {1620                    setUpgradeHighlight(feature);1621                    setSettingsInitialSection("license");1622                    setView("settings");1623                  }}1624                />1625              </div>16261627              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1628                <div class="peach-search-bar">1629                  <span class="peach-search-icon">1630                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1631                      <circle cx="5.5" cy="5.5" r="3.75" />1632                      <path d="M8.75 8.75L11.5 11.5" />1633                    </svg>1634                  </span>1635                  <input1636                    class="peach-search-input"1637                    placeholder="search vault..."1638                    autocomplete="off"1639                    aria-label="Search vault entries"1640                    onClick={openList}1641                    onFocus={openList}1642                    readOnly1643                  />1644                  <div class="peach-search-meta">1645                    <span class="peach-search-counter">1646                      {nonNoteEntries().length > 01647                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1648                        : "0 entries"}1649                    </span>1650                    <span class="peach-divider"></span>1651                    <button1652                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1653                      onClick={() => navigate(-1)}1654                      aria-label="Previous entry"1655                      disabled={nonNoteEntries().length === 0}1656                    >1657                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1658                        <path d="M5 1L1 5.5L5 10" />1659                      </svg>1660                    </button>1661                    <button1662                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1663                      onClick={() => navigate(1)}1664                      aria-label="Next entry"1665                      disabled={nonNoteEntries().length === 0}1666                    >1667                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1668                        <path d="M1 1L5 5.5L1 10" />1669                      </svg>1670                    </button>1671                  </div>1672                </div>1673              </section>1674            </div>16751676            {/* Rare device-trust alerts; normal sync changes are silent. */}1677            <Show when={alertGroups().length > 0}>1678              <div class="peach-sync-alerts">1679                <For each={alertGroups()}>1680                  {([type, group]) => {1681                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1682                    return (1683                      <div class="peach-sync-alert">1684                        <Show when={group.length > 1}>1685                          <button1686                            class="peach-sync-alert-flip"1687                            type="button"1688                            aria-label="Previous"1689                            onClick={() => flipAlert(type, -1)}1690                          >‹</button>1691                        </Show>1692                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1693                        <Show when={group.length > 1}>1694                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1695                          <button1696                            class="peach-sync-alert-flip"1697                            type="button"1698                            aria-label="Next"1699                            onClick={() => flipAlert(type, 1)}1700                          >›</button>1701                        </Show>1702                        <button1703                          class="peach-sync-alert-dismiss"1704                          type="button"1705                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1706                          onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1707                        >×</button>1708                      </div>1709                    );1710                  }}1711                </For>1712              </div>1713            </Show>17141715            <Show when={view() === "list"}>1716              <VaultView1717                entries={entries()}1718                selectedEntryId={selectedEntry()?.id}1719                currentTabDomain={currentTabDomain()}1720                sortOrder={sortOrder()}1721                searchSeed={listSearchSeed()}1722                searchSeedNonce={listSearchSeedNonce()}1723                onSelectEntry={handleSelectEntry}1724                onSortOrderChange={setSortOrder}1725                onAdd={handleAdd}1726                onClose={() => setView("entry")}1727                onOpenTrash={() => setView("trash")}1728                onDeleteMany={handleDeleteMany}1729              />1730            </Show>1731            <Show when={view() === "settings" || settingsExiting()}>1732              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1733                <SettingsView1734                  entriesCount={entries().length}1735                  breachMonitoring={breachMonitoring()}1736                  lockOnClose={lockOnClose()}1737                  autoLockMinutes={autoLockMinutes()}1738                  trashRetentionDays={trashRetentionDays()}1739                  lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1740                  theme={theme()}1741                  accentColor={accentColor()}1742                  textSize={textSize()}1743                  reduceMotion={reduceMotion()}1744                  isPro={proStatus()}1745                  initialSection={settingsInitialSection()}1746                  upgradeHighlight={upgradeHighlight()}1747                  isExiting={settingsExiting()}1748                  onBreachMonitoringChange={applyBreachMonitoring}1749                  onLockOnCloseChange={applyLockOnClose}1750                  onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1751                  onAutoLockMinutesChange={applyAutoLockMinutes}1752                  onTrashRetentionChange={applyTrashRetention}1753                  onThemeChange={applyTheme}1754                  onAccentChange={applyAccentColor}1755                  onTextSizeChange={applyTextSize}1756                  onReduceMotionChange={applyReduceMotion}1757                  shortcuts={shortcutBindings()}1758                  onShortcutsChange={applyShortcuts}1759                  onLicenseChange={async () => {1760                    // After activation/deactivation: refresh Pro status,1761                    // re-load entries (defensive — signal state can drift if1762                    // the SW briefly restarts during the verify+save round-trip),1763                    // and replay the Pro celebration if this is the first time1764                    // a valid Pro license is seen on this device.1765                    await refreshProStatus();1766                    void loadEntries();1767                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1768                    if ((should as { ok: boolean; data?: boolean }).data === true) {1769                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1770                      setView("pro-celebration");1771                      window.setTimeout(() => setView("settings"), 2500);1772                    }1773                  }}1774                  onBack={() => {1775                    setSettingsExiting(true);1776                    window.setTimeout(() => {1777                      setSettingsExiting(false);1778                      setSettingsInitialSection(null);1779                      setUpgradeHighlight(null);1780                      handleBack();1781                    }, 200);1782                  }}1783                  onImport={() => setView("import")}1784                  onExport={() => setView("export")}1785                  onBreachCheckDone={loadEntries}1786                  onOpenHealth={openHealth}1787                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1788                  onOpenFileImport={handleOpenFileImport}1789                  onOpenPanic={() => setView("panic")}1790                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1791                  onOpenCodexRestore={() => setView("codex-restore")}1792                  onCardChange={setSettingsActiveCard}1793                  onProGate={(feature) => {1794                    setUpgradeHighlight(feature);1795                    setSettingsInitialSection("license");1796                  }}1797                />1798              </Suspense>1799            </Show>1800            <Show when={view() === "codex-generate"}>1801              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1802                <CodexGenerateView1803                  isPro={proStatus()}1804                  onBack={() => setView("settings")}1805                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1806                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1807                  onVaultLocked={() => handleLock()}1808                />1809              </Suspense>1810            </Show>1811            <Show when={view() === "codex-restore"}>1812              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1813                <CodexRestoreView1814                  onBack={() => setView("lock")}1815                  onRestoreSuccess={async () => {1816                    // After restore the vault is unlocked; refresh entries1817                    // and route to the main vault list. handleUnlock is the1818                    // canonical post-unlock setup path.1819                    await handleUnlock();1820                  }}1821                />1822              </Suspense>1823            </Show>1824            <Show when={view() === "pair-device"}>1825              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1826                <PairDeviceView1827                  isPro={proStatus()}1828                  onProChanged={() => { void refreshProStatus(); }}1829                  onBack={() => setView("settings")}1830                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1831                />1832              </Suspense>1833            </Show>1834            <Show when={view() === "add"}>1835              <AddView1836                entry={editEntry()}1837                entries={entries()}1838                onBack={handleBack}1839                onSave={handleSave}1840              />1841            </Show>1842            <Show when={view() === "import"}>1843              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1844                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1845              </Suspense>1846            </Show>1847            <Show when={view() === "export"}>1848              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1849                <ExportView onBack={handleExportBack} />1850              </Suspense>1851            </Show>1852            <Show when={view() === "panic"}>1853              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1854                <PanicView1855                  onCancel={() => setView("settings")}1856                  onOpenExport={() => {1857                    setReturnToPanic(true);1858                    setView("export");1859                  }}1860                  onDestroyed={async () => {1861                    // Fresh onboarding start after a destroy — don't let the1862                    // resume markers land the user mid-carousel.1863                    setOnboardingCardAtExit(0);1864                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1865                    clearOnboardingExit();1866                    setEntries([]);1867                    setSelectedEntry(null);1868                    setEditEntry(undefined);1869                    setHasVault(false);1870                    setOnboarded(false);1871                    setProStatus(false);1872                    setPendingPasskey(null);1873                    setUpgradeHighlight(null);1874                    setSettingsInitialSection(null);1875                    setPendingProNotice(null);1876                    setPendingCelebration(false);1877                    setReturnToPanic(false);1878                    setContentPhase("idle");1879                    setView("lock");1880                  }}1881                />1882              </Suspense>1883            </Show>1884            {/* Health stays mounted while editing an entry from it so1885                computeReused() doesn't rerun on every cancel/back. */}1886            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1887              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1888                <HealthView1889                  entries={entries()}1890                  isPro={proStatus()}1891                  onFixEntry={handleFixEntry}1892                  onBack={() => setView("settings")}1893                  onRefresh={loadEntries}1894                  hidden={view() !== "health"}1895                />1896              </Suspense>1897            </Show>1898            <Show when={view() === "trash"}>1899              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1900                <TrashView1901                  onBack={() => setView("list")}1902                  onRestored={() => void loadEntries()}1903                />1904              </Suspense>1905            </Show>1906            <Show when={showNotes()}>1907              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1908                <NotesMode1909                  notes={entries().filter((e) => e.type === "note")}1910                  initialNoteId={openNoteId()}1911                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1912                  onNoteListChanged={() => void loadEntries()}1913                />1914              </div>1915            </Show>1916            <Show when={restoreView() === "file"}>1917              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1918                <ImportView1919                  mode="onboarding"1920                  replacesExistingVault={true}1921                  onBack={() => setRestoreView(null)}1922                  onImport={async () => {1923                    setRestoreView(null);1924                    await handleUnlock();1925                  }}1926                />1927              </Suspense>1928            </Show>1929            </section>1930            </Show>1931          </Show>1932        </Show>1933      </Show>1934      <Show when={showShortcutsHelp()}>1935        <ShortcutsHelpOverlay1936          bindings={shortcutBindings()}1937          onClose={() => setShowShortcutsHelp(false)}1938        />1939      </Show>1940    </div>1941  );1942}19431944function ShortcutsHelpOverlay(props: {1945  bindings: Record<ShortcutAction, Keybinding>;1946  onClose: () => void;1947}) {1948  return (1949    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1950      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1951        <div class="peach-shortcuts-help-head">1952          <h3>Keyboard shortcuts</h3>1953          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1954        </div>1955        <div class="peach-shortcuts-help-body">1956          <p class="peach-settings-sub-label">ENTRY VIEW</p>1957          <ul>1958            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1959              <li>1960                <span>{d.label}</span>1961                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1962              </li>1963            ))}1964          </ul>1965          <p class="peach-settings-sub-label">ANYWHERE</p>1966          <ul>1967            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1968              <li>1969                <span>{d.label}</span>1970                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1971              </li>1972            ))}1973          </ul>1974        </div>1975      </div>1976    </div>1977  );1978}