App.tsx

78.4 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 {14  ONBOARDING_CARD_STORAGE_KEY,15  ONBOARDING_LAST_CARD,16  Onboarding,17} from "./views/Onboarding";1819const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";20import { NotesMode } from "./views/NotesMode";21import { ProCelebration } from "./views/ProCelebration";22import { InitialSyncView } from "./views/InitialSyncView";23import { VaultView, type SortOrder } from "./views/Vault";24import { EntryView } from "./views/Entry";25import { AddView } from "./views/Add";26import { PeachWordmark } from "./components/PeachMark";27import { PasskeyApprovalView } from "./views/PasskeyApproval";28import { extractDomain } from "../shared/domain";29import {30  syncIndicator,31  syncIndicatorLabel,32  type SyncOutcomeSummary,33} from "../shared/sync-outcome";34import { popupCopy } from "./copy";35import {36  DEFAULT_BINDINGS,37  formatKeybinding,38  getActiveBindings,39  matchKeybinding,40  SHORTCUT_DESCRIPTORS,41  type Keybinding,42  type ShortcutAction,43} from "../shared/shortcuts";44import { generateTOTP } from "../crypto/totp";4546export { sendMessage } from "../shared/bridge";4748const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));49const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));50const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));51const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));52const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));53const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));54const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));55const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));56const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));57const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));5859type View =60  | "lock"61  | "pro-celebration"62  | "entry"63  | "list"64  | "add"65  | "settings"66  | "pair-device"67  | "import"68  | "export"69  | "health"70  | "trash"71  | "panic"72  | "adopting"73  | "initial-sync"74  | "codex-generate"75  | "codex-restore";76type BootState = "loading" | "ready";7778const INITIAL_SYNC_POLL_MS = 500;79const INITIAL_SYNC_SLOW_MS = 15_000;8081let shellRingAnimation: Animation | null = null;8283export function triggerRing() {84  const shell = document.getElementById("hd-shell");85  if (!shell) return;8687  shellRingAnimation?.cancel();88  shell.dataset.glowing = "true";89  const animation = shell.animate(90    [91      { offset: 0, boxShadow: "var(--copy-shadow)" },92      {93        offset: 0.3,94        boxShadow:95          "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)",96      },97      { offset: 1, boxShadow: "var(--copy-shadow)" },98    ],99    {100      duration: 720,101      easing: "cubic-bezier(0.4, 0, 0.2, 1)",102      fill: "none",103    }104  );105  shellRingAnimation = animation;106  const clearGlow = () => {107    if (shellRingAnimation !== animation) return;108    shellRingAnimation = null;109    delete shell.dataset.glowing;110  };111  animation.onfinish = clearGlow;112  animation.oncancel = clearGlow;113}114115export function triggerPipPulse() {116  const pip = document.querySelector(".peach-status-pip, .pip");117  if (pip) {118    pip.classList.remove("pulse");119    void (pip as HTMLElement).offsetWidth;120    pip.classList.add("pulse");121  }122}123124const ENTRY_TRANSITION_SETTLE_MS = 135;125126export default function App() {127  const [bootState, setBootState] = createSignal<BootState>("loading");128  const [view, setView] = createSignal<View>("lock");129  // Mobile bottom-sheet live drag offset in px (null = not actively dragging,130  // CSS transition takes over for snap animation).131  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(132    "enter"133  );134  const [contentTransitioning, setContentTransitioning] = createSignal(false);135  const [hasVault, setHasVault] = createSignal(false);136  const [onboarded, setOnboarded] = createSignal(true);137  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);138  // The restore source picked before the popup closed (the OS file picker139  // kills it), so reopening resumes the same source form instead of the140  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.141  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);142  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes143  // back. Those flows all exit from the final card, so resume there — a fresh144  // install's first mount stays at 0.145  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);146  const [entries, setEntries] = createSignal<VaultEntry[]>([]);147  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);148  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);149150  // Rare device-trust alerts stay visible; routine entry sync is silent.151  interface SyncAlert {152    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";153    entryId: string;154    entryTitle: string;155    deviceName: string;156    timestamp: number;157  }158  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);159  // Per-group flip index: which alert within a type-group is currently shown.160  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});161  async function loadSyncAlerts() {162    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {163      ok: boolean;164      data?: SyncAlert[];165    };166    if (r.ok && (r.data?.length ?? 0) > 0) {167      setSyncAlerts(r.data ?? []);168      // Acknowledge so a crash between read and render doesn't lose them —169      // they remain on screen until the user dismisses each.170      void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });171    }172  }173  function dismissAlertGroup(type: string) {174    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));175    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });176  }177  function flipAlert(type: string, dir: 1 | -1) {178    const group = syncAlerts().filter((a) => a.type === type);179    if (group.length <= 1) return;180    setAlertFlipIdx((m) => {181      const cur = m[type] ?? 0;182      return { ...m, [type]: (cur + dir + group.length) % group.length };183    });184  }185  /** Grouped alert types in display order, each with its alerts. */186  const alertGroups = createMemo(() => {187    const map = new Map<string, SyncAlert[]>();188    for (const a of syncAlerts()) {189      const g = map.get(a.type);190      if (g) g.push(a); else map.set(a.type, [a]);191    }192    return [...map.entries()];193  });194  function syncAlertText(a: SyncAlert): string {195    if (a.type === "DEVICE_REVOKED") {196      return `${a.deviceName} revoked a paired device — review your devices in Settings`;197    }198    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;199  }200  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");201  const [theme, setTheme] = createSignal<"dark" | "light">("dark");202  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");203  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);204  const [lockOnClose, setLockOnClose] = createSignal(false);205  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);206  const [breachMonitoring, setBreachMonitoring] = createSignal(true);207  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);208  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);209  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);210  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);211  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);212  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");213  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");214  // Cached Pro status — synchronous for render paths. Background's215  // GET_LICENSE is async, but render-time reads of isPro() must not block.216  // Refreshed at boot, after activate/deactivate, and whenever the popup217  // re-opens onto a stored license that has changed via another window.218  const [proStatus, setProStatus] = createSignal(false);219  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);220  // When the sync card's pairing button opens the pairing view, deep-link221  // straight into that pairing path instead of the rest screen.222  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");223  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);224  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");225  const [listSearchSeed, setListSearchSeed] = createSignal("");226  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);227  const [pendingCelebration, setPendingCelebration] = createSignal(false);228  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);229  // True while Panic Step 1 has bounced the user out to Export. When Export's230  // onBack fires we return to Panic instead of Settings so the user keeps the231  // destruction context they came from.232  const [returnToPanic, setReturnToPanic] = createSignal(false);233  // The view to return to when the add/edit view is dismissed. Normally "entry"234  // but "health" when the user arrives via the fix-entry path from Vault Health.235  const [editBackView, setEditBackView] = createSignal<View>("entry");236  const [settingsExiting, setSettingsExiting] = createSignal(false);237  const [showNotes, setShowNotes] = createSignal(false);238  // Topbar sync affordance. Always present: gating it on having a paired device239  // meant it never appeared for the person trying to set pairing up, which is240  // exactly when you reach for it.241  const [syncBusy, setSyncBusy] = createSignal(false);242  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);243  // The indicator light + its spoken-word equivalent. Colour alone is not the244  // signal; the title/aria-label always carries the same claim in English.245  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});246  const [pairedCount, setPairedCount] = createSignal(0);247  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);248  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);249  let initialSyncWaitGeneration = 0;250  const syncLight = () =>251    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());252  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());253  async function loadSyncIndicator() {254    const [stateRes, devicesRes] = await Promise.all([255      sendMessage({ type: "GET_SYNC_STATE" }),256      sendMessage({ type: "LIST_PAIRED_DEVICES" }),257    ]);258    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };259    if (s.ok && s.data) setSyncSummary(s.data);260    const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };261    if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);262  }263264  interface InitialSyncPairedDevice extends InitialSyncDevice {265    pairedAt?: number;266  }267268  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {269    const response = (await sendMessage({270      type: "LIST_PAIRED_DEVICES",271    })) as {272      ok: boolean;273      data?: { devices?: InitialSyncPairedDevice[] };274    };275    return response.ok ? response.data?.devices ?? [] : [];276  }277278  async function resolveInitialSyncDeviceId(): Promise<string | null> {279    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(280      () => null,281    );282    const devices = await listInitialSyncDevices().catch(() => []);283    if (stored && devices.some((device) => device.deviceId === stored)) {284      return stored;285    }286287    // Fresh adoption has exactly one direct source, but choosing the newest row288    // also repairs the marker if storage failed after the pair itself committed.289    const newest = [...devices].sort(290      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),291    )[0];292    if (!newest) return stored;293    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(294      () => undefined,295    );296    return newest.deviceId;297  }298299  async function finishInitialSync(generation: number): Promise<void> {300    if (generation !== initialSyncWaitGeneration) return;301    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);302    setInitialSyncDeviceId(null);303    setInitialSyncSlow(false);304    await refreshProStatus();305306    // A license transferred during pairing intentionally leaves the one-shot307    // celebration pending. Consume it only now: sync readiness → celebration308    // → populated vault, never celebration → temporarily empty vault.309    const celebration = (await sendMessage({310      type: "SHOULD_PLAY_CELEBRATION",311    })) as { ok: boolean; data?: boolean };312    const shouldCelebrate = celebration.ok && celebration.data === true;313    if (shouldCelebrate) {314      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });315    }316317    const opened = await openVault(undefined, shouldCelebrate);318    if (!opened || generation !== initialSyncWaitGeneration) return;319    if (shouldCelebrate) {320      setView("pro-celebration");321      window.setTimeout(() => {322        if (generation === initialSyncWaitGeneration) setView("entry");323      }, 2500);324    }325  }326327  async function beginInitialSyncWait(deviceId: string): Promise<void> {328    const generation = ++initialSyncWaitGeneration;329    setInitialSyncDeviceId(deviceId);330    setInitialSyncSlow(false);331    setView("initial-sync");332    const startedAt = Date.now();333334    // Best effort: the pairing write already wakes the mesh listener, while335    // this trigger covers the key-order case where this receiver is the dialer.336    void sendMessage({337      type: "TRIGGER_SYNC",338      payload: { reason: "foreground" },339    }).catch(() => undefined);340341    while (342      generation === initialSyncWaitGeneration &&343      view() === "initial-sync"344    ) {345      try {346        const devices = await listInitialSyncDevices();347        if (hasCompletedInitialSync(devices, deviceId)) {348          await finishInitialSync(generation);349          return;350        }351      } catch {352        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.353      }354      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {355        setInitialSyncSlow(true);356      }357      await new Promise((resolve) =>358        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),359      );360    }361  }362363  async function retryInitialSync(): Promise<void> {364    const deviceId =365      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());366    if (deviceId) {367      await beginInitialSyncWait(deviceId);368    } else {369      setInitialSyncSlow(true);370    }371  }372373  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);374  // Set on popup boot when peach_recovery_pending flag is found in session375  // storage — instructs LockView to skip the unlock UI and open the recovery376  // words confirmation surface instead. Cleared via handleUnlock once the377  // user has typed the 3 confirmation words.378  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);379380  let contentPhaseTimer: number | undefined;381  // Set when the popup was opened from the inline "Open Peach to unlock" button.382  // handleUnlock() closes the popup after unlock so the user returns to the page.383  let openedForInlineUnlock = false;384385  onCleanup(() => {386    initialSyncWaitGeneration += 1;387  });388389  function applyAccent(name: AccentColorName) {390    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;391    const hex = color.hex;392    document.documentElement.style.setProperty("--peach-accent", hex);393    // Ambient surface variables — derived from the accent so surfaces shift394    // subtly toward the accent hue rather than staying fixed warm-brown.395    document.documentElement.style.setProperty("--peach-glow", hex + "2A");396    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");397    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");398  }399400  createEffect(() => {401    applyAccent(accentColor());402  });403404  createEffect(() => {405    document.documentElement.classList.toggle("light", theme() === "light");406    document.body.classList.toggle("light", theme() === "light");407  });408409  createEffect(() => {410    document.documentElement.setAttribute("data-text-size", textSize());411  });412413  createEffect(() => {414    if (reduceMotion()) {415      document.documentElement.setAttribute("data-motion", "reduce");416    } else {417      document.documentElement.removeAttribute("data-motion");418    }419  });420421  // Notes are not a destination of the Entry view; selecting a note opens the422  // notes surface instead (handleSelectEntry). Anything that walks or counts423  // the Entry view's neighbours (counter, prev/next, index) must use this424  // filtered list, otherwise the user can arrow into a note and is stuck —425  // the Entry view can't render a note and the prev/next buttons stop moving.426  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));427428  const selectedIndex = createMemo(() => {429    const currentId = selectedEntry()?.id;430    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;431    return nonNoteEntries().findIndex((entry) => entry.id === currentId);432  });433434  async function applyAccentColor(name: AccentColorName) {435    applyAccent(name);436    setAccentColor(name);437    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });438  }439440  async function applyTheme(t: "dark" | "light") {441    setTheme(t);442    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });443  }444445  async function applyTextSize(size: "small" | "medium" | "large") {446    setTextSize(size);447    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });448  }449450  async function applyReduceMotion(enabled: boolean) {451    setReduceMotion(enabled);452    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });453  }454455  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload456  // small and lets future default updates land for actions the user hasn't457  // touched. Settings sends us the full active map; we filter and store.458  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {459    setShortcutBindings(active);460    const overrides: Record<string, Keybinding> = {};461    for (const action of Object.keys(active) as ShortcutAction[]) {462      const a = active[action];463      const d = DEFAULT_BINDINGS[action];464      const same = a.key === d.key465        && Boolean(a.ctrl)  === Boolean(d.ctrl)466        && Boolean(a.meta)  === Boolean(d.meta)467        && Boolean(a.shift) === Boolean(d.shift)468        && Boolean(a.alt)   === Boolean(d.alt);469      if (!same) overrides[action] = a;470    }471    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });472  }473474  async function applyLockOnClose(enabled: boolean) {475    setLockOnClose(enabled);476    await sendMessage({477      type: "UPDATE_SETTINGS",478      payload: { lockOnClose: enabled },479    });480  }481482  async function applyAutoLockMinutes(minutes: number) {483    setAutoLockMinutes(minutes);484    await sendMessage({485      type: "UPDATE_SETTINGS",486      payload: { autoLockMinutes: minutes },487    });488  }489490  async function applyBreachMonitoring(enabled: boolean) {491    setBreachMonitoring(enabled);492    await sendMessage({493      type: "UPDATE_SETTINGS",494      payload: { breachMonitoring: enabled },495    });496  }497498  // Which onboarding exit flow the user is in (create-vault / restore /499  // adopt). Persisted so closing the popup on one of those screens resumes500  // it directly instead of rewinding to the last carousel card.501  function markOnboardingExit(flow: "create" | "restore" | "adopt") {502    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);503    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});504    // Backing out of the flow remounts the carousel at this card; keep the505    // persisted card in sync so a later popup close resumes consistently.506    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});507  }508  function clearOnboardingExit() {509    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});510    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});511    setResumeRestoreSource(null);512  }513514  onMount(async () => {515    // Resolve the active tab in parallel with the unlock/has-vault queries —516    // openVault()'s smart selection picks the entry matching currentTabDomain,517    // so it must be populated before openVault runs. Previously refreshCurrentTab518    // ran after openVault, leaving popups that booted already-unlocked falling519    // back to loadedEntries[0] instead of the domain match.520    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([521      sendMessage({ type: "IS_UNLOCKED" }),522      sendMessage({ type: "HAS_VAULT" }),523      refreshCurrentTab(),524      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),525    ]);526    const unlocked = Boolean(527      (unlockResponse as { ok: boolean; data?: boolean }).data528    );529    const vaultExists = Boolean(530      (vaultResponse as { ok: boolean; data?: boolean }).data531    );532    const shouldCelebrate = Boolean(533      (celebrationResponse as { ok: boolean; data?: boolean }).data534    );535    setHasVault(vaultExists);536    // Onboarding gate — show once on first install, never again. Existing537    // users (vaultExists) are auto-marked onboarded so they skip the flow538    // even on the very first popup open after this feature ships.539    try {540      const onboarded = await storageGet("peach_onboarded");541      const seen = onboarded === "true";542      if (vaultExists && !seen) {543        await storageSet("peach_onboarded", "true");544        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});545        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});546        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});547        setOnboarded(true);548      } else {549        setOnboarded(seen);550        if (!seen) {551          // A popup close while the user is in an exit flow (create-vault /552          // restore / adopt) resumes that flow directly — landing back on the553          // carousel one screen earlier reads as losing progress.554          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);555          if (exit === "create" || exit === "restore" || exit === "adopt") {556            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);557            setOnboarded(true);558            setStartOnboardingRestore(exit === "restore");559            if (exit === "restore") {560              // The OS file picker kills the action popup mid-restore, so the561              // surface resumes on the source the user had already picked.562              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);563              if (source === "symbols" || source === "codex" || source === "csv") {564                setResumeRestoreSource(source);565              }566            }567            if (exit === "adopt") setView("adopting");568          } else {569            // Otherwise resume mid-onboarding where the user left off instead570            // of rewinding to the opening card.571            const stored = Number(572              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),573            );574            if (575              Number.isInteger(stored) &&576              stored > 0 &&577              stored <= ONBOARDING_LAST_CARD578            ) {579              setOnboardingCardAtExit(stored);580            }581          }582        }583      }584    } catch {585      // storage unavailable — default to onboarded so we never block boot586      setOnboarded(true);587    }588589    // Deep-link: ?restore=peachscript|csv — the detached-window half of the590    // file-picker flow. The action popup dismisses when the OS dialog steals591    // focus, so LockView.detachForFilePicker() reopens this URL in a592    // standalone window that owns the dialog. Routing runs before first593    // render so LockView mounts straight onto the right source form.594    try {595      const restoreRoute = new URLSearchParams(window.location.search).get("restore");596      if (restoreRoute === "peachscript" || restoreRoute === "csv") {597        setOnboarded(true);598        setStartOnboardingRestore(true);599        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");600      }601    } catch {602      // location.search unavailable — ignore.603    }604605    await Promise.all([restoreSettings(), refreshProStatus()]);606607    const pendingInitialSyncMarker =608      unlocked && vaultExists609        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)610        : null;611    const pendingInitialSyncDeviceId = pendingInitialSyncMarker612      ? await resolveInitialSyncDeviceId()613      : null;614615    // Mark played before any animation so a crash mid-sequence never replays616    // it. A newly adopted receiver deliberately defers this until its first617    // real sync finishes, preserving initial sync → celebration → vault.618    if (shouldCelebrate && !pendingInitialSyncDeviceId) {619      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });620    }621622    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome623    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.624    if (unlocked && vaultExists) {625      void sendMessage({626        type: "TRIGGER_SYNC",627        payload: { reason: "foreground" },628      });629      void loadSyncIndicator();630      // Rare device-trust alerts only; routine entry sync stays quiet.631      void loadSyncAlerts();632    }633634    // If the user created a vault but hasn't yet confirmed their recovery635    // words, route back to the recovery-words screen instead of opening the636    // entries view. The flag lives in chrome.storage.local so it survives637    // both popup close (SW suppresses lock-on-close) and full browser638    // restart (handleUnlock checks the same flag after a fresh password639    // unlock). Cleared when confirmation succeeds.640    let recoveryPending = false;641    try {642      const out = await chrome.storage.local?.get("peach_recovery_pending");643      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";644    } catch { /* best-effort */ }645646    // If this popup was opened from the inline "Open Peach to unlock" button,647    // the background set a session flag. Read and clear it so handleUnlock()648    // can close the popup after unlock and return the user to the page.649    if (chrome.storage.session) {650      try {651        const s = await chrome.storage.session.get("peach_inline_unlock");652        if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {653          openedForInlineUnlock = true;654          await chrome.storage.session.remove("peach_inline_unlock");655        }656      } catch { /* best-effort */ }657    }658659    if (recoveryPending && unlocked) {660      // forceRecoveryWords overrides LockView's hasVault gate so the661      // recovery surface renders even though the vault now exists.662      setForceRecoveryWords(true);663      setView("lock");664    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {665      void beginInitialSyncWait(pendingInitialSyncDeviceId);666    } else if (unlocked && vaultExists) {667      await openVault(undefined, shouldCelebrate);668      if (shouldCelebrate) {669        setView("pro-celebration");670        window.setTimeout(() => setView("entry"), 2500);671      }672    } else if (shouldCelebrate) {673      setPendingCelebration(true);674    }675676    await refreshPendingPasskey();677678    // Track tab changes so smart sort stays current679    const handleTabActivated = () => { void refreshCurrentTab(); };680    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {681      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();682    };683    try {684      chrome.tabs.onActivated.addListener(handleTabActivated);685      chrome.tabs.onUpdated.addListener(handleTabUpdated);686      onCleanup(() => {687        chrome.tabs.onActivated.removeListener(handleTabActivated);688        chrome.tabs.onUpdated.removeListener(handleTabUpdated);689      });690    } catch {691      // tabs permission may not be available in all contexts692    }693694    setBootState("ready");695696    // Deep-link: ?codex=pdf opens a detached window straight into the697    // Codex restore flow with auto-picked PDF. The action popup can't host698    // a file dialog without dismissing itself, so this is the only way the699    // PDF import path survives. See CodexRestoreView.openPdfPicker().700    try {701      const route = new URLSearchParams(window.location.search).get("codex");702      if (route === "pdf" || route === "restore") {703        setView("codex-restore");704      }705    } catch {706      // location.search unavailable — ignore.707    }708709    // Check for pending session-storage notices from the background.710    if (chrome.storage.session) {711      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as712        Record<string, { feature?: string } | undefined>;713      if (session.pendingProGate?.feature) {714        await chrome.storage.session.remove("pendingProGate");715        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);716        setSettingsInitialSection("license");717        setView("settings");718      } else if (session.pendingProNotice?.feature) {719        await chrome.storage.session.remove("pendingProNotice");720        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });721      }722    }723724    // Warm the lazy-module cache so the Suspense fallback never shows for725    // locally-bundled extension views. Without this, the first navigation to726    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which727    // covers the shell and absorbs clicks on the header.728    void Promise.all([729      import("./views/Settings").catch(() => {}),730      import("./views/Import").catch(() => {}),731      import("./views/Export").catch(() => {}),732      import("./views/Health").catch(() => {}),733      import("./views/TrashView").catch(() => {}),734    ]);735736    // Lock-on-close via a persistent port. sendMessage in pagehide is737    // unreliable — the popup context tears down before the message lands.738    // The SW's onConnect listener locks when this port disconnects.739    let popupPort: chrome.runtime.Port | null = null;740    try {741      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect742      // listener accepts the "popup-" prefix; the random suffix is defence in743      // depth against a port named exactly "popup" from anything but our popup.744      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });745    } catch { /* extension context invalidated — ignore */ }746    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });747748    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale749    // entries instead of waiting for the next GET_ENTRIES round-trip.750    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {751      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();752      if (msg?.type === "SYNC_STATE_CHANGED") {753        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);754      }755      if (msg?.type === "PRO_GATE_TRIGGERED") {756        setUpgradeHighlight(msg.payload?.feature ?? null);757        setSettingsInitialSection("license");758        setView("settings");759      }760    };761    chrome.runtime.onMessage.addListener(handleRuntimeMessage);762    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));763764    // Action handlers — keyed by ShortcutAction. The dispatcher below tests765    // each binding against the event and runs the matching handler. New766    // shortcuts only need an entry here; the binding is data, not code.767    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {768      copy_password: () => {769        const entry = selectedEntry();770        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });771      },772      copy_username: () => {773        const entry = selectedEntry();774        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });775      },776      copy_totp: () => {777        const entry = selectedEntry();778        if (!entry?.totp) return;779        try {780          const { code } = generateTOTP(entry.totp);781          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });782        } catch { /* invalid TOTP secret — silent */ }783      },784      new_entry: () => handleAdd(),785      edit_entry: () => {786        const entry = selectedEntry();787        // Passkey-only entries have nothing user-editable in the Add view; the788        // passkey credential itself is opaque. Skip the edit action for them.789        if (!entry || isPasskeyOnly(entry)) return;790        setEditEntry(entry);791        setView("add");792      },793      delete_entry: () => {794        if (selectedEntry()) void handleDelete();795      },796      lock_vault: () => { void handleHeaderLock(); },797      search: () => openList(),798      nav_prev: () => navigate(-1),799      nav_next: () => navigate(1),800      jump_vault: () => setView("entry"),801      jump_settings: () => setView("settings"),802      show_help: () => setShowShortcutsHelp(true),803    };804805    // Actions that are MEANINGFUL only on the entry-detail view. Outside806    // that scope the binding is dormant — pressing C while on Settings807    // does nothing rather than awkwardly copying from a now-stale entry.808    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([809      "copy_password", "copy_username", "copy_totp",810      "edit_entry", "delete_entry", "nav_prev", "nav_next",811    ]);812813    const handleGlobalKeydown = (e: KeyboardEvent) => {814      const v = view();815      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"816        || (e.target as HTMLElement)?.tagName === "TEXTAREA"817        || (e.target as HTMLElement)?.isContentEditable;818      const isPrintableSearchKey =819        e.key.length === 1 &&820        !e.ctrlKey &&821        !e.metaKey &&822        !e.altKey;823824      // No shortcuts while a full-screen surface owns the whole viewport:825      // the lock screen, the post-pairing readiness sequence, or the Pro826      // celebration. Without this the lock screen is at the mercy of whatever827      // single-key binding is active — e.g. typing "n" in a password that828      // slips out of focus triggers `new_entry` and escapes the lock screen.829      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;830831      // Help dialog: Escape closes it; eat the keypress so it doesn't832      // also navigate away from the underlying view.833      if (showShortcutsHelp()) {834        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }835        return;836      }837838      // ── Escape — fixed binding (not user-customizable) ───────839      if (e.key === "Escape") {840        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }841        if (v === "list") { setView("entry"); return; }842        if (v === "settings" || v === "add") { handleBack(); return; }843        if (v === "import" || v === "health") { setView("settings"); return; }844        if (v === "export") { handleExportBack(); return; }845        if (v === "pair-device") { setView("settings"); return; }846      }847848      // Bindings-driven dispatch. Single-key letter bindings without849      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is850      // inside an input, so users can type into search/forms normally.851      const bindings = shortcutBindings();852      for (const action of Object.keys(bindings) as ShortcutAction[]) {853        const binding = bindings[action];854        if (!matchKeybinding(e, binding)) continue;855        // Single-key letter bindings need the input-focus guard. Bindings856        // with any modifier are safe — Ctrl+L doesn't conflict with typing.857        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;858        if (!hasMod && binding.key.length === 1 && inInput) return;859        if (ENTRY_SCOPED.has(action) && v !== "entry") return;860        e.preventDefault();861        SHORTCUT_HANDLERS[action]?.();862        return;863      }864865      // Type-to-search: in entry view, any printable key not consumed by a866      // shortcut above primes the list search seed and opens the list.867      // Kept outside the bindings table because it's a behavior, not a868      // discrete action — every printable character can trigger it.869      if (v === "entry" && !inInput && isPrintableSearchKey) {870        e.preventDefault();871        primeListSearch(e.key);872        return;873      }874    };875    window.addEventListener("keydown", handleGlobalKeydown);876    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));877878    const handleStorageChanged = (879      changes: Record<string, chrome.storage.StorageChange>,880      areaName: string881    ) => {882      if (areaName !== "session") return;883      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {884        return;885      }886      void refreshPendingPasskey();887    };888    chrome.storage.onChanged.addListener(handleStorageChanged);889    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));890  });891892  async function refreshCurrentTab() {893    try {894      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });895      const url = tab?.url ?? tab?.pendingUrl ?? "";896      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");897      // Skip extension/internal URLs — adding chrome://, about:, or898      // chrome-extension:// to a vault entry is never what the user wants.899      const isWebUrl = /^https?:\/\//i.test(url);900      setCurrentTabUrl(isWebUrl ? url : "");901    } catch {902      setCurrentTabDomain("");903      setCurrentTabUrl("");904    }905  }906907  async function refreshPendingPasskey() {908    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });909    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);910  }911912  async function refreshProStatus() {913    const res = await sendMessage({ type: "GET_LICENSE" });914    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;915    setProStatus(Boolean(data?.valid && data?.plan === "pro"));916  }917918  async function restoreSettings() {919    const res = await sendMessage({ type: "GET_SETTINGS" });920    const s = (res as { ok: boolean; data?: any }).data ?? {};921    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {922      setAccentColor(s.accentColor as AccentColorName);923    }924    setTheme(s.theme === "light" ? "light" : "dark");925    if (s.textSize === "small" || s.textSize === "large") {926      setTextSize(s.textSize);927    } else {928      setTextSize("medium");929    }930    setReduceMotion(s.reduceMotion === true);931    setLockOnClose(s.lockOnClose === true);932    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);933    setBreachMonitoring(s.breachMonitoring !== false);934    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);935    setShortcutBindings(getActiveBindings(s.shortcuts));936  }937938  async function applyTrashRetention(days: number) {939    setTrashRetentionDays(days);940    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });941  }942943  function primeListSearch(seed: string) {944    setListSearchSeed(seed);945    setListSearchSeedNonce((value) => value + 1);946    openList();947  }948949  // Returns null when it detected a dead service worker and triggered handleLock().950  // Callers that need to abort (e.g. openVault) check for null rather than relying951  // on view() state, which may already be "lock" during normal startup flows.952  async function loadEntries(): Promise<VaultEntry[] | null> {953    const response = await sendMessage({ type: "GET_ENTRIES" });954    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };955956    // If the service worker was killed and restarted (MV3 workers sleep), the957    // vault will be locked again — or the worker may not respond at all.958    // In either case, send the user to the lock screen rather than silently959    // showing an empty vault (which looks like data loss).960    if (!res.ok) {961      if (962        res.error === "vault_locked" ||963        res.error === "SERVICE_WORKER_UNAVAILABLE" ||964        (typeof res.error === "string" && res.error.includes("locked"))965      ) {966        handleLock();967        return null; // signal emergency lock to callers968      }969      return [];970    }971972    const loadedEntries = res.data ?? [];973    setEntries(loadedEntries);974975    const currentId = selectedEntry()?.id;976    if (currentId) {977      setSelectedEntry(978        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)979      );980    }981982    return loadedEntries;983  }984985  async function openVault(preferredEntryId?: string, skipViewSet = false) {986    // Purge expired trash entries on each vault open987    const retDays = trashRetentionDays();988    if (retDays > 0) {989      void sendMessage({990        type: "PURGE_EXPIRED_TRASH",991        payload: { retentionMs: retDays * 86_400_000 },992      });993    }994995    const loadedEntries = await loadEntries();996997    // null means loadEntries() triggered an emergency lock (service worker died).998    // Don't override it by setting view back to "entry" with an empty vault.999    if (loadedEntries === null) return false;10001001    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1002    let nextSelectedEntry: VaultEntry | null = null;1003    if (preferredEntryId) {1004      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1005    }1006    if (!nextSelectedEntry) {1007      const tabDomain = currentTabDomain();1008      if (tabDomain) {1009        nextSelectedEntry = loadedEntries.find((e) => {1010          if (e.type === "login") {1011            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1012            for (const extra of e.additionalUrls ?? []) {1013              if ((extractDomain(extra) ?? "") === tabDomain) return true;1014            }1015            return false;1016          }1017          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1018          return false;1019        }) ?? null;1020      }1021    }1022    nextSelectedEntry ??= pickNavigable(loadedEntries);10231024    setSelectedEntry(nextSelectedEntry);1025    setEditEntry(undefined);1026    setContentTransitioning(false);1027    setContentPhase("enter");1028    queueContentIdle();1029    if (!skipViewSet) setView("entry");1030    return true;1031  }10321033  async function handleUnlock() {1034    // If the user previously created a vault but hadn't yet confirmed their1035    // recovery words (and the browser was closed before they could), the1036    // peach_recovery_pending flag is still set in storage.local. Re-route1037    // back to the recovery surface so they finish the confirmation step1038    // before reaching the entries view — the words can be re-derived now1039    // that the vault is unlocked again.1040    let recoveryPending = false;1041    try {1042      const out = await chrome.storage.local?.get("peach_recovery_pending");1043      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1044    } catch { /* best-effort */ }1045    if (recoveryPending) {1046      setForceRecoveryWords(true);1047      setView("lock");1048      triggerPipPulse();1049      await restoreSettings();1050      return;1051    }1052    setHasVault(true);1053    triggerPipPulse();1054    await restoreSettings();1055    const celebrating = pendingCelebration();1056    if (celebrating) {1057      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1058      setPendingCelebration(false);1059    }1060    await openVault(undefined, celebrating);1061    if (celebrating) {1062      setView("pro-celebration");1063      window.setTimeout(() => setView("entry"), 2500);1064    }1065    // Popup was opened from the inline "Open Peach to unlock" button — close it1066    // now so the user lands back on the page with the autofill selector ready.1067    if (openedForInlineUnlock) {1068      openedForInlineUnlock = false;1069      window.close();1070    }1071  }10721073  function handleSelectEntry(entry: VaultEntry) {1074    if (entry.type === "note") {1075      setOpenNoteId(entry.id);1076      setShowNotes(true);1077      return;1078    }1079    setSelectedEntry(entry);1080    setContentTransitioning(false);1081    setContentPhase("enter");1082    queueContentIdle();1083    setView("entry");1084  }10851086  function handleAdd() {1087    setEditEntry(undefined);1088    setView("add");1089  }10901091  async function handleDelete() {1092    const entry = selectedEntry();1093    if (!entry) return;1094    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1095    const loadedEntries = await loadEntries() ?? [];1096    setSelectedEntry(pickNavigable(loadedEntries));1097    setView("entry");1098  }10991100  async function handleDeleteMany(ids: string[]) {1101    for (const id of ids) {1102      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1103    }1104    const loadedEntries = await loadEntries() ?? [];1105    // If the currently selected entry was among the deleted, move to first1106    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1107    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1108  }11091110  async function handleSave(savedEntry?: VaultEntry) {1111    await openVault(savedEntry?.id);1112  }11131114  function handleBack() {1115    const dest = editBackView();1116    setEditBackView("entry");1117    setEditEntry(undefined);1118    setView(dest);1119  }11201121  function handleExportBack() {1122    if (returnToPanic()) {1123      setReturnToPanic(false);1124      setView("panic");1125      return;1126    }1127    setView("settings");1128  }11291130  function handleLock() {1131    setEntries([]);1132    setSelectedEntry(null);1133    setEditEntry(undefined);1134    setContentPhase("idle");1135    setView("lock");1136  }11371138  function handleHeaderLock() {1139    // Lock the UI immediately — do not wait for the service worker. If the SW1140    // is dead or slow, the 5s sendMessage timeout would create a dead-click1141    // window. The SW will lock on its next wake regardless.1142    handleLock();1143    void sendMessage({ type: "LOCK" });1144  }11451146  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1147  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1148  // the trigger's own reply.1149  async function handleHeaderSync(): Promise<void> {1150    if (syncBusy()) return;1151    setSyncBusy(true);1152    setSyncFlash(null);1153    try {1154      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1155      let state: SyncOutcomeSummary = { status: "syncing" };1156      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1157        await new Promise((r) => setTimeout(r, 250));1158        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1159          ok: boolean;1160          data?: SyncOutcomeSummary;1161        };1162        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1163      }1164      setSyncSummary(state);1165      setSyncFlash(state.status === "error" ? "error" : "ok");1166      await loadSyncAlerts();1167      await loadEntries();1168      await loadSyncIndicator();1169    } catch {1170      setSyncSummary({ status: "error", outcome: "run_failed" });1171      setSyncFlash("error");1172    } finally {1173      setSyncBusy(false);1174      setTimeout(() => setSyncFlash(null), 2_000);1175    }1176  }11771178  function navigate(dir: number) {1179    const navigable = nonNoteEntries();1180    if (navigable.length === 0) return;1181    const currentIndex = selectedIndex();1182    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1183    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1184    setSelectedEntry(navigable[nextIndex]);1185    setContentPhase("enter");1186    queueContentIdle();1187  }11881189  /**1190   * The Entry view's selection invariant: never select a note. Notes are a1191   * separate surface (handleSelectEntry opens the notes view), so any code path1192   * that picks a default — vault open, post-delete, refresh — must skip them1193   * and return null if the only entries are notes.1194   */1195  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1196    return list.find((e) => e.type !== "note") ?? null;1197  }11981199  function openList() {1200    setView("list");1201  }12021203  function openHealth() {1204    setEditEntry(undefined);1205    setView("health");1206  }12071208  function handleOpenFileImport() {1209    setRestoreView("file");1210  }12111212  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12131214  function handleFixEntry(entry: VaultEntry) {1215    setSelectedEntry(entry);1216    // Passkey-only entries have no editable surface — drop into view mode1217    // instead of opening the Add editor.1218    if (isPasskeyOnly(entry)) {1219      setView("entry");1220      return;1221    }1222    setEditBackView(view()); // preserve caller view so back returns here1223    setEditEntry(entry);1224    setView("add");1225  }12261227  const isLockedView = () => view() === "lock" || view() === "adopting";12281229  async function handleAdoptionDone() {1230    await storageSet("peach_onboarded", "true").catch(() => {});1231    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1232    clearOnboardingExit();1233    setOnboarded(true);1234    setHasVault(true);1235    setView("initial-sync");1236    // The background applies a Pro license and persists the source-device1237    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1238    // celebration pending until that exact peer records a completed sync.1239    await refreshProStatus();1240    const deviceId = await resolveInitialSyncDeviceId();1241    if (deviceId) {1242      void beginInitialSyncWait(deviceId);1243    } else {1244      // Pairing completion without a durable peer row should be impossible.1245      // Stay on the honest waiting surface and expose retry instead of falling1246      // through to an empty vault.1247      setInitialSyncSlow(true);1248    }1249  }12501251  async function handleApprovePendingPasskey() {1252    const pending = pendingPasskey();1253    if (!pending) return;1254    if (pending.browser === "firefox" && pending.type === "create") {1255      // Firefox path: popup itself runs navigator.credentials.create. The1256      // background never approved/rejected anything — it just stashed1257      // intent. After the popup-driven flow resolves (success or cancel),1258      // clear the pending key so the popup doesn't re-show this on next open.1259      const { createFirefoxPasskey } = await import("./firefox-passkey");1260      const result = await createFirefoxPasskey(pending);1261      if (!result.ok) {1262        console.warn("Peach Firefox passkey create failed:", result.error);1263      }1264      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1265      setPendingPasskey(null);1266      void loadEntries();1267      window.close();1268      return;1269    }1270    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1271    window.close();1272  }12731274  async function handleDenyPendingPasskey() {1275    const pending = pendingPasskey();1276    if (!pending) return;1277    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1278    window.close();1279  }12801281  function queueContentIdle(after?: () => void) {1282    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1283    contentPhaseTimer = window.setTimeout(() => {1284      setContentPhase("idle");1285      after?.();1286    }, ENTRY_TRANSITION_SETTLE_MS);1287  }12881289  return (1290    <div class="popup-root peach-theme">1291      <Show when={bootState() === "ready"}>1292        <Show1293          when={!pendingPasskey()}1294          fallback={1295            <PasskeyApprovalView1296              type={pendingPasskey()!.type}1297              rpName={pendingPasskey()!.rpName}1298              rpId={pendingPasskey()!.rpId}1299              userName={pendingPasskey()!.userName}1300              userDisplayName={pendingPasskey()!.userDisplayName}1301              onApprove={handleApprovePendingPasskey}1302              onDeny={handleDenyPendingPasskey}1303            />1304          }1305        >1306          <Show1307            when={!isLockedView()}1308            fallback={1309              <Show1310                when={view() !== "adopting"}1311                fallback={1312                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1313                    <AdoptVaultView1314                      onCancel={() => {1315                        clearOnboardingExit();1316                        setOnboarded(false);1317                        setView("lock");1318                      }}1319                      onDone={handleAdoptionDone}1320                    />1321                  </Suspense>1322                }1323              >1324                <Show1325                  when={onboarded() || hasVault()}1326                  fallback={1327                    <Onboarding1328                      initialCard={onboardingCardAtExit()}1329                      onComplete={() => {1330                        markOnboardingExit("create");1331                        setStartOnboardingRestore(false);1332                        setOnboarded(true);1333                      }}1334                      onRestore={() => {1335                        markOnboardingExit("restore");1336                        setStartOnboardingRestore(true);1337                        setOnboarded(true);1338                      }}1339                      onAdopt={() => {1340                        markOnboardingExit("adopt");1341                        setStartOnboardingRestore(false);1342                        setOnboarded(true);1343                        setView("adopting");1344                      }}1345                    />1346                  }1347                >1348                  <LockView1349                    hasVault={hasVault()}1350                    ready={bootState() === "ready"}1351                    isPro={proStatus()}1352                    forceRecoveryWords={forceRecoveryWords()}1353                    initialRestore={startOnboardingRestore()}1354                    initialRestoreSource={resumeRestoreSource() ?? undefined}1355                    onUnlock={() => {1356                      // RecoveryWordDisplay clears the storage flag before1357                      // calling onDone, so handleUnlock will see no pending1358                      // confirmation and proceed through the normal path.1359                      if (forceRecoveryWords()) setForceRecoveryWords(false);1360                      // The create/restore flow is done — a lock later in this1361                      // window must show the unlock screen, not re-enter the1362                      // onboarding restore surface.1363                      setStartOnboardingRestore(false);1364                      setResumeRestoreSource(null);1365                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1366                      return handleUnlock();1367                    }}1368                    onBackToOnboarding={1369                      !hasVault()1370                        ? () => {1371                            // Deliberately rewinding to the carousel — the exit1372                            // flow must not reclaim the next popup open.1373                            clearOnboardingExit();1374                            setStartOnboardingRestore(false);1375                            setOnboarded(false);1376                          }1377                        : undefined1378                    }1379                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1380                  />1381                </Show>1382              </Show>1383            }1384          >1385            <Show1386              when={1387                view() !== "pro-celebration" &&1388                view() !== "initial-sync"1389              }1390              fallback={1391                <Show1392                  when={view() === "initial-sync"}1393                  fallback={<ProCelebration />}1394                >1395                  <InitialSyncView1396                    slow={initialSyncSlow()}1397                    onRetry={() => void retryInitialSync()}1398                  />1399                </Show>1400              }1401            >1402            <section1403              id="hd-shell"1404              class="peach-shell peach-theme"1405              data-surface="extension"1406            >1407            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1408              <PeachWordmark unlocked />1409              <div class="peach-chrome-actions">1410                <button1411                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1412                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1413                  aria-label="Notes"1414                  title="Notes"1415                >1416                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1417                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1418                    <line x1="4" y1="5" x2="10" y2="5" />1419                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1420                    <line x1="4" y1="10" x2="7" y2="10" />1421                  </svg>1422                </button>1423                <button1424                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1425                    classList={{1426                      "is-syncing": syncBusy(),1427                      "is-ok": syncFlash() === "ok",1428                      "is-error": syncFlash() === "error",1429                    }}1430                    onClick={() => void handleHeaderSync()}1431                    disabled={syncBusy()}1432                    aria-label={`Sync now. ${syncLabel()}`}1433                    title={syncLabel()}1434                  >1435                    <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">1436                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1437                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1438                      <path d="M2.6 1v2.4h2.4" />1439                      <path d="M11.4 13v-2.4H9" />1440                    </svg>1441                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1442                  </button>1443                <button1444                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1445                  onClick={() => setView("settings")}1446                  aria-label="Settings"1447                >1448                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1449                    <path1450                      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"1451                      stroke="currentColor"1452                      stroke-width="0.9"1453                    />1454                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1455                  </svg>1456                </button>1457                <button1458                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1459                  onClick={handleAdd}1460                  aria-label="New entry"1461                >1462                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1463                    <path d="M5.5 1v9M1 5.5h9" />1464                  </svg>1465                </button>1466                <button1467                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1468                  onClick={() => void handleHeaderLock()}1469                  aria-label="Lock vault"1470                >1471                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1472                    <path1473                      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"1474                      stroke="currentColor"1475                      stroke-width="1"1476                      stroke-linecap="round"1477                    />1478                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1479                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1480                  </svg>1481                </button>1482              </div>1483            </header>14841485            <Show when={pendingProNotice() !== null}>1486              <div class="peach-pro-notice-banner">1487                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1488                <button1489                  class="peach-pro-notice-link"1490                  onClick={() => {1491                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1492                    setSettingsInitialSection("license");1493                    setView("settings");1494                    setPendingProNotice(null);1495                  }}1496                >1497                  Learn more →1498                </button>1499                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1500              </div>1501            </Show>15021503            <div class="peach-shell-body">1504              <div class="peach-shell-veil"></div>1505              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1506                <EntryView1507                  entry={selectedEntry()}1508                  currentTabUrl={currentTabUrl()}1509                  entries={entries()}1510                  isPro={proStatus()}1511                  onDelete={handleDelete}1512                  onFieldSaved={() => void loadEntries()}1513                  onEscape={() => { setView("list"); }}1514                  onProGate={(feature) => {1515                    setUpgradeHighlight(feature);1516                    setSettingsInitialSection("license");1517                    setView("settings");1518                  }}1519                />1520              </div>15211522              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1523                <div class="peach-search-bar">1524                  <span class="peach-search-icon">1525                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1526                      <circle cx="5.5" cy="5.5" r="3.75" />1527                      <path d="M8.75 8.75L11.5 11.5" />1528                    </svg>1529                  </span>1530                  <input1531                    class="peach-search-input"1532                    placeholder="search vault..."1533                    autocomplete="off"1534                    aria-label="Search vault entries"1535                    onClick={openList}1536                    onFocus={openList}1537                    readOnly1538                  />1539                  <div class="peach-search-meta">1540                    <span class="peach-search-counter">1541                      {nonNoteEntries().length > 01542                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1543                        : "0 entries"}1544                    </span>1545                    <span class="peach-divider"></span>1546                    <button1547                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1548                      onClick={() => navigate(-1)}1549                      aria-label="Previous entry"1550                      disabled={nonNoteEntries().length === 0}1551                    >1552                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1553                        <path d="M5 1L1 5.5L5 10" />1554                      </svg>1555                    </button>1556                    <button1557                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1558                      onClick={() => navigate(1)}1559                      aria-label="Next entry"1560                      disabled={nonNoteEntries().length === 0}1561                    >1562                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1563                        <path d="M1 1L5 5.5L1 10" />1564                      </svg>1565                    </button>1566                  </div>1567                </div>1568              </section>1569            </div>15701571            {/* Rare device-trust alerts; normal sync changes are silent. */}1572            <Show when={alertGroups().length > 0}>1573              <div class="peach-sync-alerts">1574                <For each={alertGroups()}>1575                  {([type, group]) => {1576                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1577                    return (1578                      <div class="peach-sync-alert">1579                        <Show when={group.length > 1}>1580                          <button1581                            class="peach-sync-alert-flip"1582                            type="button"1583                            aria-label="Previous"1584                            onClick={() => flipAlert(type, -1)}1585                          >‹</button>1586                        </Show>1587                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1588                        <Show when={group.length > 1}>1589                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1590                          <button1591                            class="peach-sync-alert-flip"1592                            type="button"1593                            aria-label="Next"1594                            onClick={() => flipAlert(type, 1)}1595                          >›</button>1596                        </Show>1597                        <button1598                          class="peach-sync-alert-dismiss"1599                          type="button"1600                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1601                          onClick={() => dismissAlertGroup(type)}1602                        >×</button>1603                      </div>1604                    );1605                  }}1606                </For>1607              </div>1608            </Show>16091610            <Show when={view() === "list"}>1611              <VaultView1612                entries={entries()}1613                selectedEntryId={selectedEntry()?.id}1614                currentTabDomain={currentTabDomain()}1615                sortOrder={sortOrder()}1616                searchSeed={listSearchSeed()}1617                searchSeedNonce={listSearchSeedNonce()}1618                onSelectEntry={handleSelectEntry}1619                onSortOrderChange={setSortOrder}1620                onAdd={handleAdd}1621                onClose={() => setView("entry")}1622                onOpenTrash={() => setView("trash")}1623                onDeleteMany={handleDeleteMany}1624              />1625            </Show>1626            <Show when={view() === "settings" || settingsExiting()}>1627              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1628                <SettingsView1629                  entriesCount={entries().length}1630                  breachMonitoring={breachMonitoring()}1631                  lockOnClose={lockOnClose()}1632                  autoLockMinutes={autoLockMinutes()}1633                  trashRetentionDays={trashRetentionDays()}1634                  theme={theme()}1635                  accentColor={accentColor()}1636                  textSize={textSize()}1637                  reduceMotion={reduceMotion()}1638                  isPro={proStatus()}1639                  initialSection={settingsInitialSection()}1640                  upgradeHighlight={upgradeHighlight()}1641                  isExiting={settingsExiting()}1642                  onBreachMonitoringChange={applyBreachMonitoring}1643                  onLockOnCloseChange={applyLockOnClose}1644                  onAutoLockMinutesChange={applyAutoLockMinutes}1645                  onTrashRetentionChange={applyTrashRetention}1646                  onThemeChange={applyTheme}1647                  onAccentChange={applyAccentColor}1648                  onTextSizeChange={applyTextSize}1649                  onReduceMotionChange={applyReduceMotion}1650                  shortcuts={shortcutBindings()}1651                  onShortcutsChange={applyShortcuts}1652                  onLicenseChange={async () => {1653                    // After activation/deactivation: refresh Pro status,1654                    // re-load entries (defensive — signal state can drift if1655                    // the SW briefly restarts during the verify+save round-trip),1656                    // and replay the Pro celebration if this is the first time1657                    // a valid Pro license is seen on this device.1658                    await refreshProStatus();1659                    void loadEntries();1660                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1661                    if ((should as { ok: boolean; data?: boolean }).data === true) {1662                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1663                      setView("pro-celebration");1664                      window.setTimeout(() => setView("settings"), 2500);1665                    }1666                  }}1667                  onBack={() => {1668                    setSettingsExiting(true);1669                    window.setTimeout(() => {1670                      setSettingsExiting(false);1671                      setSettingsInitialSection(null);1672                      setUpgradeHighlight(null);1673                      handleBack();1674                    }, 200);1675                  }}1676                  onImport={() => setView("import")}1677                  onExport={() => setView("export")}1678                  onBreachCheckDone={loadEntries}1679                  onOpenHealth={openHealth}1680                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1681                  onOpenFileImport={handleOpenFileImport}1682                  onOpenPanic={() => setView("panic")}1683                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1684                  onOpenCodexRestore={() => setView("codex-restore")}1685                  onCardChange={setSettingsActiveCard}1686                  onProGate={(feature) => {1687                    setUpgradeHighlight(feature);1688                    setSettingsInitialSection("license");1689                  }}1690                />1691              </Suspense>1692            </Show>1693            <Show when={view() === "codex-generate"}>1694              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1695                <CodexGenerateView1696                  isPro={proStatus()}1697                  onBack={() => setView("settings")}1698                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1699                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1700                  onVaultLocked={() => handleLock()}1701                />1702              </Suspense>1703            </Show>1704            <Show when={view() === "codex-restore"}>1705              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1706                <CodexRestoreView1707                  onBack={() => setView("lock")}1708                  onRestoreSuccess={async () => {1709                    // After restore the vault is unlocked; refresh entries1710                    // and route to the main vault list. handleUnlock is the1711                    // canonical post-unlock setup path.1712                    await handleUnlock();1713                  }}1714                />1715              </Suspense>1716            </Show>1717            <Show when={view() === "pair-device"}>1718              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1719                <PairDeviceView1720                  isPro={proStatus()}1721                  onProChanged={() => { void refreshProStatus(); }}1722                  onBack={() => setView("settings")}1723                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1724                />1725              </Suspense>1726            </Show>1727            <Show when={view() === "add"}>1728              <AddView1729                entry={editEntry()}1730                entries={entries()}1731                onBack={handleBack}1732                onSave={handleSave}1733              />1734            </Show>1735            <Show when={view() === "import"}>1736              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1737                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1738              </Suspense>1739            </Show>1740            <Show when={view() === "export"}>1741              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1742                <ExportView onBack={handleExportBack} />1743              </Suspense>1744            </Show>1745            <Show when={view() === "panic"}>1746              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1747                <PanicView1748                  onCancel={() => setView("settings")}1749                  onOpenExport={() => {1750                    setReturnToPanic(true);1751                    setView("export");1752                  }}1753                  onDestroyed={async () => {1754                    // Fresh onboarding start after a destroy — don't let the1755                    // resume markers land the user mid-carousel.1756                    setOnboardingCardAtExit(0);1757                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1758                    clearOnboardingExit();1759                    setEntries([]);1760                    setSelectedEntry(null);1761                    setEditEntry(undefined);1762                    setHasVault(false);1763                    setOnboarded(false);1764                    setProStatus(false);1765                    setPendingPasskey(null);1766                    setUpgradeHighlight(null);1767                    setSettingsInitialSection(null);1768                    setPendingProNotice(null);1769                    setPendingCelebration(false);1770                    setReturnToPanic(false);1771                    setContentPhase("idle");1772                    setView("lock");1773                  }}1774                />1775              </Suspense>1776            </Show>1777            {/* Health stays mounted while editing an entry from it so1778                computeReused() doesn't rerun on every cancel/back. */}1779            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1780              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1781                <HealthView1782                  entries={entries()}1783                  isPro={proStatus()}1784                  onFixEntry={handleFixEntry}1785                  onBack={() => setView("settings")}1786                  onRefresh={loadEntries}1787                  hidden={view() !== "health"}1788                />1789              </Suspense>1790            </Show>1791            <Show when={view() === "trash"}>1792              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1793                <TrashView1794                  onBack={() => setView("list")}1795                  onRestored={() => void loadEntries()}1796                />1797              </Suspense>1798            </Show>1799            <Show when={showNotes()}>1800              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1801                <NotesMode1802                  notes={entries().filter((e) => e.type === "note")}1803                  initialNoteId={openNoteId()}1804                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1805                  onNoteListChanged={() => void loadEntries()}1806                />1807              </div>1808            </Show>1809            <Show when={restoreView() === "file"}>1810              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1811                <ImportView1812                  mode="onboarding"1813                  replacesExistingVault={true}1814                  onBack={() => setRestoreView(null)}1815                  onImport={async () => {1816                    setRestoreView(null);1817                    await handleUnlock();1818                  }}1819                />1820              </Suspense>1821            </Show>1822            </section>1823            </Show>1824          </Show>1825        </Show>1826      </Show>1827      <Show when={showShortcutsHelp()}>1828        <ShortcutsHelpOverlay1829          bindings={shortcutBindings()}1830          onClose={() => setShowShortcutsHelp(false)}1831        />1832      </Show>1833    </div>1834  );1835}18361837function ShortcutsHelpOverlay(props: {1838  bindings: Record<ShortcutAction, Keybinding>;1839  onClose: () => void;1840}) {1841  return (1842    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1843      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1844        <div class="peach-shortcuts-help-head">1845          <h3>Keyboard shortcuts</h3>1846          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1847        </div>1848        <div class="peach-shortcuts-help-body">1849          <p class="peach-settings-sub-label">ENTRY VIEW</p>1850          <ul>1851            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1852              <li>1853                <span>{d.label}</span>1854                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1855              </li>1856            ))}1857          </ul>1858          <p class="peach-settings-sub-label">ANYWHERE</p>1859          <ul>1860            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1861              <li>1862                <span>{d.label}</span>1863                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1864              </li>1865            ))}1866          </ul>1867        </div>1868      </div>1869    </div>1870  );1871}