App.tsx

78.9 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;8081const canarySuppressMap = new Map<string, boolean>();82let shellRingAnimation: Animation | null = null;8384export function isCanarySuppressed(entryId: string): boolean {85  return canarySuppressMap.get(entryId) ?? false;86}8788export function setCanarySuppressed(entryId: string, suppressed: boolean): void {89  canarySuppressMap.set(entryId, suppressed);90}9192export function clearCanarySuppressions(): void {93  canarySuppressMap.clear();94}9596export function triggerRing() {97  const shell = document.getElementById("hd-shell");98  if (!shell) return;99100  shellRingAnimation?.cancel();101  shell.dataset.glowing = "true";102  const animation = shell.animate(103    [104      { offset: 0, boxShadow: "var(--copy-shadow)" },105      {106        offset: 0.3,107        boxShadow:108          "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)",109      },110      { offset: 1, boxShadow: "var(--copy-shadow)" },111    ],112    {113      duration: 720,114      easing: "cubic-bezier(0.4, 0, 0.2, 1)",115      fill: "none",116    }117  );118  shellRingAnimation = animation;119  const clearGlow = () => {120    if (shellRingAnimation !== animation) return;121    shellRingAnimation = null;122    delete shell.dataset.glowing;123  };124  animation.onfinish = clearGlow;125  animation.oncancel = clearGlow;126}127128129export function triggerPipPulse() {130  const pip = document.querySelector(".peach-status-pip, .pip");131  if (pip) {132    pip.classList.remove("pulse");133    void (pip as HTMLElement).offsetWidth;134    pip.classList.add("pulse");135  }136}137138const ENTRY_TRANSITION_SETTLE_MS = 135;139140export default function App() {141  const [bootState, setBootState] = createSignal<BootState>("loading");142  const [view, setView] = createSignal<View>("lock");143  // Mobile bottom-sheet live drag offset in px (null = not actively dragging,144  // CSS transition takes over for snap animation).145  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(146    "enter"147  );148  const [contentTransitioning, setContentTransitioning] = createSignal(false);149  const [hasVault, setHasVault] = createSignal(false);150  const [onboarded, setOnboarded] = createSignal(true);151  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);152  // The restore source picked before the popup closed (the OS file picker153  // kills it), so reopening resumes the same source form instead of the154  // picker. Read from RESTORE_SOURCE_STORAGE_KEY at boot.155  const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);156  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes157  // back. Those flows all exit from the final card, so resume there — a fresh158  // install's first mount stays at 0.159  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);160  const [entries, setEntries] = createSignal<VaultEntry[]>([]);161  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);162  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);163164  // Rare device-trust alerts stay visible; routine entry sync is silent.165  interface SyncAlert {166    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";167    entryId: string;168    entryTitle: string;169    deviceName: string;170    timestamp: number;171  }172  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);173  // Per-group flip index: which alert within a type-group is currently shown.174  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});175  async function loadSyncAlerts() {176    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {177      ok: boolean;178      data?: SyncAlert[];179    };180    if (r.ok && (r.data?.length ?? 0) > 0) {181      setSyncAlerts(r.data ?? []);182      // Acknowledge so a crash between read and render doesn't lose them —183      // they remain on screen until the user dismisses each.184      void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });185    }186  }187  function dismissAlertGroup(type: string) {188    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));189    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });190  }191  function flipAlert(type: string, dir: 1 | -1) {192    const group = syncAlerts().filter((a) => a.type === type);193    if (group.length <= 1) return;194    setAlertFlipIdx((m) => {195      const cur = m[type] ?? 0;196      return { ...m, [type]: (cur + dir + group.length) % group.length };197    });198  }199  /** Grouped alert types in display order, each with its alerts. */200  const alertGroups = createMemo(() => {201    const map = new Map<string, SyncAlert[]>();202    for (const a of syncAlerts()) {203      const g = map.get(a.type);204      if (g) g.push(a); else map.set(a.type, [a]);205    }206    return [...map.entries()];207  });208  function syncAlertText(a: SyncAlert): string {209    if (a.type === "DEVICE_REVOKED") {210      return `${a.deviceName} revoked a paired device — review your devices in Settings`;211    }212    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;213  }214  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");215  const [theme, setTheme] = createSignal<"dark" | "light">("dark");216  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");217  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);218  const [lockOnClose, setLockOnClose] = createSignal(false);219  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);220  const [breachMonitoring, setBreachMonitoring] = createSignal(true);221  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);222  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);223  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);224  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);225  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);226  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");227  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");228  // Cached Pro status — synchronous for render paths. Background's229  // GET_LICENSE is async, but render-time reads of isPro() must not block.230  // Refreshed at boot, after activate/deactivate, and whenever the popup231  // re-opens onto a stored license that has changed via another window.232  const [proStatus, setProStatus] = createSignal(false);233  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);234  // When the sync card's pairing button opens the pairing view, deep-link235  // straight into that pairing path instead of the rest screen.236  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");237  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);238  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");239  const [listSearchSeed, setListSearchSeed] = createSignal("");240  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);241  const [pendingCelebration, setPendingCelebration] = createSignal(false);242  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);243  // True while Panic Step 1 has bounced the user out to Export. When Export's244  // onBack fires we return to Panic instead of Settings so the user keeps the245  // destruction context they came from.246  const [returnToPanic, setReturnToPanic] = createSignal(false);247  // The view to return to when the add/edit view is dismissed. Normally "entry"248  // but "health" when the user arrives via the fix-entry path from Vault Health.249  const [editBackView, setEditBackView] = createSignal<View>("entry");250  const [settingsExiting, setSettingsExiting] = createSignal(false);251  const [showNotes, setShowNotes] = createSignal(false);252  // Topbar sync affordance. Always present: gating it on having a paired device253  // meant it never appeared for the person trying to set pairing up, which is254  // exactly when you reach for it.255  const [syncBusy, setSyncBusy] = createSignal(false);256  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);257  // The indicator light + its spoken-word equivalent. Colour alone is not the258  // signal; the title/aria-label always carries the same claim in English.259  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});260  const [pairedCount, setPairedCount] = createSignal(0);261  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);262  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);263  let initialSyncWaitGeneration = 0;264  const syncLight = () =>265    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());266  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());267  async function loadSyncIndicator() {268    const [stateRes, devicesRes] = await Promise.all([269      sendMessage({ type: "GET_SYNC_STATE" }),270      sendMessage({ type: "LIST_PAIRED_DEVICES" }),271    ]);272    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };273    if (s.ok && s.data) setSyncSummary(s.data);274    const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };275    if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);276  }277278  interface InitialSyncPairedDevice extends InitialSyncDevice {279    pairedAt?: number;280  }281282  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {283    const response = (await sendMessage({284      type: "LIST_PAIRED_DEVICES",285    })) as {286      ok: boolean;287      data?: { devices?: InitialSyncPairedDevice[] };288    };289    return response.ok ? response.data?.devices ?? [] : [];290  }291292  async function resolveInitialSyncDeviceId(): Promise<string | null> {293    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(294      () => null,295    );296    const devices = await listInitialSyncDevices().catch(() => []);297    if (stored && devices.some((device) => device.deviceId === stored)) {298      return stored;299    }300301    // Fresh adoption has exactly one direct source, but choosing the newest row302    // also repairs the marker if storage failed after the pair itself committed.303    const newest = [...devices].sort(304      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),305    )[0];306    if (!newest) return stored;307    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(308      () => undefined,309    );310    return newest.deviceId;311  }312313  async function finishInitialSync(generation: number): Promise<void> {314    if (generation !== initialSyncWaitGeneration) return;315    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);316    setInitialSyncDeviceId(null);317    setInitialSyncSlow(false);318    await refreshProStatus();319320    // A license transferred during pairing intentionally leaves the one-shot321    // celebration pending. Consume it only now: sync readiness → celebration322    // → populated vault, never celebration → temporarily empty vault.323    const celebration = (await sendMessage({324      type: "SHOULD_PLAY_CELEBRATION",325    })) as { ok: boolean; data?: boolean };326    const shouldCelebrate = celebration.ok && celebration.data === true;327    if (shouldCelebrate) {328      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });329    }330331    const opened = await openVault(undefined, shouldCelebrate);332    if (!opened || generation !== initialSyncWaitGeneration) return;333    if (shouldCelebrate) {334      setView("pro-celebration");335      window.setTimeout(() => {336        if (generation === initialSyncWaitGeneration) setView("entry");337      }, 2500);338    }339  }340341  async function beginInitialSyncWait(deviceId: string): Promise<void> {342    const generation = ++initialSyncWaitGeneration;343    setInitialSyncDeviceId(deviceId);344    setInitialSyncSlow(false);345    setView("initial-sync");346    const startedAt = Date.now();347348    // Best effort: the pairing write already wakes the mesh listener, while349    // this trigger covers the key-order case where this receiver is the dialer.350    void sendMessage({351      type: "TRIGGER_SYNC",352      payload: { reason: "foreground" },353    }).catch(() => undefined);354355    while (356      generation === initialSyncWaitGeneration &&357      view() === "initial-sync"358    ) {359      try {360        const devices = await listInitialSyncDevices();361        if (hasCompletedInitialSync(devices, deviceId)) {362          await finishInitialSync(generation);363          return;364        }365      } catch {366        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.367      }368      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {369        setInitialSyncSlow(true);370      }371      await new Promise((resolve) =>372        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),373      );374    }375  }376377  async function retryInitialSync(): Promise<void> {378    const deviceId =379      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());380    if (deviceId) {381      await beginInitialSyncWait(deviceId);382    } else {383      setInitialSyncSlow(true);384    }385  }386387  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);388  // Set on popup boot when peach_recovery_pending flag is found in session389  // storage — instructs LockView to skip the unlock UI and open the recovery390  // words confirmation surface instead. Cleared via handleUnlock once the391  // user has typed the 3 confirmation words.392  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);393394  let contentPhaseTimer: number | undefined;395  // Set when the popup was opened from the inline "Open Peach to unlock" button.396  // handleUnlock() closes the popup after unlock so the user returns to the page.397  let openedForInlineUnlock = false;398399  onCleanup(() => {400    initialSyncWaitGeneration += 1;401  });402403  function applyAccent(name: AccentColorName) {404    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;405    const hex = color.hex;406    document.documentElement.style.setProperty("--peach-accent", hex);407    // Ambient surface variables — derived from the accent so surfaces shift408    // subtly toward the accent hue rather than staying fixed warm-brown.409    document.documentElement.style.setProperty("--peach-glow", hex + "2A");410    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");411    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");412  }413414  createEffect(() => {415    applyAccent(accentColor());416  });417418  createEffect(() => {419    document.documentElement.classList.toggle("light", theme() === "light");420    document.body.classList.toggle("light", theme() === "light");421  });422423  createEffect(() => {424    document.documentElement.setAttribute("data-text-size", textSize());425  });426427  createEffect(() => {428    if (reduceMotion()) {429      document.documentElement.setAttribute("data-motion", "reduce");430    } else {431      document.documentElement.removeAttribute("data-motion");432    }433  });434435  // Notes are not a destination of the Entry view; selecting a note opens the436  // notes surface instead (handleSelectEntry). Anything that walks or counts437  // the Entry view's neighbours (counter, prev/next, index) must use this438  // filtered list, otherwise the user can arrow into a note and is stuck —439  // the Entry view can't render a note and the prev/next buttons stop moving.440  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));441442  const selectedIndex = createMemo(() => {443    const currentId = selectedEntry()?.id;444    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;445    return nonNoteEntries().findIndex((entry) => entry.id === currentId);446  });447448  async function applyAccentColor(name: AccentColorName) {449    applyAccent(name);450    setAccentColor(name);451    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });452  }453454  async function applyTheme(t: "dark" | "light") {455    setTheme(t);456    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });457  }458459  async function applyTextSize(size: "small" | "medium" | "large") {460    setTextSize(size);461    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });462  }463464  async function applyReduceMotion(enabled: boolean) {465    setReduceMotion(enabled);466    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });467  }468469  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload470  // small and lets future default updates land for actions the user hasn't471  // touched. Settings sends us the full active map; we filter and store.472  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {473    setShortcutBindings(active);474    const overrides: Record<string, Keybinding> = {};475    for (const action of Object.keys(active) as ShortcutAction[]) {476      const a = active[action];477      const d = DEFAULT_BINDINGS[action];478      const same = a.key === d.key479        && Boolean(a.ctrl)  === Boolean(d.ctrl)480        && Boolean(a.meta)  === Boolean(d.meta)481        && Boolean(a.shift) === Boolean(d.shift)482        && Boolean(a.alt)   === Boolean(d.alt);483      if (!same) overrides[action] = a;484    }485    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });486  }487488  async function applyLockOnClose(enabled: boolean) {489    setLockOnClose(enabled);490    await sendMessage({491      type: "UPDATE_SETTINGS",492      payload: { lockOnClose: enabled },493    });494  }495496  async function applyAutoLockMinutes(minutes: number) {497    setAutoLockMinutes(minutes);498    await sendMessage({499      type: "UPDATE_SETTINGS",500      payload: { autoLockMinutes: minutes },501    });502  }503504  async function applyBreachMonitoring(enabled: boolean) {505    setBreachMonitoring(enabled);506    await sendMessage({507      type: "UPDATE_SETTINGS",508      payload: { breachMonitoring: enabled },509    });510  }511512  // Which onboarding exit flow the user is in (create-vault / restore /513  // adopt). Persisted so closing the popup on one of those screens resumes514  // it directly instead of rewinding to the last carousel card.515  function markOnboardingExit(flow: "create" | "restore" | "adopt") {516    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);517    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});518    // Backing out of the flow remounts the carousel at this card; keep the519    // persisted card in sync so a later popup close resumes consistently.520    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});521  }522  function clearOnboardingExit() {523    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});524    void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});525    setResumeRestoreSource(null);526  }527528  onMount(async () => {529    // Resolve the active tab in parallel with the unlock/has-vault queries —530    // openVault()'s smart selection picks the entry matching currentTabDomain,531    // so it must be populated before openVault runs. Previously refreshCurrentTab532    // ran after openVault, leaving popups that booted already-unlocked falling533    // back to loadedEntries[0] instead of the domain match.534    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([535      sendMessage({ type: "IS_UNLOCKED" }),536      sendMessage({ type: "HAS_VAULT" }),537      refreshCurrentTab(),538      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),539    ]);540    const unlocked = Boolean(541      (unlockResponse as { ok: boolean; data?: boolean }).data542    );543    const vaultExists = Boolean(544      (vaultResponse as { ok: boolean; data?: boolean }).data545    );546    const shouldCelebrate = Boolean(547      (celebrationResponse as { ok: boolean; data?: boolean }).data548    );549    setHasVault(vaultExists);550    // Onboarding gate — show once on first install, never again. Existing551    // users (vaultExists) are auto-marked onboarded so they skip the flow552    // even on the very first popup open after this feature ships.553    try {554      const onboarded = await storageGet("peach_onboarded");555      const seen = onboarded === "true";556      if (vaultExists && !seen) {557        await storageSet("peach_onboarded", "true");558        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});559        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});560        await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});561        setOnboarded(true);562      } else {563        setOnboarded(seen);564        if (!seen) {565          // A popup close while the user is in an exit flow (create-vault /566          // restore / adopt) resumes that flow directly — landing back on the567          // carousel one screen earlier reads as losing progress.568          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);569          if (exit === "create" || exit === "restore" || exit === "adopt") {570            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);571            setOnboarded(true);572            setStartOnboardingRestore(exit === "restore");573            if (exit === "restore") {574              // The OS file picker kills the action popup mid-restore, so the575              // surface resumes on the source the user had already picked.576              const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);577              if (source === "symbols" || source === "codex" || source === "csv") {578                setResumeRestoreSource(source);579              }580            }581            if (exit === "adopt") setView("adopting");582          } else {583            // Otherwise resume mid-onboarding where the user left off instead584            // of rewinding to the opening card.585            const stored = Number(586              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),587            );588            if (589              Number.isInteger(stored) &&590              stored > 0 &&591              stored <= ONBOARDING_LAST_CARD592            ) {593              setOnboardingCardAtExit(stored);594            }595          }596        }597      }598    } catch {599      // storage unavailable — default to onboarded so we never block boot600      setOnboarded(true);601    }602603    // Deep-link: ?restore=peachscript|csv — the detached-window half of the604    // file-picker flow. The action popup dismisses when the OS dialog steals605    // focus, so LockView.detachForFilePicker() reopens this URL in a606    // standalone window that owns the dialog. Routing runs before first607    // render so LockView mounts straight onto the right source form.608    try {609      const restoreRoute = new URLSearchParams(window.location.search).get("restore");610      if (restoreRoute === "peachscript" || restoreRoute === "csv") {611        setOnboarded(true);612        setStartOnboardingRestore(true);613        setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");614      }615    } catch {616      // location.search unavailable — ignore.617    }618619    await Promise.all([restoreSettings(), refreshProStatus()]);620621    const pendingInitialSyncMarker =622      unlocked && vaultExists623        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)624        : null;625    const pendingInitialSyncDeviceId = pendingInitialSyncMarker626      ? await resolveInitialSyncDeviceId()627      : null;628629    // Mark played before any animation so a crash mid-sequence never replays630    // it. A newly adopted receiver deliberately defers this until its first631    // real sync finishes, preserving initial sync → celebration → vault.632    if (shouldCelebrate && !pendingInitialSyncDeviceId) {633      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });634    }635636    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome637    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.638    if (unlocked && vaultExists) {639      void sendMessage({640        type: "TRIGGER_SYNC",641        payload: { reason: "foreground" },642      });643      void loadSyncIndicator();644      // Rare device-trust alerts only; routine entry sync stays quiet.645      void loadSyncAlerts();646    }647648    // If the user created a vault but hasn't yet confirmed their recovery649    // words, route back to the recovery-words screen instead of opening the650    // entries view. The flag lives in chrome.storage.local so it survives651    // both popup close (SW suppresses lock-on-close) and full browser652    // restart (handleUnlock checks the same flag after a fresh password653    // unlock). Cleared when confirmation succeeds.654    let recoveryPending = false;655    try {656      const out = await chrome.storage.local?.get("peach_recovery_pending");657      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";658    } catch { /* best-effort */ }659660    // If this popup was opened from the inline "Open Peach to unlock" button,661    // the background set a session flag. Read and clear it so handleUnlock()662    // can close the popup after unlock and return the user to the page.663    if (chrome.storage.session) {664      try {665        const s = await chrome.storage.session.get("peach_inline_unlock");666        if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {667          openedForInlineUnlock = true;668          await chrome.storage.session.remove("peach_inline_unlock");669        }670      } catch { /* best-effort */ }671    }672673    if (recoveryPending && unlocked) {674      // forceRecoveryWords overrides LockView's hasVault gate so the675      // recovery surface renders even though the vault now exists.676      setForceRecoveryWords(true);677      setView("lock");678    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {679      void beginInitialSyncWait(pendingInitialSyncDeviceId);680    } else if (unlocked && vaultExists) {681      await openVault(undefined, shouldCelebrate);682      if (shouldCelebrate) {683        setView("pro-celebration");684        window.setTimeout(() => setView("entry"), 2500);685      }686    } else if (shouldCelebrate) {687      setPendingCelebration(true);688    }689690    await refreshPendingPasskey();691692    // Track tab changes so smart sort stays current693    const handleTabActivated = () => { void refreshCurrentTab(); };694    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {695      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();696    };697    try {698      chrome.tabs.onActivated.addListener(handleTabActivated);699      chrome.tabs.onUpdated.addListener(handleTabUpdated);700      onCleanup(() => {701        chrome.tabs.onActivated.removeListener(handleTabActivated);702        chrome.tabs.onUpdated.removeListener(handleTabUpdated);703      });704    } catch {705      // tabs permission may not be available in all contexts706    }707708    setBootState("ready");709710    // Deep-link: ?codex=pdf opens a detached window straight into the711    // Codex restore flow with auto-picked PDF. The action popup can't host712    // a file dialog without dismissing itself, so this is the only way the713    // PDF import path survives. See CodexRestoreView.openPdfPicker().714    try {715      const route = new URLSearchParams(window.location.search).get("codex");716      if (route === "pdf" || route === "restore") {717        setView("codex-restore");718      }719    } catch {720      // location.search unavailable — ignore.721    }722723    // Check for pending session-storage notices from the background.724    if (chrome.storage.session) {725      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as726        Record<string, { feature?: string } | undefined>;727      if (session.pendingProGate?.feature) {728        await chrome.storage.session.remove("pendingProGate");729        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);730        setSettingsInitialSection("license");731        setView("settings");732      } else if (session.pendingProNotice?.feature) {733        await chrome.storage.session.remove("pendingProNotice");734        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });735      }736    }737738    // Warm the lazy-module cache so the Suspense fallback never shows for739    // locally-bundled extension views. Without this, the first navigation to740    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which741    // covers the shell and absorbs clicks on the header.742    void Promise.all([743      import("./views/Settings").catch(() => {}),744      import("./views/Import").catch(() => {}),745      import("./views/Export").catch(() => {}),746      import("./views/Health").catch(() => {}),747      import("./views/TrashView").catch(() => {}),748    ]);749750    // Lock-on-close via a persistent port. sendMessage in pagehide is751    // unreliable — the popup context tears down before the message lands.752    // The SW's onConnect listener locks when this port disconnects.753    let popupPort: chrome.runtime.Port | null = null;754    try {755      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect756      // listener accepts the "popup-" prefix; the random suffix is defence in757      // depth against a port named exactly "popup" from anything but our popup.758      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });759    } catch { /* extension context invalidated — ignore */ }760    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });761762    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale763    // entries instead of waiting for the next GET_ENTRIES round-trip.764    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {765      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();766      if (msg?.type === "SYNC_STATE_CHANGED") {767        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);768      }769      if (msg?.type === "PRO_GATE_TRIGGERED") {770        setUpgradeHighlight(msg.payload?.feature ?? null);771        setSettingsInitialSection("license");772        setView("settings");773      }774    };775    chrome.runtime.onMessage.addListener(handleRuntimeMessage);776    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));777778    // Action handlers — keyed by ShortcutAction. The dispatcher below tests779    // each binding against the event and runs the matching handler. New780    // shortcuts only need an entry here; the binding is data, not code.781    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {782      copy_password: () => {783        const entry = selectedEntry();784        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });785      },786      copy_username: () => {787        const entry = selectedEntry();788        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });789      },790      copy_totp: () => {791        const entry = selectedEntry();792        if (!entry?.totp) return;793        try {794          const { code } = generateTOTP(entry.totp);795          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });796        } catch { /* invalid TOTP secret — silent */ }797      },798      new_entry: () => handleAdd(),799      edit_entry: () => {800        const entry = selectedEntry();801        // Passkey-only entries have nothing user-editable in the Add view; the802        // passkey credential itself is opaque. Skip the edit action for them.803        if (!entry || isPasskeyOnly(entry)) return;804        setEditEntry(entry);805        setView("add");806      },807      delete_entry: () => {808        if (selectedEntry()) void handleDelete();809      },810      lock_vault: () => { void handleHeaderLock(); },811      search: () => openList(),812      nav_prev: () => navigate(-1),813      nav_next: () => navigate(1),814      jump_vault: () => setView("entry"),815      jump_settings: () => setView("settings"),816      show_help: () => setShowShortcutsHelp(true),817    };818819    // Actions that are MEANINGFUL only on the entry-detail view. Outside820    // that scope the binding is dormant — pressing C while on Settings821    // does nothing rather than awkwardly copying from a now-stale entry.822    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([823      "copy_password", "copy_username", "copy_totp",824      "edit_entry", "delete_entry", "nav_prev", "nav_next",825    ]);826827    const handleGlobalKeydown = (e: KeyboardEvent) => {828      const v = view();829      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"830        || (e.target as HTMLElement)?.tagName === "TEXTAREA"831        || (e.target as HTMLElement)?.isContentEditable;832      const isPrintableSearchKey =833        e.key.length === 1 &&834        !e.ctrlKey &&835        !e.metaKey &&836        !e.altKey;837838      // No shortcuts while a full-screen surface owns the whole viewport:839      // the lock screen, the post-pairing readiness sequence, or the Pro840      // celebration. Without this the lock screen is at the mercy of whatever841      // single-key binding is active — e.g. typing "n" in a password that842      // slips out of focus triggers `new_entry` and escapes the lock screen.843      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;844845      // Help dialog: Escape closes it; eat the keypress so it doesn't846      // also navigate away from the underlying view.847      if (showShortcutsHelp()) {848        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }849        return;850      }851852      // ── Escape — fixed binding (not user-customizable) ───────853      if (e.key === "Escape") {854        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }855        if (v === "list") { setView("entry"); return; }856        if (v === "settings" || v === "add") { handleBack(); return; }857        if (v === "import" || v === "health") { setView("settings"); return; }858        if (v === "export") { handleExportBack(); return; }859        if (v === "pair-device") { setView("settings"); return; }860      }861862      // Bindings-driven dispatch. Single-key letter bindings without863      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is864      // inside an input, so users can type into search/forms normally.865      const bindings = shortcutBindings();866      for (const action of Object.keys(bindings) as ShortcutAction[]) {867        const binding = bindings[action];868        if (!matchKeybinding(e, binding)) continue;869        // Single-key letter bindings need the input-focus guard. Bindings870        // with any modifier are safe — Ctrl+L doesn't conflict with typing.871        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;872        if (!hasMod && binding.key.length === 1 && inInput) return;873        if (ENTRY_SCOPED.has(action) && v !== "entry") return;874        e.preventDefault();875        SHORTCUT_HANDLERS[action]?.();876        return;877      }878879      // Type-to-search: in entry view, any printable key not consumed by a880      // shortcut above primes the list search seed and opens the list.881      // Kept outside the bindings table because it's a behavior, not a882      // discrete action — every printable character can trigger it.883      if (v === "entry" && !inInput && isPrintableSearchKey) {884        e.preventDefault();885        primeListSearch(e.key);886        return;887      }888    };889    window.addEventListener("keydown", handleGlobalKeydown);890    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));891892    const handleStorageChanged = (893      changes: Record<string, chrome.storage.StorageChange>,894      areaName: string895    ) => {896      if (areaName !== "session") return;897      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {898        return;899      }900      void refreshPendingPasskey();901    };902    chrome.storage.onChanged.addListener(handleStorageChanged);903    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));904  });905906  async function refreshCurrentTab() {907    try {908      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });909      const url = tab?.url ?? tab?.pendingUrl ?? "";910      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");911      // Skip extension/internal URLs — adding chrome://, about:, or912      // chrome-extension:// to a vault entry is never what the user wants.913      const isWebUrl = /^https?:\/\//i.test(url);914      setCurrentTabUrl(isWebUrl ? url : "");915    } catch {916      setCurrentTabDomain("");917      setCurrentTabUrl("");918    }919  }920921  async function refreshPendingPasskey() {922    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });923    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);924  }925926  async function refreshProStatus() {927    const res = await sendMessage({ type: "GET_LICENSE" });928    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;929    setProStatus(Boolean(data?.valid && data?.plan === "pro"));930  }931932  async function restoreSettings() {933    const res = await sendMessage({ type: "GET_SETTINGS" });934    const s = (res as { ok: boolean; data?: any }).data ?? {};935    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {936      setAccentColor(s.accentColor as AccentColorName);937    }938    setTheme(s.theme === "light" ? "light" : "dark");939    if (s.textSize === "small" || s.textSize === "large") {940      setTextSize(s.textSize);941    } else {942      setTextSize("medium");943    }944    setReduceMotion(s.reduceMotion === true);945    setLockOnClose(s.lockOnClose === true);946    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);947    setBreachMonitoring(s.breachMonitoring !== false);948    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);949    setShortcutBindings(getActiveBindings(s.shortcuts));950  }951952  async function applyTrashRetention(days: number) {953    setTrashRetentionDays(days);954    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });955  }956957  function primeListSearch(seed: string) {958    setListSearchSeed(seed);959    setListSearchSeedNonce((value) => value + 1);960    openList();961  }962963  // Returns null when it detected a dead service worker and triggered handleLock().964  // Callers that need to abort (e.g. openVault) check for null rather than relying965  // on view() state, which may already be "lock" during normal startup flows.966  async function loadEntries(): Promise<VaultEntry[] | null> {967    const response = await sendMessage({ type: "GET_ENTRIES" });968    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };969970    // If the service worker was killed and restarted (MV3 workers sleep), the971    // vault will be locked again — or the worker may not respond at all.972    // In either case, send the user to the lock screen rather than silently973    // showing an empty vault (which looks like data loss).974    if (!res.ok) {975      if (976        res.error === "vault_locked" ||977        res.error === "SERVICE_WORKER_UNAVAILABLE" ||978        (typeof res.error === "string" && res.error.includes("locked"))979      ) {980        handleLock();981        return null; // signal emergency lock to callers982      }983      return [];984    }985986    const loadedEntries = res.data ?? [];987    setEntries(loadedEntries);988989    const currentId = selectedEntry()?.id;990    if (currentId) {991      setSelectedEntry(992        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)993      );994    }995996    return loadedEntries;997  }998999  async function openVault(preferredEntryId?: string, skipViewSet = false) {1000    // Purge expired trash entries on each vault open1001    const retDays = trashRetentionDays();1002    if (retDays > 0) {1003      void sendMessage({1004        type: "PURGE_EXPIRED_TRASH",1005        payload: { retentionMs: retDays * 86_400_000 },1006      });1007    }10081009    const loadedEntries = await loadEntries();10101011    // null means loadEntries() triggered an emergency lock (service worker died).1012    // Don't override it by setting view back to "entry" with an empty vault.1013    if (loadedEntries === null) return false;10141015    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1016    let nextSelectedEntry: VaultEntry | null = null;1017    if (preferredEntryId) {1018      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1019    }1020    if (!nextSelectedEntry) {1021      const tabDomain = currentTabDomain();1022      if (tabDomain) {1023        nextSelectedEntry = loadedEntries.find((e) => {1024          if (e.type === "login") {1025            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1026            for (const extra of e.additionalUrls ?? []) {1027              if ((extractDomain(extra) ?? "") === tabDomain) return true;1028            }1029            return false;1030          }1031          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1032          return false;1033        }) ?? null;1034      }1035    }1036    nextSelectedEntry ??= pickNavigable(loadedEntries);10371038    setSelectedEntry(nextSelectedEntry);1039    setEditEntry(undefined);1040    setContentTransitioning(false);1041    setContentPhase("enter");1042    queueContentIdle();1043    if (!skipViewSet) setView("entry");1044    return true;1045  }10461047  async function handleUnlock() {1048    // If the user previously created a vault but hadn't yet confirmed their1049    // recovery words (and the browser was closed before they could), the1050    // peach_recovery_pending flag is still set in storage.local. Re-route1051    // back to the recovery surface so they finish the confirmation step1052    // before reaching the entries view — the words can be re-derived now1053    // that the vault is unlocked again.1054    let recoveryPending = false;1055    try {1056      const out = await chrome.storage.local?.get("peach_recovery_pending");1057      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1058    } catch { /* best-effort */ }1059    if (recoveryPending) {1060      setForceRecoveryWords(true);1061      setView("lock");1062      triggerPipPulse();1063      await restoreSettings();1064      return;1065    }1066    setHasVault(true);1067    triggerPipPulse();1068    await restoreSettings();1069    const celebrating = pendingCelebration();1070    if (celebrating) {1071      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1072      setPendingCelebration(false);1073    }1074    await openVault(undefined, celebrating);1075    if (celebrating) {1076      setView("pro-celebration");1077      window.setTimeout(() => setView("entry"), 2500);1078    }1079    // Popup was opened from the inline "Open Peach to unlock" button — close it1080    // now so the user lands back on the page with the autofill selector ready.1081    if (openedForInlineUnlock) {1082      openedForInlineUnlock = false;1083      window.close();1084    }1085  }10861087  function handleSelectEntry(entry: VaultEntry) {1088    if (entry.type === "note") {1089      setOpenNoteId(entry.id);1090      setShowNotes(true);1091      return;1092    }1093    setSelectedEntry(entry);1094    setContentTransitioning(false);1095    setContentPhase("enter");1096    queueContentIdle();1097    setView("entry");1098  }10991100  function handleAdd() {1101    setEditEntry(undefined);1102    setView("add");1103  }11041105  async function handleDelete() {1106    const entry = selectedEntry();1107    if (!entry) return;1108    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1109    const loadedEntries = await loadEntries() ?? [];1110    setSelectedEntry(pickNavigable(loadedEntries));1111    setView("entry");1112  }11131114  async function handleDeleteMany(ids: string[]) {1115    for (const id of ids) {1116      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1117    }1118    const loadedEntries = await loadEntries() ?? [];1119    // If the currently selected entry was among the deleted, move to first1120    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1121    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1122  }11231124  async function handleSave(savedEntry?: VaultEntry) {1125    await openVault(savedEntry?.id);1126  }11271128  function handleBack() {1129    const dest = editBackView();1130    setEditBackView("entry");1131    setEditEntry(undefined);1132    setView(dest);1133  }11341135  function handleExportBack() {1136    if (returnToPanic()) {1137      setReturnToPanic(false);1138      setView("panic");1139      return;1140    }1141    setView("settings");1142  }11431144  function handleLock() {1145    clearCanarySuppressions();1146    setEntries([]);1147    setSelectedEntry(null);1148    setEditEntry(undefined);1149    setContentPhase("idle");1150    setView("lock");1151  }11521153  function handleHeaderLock() {1154    // Lock the UI immediately — do not wait for the service worker. If the SW1155    // is dead or slow, the 5s sendMessage timeout would create a dead-click1156    // window. The SW will lock on its next wake regardless.1157    handleLock();1158    void sendMessage({ type: "LOCK" });1159  }11601161  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1162  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1163  // the trigger's own reply.1164  async function handleHeaderSync(): Promise<void> {1165    if (syncBusy()) return;1166    setSyncBusy(true);1167    setSyncFlash(null);1168    try {1169      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1170      let state: SyncOutcomeSummary = { status: "syncing" };1171      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1172        await new Promise((r) => setTimeout(r, 250));1173        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1174          ok: boolean;1175          data?: SyncOutcomeSummary;1176        };1177        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1178      }1179      setSyncSummary(state);1180      setSyncFlash(state.status === "error" ? "error" : "ok");1181      await loadSyncAlerts();1182      await loadEntries();1183      await loadSyncIndicator();1184    } catch {1185      setSyncSummary({ status: "error", outcome: "run_failed" });1186      setSyncFlash("error");1187    } finally {1188      setSyncBusy(false);1189      setTimeout(() => setSyncFlash(null), 2_000);1190    }1191  }11921193  function navigate(dir: number) {1194    const navigable = nonNoteEntries();1195    if (navigable.length === 0) return;1196    const currentIndex = selectedIndex();1197    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1198    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1199    setSelectedEntry(navigable[nextIndex]);1200    setContentPhase("enter");1201    queueContentIdle();1202  }12031204  /**1205   * The Entry view's selection invariant: never select a note. Notes are a1206   * separate surface (handleSelectEntry opens the notes view), so any code path1207   * that picks a default — vault open, post-delete, refresh — must skip them1208   * and return null if the only entries are notes.1209   */1210  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1211    return list.find((e) => e.type !== "note") ?? null;1212  }12131214  function openList() {1215    setView("list");1216  }12171218  function openHealth() {1219    setEditEntry(undefined);1220    setView("health");1221  }12221223  function handleOpenFileImport() {1224    setRestoreView("file");1225  }12261227  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12281229  function handleFixEntry(entry: VaultEntry) {1230    setSelectedEntry(entry);1231    // Passkey-only entries have no editable surface — drop into view mode1232    // instead of opening the Add editor.1233    if (isPasskeyOnly(entry)) {1234      setView("entry");1235      return;1236    }1237    setEditBackView(view()); // preserve caller view so back returns here1238    setEditEntry(entry);1239    setView("add");1240  }12411242  const isLockedView = () => view() === "lock" || view() === "adopting";12431244  async function handleAdoptionDone() {1245    await storageSet("peach_onboarded", "true").catch(() => {});1246    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1247    clearOnboardingExit();1248    setOnboarded(true);1249    setHasVault(true);1250    setView("initial-sync");1251    // The background applies a Pro license and persists the source-device1252    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1253    // celebration pending until that exact peer records a completed sync.1254    await refreshProStatus();1255    const deviceId = await resolveInitialSyncDeviceId();1256    if (deviceId) {1257      void beginInitialSyncWait(deviceId);1258    } else {1259      // Pairing completion without a durable peer row should be impossible.1260      // Stay on the honest waiting surface and expose retry instead of falling1261      // through to an empty vault.1262      setInitialSyncSlow(true);1263    }1264  }12651266  async function handleApprovePendingPasskey() {1267    const pending = pendingPasskey();1268    if (!pending) return;1269    if (pending.browser === "firefox" && pending.type === "create") {1270      // Firefox path: popup itself runs navigator.credentials.create. The1271      // background never approved/rejected anything — it just stashed1272      // intent. After the popup-driven flow resolves (success or cancel),1273      // clear the pending key so the popup doesn't re-show this on next open.1274      const { createFirefoxPasskey } = await import("./firefox-passkey");1275      const result = await createFirefoxPasskey(pending);1276      if (!result.ok) {1277        console.warn("Peach Firefox passkey create failed:", result.error);1278      }1279      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1280      setPendingPasskey(null);1281      void loadEntries();1282      window.close();1283      return;1284    }1285    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1286    window.close();1287  }12881289  async function handleDenyPendingPasskey() {1290    const pending = pendingPasskey();1291    if (!pending) return;1292    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1293    window.close();1294  }12951296  function queueContentIdle(after?: () => void) {1297    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1298    contentPhaseTimer = window.setTimeout(() => {1299      setContentPhase("idle");1300      after?.();1301    }, ENTRY_TRANSITION_SETTLE_MS);1302  }13031304  return (1305    <div class="popup-root peach-theme">1306      <Show when={bootState() === "ready"}>1307        <Show1308          when={!pendingPasskey()}1309          fallback={1310            <PasskeyApprovalView1311              type={pendingPasskey()!.type}1312              rpName={pendingPasskey()!.rpName}1313              rpId={pendingPasskey()!.rpId}1314              userName={pendingPasskey()!.userName}1315              userDisplayName={pendingPasskey()!.userDisplayName}1316              onApprove={handleApprovePendingPasskey}1317              onDeny={handleDenyPendingPasskey}1318            />1319          }1320        >1321          <Show1322            when={!isLockedView()}1323            fallback={1324              <Show1325                when={view() !== "adopting"}1326                fallback={1327                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1328                    <AdoptVaultView1329                      onCancel={() => {1330                        clearOnboardingExit();1331                        setOnboarded(false);1332                        setView("lock");1333                      }}1334                      onDone={handleAdoptionDone}1335                    />1336                  </Suspense>1337                }1338              >1339                <Show1340                  when={onboarded() || hasVault()}1341                  fallback={1342                    <Onboarding1343                      initialCard={onboardingCardAtExit()}1344                      onComplete={() => {1345                        markOnboardingExit("create");1346                        setStartOnboardingRestore(false);1347                        setOnboarded(true);1348                      }}1349                      onRestore={() => {1350                        markOnboardingExit("restore");1351                        setStartOnboardingRestore(true);1352                        setOnboarded(true);1353                      }}1354                      onAdopt={() => {1355                        markOnboardingExit("adopt");1356                        setStartOnboardingRestore(false);1357                        setOnboarded(true);1358                        setView("adopting");1359                      }}1360                    />1361                  }1362                >1363                  <LockView1364                    hasVault={hasVault()}1365                    ready={bootState() === "ready"}1366                    isPro={proStatus()}1367                    forceRecoveryWords={forceRecoveryWords()}1368                    initialRestore={startOnboardingRestore()}1369                    initialRestoreSource={resumeRestoreSource() ?? undefined}1370                    onUnlock={() => {1371                      // RecoveryWordDisplay clears the storage flag before1372                      // calling onDone, so handleUnlock will see no pending1373                      // confirmation and proceed through the normal path.1374                      if (forceRecoveryWords()) setForceRecoveryWords(false);1375                      // The create/restore flow is done — a lock later in this1376                      // window must show the unlock screen, not re-enter the1377                      // onboarding restore surface.1378                      setStartOnboardingRestore(false);1379                      setResumeRestoreSource(null);1380                      void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1381                      return handleUnlock();1382                    }}1383                    onBackToOnboarding={1384                      !hasVault()1385                        ? () => {1386                            // Deliberately rewinding to the carousel — the exit1387                            // flow must not reclaim the next popup open.1388                            clearOnboardingExit();1389                            setStartOnboardingRestore(false);1390                            setOnboarded(false);1391                          }1392                        : undefined1393                    }1394                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1395                  />1396                </Show>1397              </Show>1398            }1399          >1400            <Show1401              when={1402                view() !== "pro-celebration" &&1403                view() !== "initial-sync"1404              }1405              fallback={1406                <Show1407                  when={view() === "initial-sync"}1408                  fallback={<ProCelebration />}1409                >1410                  <InitialSyncView1411                    slow={initialSyncSlow()}1412                    onRetry={() => void retryInitialSync()}1413                  />1414                </Show>1415              }1416            >1417            <section1418              id="hd-shell"1419              class="peach-shell peach-theme"1420              data-surface="extension"1421            >1422            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1423              <PeachWordmark unlocked />1424              <div class="peach-chrome-actions">1425                <button1426                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1427                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1428                  aria-label="Notes"1429                  title="Notes"1430                >1431                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1432                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1433                    <line x1="4" y1="5" x2="10" y2="5" />1434                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1435                    <line x1="4" y1="10" x2="7" y2="10" />1436                  </svg>1437                </button>1438                <button1439                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1440                    classList={{1441                      "is-syncing": syncBusy(),1442                      "is-ok": syncFlash() === "ok",1443                      "is-error": syncFlash() === "error",1444                    }}1445                    onClick={() => void handleHeaderSync()}1446                    disabled={syncBusy()}1447                    aria-label={`Sync now. ${syncLabel()}`}1448                    title={syncLabel()}1449                  >1450                    <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">1451                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1452                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1453                      <path d="M2.6 1v2.4h2.4" />1454                      <path d="M11.4 13v-2.4H9" />1455                    </svg>1456                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1457                  </button>1458                <button1459                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1460                  onClick={() => setView("settings")}1461                  aria-label="Settings"1462                >1463                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1464                    <path1465                      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"1466                      stroke="currentColor"1467                      stroke-width="0.9"1468                    />1469                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1470                  </svg>1471                </button>1472                <button1473                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1474                  onClick={handleAdd}1475                  aria-label="New entry"1476                >1477                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1478                    <path d="M5.5 1v9M1 5.5h9" />1479                  </svg>1480                </button>1481                <button1482                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1483                  onClick={() => void handleHeaderLock()}1484                  aria-label="Lock vault"1485                >1486                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1487                    <path1488                      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"1489                      stroke="currentColor"1490                      stroke-width="1"1491                      stroke-linecap="round"1492                    />1493                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1494                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1495                  </svg>1496                </button>1497              </div>1498            </header>14991500            <Show when={pendingProNotice() !== null}>1501              <div class="peach-pro-notice-banner">1502                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1503                <button1504                  class="peach-pro-notice-link"1505                  onClick={() => {1506                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1507                    setSettingsInitialSection("license");1508                    setView("settings");1509                    setPendingProNotice(null);1510                  }}1511                >1512                  Learn more →1513                </button>1514                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1515              </div>1516            </Show>15171518            <div class="peach-shell-body">1519              <div class="peach-shell-veil"></div>1520              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1521                <EntryView1522                  entry={selectedEntry()}1523                  currentTabUrl={currentTabUrl()}1524                  entries={entries()}1525                  isPro={proStatus()}1526                  onDelete={handleDelete}1527                  onFieldSaved={() => void loadEntries()}1528                  onEscape={() => { setView("list"); }}1529                  onProGate={(feature) => {1530                    setUpgradeHighlight(feature);1531                    setSettingsInitialSection("license");1532                    setView("settings");1533                  }}1534                />1535              </div>15361537              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1538                <div class="peach-search-bar">1539                  <span class="peach-search-icon">1540                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1541                      <circle cx="5.5" cy="5.5" r="3.75" />1542                      <path d="M8.75 8.75L11.5 11.5" />1543                    </svg>1544                  </span>1545                  <input1546                    class="peach-search-input"1547                    placeholder="search vault..."1548                    autocomplete="off"1549                    aria-label="Search vault entries"1550                    onClick={openList}1551                    onFocus={openList}1552                    readOnly1553                  />1554                  <div class="peach-search-meta">1555                    <span class="peach-search-counter">1556                      {nonNoteEntries().length > 01557                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1558                        : "0 entries"}1559                    </span>1560                    <span class="peach-divider"></span>1561                    <button1562                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1563                      onClick={() => navigate(-1)}1564                      aria-label="Previous entry"1565                      disabled={nonNoteEntries().length === 0}1566                    >1567                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1568                        <path d="M5 1L1 5.5L5 10" />1569                      </svg>1570                    </button>1571                    <button1572                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1573                      onClick={() => navigate(1)}1574                      aria-label="Next entry"1575                      disabled={nonNoteEntries().length === 0}1576                    >1577                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1578                        <path d="M1 1L5 5.5L1 10" />1579                      </svg>1580                    </button>1581                  </div>1582                </div>1583              </section>1584            </div>15851586            {/* Rare device-trust alerts; normal sync changes are silent. */}1587            <Show when={alertGroups().length > 0}>1588              <div class="peach-sync-alerts">1589                <For each={alertGroups()}>1590                  {([type, group]) => {1591                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1592                    return (1593                      <div class="peach-sync-alert">1594                        <Show when={group.length > 1}>1595                          <button1596                            class="peach-sync-alert-flip"1597                            type="button"1598                            aria-label="Previous"1599                            onClick={() => flipAlert(type, -1)}1600                          >‹</button>1601                        </Show>1602                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1603                        <Show when={group.length > 1}>1604                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1605                          <button1606                            class="peach-sync-alert-flip"1607                            type="button"1608                            aria-label="Next"1609                            onClick={() => flipAlert(type, 1)}1610                          >›</button>1611                        </Show>1612                        <button1613                          class="peach-sync-alert-dismiss"1614                          type="button"1615                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1616                          onClick={() => dismissAlertGroup(type)}1617                        >×</button>1618                      </div>1619                    );1620                  }}1621                </For>1622              </div>1623            </Show>16241625            <Show when={view() === "list"}>1626              <VaultView1627                entries={entries()}1628                selectedEntryId={selectedEntry()?.id}1629                currentTabDomain={currentTabDomain()}1630                sortOrder={sortOrder()}1631                searchSeed={listSearchSeed()}1632                searchSeedNonce={listSearchSeedNonce()}1633                onSelectEntry={handleSelectEntry}1634                onSortOrderChange={setSortOrder}1635                onAdd={handleAdd}1636                onClose={() => setView("entry")}1637                onOpenTrash={() => setView("trash")}1638                onDeleteMany={handleDeleteMany}1639              />1640            </Show>1641            <Show when={view() === "settings" || settingsExiting()}>1642              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1643                <SettingsView1644                  entriesCount={entries().length}1645                  breachMonitoring={breachMonitoring()}1646                  lockOnClose={lockOnClose()}1647                  autoLockMinutes={autoLockMinutes()}1648                  trashRetentionDays={trashRetentionDays()}1649                  theme={theme()}1650                  accentColor={accentColor()}1651                  textSize={textSize()}1652                  reduceMotion={reduceMotion()}1653                  isPro={proStatus()}1654                  initialSection={settingsInitialSection()}1655                  upgradeHighlight={upgradeHighlight()}1656                  isExiting={settingsExiting()}1657                  onBreachMonitoringChange={applyBreachMonitoring}1658                  onLockOnCloseChange={applyLockOnClose}1659                  onAutoLockMinutesChange={applyAutoLockMinutes}1660                  onTrashRetentionChange={applyTrashRetention}1661                  onThemeChange={applyTheme}1662                  onAccentChange={applyAccentColor}1663                  onTextSizeChange={applyTextSize}1664                  onReduceMotionChange={applyReduceMotion}1665                  shortcuts={shortcutBindings()}1666                  onShortcutsChange={applyShortcuts}1667                  onLicenseChange={async () => {1668                    // After activation/deactivation: refresh Pro status,1669                    // re-load entries (defensive — signal state can drift if1670                    // the SW briefly restarts during the verify+save round-trip),1671                    // and replay the Pro celebration if this is the first time1672                    // a valid Pro license is seen on this device.1673                    await refreshProStatus();1674                    void loadEntries();1675                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1676                    if ((should as { ok: boolean; data?: boolean }).data === true) {1677                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1678                      setView("pro-celebration");1679                      window.setTimeout(() => setView("settings"), 2500);1680                    }1681                  }}1682                  onBack={() => {1683                    setSettingsExiting(true);1684                    window.setTimeout(() => {1685                      setSettingsExiting(false);1686                      setSettingsInitialSection(null);1687                      setUpgradeHighlight(null);1688                      handleBack();1689                    }, 200);1690                  }}1691                  onImport={() => setView("import")}1692                  onExport={() => setView("export")}1693                  onBreachCheckDone={loadEntries}1694                  onOpenHealth={openHealth}1695                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1696                  onOpenFileImport={handleOpenFileImport}1697                  onOpenPanic={() => setView("panic")}1698                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1699                  onOpenCodexRestore={() => setView("codex-restore")}1700                  onCardChange={setSettingsActiveCard}1701                  onProGate={(feature) => {1702                    setUpgradeHighlight(feature);1703                    setSettingsInitialSection("license");1704                  }}1705                />1706              </Suspense>1707            </Show>1708            <Show when={view() === "codex-generate"}>1709              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1710                <CodexGenerateView1711                  isPro={proStatus()}1712                  onBack={() => setView("settings")}1713                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1714                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1715                  onVaultLocked={() => handleLock()}1716                />1717              </Suspense>1718            </Show>1719            <Show when={view() === "codex-restore"}>1720              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1721                <CodexRestoreView1722                  onBack={() => setView("lock")}1723                  onRestoreSuccess={async () => {1724                    // After restore the vault is unlocked; refresh entries1725                    // and route to the main vault list. handleUnlock is the1726                    // canonical post-unlock setup path.1727                    await handleUnlock();1728                  }}1729                />1730              </Suspense>1731            </Show>1732            <Show when={view() === "pair-device"}>1733              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1734                <PairDeviceView1735                  isPro={proStatus()}1736                  onProChanged={() => { void refreshProStatus(); }}1737                  onBack={() => setView("settings")}1738                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1739                />1740              </Suspense>1741            </Show>1742            <Show when={view() === "add"}>1743              <AddView1744                entry={editEntry()}1745                entries={entries()}1746                onBack={handleBack}1747                onSave={handleSave}1748              />1749            </Show>1750            <Show when={view() === "import"}>1751              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1752                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1753              </Suspense>1754            </Show>1755            <Show when={view() === "export"}>1756              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1757                <ExportView onBack={handleExportBack} />1758              </Suspense>1759            </Show>1760            <Show when={view() === "panic"}>1761              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1762                <PanicView1763                  onCancel={() => setView("settings")}1764                  onOpenExport={() => {1765                    setReturnToPanic(true);1766                    setView("export");1767                  }}1768                  onDestroyed={async () => {1769                    clearCanarySuppressions();1770                    // Fresh onboarding start after a destroy — don't let the1771                    // resume markers land the user mid-carousel.1772                    setOnboardingCardAtExit(0);1773                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1774                    clearOnboardingExit();1775                    setEntries([]);1776                    setSelectedEntry(null);1777                    setEditEntry(undefined);1778                    setHasVault(false);1779                    setOnboarded(false);1780                    setProStatus(false);1781                    setPendingPasskey(null);1782                    setUpgradeHighlight(null);1783                    setSettingsInitialSection(null);1784                    setPendingProNotice(null);1785                    setPendingCelebration(false);1786                    setReturnToPanic(false);1787                    setContentPhase("idle");1788                    setView("lock");1789                  }}1790                />1791              </Suspense>1792            </Show>1793            {/* Health stays mounted while editing an entry from it so1794                computeReused() doesn't rerun on every cancel/back. */}1795            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1796              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1797                <HealthView1798                  entries={entries()}1799                  isPro={proStatus()}1800                  onFixEntry={handleFixEntry}1801                  onBack={() => setView("settings")}1802                  onRefresh={loadEntries}1803                  hidden={view() !== "health"}1804                />1805              </Suspense>1806            </Show>1807            <Show when={view() === "trash"}>1808              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1809                <TrashView1810                  onBack={() => setView("list")}1811                  onRestored={() => void loadEntries()}1812                />1813              </Suspense>1814            </Show>1815            <Show when={showNotes()}>1816              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1817                <NotesMode1818                  notes={entries().filter((e) => e.type === "note")}1819                  initialNoteId={openNoteId()}1820                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1821                  onNoteListChanged={() => void loadEntries()}1822                />1823              </div>1824            </Show>1825            <Show when={restoreView() === "file"}>1826              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1827                <ImportView1828                  mode="onboarding"1829                  replacesExistingVault={true}1830                  onBack={() => setRestoreView(null)}1831                  onImport={async () => {1832                    setRestoreView(null);1833                    await handleUnlock();1834                  }}1835                />1836              </Suspense>1837            </Show>1838            </section>1839            </Show>1840          </Show>1841        </Show>1842      </Show>1843      <Show when={showShortcutsHelp()}>1844        <ShortcutsHelpOverlay1845          bindings={shortcutBindings()}1846          onClose={() => setShowShortcutsHelp(false)}1847        />1848      </Show>1849    </div>1850  );1851}18521853function ShortcutsHelpOverlay(props: {1854  bindings: Record<ShortcutAction, Keybinding>;1855  onClose: () => void;1856}) {1857  return (1858    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1859      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1860        <div class="peach-shortcuts-help-head">1861          <h3>Keyboard shortcuts</h3>1862          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1863        </div>1864        <div class="peach-shortcuts-help-body">1865          <p class="peach-settings-sub-label">ENTRY VIEW</p>1866          <ul>1867            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1868              <li>1869                <span>{d.label}</span>1870                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1871              </li>1872            ))}1873          </ul>1874          <p class="peach-settings-sub-label">ANYWHERE</p>1875          <ul>1876            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1877              <li>1878                <span>{d.label}</span>1879                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1880              </li>1881            ))}1882          </ul>1883        </div>1884      </div>1885    </div>1886  );1887}