App.tsx

87.9 KB · raw

1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { Portal } from "solid-js/web";3import { storageGet, storageRemove, storageSet } from "../shared/storage";4import { sendMessage } from "../shared/bridge";5import { ACCENT_COLORS } from "../shared/types";6import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7import { isPasskeyOnly } from "../shared/types";8import {9  hasCompletedInitialSync,10  INITIAL_SYNC_PENDING_DEVICE_KEY,11  type InitialSyncDevice,12} from "../shared/initial-sync";13import { LockView } from "./views/Lock";14import {15  ONBOARDING_CARD_STORAGE_KEY,16  ONBOARDING_LAST_CARD,17  Onboarding,18} from "./views/Onboarding";1920const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";21import { NotesMode } from "./views/NotesMode";22import { ProCelebration } from "./views/ProCelebration";23import { InitialSyncView } from "./views/InitialSyncView";24import { VaultView, type SortOrder } from "./views/Vault";25import { EntryView } from "./views/Entry";26import { AddView } from "./views/Add";27import { PeachWordmark } from "./components/PeachMark";28import { PasskeyApprovalView } from "./views/PasskeyApproval";29import { extractDomain } from "../shared/domain";30import {31  syncIndicator,32  syncIndicatorLabel,33  type SyncOutcomeSummary,34} from "../shared/sync-outcome";35import { popupCopy } from "./copy";36import {37  DEFAULT_BINDINGS,38  formatKeybinding,39  getActiveBindings,40  matchKeybinding,41  SHORTCUT_DESCRIPTORS,42  type Keybinding,43  type ShortcutAction,44} from "../shared/shortcuts";45import { generateTOTP } from "../crypto/totp";46import { isNativePlatform, nativePlugin } from "../shared/native";4748export { sendMessage } from "../shared/bridge";4950const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));51const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));52const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));53const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));54const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));55const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));56const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));57const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));58const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));59const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6061type View =62  | "lock"63  | "pro-celebration"64  | "entry"65  | "list"66  | "add"67  | "settings"68  | "pair-device"69  | "import"70  | "export"71  | "health"72  | "trash"73  | "panic"74  | "adopting"75  | "initial-sync"76  | "codex-generate"77  | "codex-restore";78type BootState = "loading" | "ready";7980const INITIAL_SYNC_POLL_MS = 500;81const INITIAL_SYNC_SLOW_MS = 15_000;8283const canarySuppressMap = new Map<string, boolean>();84let shellRingAnimation: Animation | null = null;8586export function isCanarySuppressed(entryId: string): boolean {87  return canarySuppressMap.get(entryId) ?? false;88}8990export function setCanarySuppressed(entryId: string, suppressed: boolean): void {91  canarySuppressMap.set(entryId, suppressed);92}9394export function clearCanarySuppressions(): void {95  canarySuppressMap.clear();96}9798export function triggerRing() {99  const shell = document.getElementById("hd-shell");100  if (!shell) return;101102  shellRingAnimation?.cancel();103  shell.dataset.glowing = "true";104  const animation = shell.animate(105    [106      { offset: 0, boxShadow: "var(--copy-shadow)" },107      {108        offset: 0.3,109        boxShadow:110          "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)",111      },112      { offset: 1, boxShadow: "var(--copy-shadow)" },113    ],114    {115      duration: 720,116      easing: "cubic-bezier(0.4, 0, 0.2, 1)",117      fill: "none",118    }119  );120  shellRingAnimation = animation;121  const clearGlow = () => {122    if (shellRingAnimation !== animation) return;123    shellRingAnimation = null;124    delete shell.dataset.glowing;125  };126  animation.onfinish = clearGlow;127  animation.oncancel = clearGlow;128}129130131export function triggerPipPulse() {132  const pip = document.querySelector(".peach-status-pip, .pip");133  if (pip) {134    pip.classList.remove("pulse");135    void (pip as HTMLElement).offsetWidth;136    pip.classList.add("pulse");137  }138}139140const ENTRY_TRANSITION_SETTLE_MS = 135;141142export default function App() {143  const [bootState, setBootState] = createSignal<BootState>("loading");144  const [view, setView] = createSignal<View>("lock");145  // Mobile bottom-sheet live drag offset in px (null = not actively dragging,146  // CSS transition takes over for snap animation).147  const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);148  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(149    "enter"150  );151  const [contentTransitioning, setContentTransitioning] = createSignal(false);152  const [hasVault, setHasVault] = createSignal(false);153  const [onboarded, setOnboarded] = createSignal(true);154  const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);155  // Onboarding remounts whenever it leaves (create/adopt/restore) and comes156  // back. Those flows all exit from the final card, so resume there — a fresh157  // install's first mount stays at 0.158  const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);159  const [entries, setEntries] = createSignal<VaultEntry[]>([]);160  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);161  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);162163  // Rare device-trust alerts stay visible; routine entry sync is silent.164  interface SyncAlert {165    type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";166    entryId: string;167    entryTitle: string;168    deviceName: string;169    timestamp: number;170  }171  const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);172  // Per-group flip index: which alert within a type-group is currently shown.173  const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});174  async function loadSyncAlerts() {175    const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {176      ok: boolean;177      data?: SyncAlert[];178    };179    if (r.ok && (r.data?.length ?? 0) > 0) {180      setSyncAlerts(r.data ?? []);181      // Acknowledge so a crash between read and render doesn't lose them —182      // they remain on screen until the user dismisses each.183      void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });184    }185  }186  function dismissAlertGroup(type: string) {187    setSyncAlerts((arr) => arr.filter((a) => a.type !== type));188    setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });189  }190  function flipAlert(type: string, dir: 1 | -1) {191    const group = syncAlerts().filter((a) => a.type === type);192    if (group.length <= 1) return;193    setAlertFlipIdx((m) => {194      const cur = m[type] ?? 0;195      return { ...m, [type]: (cur + dir + group.length) % group.length };196    });197  }198  /** Grouped alert types in display order, each with its alerts. */199  const alertGroups = createMemo(() => {200    const map = new Map<string, SyncAlert[]>();201    for (const a of syncAlerts()) {202      const g = map.get(a.type);203      if (g) g.push(a); else map.set(a.type, [a]);204    }205    return [...map.entries()];206  });207  function syncAlertText(a: SyncAlert): string {208    if (a.type === "DEVICE_REVOKED") {209      return `${a.deviceName} revoked a paired device — review your devices in Settings`;210    }211    return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;212  }213  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");214  const [theme, setTheme] = createSignal<"dark" | "light">("dark");215  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");216  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);217  const [lockOnClose, setLockOnClose] = createSignal(false);218  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);219  const [breachMonitoring, setBreachMonitoring] = createSignal(true);220  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);221  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);222  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);223  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);224  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);225  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");226  const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");227  // Cached Pro status — synchronous for render paths. Background's228  // GET_LICENSE is async, but render-time reads of isPro() must not block.229  // Refreshed at boot, after activate/deactivate, and whenever the popup230  // re-opens onto a stored license that has changed via another window.231  const [proStatus, setProStatus] = createSignal(false);232  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);233  // When the sync card's pairing button opens the pairing view, deep-link234  // straight into that pairing path instead of the rest screen.235  const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");236  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);237  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");238  const [listSearchSeed, setListSearchSeed] = createSignal("");239  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);240  const [pendingCelebration, setPendingCelebration] = createSignal(false);241  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);242  // True while Panic Step 1 has bounced the user out to Export. When Export's243  // onBack fires we return to Panic instead of Settings so the user keeps the244  // destruction context they came from.245  const [returnToPanic, setReturnToPanic] = createSignal(false);246  // The view to return to when the add/edit view is dismissed. Normally "entry"247  // but "health" when the user arrives via the fix-entry path from Vault Health.248  const [editBackView, setEditBackView] = createSignal<View>("entry");249  const [settingsExiting, setSettingsExiting] = createSignal(false);250  const [showNotes, setShowNotes] = createSignal(false);251  // Topbar sync affordance. Always present: gating it on having a paired device252  // meant it never appeared for the person trying to set pairing up, which is253  // exactly when you reach for it.254  const [syncBusy, setSyncBusy] = createSignal(false);255  const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);256  // The indicator light + its spoken-word equivalent. Colour alone is not the257  // signal; the title/aria-label always carries the same claim in English.258  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});259  const [pairedCount, setPairedCount] = createSignal(0);260  const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);261  const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);262  let initialSyncWaitGeneration = 0;263  const syncLight = () =>264    syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());265  const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());266  async function loadSyncIndicator() {267    const [stateRes, devicesRes] = await Promise.all([268      sendMessage({ type: "GET_SYNC_STATE" }),269      sendMessage({ type: "LIST_PAIRED_DEVICES" }),270    ]);271    const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };272    if (s.ok && s.data) setSyncSummary(s.data);273    const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };274    if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);275  }276277  interface InitialSyncPairedDevice extends InitialSyncDevice {278    pairedAt?: number;279  }280281  async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {282    const response = (await sendMessage({283      type: "LIST_PAIRED_DEVICES",284    })) as {285      ok: boolean;286      data?: { devices?: InitialSyncPairedDevice[] };287    };288    return response.ok ? response.data?.devices ?? [] : [];289  }290291  async function resolveInitialSyncDeviceId(): Promise<string | null> {292    const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(293      () => null,294    );295    const devices = await listInitialSyncDevices().catch(() => []);296    if (stored && devices.some((device) => device.deviceId === stored)) {297      return stored;298    }299300    // Fresh adoption has exactly one direct source, but choosing the newest row301    // also repairs the marker if storage failed after the pair itself committed.302    const newest = [...devices].sort(303      (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),304    )[0];305    if (!newest) return stored;306    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(307      () => undefined,308    );309    return newest.deviceId;310  }311312  async function finishInitialSync(generation: number): Promise<void> {313    if (generation !== initialSyncWaitGeneration) return;314    await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);315    setInitialSyncDeviceId(null);316    setInitialSyncSlow(false);317    await refreshProStatus();318319    // A license transferred during pairing intentionally leaves the one-shot320    // celebration pending. Consume it only now: sync readiness → celebration321    // → populated vault, never celebration → temporarily empty vault.322    const celebration = (await sendMessage({323      type: "SHOULD_PLAY_CELEBRATION",324    })) as { ok: boolean; data?: boolean };325    const shouldCelebrate = celebration.ok && celebration.data === true;326    if (shouldCelebrate) {327      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });328    }329330    const opened = await openVault(undefined, shouldCelebrate);331    if (!opened || generation !== initialSyncWaitGeneration) return;332    if (shouldCelebrate) {333      setView("pro-celebration");334      window.setTimeout(() => {335        if (generation === initialSyncWaitGeneration) setView("entry");336      }, 2500);337    }338  }339340  async function beginInitialSyncWait(deviceId: string): Promise<void> {341    const generation = ++initialSyncWaitGeneration;342    setInitialSyncDeviceId(deviceId);343    setInitialSyncSlow(false);344    setView("initial-sync");345    const startedAt = Date.now();346347    // Best effort: the pairing write already wakes the mesh listener, while348    // this trigger covers the key-order case where this receiver is the dialer.349    void sendMessage({350      type: "TRIGGER_SYNC",351      payload: { reason: "foreground" },352    }).catch(() => undefined);353354    while (355      generation === initialSyncWaitGeneration &&356      view() === "initial-sync"357    ) {358      try {359        const devices = await listInitialSyncDevices();360        if (hasCompletedInitialSync(devices, deviceId)) {361          await finishInitialSync(generation);362          return;363        }364      } catch {365        // A sleeping MV3 worker may miss one poll. The next poll wakes it again.366      }367      if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {368        setInitialSyncSlow(true);369      }370      await new Promise((resolve) =>371        window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),372      );373    }374  }375376  async function retryInitialSync(): Promise<void> {377    const deviceId =378      initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());379    if (deviceId) {380      await beginInitialSyncWait(deviceId);381    } else {382      setInitialSyncSlow(true);383    }384  }385386  const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);387  // Set on popup boot when peach_recovery_pending flag is found in session388  // storage — instructs LockView to skip the unlock UI and open the recovery389  // words confirmation surface instead. Cleared via handleUnlock once the390  // user has typed the 3 confirmation words.391  const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);392393  let contentPhaseTimer: number | undefined;394  // Set when the popup was opened from the inline "Open Peach to unlock" button.395  // handleUnlock() closes the popup after unlock so the user returns to the page.396  let openedForInlineUnlock = false;397398  onCleanup(() => {399    initialSyncWaitGeneration += 1;400  });401402  function applyAccent(name: AccentColorName) {403    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;404    const hex = color.hex;405    document.documentElement.style.setProperty("--peach-accent", hex);406    // Ambient surface variables — derived from the accent so surfaces shift407    // subtly toward the accent hue rather than staying fixed warm-brown.408    document.documentElement.style.setProperty("--peach-glow", hex + "2A");409    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");410    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");411  }412413  createEffect(() => {414    applyAccent(accentColor());415  });416417  createEffect(() => {418    document.documentElement.classList.toggle("light", theme() === "light");419    document.body.classList.toggle("light", theme() === "light");420  });421422  createEffect(() => {423    document.documentElement.setAttribute("data-text-size", textSize());424  });425426  createEffect(() => {427    if (reduceMotion()) {428      document.documentElement.setAttribute("data-motion", "reduce");429    } else {430      document.documentElement.removeAttribute("data-motion");431    }432  });433434  // Notes are not a destination of the Entry view; selecting a note opens the435  // notes surface instead (handleSelectEntry). Anything that walks or counts436  // the Entry view's neighbours (counter, prev/next, index) must use this437  // filtered list, otherwise the user can arrow into a note and is stuck —438  // the Entry view can't render a note and the prev/next buttons stop moving.439  const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));440441  const selectedIndex = createMemo(() => {442    const currentId = selectedEntry()?.id;443    if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;444    return nonNoteEntries().findIndex((entry) => entry.id === currentId);445  });446447  async function applyAccentColor(name: AccentColorName) {448    applyAccent(name);449    setAccentColor(name);450    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });451  }452453  async function applyTheme(t: "dark" | "light") {454    setTheme(t);455    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });456  }457458  async function applyTextSize(size: "small" | "medium" | "large") {459    setTextSize(size);460    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });461  }462463  async function applyReduceMotion(enabled: boolean) {464    setReduceMotion(enabled);465    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });466  }467468  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload469  // small and lets future default updates land for actions the user hasn't470  // touched. Settings sends us the full active map; we filter and store.471  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {472    setShortcutBindings(active);473    const overrides: Record<string, Keybinding> = {};474    for (const action of Object.keys(active) as ShortcutAction[]) {475      const a = active[action];476      const d = DEFAULT_BINDINGS[action];477      const same = a.key === d.key478        && Boolean(a.ctrl)  === Boolean(d.ctrl)479        && Boolean(a.meta)  === Boolean(d.meta)480        && Boolean(a.shift) === Boolean(d.shift)481        && Boolean(a.alt)   === Boolean(d.alt);482      if (!same) overrides[action] = a;483    }484    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });485  }486487  async function applyLockOnClose(enabled: boolean) {488    setLockOnClose(enabled);489    await sendMessage({490      type: "UPDATE_SETTINGS",491      payload: { lockOnClose: enabled },492    });493  }494495  async function applyAutoLockMinutes(minutes: number) {496    setAutoLockMinutes(minutes);497    await sendMessage({498      type: "UPDATE_SETTINGS",499      payload: { autoLockMinutes: minutes },500    });501  }502503  async function applyBreachMonitoring(enabled: boolean) {504    setBreachMonitoring(enabled);505    await sendMessage({506      type: "UPDATE_SETTINGS",507      payload: { breachMonitoring: enabled },508    });509  }510511  // Which onboarding exit flow the user is in (create-vault / restore /512  // adopt). Persisted so closing the popup on one of those screens resumes513  // it directly instead of rewinding to the last carousel card.514  function markOnboardingExit(flow: "create" | "restore" | "adopt") {515    setOnboardingCardAtExit(ONBOARDING_LAST_CARD);516    void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});517    // Backing out of the flow remounts the carousel at this card; keep the518    // persisted card in sync so a later popup close resumes consistently.519    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});520  }521  function clearOnboardingExit() {522    void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});523  }524525  onMount(async () => {526    // Resolve the active tab in parallel with the unlock/has-vault queries —527    // openVault()'s smart selection picks the entry matching currentTabDomain,528    // so it must be populated before openVault runs. Previously refreshCurrentTab529    // ran after openVault, leaving popups that booted already-unlocked falling530    // back to loadedEntries[0] instead of the domain match.531    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([532      sendMessage({ type: "IS_UNLOCKED" }),533      sendMessage({ type: "HAS_VAULT" }),534      refreshCurrentTab(),535      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),536    ]);537    const unlocked = Boolean(538      (unlockResponse as { ok: boolean; data?: boolean }).data539    );540    const vaultExists = Boolean(541      (vaultResponse as { ok: boolean; data?: boolean }).data542    );543    const shouldCelebrate = Boolean(544      (celebrationResponse as { ok: boolean; data?: boolean }).data545    );546    setHasVault(vaultExists);547    // Onboarding gate — show once on first install, never again. Existing548    // users (vaultExists) are auto-marked onboarded so they skip the flow549    // even on the very first popup open after this feature ships.550    try {551      const onboarded = await storageGet("peach_onboarded");552      const seen = onboarded === "true";553      if (vaultExists && !seen) {554        await storageSet("peach_onboarded", "true");555        await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});556        await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});557        setOnboarded(true);558      } else {559        setOnboarded(seen);560        if (!seen) {561          // A popup close while the user is in an exit flow (create-vault /562          // restore / adopt) resumes that flow directly — landing back on the563          // carousel one screen earlier reads as losing progress.564          const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);565          if (exit === "create" || exit === "restore" || exit === "adopt") {566            setOnboardingCardAtExit(ONBOARDING_LAST_CARD);567            setOnboarded(true);568            setStartOnboardingRestore(exit === "restore");569            if (exit === "adopt") setView("adopting");570          } else {571            // Otherwise resume mid-onboarding where the user left off instead572            // of rewinding to the opening card.573            const stored = Number(574              await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),575            );576            if (577              Number.isInteger(stored) &&578              stored > 0 &&579              stored <= ONBOARDING_LAST_CARD580            ) {581              setOnboardingCardAtExit(stored);582            }583          }584        }585      }586    } catch {587      // storage unavailable — default to onboarded so we never block boot588      setOnboarded(true);589    }590    await Promise.all([restoreSettings(), refreshProStatus()]);591592    const pendingInitialSyncMarker =593      unlocked && vaultExists594        ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)595        : null;596    const pendingInitialSyncDeviceId = pendingInitialSyncMarker597      ? await resolveInitialSyncDeviceId()598      : null;599600    // Mark played before any animation so a crash mid-sequence never replays601    // it. A newly adopted receiver deliberately defers this until its first602    // real sync finishes, preserving initial sync → celebration → vault.603    if (shouldCelebrate && !pendingInitialSyncDeviceId) {604      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });605    }606607    // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome608    // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.609    if (unlocked && vaultExists) {610      void sendMessage({611        type: "TRIGGER_SYNC",612        payload: { reason: "foreground" },613      });614      void loadSyncIndicator();615      // Rare device-trust alerts only; routine entry sync stays quiet.616      void loadSyncAlerts();617    }618619    // If the user created a vault but hasn't yet confirmed their recovery620    // words, route back to the recovery-words screen instead of opening the621    // entries view. The flag lives in chrome.storage.local so it survives622    // both popup close (SW suppresses lock-on-close) and full browser623    // restart (handleUnlock checks the same flag after a fresh password624    // unlock). Cleared when confirmation succeeds.625    let recoveryPending = false;626    try {627      const out = await chrome.storage.local?.get("peach_recovery_pending");628      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";629    } catch { /* best-effort */ }630631    // If this popup was opened from the inline "Open Peach to unlock" button,632    // the background set a session flag. Read and clear it so handleUnlock()633    // can close the popup after unlock and return the user to the page.634    if (chrome.storage.session) {635      try {636        const s = await chrome.storage.session.get("peach_inline_unlock");637        if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {638          openedForInlineUnlock = true;639          await chrome.storage.session.remove("peach_inline_unlock");640        }641      } catch { /* best-effort */ }642    }643644    // Native: closing the app before recovery-words confirmation means the645    // user didn't finish vault creation. Throw the partial vault away and646    // restart onboarding rather than asking them to unlock a vault they647    // never finished creating. Desktop keeps the existing "unlock to648    // resume" flow because closing the popup there is much more casual.649    if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {650      try {651        await chrome.storage.local?.remove([652          "peach_vault_a",653          "peach_vault_b",654          "peach_recovery_pending",655          "peach_onboarded",656          "peach_vault_history",657          ONBOARDING_CARD_STORAGE_KEY,658          ONBOARDING_EXIT_STORAGE_KEY,659        ]);660      } catch { /* best-effort */ }661      setHasVault(false);662      setOnboarded(false);663      setView("lock");664      setBootState("ready");665      return;666    }667668    if (recoveryPending && unlocked) {669      // forceRecoveryWords overrides LockView's hasVault gate so the670      // recovery surface renders even though the vault now exists.671      setForceRecoveryWords(true);672      setView("lock");673    } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {674      void beginInitialSyncWait(pendingInitialSyncDeviceId);675    } else if (unlocked && vaultExists) {676      await openVault(undefined, shouldCelebrate);677      if (shouldCelebrate) {678        setView("pro-celebration");679        window.setTimeout(() => setView("entry"), 2500);680      }681    } else if (shouldCelebrate) {682      setPendingCelebration(true);683    }684685    await refreshPendingPasskey();686687    // Track tab changes so smart sort stays current688    const handleTabActivated = () => { void refreshCurrentTab(); };689    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {690      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();691    };692    try {693      chrome.tabs.onActivated.addListener(handleTabActivated);694      chrome.tabs.onUpdated.addListener(handleTabUpdated);695      onCleanup(() => {696        chrome.tabs.onActivated.removeListener(handleTabActivated);697        chrome.tabs.onUpdated.removeListener(handleTabUpdated);698      });699    } catch {700      // tabs permission may not be available in all contexts701    }702703    setBootState("ready");704705    // Deep-link: ?codex=pdf opens a detached window straight into the706    // Codex restore flow with auto-picked PDF. The action popup can't host707    // a file dialog without dismissing itself, so this is the only way the708    // PDF import path survives. See CodexRestoreView.openPdfPicker().709    try {710      const route = new URLSearchParams(window.location.search).get("codex");711      if (route === "pdf" || route === "restore") {712        setView("codex-restore");713      }714    } catch {715      // location.search unavailable — ignore.716    }717718    // Check for pending session-storage notices from the background.719    if (chrome.storage.session) {720      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as721        Record<string, { feature?: string } | undefined>;722      if (session.pendingProGate?.feature) {723        await chrome.storage.session.remove("pendingProGate");724        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);725        setSettingsInitialSection("license");726        setView("settings");727      } else if (session.pendingProNotice?.feature) {728        await chrome.storage.session.remove("pendingProNotice");729        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });730      }731    }732733    // Warm the lazy-module cache so the Suspense fallback never shows for734    // locally-bundled extension views. Without this, the first navigation to735    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which736    // covers the shell and absorbs clicks on the header.737    void Promise.all([738      import("./views/Settings").catch(() => {}),739      import("./views/Import").catch(() => {}),740      import("./views/Export").catch(() => {}),741      import("./views/Health").catch(() => {}),742      import("./views/TrashView").catch(() => {}),743    ]);744745    // Lock-on-close via a persistent port. sendMessage in pagehide is746    // unreliable — the popup context tears down before the message lands.747    // The SW's onConnect listener locks when this port disconnects.748    let popupPort: chrome.runtime.Port | null = null;749    try {750      // UI-6 / LV-5: nonce the port name per popup open. The SW's onConnect751      // listener accepts the "popup-" prefix; the random suffix is defence in752      // depth against a port named exactly "popup" from anything but our popup.753      popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });754    } catch { /* extension context invalidated — ignore */ }755    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });756757    // Android autolock. chrome.alarms is no-op'd on Capacitor, so the only758    // reliable signal is appStateChange — setTimeout is killed when the OS759    // backgrounds the app. We measure elapsed background time on resume.760    if (isNativePlatform()) {761      let backgroundedAt: number | null = null;762763      const CapApp = nativePlugin<{764        addListener: (765          ev: string,766          cb: (s: { isActive: boolean }) => void,767        ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };768      }>("App");769      if (CapApp?.addListener) {770        const sub = CapApp.addListener(771          "appStateChange",772          ({ isActive }: { isActive: boolean }) => {773            if (!isActive) {774              backgroundedAt = Date.now();775              return;776            }777            if (backgroundedAt !== null && autoLockMinutes() > 0) {778              const elapsed = Date.now() - backgroundedAt;779              if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();780            }781            backgroundedAt = null;782          }783        );784        // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.785        onCleanup(() => {786          Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});787        });788      }789790      // L12/COR-011: foreground idle autolock. appStateChange only fires when791      // the OS backgrounds the app — if the user idles with the screen on,792      // chrome.alarms is no-op'd in Capacitor so the vault would stay open793      // indefinitely. Poll every 30s and lock if elapsed since last user794      // interaction exceeds autoLockMinutes.795      let lastUserActivityAt = Date.now();796      const bumpActivity = () => { lastUserActivityAt = Date.now(); };797      const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;798      for (const ev of ACTIVITY_EVENTS) {799        document.addEventListener(ev, bumpActivity, { passive: true, capture: true });800      }801      const idleLockInterval = setInterval(() => {802        const minutes = autoLockMinutes();803        if (!minutes || minutes <= 0) return;804        const elapsed = Date.now() - lastUserActivityAt;805        if (elapsed >= minutes * 60_000) {806          lastUserActivityAt = Date.now();807          handleHeaderLock();808        }809      }, 30_000);810      onCleanup(() => {811        clearInterval(idleLockInterval);812        for (const ev of ACTIVITY_EVENTS) {813          document.removeEventListener(ev, bumpActivity, { capture: true });814        }815      });816817      // The retired native peer-discovery trigger went with the old Capacitor818      // pairing plugin. Reconnection is address-cache first, subnet probe second819      // (sync-runner.ts); relocating a peer that changed networks is an820      // Android-side discovery concern now.821      if (isNativePlatform()) {822        // Android runs the background/sync dispatcher in-process with the823        // popup, so the production runner has to be registered from the824        // UI bootstrap path instead of the MV3 service-worker entry.825        void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>826          import("../background/sync-runner").then(({ productionSyncRunner }) => {827            setSyncRunner(productionSyncRunner);828            void hydratePersistedSyncState();829          })830        );831      }832    }833834    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale835    // entries instead of waiting for the next GET_ENTRIES round-trip.836    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {837      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();838      if (msg?.type === "SYNC_STATE_CHANGED") {839        setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);840      }841      if (msg?.type === "PRO_GATE_TRIGGERED") {842        setUpgradeHighlight(msg.payload?.feature ?? null);843        setSettingsInitialSection("license");844        setView("settings");845      }846    };847    chrome.runtime.onMessage.addListener(handleRuntimeMessage);848    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));849850    // Android hardware/gesture back. MainActivity fires peach:back; the popup851    // decides what "back" means per surface. Lock+no-vault is the common case852    // — return to onboarding rather than letting the OS dismiss the activity.853    const handlePeachBack = () => {854      // Notes mode captures back first855      if (showNotes()) {856        setShowNotes(false);857        setOpenNoteId(undefined);858        return;859      }860      const v = view();861      if (v === "lock" && !hasVault() && onboarded()) {862        setOnboarded(false);863        return;864      }865      if (v === "list") { setView("entry"); return; }866      if (v === "settings") {867        window.dispatchEvent(new Event("peach:settings-back"));868        return;869      }870      if (v === "import" || v === "health") { setView("settings"); return; }871      if (v === "export") { handleExportBack(); return; }872      if (v === "add") { handleBack(); return; }873      if (v === "pair-device") { setView("settings"); return; }874    };875    window.addEventListener("peach:back", handlePeachBack);876    onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));877878    // Mobile bottom-sheet drag tracking. The vault list sits as a sheet that879    // peeks from the bottom (search bar handle visible) and drags up to fill880    // the screen. dragOffset is the LIVE finger offset during a drag, in px,881    // 0 when no drag in progress. The wrapper element computes transform =882    // (rest position) + dragOffset, so the user sees 1:1 finger tracking.883    if (isNativePlatform()) {884      let touchStartY = 0;885      let touchStartX = 0;886      let lastY = 0;887      let lastT = 0;888      let dragging = false;889      let captured = false;890      const HORIZONTAL_TOLERANCE = 16;891      const ACTIVATION_DY = 8;892      const PEEK_PX = 60;893894      const onTouchStart = (e: TouchEvent) => {895        const t = e.touches[0];896        if (!t) return;897        const v = view();898        if (v !== "entry" && v !== "list") return;899        // Entry view: only intercept drags starting in the bottom 40%.900        // List view: intercept drags starting in the top 35% (the search/handle row).901        const inBand = v === "entry"902          ? t.clientY > window.innerHeight * 0.6903          : t.clientY < window.innerHeight * 0.35;904        if (!inBand) return;905        touchStartY = lastY = t.clientY;906        touchStartX = t.clientX;907        lastT = e.timeStamp;908        dragging = true;909        captured = false;910      };911912      const onTouchMove = (e: TouchEvent) => {913        if (!dragging) return;914        const t = e.touches[0];915        if (!t) return;916        const dy = t.clientY - touchStartY;917        const dx = Math.abs(t.clientX - touchStartX);918919        if (!captured) {920          if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {921            // Looks like a horizontal swipe — release.922            dragging = false;923            return;924          }925          if (Math.abs(dy) < ACTIVATION_DY) return;926          captured = true;927        }928929        // Translate the sheet by dy from its rest (peek) position. Peek is930        // (viewport height - PEEK_PX). Open is 0.931        const v = view();932        const restClosed = window.innerHeight - PEEK_PX;933        const baseTranslate = v === "list" ? 0 : restClosed;934        const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));935        setSheetTranslate(next);936        lastY = t.clientY;937        lastT = e.timeStamp;938      };939940      const onTouchEnd = (e: TouchEvent) => {941        if (!dragging) { return; }942        const t = e.changedTouches[0];943        const elapsed = Math.max(1, e.timeStamp - lastT);944        const velocity = t ? (t.clientY - lastY) / elapsed : 0; // px/ms945        const wasCaptured = captured;946        dragging = false;947        captured = false;948        const v = view();949950        if (!wasCaptured) {951          // Tap in the peek strip while in entry view → open the vault list.952          if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");953          setSheetTranslate(null);954          return;955        }956957        const restClosed = window.innerHeight - PEEK_PX;958        const current = sheetTranslate() ?? restClosed;959960        // Snap rules:961        //   - Strong upward velocity → open962        //   - Strong downward velocity → close963        //   - Otherwise snap to whichever rest is closer964        let nextOpen: boolean;965        if (velocity < -0.05) nextOpen = true;966        else if (velocity > 0.05) nextOpen = false;967        else nextOpen = current < restClosed * 0.5;968969        if (nextOpen && v !== "list") setView("list");970        if (!nextOpen && v !== "entry") setView("entry");971        // Release the live offset so CSS transition handles the snap.972        setSheetTranslate(null);973      };974975      document.addEventListener("touchstart", onTouchStart, { passive: true });976      document.addEventListener("touchmove",  onTouchMove,  { passive: true });977      document.addEventListener("touchend",   onTouchEnd,   { passive: true });978      document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });979      onCleanup(() => {980        document.removeEventListener("touchstart", onTouchStart);981        document.removeEventListener("touchmove",  onTouchMove);982        document.removeEventListener("touchend",   onTouchEnd);983      });984    }985986    // Action handlers — keyed by ShortcutAction. The dispatcher below tests987    // each binding against the event and runs the matching handler. New988    // shortcuts only need an entry here; the binding is data, not code.989    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {990      copy_password: () => {991        const entry = selectedEntry();992        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });993      },994      copy_username: () => {995        const entry = selectedEntry();996        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });997      },998      copy_totp: () => {999        const entry = selectedEntry();1000        if (!entry?.totp) return;1001        try {1002          const { code } = generateTOTP(entry.totp);1003          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });1004        } catch { /* invalid TOTP secret — silent */ }1005      },1006      new_entry: () => handleAdd(),1007      edit_entry: () => {1008        const entry = selectedEntry();1009        // Passkey-only entries have nothing user-editable in the Add view; the1010        // passkey credential itself is opaque. Skip the edit action for them.1011        if (!entry || isPasskeyOnly(entry)) return;1012        setEditEntry(entry);1013        setView("add");1014      },1015      delete_entry: () => {1016        if (selectedEntry()) void handleDelete();1017      },1018      lock_vault: () => { void handleHeaderLock(); },1019      search: () => openList(),1020      nav_prev: () => navigate(-1),1021      nav_next: () => navigate(1),1022      jump_vault: () => setView("entry"),1023      jump_settings: () => setView("settings"),1024      show_help: () => setShowShortcutsHelp(true),1025    };10261027    // Actions that are MEANINGFUL only on the entry-detail view. Outside1028    // that scope the binding is dormant — pressing C while on Settings1029    // does nothing rather than awkwardly copying from a now-stale entry.1030    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([1031      "copy_password", "copy_username", "copy_totp",1032      "edit_entry", "delete_entry", "nav_prev", "nav_next",1033    ]);10341035    const handleGlobalKeydown = (e: KeyboardEvent) => {1036      const v = view();1037      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"1038        || (e.target as HTMLElement)?.tagName === "TEXTAREA"1039        || (e.target as HTMLElement)?.isContentEditable;1040      const isPrintableSearchKey =1041        e.key.length === 1 &&1042        !e.ctrlKey &&1043        !e.metaKey &&1044        !e.altKey;10451046      // No shortcuts while a full-screen surface owns the whole viewport:1047      // the lock screen, the post-pairing readiness sequence, or the Pro1048      // celebration. Without this the lock screen is at the mercy of whatever1049      // single-key binding is active — e.g. typing "n" in a password that1050      // slips out of focus triggers `new_entry` and escapes the lock screen.1051      if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;10521053      // Help dialog: Escape closes it; eat the keypress so it doesn't1054      // also navigate away from the underlying view.1055      if (showShortcutsHelp()) {1056        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }1057        return;1058      }10591060      // ── Escape — fixed binding (not user-customizable) ───────1061      if (e.key === "Escape") {1062        if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }1063        if (v === "list") { setView("entry"); return; }1064        if (v === "settings" || v === "add") { handleBack(); return; }1065        if (v === "import" || v === "health") { setView("settings"); return; }1066        if (v === "export") { handleExportBack(); return; }1067        if (v === "pair-device") { setView("settings"); return; }1068      }10691070      // Bindings-driven dispatch. Single-key letter bindings without1071      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is1072      // inside an input, so users can type into search/forms normally.1073      const bindings = shortcutBindings();1074      for (const action of Object.keys(bindings) as ShortcutAction[]) {1075        const binding = bindings[action];1076        if (!matchKeybinding(e, binding)) continue;1077        // Single-key letter bindings need the input-focus guard. Bindings1078        // with any modifier are safe — Ctrl+L doesn't conflict with typing.1079        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;1080        if (!hasMod && binding.key.length === 1 && inInput) return;1081        if (ENTRY_SCOPED.has(action) && v !== "entry") return;1082        e.preventDefault();1083        SHORTCUT_HANDLERS[action]?.();1084        return;1085      }10861087      // Type-to-search: in entry view, any printable key not consumed by a1088      // shortcut above primes the list search seed and opens the list.1089      // Kept outside the bindings table because it's a behavior, not a1090      // discrete action — every printable character can trigger it.1091      if (v === "entry" && !inInput && isPrintableSearchKey) {1092        e.preventDefault();1093        primeListSearch(e.key);1094        return;1095      }1096    };1097    window.addEventListener("keydown", handleGlobalKeydown);1098    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));10991100    const handleStorageChanged = (1101      changes: Record<string, chrome.storage.StorageChange>,1102      areaName: string1103    ) => {1104      if (areaName !== "session") return;1105      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {1106        return;1107      }1108      void refreshPendingPasskey();1109    };1110    chrome.storage.onChanged.addListener(handleStorageChanged);1111    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));1112  });11131114  async function refreshCurrentTab() {1115    try {1116      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });1117      const url = tab?.url ?? tab?.pendingUrl ?? "";1118      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");1119      // Skip extension/internal URLs — adding chrome://, about:, or1120      // chrome-extension:// to a vault entry is never what the user wants.1121      const isWebUrl = /^https?:\/\//i.test(url);1122      setCurrentTabUrl(isWebUrl ? url : "");1123    } catch {1124      setCurrentTabDomain("");1125      setCurrentTabUrl("");1126    }1127  }11281129  async function refreshPendingPasskey() {1130    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });1131    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);1132  }11331134  async function refreshProStatus() {1135    const res = await sendMessage({ type: "GET_LICENSE" });1136    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;1137    setProStatus(Boolean(data?.valid && data?.plan === "pro"));1138  }11391140  async function restoreSettings() {1141    const res = await sendMessage({ type: "GET_SETTINGS" });1142    const s = (res as { ok: boolean; data?: any }).data ?? {};1143    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {1144      setAccentColor(s.accentColor as AccentColorName);1145    }1146    setTheme(s.theme === "light" ? "light" : "dark");1147    if (s.textSize === "small" || s.textSize === "large") {1148      setTextSize(s.textSize);1149    } else {1150      setTextSize("medium");1151    }1152    setReduceMotion(s.reduceMotion === true);1153    setLockOnClose(s.lockOnClose === true);1154    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);1155    setBreachMonitoring(s.breachMonitoring !== false);1156    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);1157    setShortcutBindings(getActiveBindings(s.shortcuts));1158  }11591160  async function applyTrashRetention(days: number) {1161    setTrashRetentionDays(days);1162    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1163  }11641165  function primeListSearch(seed: string) {1166    setListSearchSeed(seed);1167    setListSearchSeedNonce((value) => value + 1);1168    openList();1169  }11701171  // Returns null when it detected a dead service worker and triggered handleLock().1172  // Callers that need to abort (e.g. openVault) check for null rather than relying1173  // on view() state, which may already be "lock" during normal startup flows.1174  async function loadEntries(): Promise<VaultEntry[] | null> {1175    const response = await sendMessage({ type: "GET_ENTRIES" });1176    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };11771178    // If the service worker was killed and restarted (MV3 workers sleep), the1179    // vault will be locked again — or the worker may not respond at all.1180    // In either case, send the user to the lock screen rather than silently1181    // showing an empty vault (which looks like data loss).1182    if (!res.ok) {1183      if (1184        res.error === "vault_locked" ||1185        res.error === "SERVICE_WORKER_UNAVAILABLE" ||1186        (typeof res.error === "string" && res.error.includes("locked"))1187      ) {1188        handleLock();1189        return null; // signal emergency lock to callers1190      }1191      return [];1192    }11931194    const loadedEntries = res.data ?? [];1195    setEntries(loadedEntries);11961197    const currentId = selectedEntry()?.id;1198    if (currentId) {1199      setSelectedEntry(1200        loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1201      );1202    }12031204    return loadedEntries;1205  }12061207  async function openVault(preferredEntryId?: string, skipViewSet = false) {1208    // Purge expired trash entries on each vault open1209    const retDays = trashRetentionDays();1210    if (retDays > 0) {1211      void sendMessage({1212        type: "PURGE_EXPIRED_TRASH",1213        payload: { retentionMs: retDays * 86_400_000 },1214      });1215    }12161217    const loadedEntries = await loadEntries();12181219    // null means loadEntries() triggered an emergency lock (service worker died).1220    // Don't override it by setting view back to "entry" with an empty vault.1221    if (loadedEntries === null) return false;12221223    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry1224    let nextSelectedEntry: VaultEntry | null = null;1225    if (preferredEntryId) {1226      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1227    }1228    if (!nextSelectedEntry) {1229      const tabDomain = currentTabDomain();1230      if (tabDomain) {1231        nextSelectedEntry = loadedEntries.find((e) => {1232          if (e.type === "login") {1233            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1234            for (const extra of e.additionalUrls ?? []) {1235              if ((extractDomain(extra) ?? "") === tabDomain) return true;1236            }1237            return false;1238          }1239          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1240          return false;1241        }) ?? null;1242      }1243    }1244    nextSelectedEntry ??= pickNavigable(loadedEntries);12451246    setSelectedEntry(nextSelectedEntry);1247    setEditEntry(undefined);1248    setContentTransitioning(false);1249    setContentPhase("enter");1250    queueContentIdle();1251    if (!skipViewSet) setView("entry");1252    return true;1253  }12541255  async function handleUnlock() {1256    // If the user previously created a vault but hadn't yet confirmed their1257    // recovery words (and the browser was closed before they could), the1258    // peach_recovery_pending flag is still set in storage.local. Re-route1259    // back to the recovery surface so they finish the confirmation step1260    // before reaching the entries view — the words can be re-derived now1261    // that the vault is unlocked again.1262    let recoveryPending = false;1263    try {1264      const out = await chrome.storage.local?.get("peach_recovery_pending");1265      recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1266    } catch { /* best-effort */ }1267    if (recoveryPending) {1268      setForceRecoveryWords(true);1269      setView("lock");1270      triggerPipPulse();1271      await restoreSettings();1272      return;1273    }1274    setHasVault(true);1275    triggerPipPulse();1276    await restoreSettings();1277    const celebrating = pendingCelebration();1278    if (celebrating) {1279      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1280      setPendingCelebration(false);1281    }1282    await openVault(undefined, celebrating);1283    if (celebrating) {1284      setView("pro-celebration");1285      window.setTimeout(() => setView("entry"), 2500);1286    }1287    // Popup was opened from the inline "Open Peach to unlock" button — close it1288    // now so the user lands back on the page with the autofill selector ready.1289    if (openedForInlineUnlock) {1290      openedForInlineUnlock = false;1291      window.close();1292    }1293  }12941295  function handleSelectEntry(entry: VaultEntry) {1296    if (entry.type === "note") {1297      setOpenNoteId(entry.id);1298      setShowNotes(true);1299      return;1300    }1301    setSelectedEntry(entry);1302    setContentTransitioning(false);1303    setContentPhase("enter");1304    queueContentIdle();1305    setView("entry");1306  }13071308  function handleAdd() {1309    setEditEntry(undefined);1310    setView("add");1311  }13121313  async function handleDelete() {1314    const entry = selectedEntry();1315    if (!entry) return;1316    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1317    const loadedEntries = await loadEntries() ?? [];1318    setSelectedEntry(pickNavigable(loadedEntries));1319    setView("entry");1320  }13211322  async function handleDeleteMany(ids: string[]) {1323    for (const id of ids) {1324      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1325    }1326    const loadedEntries = await loadEntries() ?? [];1327    // If the currently selected entry was among the deleted, move to first1328    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1329    if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1330  }13311332  async function handleSave(savedEntry?: VaultEntry) {1333    await openVault(savedEntry?.id);1334  }13351336  function handleBack() {1337    const dest = editBackView();1338    setEditBackView("entry");1339    setEditEntry(undefined);1340    setView(dest);1341  }13421343  function handleExportBack() {1344    if (returnToPanic()) {1345      setReturnToPanic(false);1346      setView("panic");1347      return;1348    }1349    setView("settings");1350  }13511352  function handleLock() {1353    clearCanarySuppressions();1354    setEntries([]);1355    setSelectedEntry(null);1356    setEditEntry(undefined);1357    setContentPhase("idle");1358    setView("lock");1359  }13601361  function handleHeaderLock() {1362    // Lock the UI immediately — do not wait for the service worker. If the SW1363    // is dead or slow, the 5s sendMessage timeout would create a dead-click1364    // window. The SW will lock on its next wake regardless.1365    handleLock();1366    void sendMessage({ type: "LOCK" });1367  }13681369  // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1370  // queued, so the outcome is read back from GET_SYNC_STATE rather than from1371  // the trigger's own reply.1372  async function handleHeaderSync(): Promise<void> {1373    if (syncBusy()) return;1374    setSyncBusy(true);1375    setSyncFlash(null);1376    try {1377      await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1378      let state: SyncOutcomeSummary = { status: "syncing" };1379      for (let i = 0; i < 40 && state.status === "syncing"; i++) {1380        await new Promise((r) => setTimeout(r, 250));1381        const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1382          ok: boolean;1383          data?: SyncOutcomeSummary;1384        };1385        state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1386      }1387      setSyncSummary(state);1388      setSyncFlash(state.status === "error" ? "error" : "ok");1389      await loadSyncAlerts();1390      await loadEntries();1391      await loadSyncIndicator();1392    } catch {1393      setSyncSummary({ status: "error", outcome: "run_failed" });1394      setSyncFlash("error");1395    } finally {1396      setSyncBusy(false);1397      setTimeout(() => setSyncFlash(null), 2_000);1398    }1399  }14001401  function navigate(dir: number) {1402    const navigable = nonNoteEntries();1403    if (navigable.length === 0) return;1404    const currentIndex = selectedIndex();1405    const baseIndex = currentIndex >= 0 ? currentIndex : 0;1406    const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1407    setSelectedEntry(navigable[nextIndex]);1408    setContentPhase("enter");1409    queueContentIdle();1410  }14111412  /**1413   * The Entry view's selection invariant: never select a note. Notes are a1414   * separate surface (handleSelectEntry opens the notes view), so any code path1415   * that picks a default — vault open, post-delete, refresh — must skip them1416   * and return null if the only entries are notes.1417   */1418  function pickNavigable(list: VaultEntry[]): VaultEntry | null {1419    return list.find((e) => e.type !== "note") ?? null;1420  }14211422  function openList() {1423    setView("list");1424  }14251426  function openHealth() {1427    setEditEntry(undefined);1428    setView("health");1429  }14301431  function handleOpenFileImport() {1432    setRestoreView("file");1433  }14341435  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;14361437  function handleFixEntry(entry: VaultEntry) {1438    setSelectedEntry(entry);1439    // Passkey-only entries have no editable surface — drop into view mode1440    // instead of opening the Add editor.1441    if (isPasskeyOnly(entry)) {1442      setView("entry");1443      return;1444    }1445    setEditBackView(view()); // preserve caller view so back returns here1446    setEditEntry(entry);1447    setView("add");1448  }14491450  const isLockedView = () => view() === "lock" || view() === "adopting";14511452  async function handleAdoptionDone() {1453    await storageSet("peach_onboarded", "true").catch(() => {});1454    await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1455    clearOnboardingExit();1456    setOnboarded(true);1457    setHasVault(true);1458    setView("initial-sync");1459    // The background applies a Pro license and persists the source-device1460    // readiness marker before it acknowledges adoption. Keep the one-shot Pro1461    // celebration pending until that exact peer records a completed sync.1462    await refreshProStatus();1463    const deviceId = await resolveInitialSyncDeviceId();1464    if (deviceId) {1465      void beginInitialSyncWait(deviceId);1466    } else {1467      // Pairing completion without a durable peer row should be impossible.1468      // Stay on the honest waiting surface and expose retry instead of falling1469      // through to an empty vault.1470      setInitialSyncSlow(true);1471    }1472  }14731474  async function handleApprovePendingPasskey() {1475    const pending = pendingPasskey();1476    if (!pending) return;1477    if (pending.browser === "firefox" && pending.type === "create") {1478      // Firefox path: popup itself runs navigator.credentials.create. The1479      // background never approved/rejected anything — it just stashed1480      // intent. After the popup-driven flow resolves (success or cancel),1481      // clear the pending key so the popup doesn't re-show this on next open.1482      const { createFirefoxPasskey } = await import("./firefox-passkey");1483      const result = await createFirefoxPasskey(pending);1484      if (!result.ok) {1485        console.warn("Peach Firefox passkey create failed:", result.error);1486      }1487      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1488      setPendingPasskey(null);1489      void loadEntries();1490      window.close();1491      return;1492    }1493    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1494    window.close();1495  }14961497  async function handleDenyPendingPasskey() {1498    const pending = pendingPasskey();1499    if (!pending) return;1500    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1501    window.close();1502  }15031504  function queueContentIdle(after?: () => void) {1505    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1506    contentPhaseTimer = window.setTimeout(() => {1507      setContentPhase("idle");1508      after?.();1509    }, ENTRY_TRANSITION_SETTLE_MS);1510  }15111512  return (1513    <div class="popup-root peach-theme">1514      <Show when={bootState() === "ready"}>1515        <Show1516          when={!pendingPasskey()}1517          fallback={1518            <PasskeyApprovalView1519              type={pendingPasskey()!.type}1520              rpName={pendingPasskey()!.rpName}1521              rpId={pendingPasskey()!.rpId}1522              userName={pendingPasskey()!.userName}1523              userDisplayName={pendingPasskey()!.userDisplayName}1524              onApprove={handleApprovePendingPasskey}1525              onDeny={handleDenyPendingPasskey}1526            />1527          }1528        >1529          <Show1530            when={!isLockedView()}1531            fallback={1532              <Show1533                when={view() !== "adopting"}1534                fallback={1535                  <Suspense fallback={<div class="peach-loading">loading...</div>}>1536                    <AdoptVaultView1537                      onCancel={() => {1538                        clearOnboardingExit();1539                        setOnboarded(false);1540                        setView("lock");1541                      }}1542                      onDone={handleAdoptionDone}1543                    />1544                  </Suspense>1545                }1546              >1547                <Show1548                  when={onboarded() || hasVault()}1549                  fallback={1550                    <Onboarding1551                      initialCard={onboardingCardAtExit()}1552                      onComplete={() => {1553                        markOnboardingExit("create");1554                        setStartOnboardingRestore(false);1555                        setOnboarded(true);1556                      }}1557                      onRestore={() => {1558                        markOnboardingExit("restore");1559                        setStartOnboardingRestore(true);1560                        setOnboarded(true);1561                      }}1562                      onAdopt={() => {1563                        markOnboardingExit("adopt");1564                        setStartOnboardingRestore(false);1565                        setOnboarded(true);1566                        setView("adopting");1567                      }}1568                    />1569                  }1570                >1571                  <LockView1572                    hasVault={hasVault()}1573                    ready={bootState() === "ready"}1574                    isPro={proStatus()}1575                    forceRecoveryWords={forceRecoveryWords()}1576                    initialRestore={startOnboardingRestore()}1577                    onUnlock={() => {1578                      // RecoveryWordDisplay clears the storage flag before1579                      // calling onDone, so handleUnlock will see no pending1580                      // confirmation and proceed through the normal path.1581                      if (forceRecoveryWords()) setForceRecoveryWords(false);1582                      return handleUnlock();1583                    }}1584                    onBackToOnboarding={1585                      !hasVault()1586                        ? () => {1587                            // Deliberately rewinding to the carousel — the exit1588                            // flow must not reclaim the next popup open.1589                            clearOnboardingExit();1590                            setStartOnboardingRestore(false);1591                            setOnboarded(false);1592                          }1593                        : undefined1594                    }1595                    onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1596                  />1597                </Show>1598              </Show>1599            }1600          >1601            <Show1602              when={1603                view() !== "pro-celebration" &&1604                view() !== "initial-sync"1605              }1606              fallback={1607                <Show1608                  when={view() === "initial-sync"}1609                  fallback={<ProCelebration />}1610                >1611                  <InitialSyncView1612                    slow={initialSyncSlow()}1613                    onRetry={() => void retryInitialSync()}1614                  />1615                </Show>1616              }1617            >1618            <section1619              id="hd-shell"1620              class="peach-shell peach-theme"1621              data-surface="extension"1622            >1623            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1624              <PeachWordmark unlocked />1625              <div class="peach-chrome-actions">1626                <button1627                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1628                  onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1629                  aria-label="Notes"1630                  title="Notes"1631                >1632                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1633                    <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1634                    <line x1="4" y1="5" x2="10" y2="5" />1635                    <line x1="4" y1="7.5" x2="10" y2="7.5" />1636                    <line x1="4" y1="10" x2="7" y2="10" />1637                  </svg>1638                </button>1639                <button1640                    class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1641                    classList={{1642                      "is-syncing": syncBusy(),1643                      "is-ok": syncFlash() === "ok",1644                      "is-error": syncFlash() === "error",1645                    }}1646                    onClick={() => void handleHeaderSync()}1647                    disabled={syncBusy()}1648                    aria-label={`Sync now. ${syncLabel()}`}1649                    title={syncLabel()}1650                  >1651                    <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">1652                      <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1653                      <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1654                      <path d="M2.6 1v2.4h2.4" />1655                      <path d="M11.4 13v-2.4H9" />1656                    </svg>1657                    <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1658                  </button>1659                <button1660                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1661                  onClick={() => setView("settings")}1662                  aria-label="Settings"1663                >1664                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1665                    <path1666                      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"1667                      stroke="currentColor"1668                      stroke-width="0.9"1669                    />1670                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1671                  </svg>1672                </button>1673                <button1674                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1675                  onClick={handleAdd}1676                  aria-label="New entry"1677                >1678                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1679                    <path d="M5.5 1v9M1 5.5h9" />1680                  </svg>1681                </button>1682                <button1683                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1684                  onClick={() => void handleHeaderLock()}1685                  aria-label="Lock vault"1686                >1687                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1688                    <path1689                      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"1690                      stroke="currentColor"1691                      stroke-width="1"1692                      stroke-linecap="round"1693                    />1694                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1695                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />1696                  </svg>1697                </button>1698              </div>1699            </header>17001701            <Show when={pendingProNotice() !== null}>1702              <div class="peach-pro-notice-banner">1703                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1704                <button1705                  class="peach-pro-notice-link"1706                  onClick={() => {1707                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1708                    setSettingsInitialSection("license");1709                    setView("settings");1710                    setPendingProNotice(null);1711                  }}1712                >1713                  Learn more →1714                </button>1715                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1716              </div>1717            </Show>17181719            <div class="peach-shell-body">1720              <div class="peach-shell-veil"></div>1721              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1722                <EntryView1723                  entry={selectedEntry()}1724                  currentTabUrl={currentTabUrl()}1725                  entries={entries()}1726                  isPro={proStatus()}1727                  onDelete={handleDelete}1728                  onFieldSaved={() => void loadEntries()}1729                  onEscape={() => { setView("list"); }}1730                  onProGate={(feature) => {1731                    setUpgradeHighlight(feature);1732                    setSettingsInitialSection("license");1733                    setView("settings");1734                  }}1735                />1736              </div>17371738              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1739                <div class="peach-search-bar">1740                  <span class="peach-search-icon">1741                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1742                      <circle cx="5.5" cy="5.5" r="3.75" />1743                      <path d="M8.75 8.75L11.5 11.5" />1744                    </svg>1745                  </span>1746                  <input1747                    class="peach-search-input"1748                    placeholder="search vault..."1749                    autocomplete="off"1750                    aria-label="Search vault entries"1751                    onClick={openList}1752                    onFocus={openList}1753                    readOnly1754                  />1755                  <div class="peach-search-meta">1756                    <span class="peach-search-counter">1757                      {nonNoteEntries().length > 01758                        ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1759                        : "0 entries"}1760                    </span>1761                    <span class="peach-divider"></span>1762                    <button1763                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1764                      onClick={() => navigate(-1)}1765                      aria-label="Previous entry"1766                      disabled={nonNoteEntries().length === 0}1767                    >1768                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1769                        <path d="M5 1L1 5.5L5 10" />1770                      </svg>1771                    </button>1772                    <button1773                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1774                      onClick={() => navigate(1)}1775                      aria-label="Next entry"1776                      disabled={nonNoteEntries().length === 0}1777                    >1778                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1779                        <path d="M1 1L5 5.5L1 10" />1780                      </svg>1781                    </button>1782                  </div>1783                </div>1784              </section>1785            </div>17861787            {/* Desktop: shown on demand via Show. Native sheet is rendered1788                via Portal below at the document root so its position:fixed1789                isn't trapped by .peach-shell's transform-based animations. */}1790            {/* Rare device-trust alerts; normal sync changes are silent. */}1791            <Show when={alertGroups().length > 0}>1792              <div class="peach-sync-alerts">1793                <For each={alertGroups()}>1794                  {([type, group]) => {1795                    const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1796                    return (1797                      <div class="peach-sync-alert">1798                        <Show when={group.length > 1}>1799                          <button1800                            class="peach-sync-alert-flip"1801                            type="button"1802                            aria-label="Previous"1803                            onClick={() => flipAlert(type, -1)}1804                          >‹</button>1805                        </Show>1806                        <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1807                        <Show when={group.length > 1}>1808                          <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1809                          <button1810                            class="peach-sync-alert-flip"1811                            type="button"1812                            aria-label="Next"1813                            onClick={() => flipAlert(type, 1)}1814                          >›</button>1815                        </Show>1816                        <button1817                          class="peach-sync-alert-dismiss"1818                          type="button"1819                          aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1820                          onClick={() => dismissAlertGroup(type)}1821                        >×</button>1822                      </div>1823                    );1824                  }}1825                </For>1826              </div>1827            </Show>18281829            <Show when={!isNativePlatform() && view() === "list"}>1830              <VaultView1831                entries={entries()}1832                selectedEntryId={selectedEntry()?.id}1833                currentTabDomain={currentTabDomain()}1834                sortOrder={sortOrder()}1835                searchSeed={listSearchSeed()}1836                searchSeedNonce={listSearchSeedNonce()}1837                onSelectEntry={handleSelectEntry}1838                onSortOrderChange={setSortOrder}1839                onAdd={handleAdd}1840                onClose={() => setView("entry")}1841                onOpenTrash={() => setView("trash")}1842                onDeleteMany={handleDeleteMany}1843              />1844            </Show>1845            <Show when={isNativePlatform() && hasVault()}>1846              <Portal>1847                <div1848                  class="peach-vault-sheet"1849                  classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1850                  style={(() => {1851                    const live = sheetTranslate();1852                    if (live === null) return undefined;1853                    return { transform: `translateY(${live}px)` };1854                  })()}1855                >1856                  <VaultView1857                    entries={entries()}1858                    selectedEntryId={selectedEntry()?.id}1859                    currentTabDomain={currentTabDomain()}1860                    sortOrder={sortOrder()}1861                    searchSeed={listSearchSeed()}1862                    searchSeedNonce={listSearchSeedNonce()}1863                    isNative={true}1864                    onSelectEntry={handleSelectEntry}1865                    onSortOrderChange={setSortOrder}1866                    onAdd={handleAdd}1867                    onClose={() => setView("entry")}1868                    onOpenTrash={() => setView("trash")}1869                    onDeleteMany={handleDeleteMany}1870                  />1871                </div>1872              </Portal>1873            </Show>1874            <Show when={view() === "settings" || settingsExiting()}>1875              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1876                <SettingsView1877                  entriesCount={entries().length}1878                  breachMonitoring={breachMonitoring()}1879                  lockOnClose={lockOnClose()}1880                  autoLockMinutes={autoLockMinutes()}1881                  trashRetentionDays={trashRetentionDays()}1882                  theme={theme()}1883                  accentColor={accentColor()}1884                  textSize={textSize()}1885                  reduceMotion={reduceMotion()}1886                  isPro={proStatus()}1887                  initialSection={settingsInitialSection()}1888                  upgradeHighlight={upgradeHighlight()}1889                  isExiting={settingsExiting()}1890                  onBreachMonitoringChange={applyBreachMonitoring}1891                  onLockOnCloseChange={applyLockOnClose}1892                  onAutoLockMinutesChange={applyAutoLockMinutes}1893                  onTrashRetentionChange={applyTrashRetention}1894                  onThemeChange={applyTheme}1895                  onAccentChange={applyAccentColor}1896                  onTextSizeChange={applyTextSize}1897                  onReduceMotionChange={applyReduceMotion}1898                  shortcuts={shortcutBindings()}1899                  onShortcutsChange={applyShortcuts}1900                  onLicenseChange={async () => {1901                    // After activation/deactivation: refresh Pro status,1902                    // re-load entries (defensive — signal state can drift if1903                    // the SW briefly restarts during the verify+save round-trip),1904                    // and replay the Pro celebration if this is the first time1905                    // a valid Pro license is seen on this device.1906                    await refreshProStatus();1907                    void loadEntries();1908                    const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1909                    if ((should as { ok: boolean; data?: boolean }).data === true) {1910                      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1911                      setView("pro-celebration");1912                      window.setTimeout(() => setView("settings"), 2500);1913                    }1914                  }}1915                  onBack={() => {1916                    setSettingsExiting(true);1917                    window.setTimeout(() => {1918                      setSettingsExiting(false);1919                      setSettingsInitialSection(null);1920                      setUpgradeHighlight(null);1921                      handleBack();1922                    }, 200);1923                  }}1924                  onImport={() => setView("import")}1925                  onExport={() => setView("export")}1926                  onBreachCheckDone={loadEntries}1927                  onOpenHealth={openHealth}1928                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1929                  onOpenFileImport={handleOpenFileImport}1930                  onOpenPanic={() => setView("panic")}1931                  onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1932                  onOpenCodexRestore={() => setView("codex-restore")}1933                  onCardChange={setSettingsActiveCard}1934                  onProGate={(feature) => {1935                    setUpgradeHighlight(feature);1936                    setSettingsInitialSection("license");1937                  }}1938                />1939              </Suspense>1940            </Show>1941            <Show when={view() === "codex-generate"}>1942              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1943                <CodexGenerateView1944                  isPro={proStatus()}1945                  onBack={() => setView("settings")}1946                  onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1947                  onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1948                  onVaultLocked={() => handleLock()}1949                />1950              </Suspense>1951            </Show>1952            <Show when={view() === "codex-restore"}>1953              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1954                <CodexRestoreView1955                  onBack={() => setView("lock")}1956                  onRestoreSuccess={async () => {1957                    // After restore the vault is unlocked; refresh entries1958                    // and route to the main vault list. handleUnlock is the1959                    // canonical post-unlock setup path.1960                    await handleUnlock();1961                  }}1962                />1963              </Suspense>1964            </Show>1965            <Show when={view() === "pair-device"}>1966              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1967                <PairDeviceView1968                  isPro={proStatus()}1969                  onProChanged={() => { void refreshProStatus(); }}1970                  onBack={() => setView("settings")}1971                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1972                />1973              </Suspense>1974            </Show>1975            <Show when={view() === "add"}>1976              <AddView1977                entry={editEntry()}1978                entries={entries()}1979                onBack={handleBack}1980                onSave={handleSave}1981              />1982            </Show>1983            <Show when={view() === "import"}>1984              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1985                <ImportView onBack={() => setView("settings")} onImport={handleSave} />1986              </Suspense>1987            </Show>1988            <Show when={view() === "export"}>1989              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1990                <ExportView onBack={handleExportBack} />1991              </Suspense>1992            </Show>1993            <Show when={view() === "panic"}>1994              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1995                <PanicView1996                  onCancel={() => setView("settings")}1997                  onOpenExport={() => {1998                    setReturnToPanic(true);1999                    setView("export");2000                  }}2001                  onDestroyed={async () => {2002                    clearCanarySuppressions();2003                    // Fresh onboarding start after a destroy — don't let the2004                    // resume markers land the user mid-carousel.2005                    setOnboardingCardAtExit(0);2006                    void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});2007                    clearOnboardingExit();2008                    setEntries([]);2009                    setSelectedEntry(null);2010                    setEditEntry(undefined);2011                    setHasVault(false);2012                    setOnboarded(false);2013                    setProStatus(false);2014                    setPendingPasskey(null);2015                    setUpgradeHighlight(null);2016                    setSettingsInitialSection(null);2017                    setPendingProNotice(null);2018                    setPendingCelebration(false);2019                    setReturnToPanic(false);2020                    setContentPhase("idle");2021                    setView("lock");2022                  }}2023                />2024              </Suspense>2025            </Show>2026            {/* Health stays mounted while editing an entry from it so2027                computeReused() doesn't rerun on every cancel/back. */}2028            <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>2029              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>2030                <HealthView2031                  entries={entries()}2032                  isPro={proStatus()}2033                  onFixEntry={handleFixEntry}2034                  onBack={() => setView("settings")}2035                  onRefresh={loadEntries}2036                  hidden={view() !== "health"}2037                />2038              </Suspense>2039            </Show>2040            <Show when={view() === "trash"}>2041              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>2042                <TrashView2043                  onBack={() => setView("list")}2044                  onRestored={() => void loadEntries()}2045                />2046              </Suspense>2047            </Show>2048            <Show when={showNotes()}>2049              <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>2050                <NotesMode2051                  notes={entries().filter((e) => e.type === "note")}2052                  initialNoteId={openNoteId()}2053                  onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}2054                  onNoteListChanged={() => void loadEntries()}2055                />2056              </div>2057            </Show>2058            <Show when={restoreView() === "file"}>2059              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>2060                <ImportView2061                  mode="onboarding"2062                  replacesExistingVault={true}2063                  onBack={() => setRestoreView(null)}2064                  onImport={async () => {2065                    setRestoreView(null);2066                    await handleUnlock();2067                  }}2068                />2069              </Suspense>2070            </Show>2071            </section>2072            </Show>2073          </Show>2074        </Show>2075      </Show>2076      <Show when={showShortcutsHelp()}>2077        <ShortcutsHelpOverlay2078          bindings={shortcutBindings()}2079          onClose={() => setShowShortcutsHelp(false)}2080        />2081      </Show>2082    </div>2083  );2084}20852086function ShortcutsHelpOverlay(props: {2087  bindings: Record<ShortcutAction, Keybinding>;2088  onClose: () => void;2089}) {2090  return (2091    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>2092      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>2093        <div class="peach-shortcuts-help-head">2094          <h3>Keyboard shortcuts</h3>2095          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>2096        </div>2097        <div class="peach-shortcuts-help-body">2098          <p class="peach-settings-sub-label">ENTRY VIEW</p>2099          <ul>2100            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (2101              <li>2102                <span>{d.label}</span>2103                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2104              </li>2105            ))}2106          </ul>2107          <p class="peach-settings-sub-label">ANYWHERE</p>2108          <ul>2109            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (2110              <li>2111                <span>{d.label}</span>2112                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>2113              </li>2114            ))}2115          </ul>2116        </div>2117      </div>2118    </div>2119  );2120}