App.tsx

44.6 KB · raw

1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { ACCENT_COLORS } from "../shared/types";3import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";4import { LockView } from "./views/Lock";5import { ProCelebration } from "./views/ProCelebration";6import { VaultView, type SortOrder } from "./views/Vault";7import { EntryView } from "./views/Entry";8import { AddView } from "./views/Add";9import { PeachWordmark } from "./components/PeachMark";10import { PasskeyApprovalView } from "./views/PasskeyApproval";11import { PasskeyEntryView } from "./views/PasskeyEntry";12import { extractDomain } from "../shared/domain";13import { popupCopy } from "./copy";14import {15  DEFAULT_BINDINGS,16  formatKeybinding,17  getActiveBindings,18  matchKeybinding,19  SHORTCUT_DESCRIPTORS,20  type Keybinding,21  type ShortcutAction,22} from "../shared/shortcuts";23import { generateTOTP } from "../crypto/totp";2425const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));26const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));27const CJKImport     = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));28const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));29const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));30const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3132type View =33  | "lock"34  | "pro-celebration"35  | "entry"36  | "list"37  | "add"38  | "settings"39  | "import"40  | "export"41  | "health"42  | "trash";43type BootState = "loading" | "ready";4445const canarySuppressMap = new Map<string, boolean>();46let shellRingAnimation: Animation | null = null;4748export function isCanarySuppressed(entryId: string): boolean {49  return canarySuppressMap.get(entryId) ?? false;50}5152export function setCanarySuppressed(entryId: string, suppressed: boolean): void {53  canarySuppressMap.set(entryId, suppressed);54}5556export function clearCanarySuppressions(): void {57  canarySuppressMap.clear();58}5960export function triggerRing() {61  const shell = document.getElementById("hd-shell");62  if (!shell) return;6364  shellRingAnimation?.cancel();65  shell.dataset.glowing = "true";66  const animation = shell.animate(67    [68      { offset: 0, boxShadow: "var(--copy-shadow)" },69      {70        offset: 0.3,71        boxShadow:72          "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)",73      },74      { offset: 1, boxShadow: "var(--copy-shadow)" },75    ],76    {77      duration: 720,78      easing: "cubic-bezier(0.4, 0, 0.2, 1)",79      fill: "none",80    }81  );82  shellRingAnimation = animation;83  const clearGlow = () => {84    if (shellRingAnimation !== animation) return;85    shellRingAnimation = null;86    delete shell.dataset.glowing;87  };88  animation.onfinish = clearGlow;89  animation.oncancel = clearGlow;90}919293export function triggerPipPulse() {94  const pip = document.querySelector(".peach-status-pip, .pip");95  if (pip) {96    pip.classList.remove("pulse");97    void (pip as HTMLElement).offsetWidth;98    pip.classList.add("pulse");99  }100}101102function humanizeError(error: string | undefined): string | undefined {103  if (!error) return error;104  if (error === "STORAGE_QUOTA_EXCEEDED") {105    return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";106  }107  return error;108}109110async function sendMessage(msg: any): Promise<any> {111  const MESSAGE_TIMEOUT_MS = 5000;112113  return new Promise((resolve) => {114    let settled = false;115    const timeoutId = window.setTimeout(() => {116      if (settled) return;117      settled = true;118      resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });119    }, MESSAGE_TIMEOUT_MS);120121    try {122      chrome.runtime.sendMessage(msg, (response) => {123        if (settled) return;124        settled = true;125        window.clearTimeout(timeoutId);126127        if (chrome.runtime.lastError) {128          resolve({129            ok: false,130            error:131              chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",132          });133          return;134        }135136        if (response === undefined) {137          resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });138          return;139        }140141        if (response && response.ok === false) {142          response.error = humanizeError(response.error);143        }144        resolve(response);145      });146    } catch (error) {147      if (settled) return;148      settled = true;149      window.clearTimeout(timeoutId);150      resolve({151        ok: false,152        error:153          error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",154      });155    }156  });157}158159export { sendMessage };160161const ENTRY_TRANSITION_EXIT_MS = 95;162const ENTRY_TRANSITION_SETTLE_MS = 135;163164export default function App() {165  const [bootState, setBootState] = createSignal<BootState>("loading");166  const [view, setView] = createSignal<View>("lock");167  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(168    "enter"169  );170  const [contentTransitioning, setContentTransitioning] = createSignal(false);171  const [hasVault, setHasVault] = createSignal(false);172  const [entries, setEntries] = createSignal<VaultEntry[]>([]);173  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);174  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);175  const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");176  const [theme, setTheme] = createSignal<"dark" | "light">("dark");177  const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");178  const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");179  const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);180  const [lockOnClose, setLockOnClose] = createSignal(true);181  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);182  const [breachMonitoring, setBreachMonitoring] = createSignal(true);183  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);184  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);185  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);186  const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);187  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);188  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");189  // Cached Pro status — synchronous for render paths. Background's190  // GET_LICENSE is async, but render-time reads of isPro() must not block.191  // Refreshed at boot, after activate/deactivate, and whenever the popup192  // re-opens onto a stored license that has changed via another window.193  const [proStatus, setProStatus] = createSignal(false);194  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);195  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);196  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");197  const [listSearchSeed, setListSearchSeed] = createSignal("");198  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);199  const [pendingCelebration, setPendingCelebration] = createSignal(false);200  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);201202  let contentPhaseTimer: number | undefined;203204  function applyAccent(name: AccentColorName) {205    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;206    const hex = color.hex;207    document.documentElement.style.setProperty("--peach-accent", hex);208    // Ambient surface variables — derived from the accent so surfaces shift209    // subtly toward the accent hue rather than staying fixed warm-brown.210    document.documentElement.style.setProperty("--peach-glow", hex + "2A");211    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");212    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");213  }214215  createEffect(() => {216    applyAccent(accentColor());217  });218219  createEffect(() => {220    document.documentElement.classList.toggle("light", theme() === "light");221    document.body.classList.toggle("light", theme() === "light");222  });223224  createEffect(() => {225    document.documentElement.setAttribute("data-display-font", displayFont());226  });227228  createEffect(() => {229    document.documentElement.setAttribute("data-text-size", textSize());230  });231232  createEffect(() => {233    if (reduceMotion()) {234      document.documentElement.setAttribute("data-motion", "reduce");235    } else {236      document.documentElement.removeAttribute("data-motion");237    }238  });239240  const selectedIndex = createMemo(() => {241    const currentId = selectedEntry()?.id;242    if (!currentId) return entries().length > 0 ? 0 : -1;243    return entries().findIndex((entry) => entry.id === currentId);244  });245246  async function applyAccentColor(name: AccentColorName) {247    applyAccent(name);248    setAccentColor(name);249    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });250  }251252  async function applyTheme(t: "dark" | "light") {253    setTheme(t);254    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });255  }256257  async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {258    setDisplayFont(f);259    await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });260  }261262  async function applyTextSize(size: "small" | "medium" | "large") {263    setTextSize(size);264    await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });265  }266267  async function applyReduceMotion(enabled: boolean) {268    setReduceMotion(enabled);269    await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });270  }271272  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload273  // small and lets future default updates land for actions the user hasn't274  // touched. Settings sends us the full active map; we filter and store.275  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {276    setShortcutBindings(active);277    const overrides: Record<string, Keybinding> = {};278    for (const action of Object.keys(active) as ShortcutAction[]) {279      const a = active[action];280      const d = DEFAULT_BINDINGS[action];281      const same = a.key === d.key282        && Boolean(a.ctrl)  === Boolean(d.ctrl)283        && Boolean(a.meta)  === Boolean(d.meta)284        && Boolean(a.shift) === Boolean(d.shift)285        && Boolean(a.alt)   === Boolean(d.alt);286      if (!same) overrides[action] = a;287    }288    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });289  }290291  async function applyLockOnClose(enabled: boolean) {292    setLockOnClose(enabled);293    await sendMessage({294      type: "UPDATE_SETTINGS",295      payload: { lockOnClose: enabled },296    });297  }298299  async function applyAutoLockMinutes(minutes: number) {300    setAutoLockMinutes(minutes);301    await sendMessage({302      type: "UPDATE_SETTINGS",303      payload: { autoLockMinutes: minutes },304    });305  }306307  async function applyBreachMonitoring(enabled: boolean) {308    setBreachMonitoring(enabled);309    await sendMessage({310      type: "UPDATE_SETTINGS",311      payload: { breachMonitoring: enabled },312    });313  }314315  onMount(async () => {316    // Resolve the active tab in parallel with the unlock/has-vault queries —317    // openVault()'s smart selection picks the entry matching currentTabDomain,318    // so it must be populated before openVault runs. Previously refreshCurrentTab319    // ran after openVault, leaving popups that booted already-unlocked falling320    // back to loadedEntries[0] instead of the domain match.321    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([322      sendMessage({ type: "IS_UNLOCKED" }),323      sendMessage({ type: "HAS_VAULT" }),324      refreshCurrentTab(),325      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),326    ]);327    const unlocked = Boolean(328      (unlockResponse as { ok: boolean; data?: boolean }).data329    );330    const vaultExists = Boolean(331      (vaultResponse as { ok: boolean; data?: boolean }).data332    );333    const shouldCelebrate = Boolean(334      (celebrationResponse as { ok: boolean; data?: boolean }).data335    );336    setHasVault(vaultExists);337    await Promise.all([restoreSettings(), refreshProStatus()]);338339    // Mark played before any animation so a crash mid-sequence never replays it340    if (shouldCelebrate) {341      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });342    }343344    if (unlocked && vaultExists) {345      await openVault(undefined, shouldCelebrate);346      if (shouldCelebrate) {347        setView("pro-celebration");348        window.setTimeout(() => setView("entry"), 2500);349      }350    } else if (shouldCelebrate) {351      setPendingCelebration(true);352    }353354    await refreshPendingPasskey();355356    // Track tab changes so smart sort stays current357    const handleTabActivated = () => { void refreshCurrentTab(); };358    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {359      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();360    };361    try {362      chrome.tabs.onActivated.addListener(handleTabActivated);363      chrome.tabs.onUpdated.addListener(handleTabUpdated);364      onCleanup(() => {365        chrome.tabs.onActivated.removeListener(handleTabActivated);366        chrome.tabs.onUpdated.removeListener(handleTabUpdated);367      });368    } catch {369      // tabs permission may not be available in all contexts370    }371372    setBootState("ready");373    document.getElementById("pre-lock")?.setAttribute("hidden", "true");374375    // Check for pending session-storage notices from the background.376    if (chrome.storage.session) {377      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as378        Record<string, { feature?: string } | undefined>;379      if (session.pendingProGate?.feature) {380        await chrome.storage.session.remove("pendingProGate");381        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);382        setSettingsInitialSection("license");383        setView("settings");384      } else if (session.pendingProNotice?.feature) {385        await chrome.storage.session.remove("pendingProNotice");386        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });387      }388    }389390    // Warm the lazy-module cache so the Suspense fallback never shows for391    // locally-bundled extension views. Without this, the first navigation to392    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which393    // covers the shell and absorbs clicks on the header.394    void Promise.all([395      import("./views/Settings").catch(() => {}),396      import("./views/Import").catch(() => {}),397      import("./views/Export").catch(() => {}),398      import("./views/Health").catch(() => {}),399      import("./views/TrashView").catch(() => {}),400      import("./views/CJKImport").catch(() => {}),401    ]);402403    // Lock-on-close via a persistent port. sendMessage in pagehide is404    // unreliable — the popup context tears down before the message lands.405    // The SW's onConnect listener locks when this port disconnects.406    let popupPort: chrome.runtime.Port | null = null;407    try {408      popupPort = chrome.runtime.connect({ name: "popup" });409    } catch { /* extension context invalidated — ignore */ }410    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });411412    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale413    // entries instead of waiting for the next GET_ENTRIES round-trip.414    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {415      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();416      if (msg?.type === "PRO_GATE_TRIGGERED") {417        // Background never navigates UI directly — it asks; the popup decides.418        // Land on Settings → License with the triggering feature highlighted.419        setUpgradeHighlight(msg.payload?.feature ?? null);420        setSettingsInitialSection("license");421        setView("settings");422      }423    };424    chrome.runtime.onMessage.addListener(handleRuntimeMessage);425    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));426427    // Action handlers — keyed by ShortcutAction. The dispatcher below tests428    // each binding against the event and runs the matching handler. New429    // shortcuts only need an entry here; the binding is data, not code.430    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {431      copy_password: () => {432        const entry = selectedEntry();433        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });434      },435      copy_username: () => {436        const entry = selectedEntry();437        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });438      },439      copy_totp: () => {440        const entry = selectedEntry();441        if (!entry?.totp) return;442        try {443          const { code } = generateTOTP(entry.totp);444          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });445        } catch { /* invalid TOTP secret — silent */ }446      },447      new_entry: () => handleAdd(),448      edit_entry: () => {449        const entry = selectedEntry();450        if (!entry || entry.type === "passkey") return;451        setEditEntry(entry);452        setView("add");453      },454      delete_entry: () => {455        if (selectedEntry()) void handleDelete();456      },457      lock_vault: () => { void handleHeaderLock(); },458      search: () => openList(),459      nav_prev: () => navigate(-1),460      nav_next: () => navigate(1),461      jump_vault: () => setView("entry"),462      jump_settings: () => setView("settings"),463      show_help: () => setShowShortcutsHelp(true),464    };465466    // Actions that are MEANINGFUL only on the entry-detail view. Outside467    // that scope the binding is dormant — pressing C while on Settings468    // does nothing rather than awkwardly copying from a now-stale entry.469    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([470      "copy_password", "copy_username", "copy_totp",471      "edit_entry", "delete_entry", "nav_prev", "nav_next",472    ]);473474    const handleGlobalKeydown = (e: KeyboardEvent) => {475      const v = view();476      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"477        || (e.target as HTMLElement)?.tagName === "TEXTAREA"478        || (e.target as HTMLElement)?.isContentEditable;479      const isPrintableSearchKey =480        e.key.length === 1 &&481        !e.ctrlKey &&482        !e.metaKey &&483        !e.altKey;484485      // No input during the pro celebration sequence486      if (v === "pro-celebration") return;487488      // Help dialog: Escape closes it; eat the keypress so it doesn't489      // also navigate away from the underlying view.490      if (showShortcutsHelp()) {491        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }492        return;493      }494495      // ── Escape — fixed binding (not user-customizable) ───────496      if (e.key === "Escape") {497        if (v === "list") { setView("entry"); return; }498        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }499      }500501      // Bindings-driven dispatch. Single-key letter bindings without502      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is503      // inside an input, so users can type into search/forms normally.504      const bindings = shortcutBindings();505      for (const action of Object.keys(bindings) as ShortcutAction[]) {506        const binding = bindings[action];507        if (!matchKeybinding(e, binding)) continue;508        // Single-key letter bindings need the input-focus guard. Bindings509        // with any modifier are safe — Ctrl+L doesn't conflict with typing.510        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;511        if (!hasMod && binding.key.length === 1 && inInput) return;512        if (ENTRY_SCOPED.has(action) && v !== "entry") return;513        e.preventDefault();514        SHORTCUT_HANDLERS[action]?.();515        return;516      }517518      // Type-to-search: in entry view, any printable key not consumed by a519      // shortcut above primes the list search seed and opens the list.520      // Kept outside the bindings table because it's a behavior, not a521      // discrete action — every printable character can trigger it.522      if (v === "entry" && !inInput && isPrintableSearchKey) {523        e.preventDefault();524        primeListSearch(e.key);525        return;526      }527    };528    window.addEventListener("keydown", handleGlobalKeydown);529    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));530531    const handleStorageChanged = (532      changes: Record<string, chrome.storage.StorageChange>,533      areaName: string534    ) => {535      if (areaName !== "session") return;536      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {537        return;538      }539      void refreshPendingPasskey();540    };541542    chrome.storage.onChanged.addListener(handleStorageChanged);543    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));544  });545546  async function refreshCurrentTab() {547    try {548      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });549      const url = tab?.url ?? tab?.pendingUrl ?? "";550      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");551    } catch {552      setCurrentTabDomain("");553    }554  }555556  async function refreshPendingPasskey() {557    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });558    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);559  }560561  async function refreshProStatus() {562    const res = await sendMessage({ type: "GET_LICENSE" });563    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;564    setProStatus(Boolean(data?.valid && data?.plan === "pro"));565  }566567  async function restoreSettings() {568    const res = await sendMessage({ type: "GET_SETTINGS" });569    const s = (res as { ok: boolean; data?: any }).data ?? {};570    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {571      setAccentColor(s.accentColor as AccentColorName);572    }573    setTheme(s.theme === "light" ? "light" : "dark");574    if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {575      setDisplayFont(s.displayFont);576    } else {577      setDisplayFont("cormorant-italic");578    }579    if (s.textSize === "small" || s.textSize === "large") {580      setTextSize(s.textSize);581    } else {582      setTextSize("medium");583    }584    setReduceMotion(s.reduceMotion === true);585    setLockOnClose(s.lockOnClose !== false);586    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);587    setBreachMonitoring(s.breachMonitoring !== false);588    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);589    setShortcutBindings(getActiveBindings(s.shortcuts));590  }591592  async function applyTrashRetention(days: number) {593    setTrashRetentionDays(days);594    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });595  }596597  function primeListSearch(seed: string) {598    setListSearchSeed(seed);599    setListSearchSeedNonce((value) => value + 1);600    openList();601  }602603  // Returns null when it detected a dead service worker and triggered handleLock().604  // Callers that need to abort (e.g. openVault) check for null rather than relying605  // on view() state, which may already be "lock" during normal startup flows.606  async function loadEntries(): Promise<VaultEntry[] | null> {607    const response = await sendMessage({ type: "GET_ENTRIES" });608    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };609610    // If the service worker was killed and restarted (MV3 workers sleep), the611    // vault will be locked again — or the worker may not respond at all.612    // In either case, send the user to the lock screen rather than silently613    // showing an empty vault (which looks like data loss).614    if (!res.ok) {615      if (616        res.error === "vault locked" ||617        res.error === "SERVICE_WORKER_UNAVAILABLE" ||618        (typeof res.error === "string" && res.error.includes("locked"))619      ) {620        handleLock();621        return null; // signal emergency lock to callers622      }623      return [];624    }625626    const loadedEntries = res.data ?? [];627    setEntries(loadedEntries);628629    const currentId = selectedEntry()?.id;630    if (currentId) {631      setSelectedEntry(632        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null633      );634    }635636    return loadedEntries;637  }638639  async function openVault(preferredEntryId?: string, skipViewSet = false) {640    // Purge expired trash entries on each vault open641    const retDays = trashRetentionDays();642    if (retDays > 0) {643      void sendMessage({644        type: "PURGE_EXPIRED_TRASH",645        payload: { retentionMs: retDays * 86_400_000 },646      });647    }648649    const loadedEntries = await loadEntries();650651    // null means loadEntries() triggered an emergency lock (service worker died).652    // Don't override it by setting view back to "entry" with an empty vault.653    if (loadedEntries === null) return;654655    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry656    let nextSelectedEntry: VaultEntry | null = null;657    if (preferredEntryId) {658      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;659    }660    if (!nextSelectedEntry) {661      const tabDomain = currentTabDomain();662      if (tabDomain) {663        nextSelectedEntry = loadedEntries.find((e) => {664          if (e.type === "login") {665            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;666            for (const extra of e.additionalUrls ?? []) {667              if ((extractDomain(extra) ?? "") === tabDomain) return true;668            }669            return false;670          }671          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;672          return false;673        }) ?? null;674      }675    }676    nextSelectedEntry ??= loadedEntries[0] ?? null;677678    setSelectedEntry(nextSelectedEntry);679    setEditEntry(undefined);680    setContentTransitioning(false);681    setContentPhase("enter");682    queueContentIdle();683    if (!skipViewSet) setView("entry");684  }685686  async function handleUnlock() {687    setHasVault(true);688    triggerPipPulse();689    await restoreSettings();690    const celebrating = pendingCelebration();691    if (celebrating) {692      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });693      setPendingCelebration(false);694    }695    await openVault(undefined, celebrating);696    if (celebrating) {697      setView("pro-celebration");698      window.setTimeout(() => setView("entry"), 2500);699    }700  }701702  function handleSelectEntry(entry: VaultEntry) {703    setSelectedEntry(entry);704    setContentTransitioning(false);705    setContentPhase("enter");706    queueContentIdle();707    setView("entry");708  }709710  function handleAdd() {711    setEditEntry(undefined);712    setView("add");713  }714715  async function handleDelete() {716    const entry = selectedEntry();717    if (!entry) return;718    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });719    const loadedEntries = await loadEntries() ?? [];720    setSelectedEntry(loadedEntries[0] ?? null);721    setView("entry");722  }723724  async function handleDeleteMany(ids: string[]) {725    for (const id of ids) {726      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });727    }728    const loadedEntries = await loadEntries() ?? [];729    // If the currently selected entry was among the deleted, move to first730    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);731    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);732  }733734  async function handleSave(savedEntry?: VaultEntry) {735    await openVault(savedEntry?.id);736  }737738  function handleBack() {739    setEditEntry(undefined);740    setView("entry");741  }742743  function handleLock() {744    clearCanarySuppressions();745    setEntries([]);746    setSelectedEntry(null);747    setEditEntry(undefined);748    setContentPhase("idle");749    setView("lock");750  }751752  function handleHeaderLock() {753    // Lock the UI immediately — do not wait for the service worker. If the SW754    // is dead or slow, the 5s sendMessage timeout would create a dead-click755    // window. The SW will lock on its next wake regardless.756    handleLock();757    void sendMessage({ type: "LOCK" });758  }759760  function navigate(dir: number) {761    const allEntries = entries();762    if (allEntries.length === 0) return;763    const currentIndex = selectedIndex();764    const baseIndex = currentIndex >= 0 ? currentIndex : 0;765    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;766    setSelectedEntry(allEntries[nextIndex]);767    setContentPhase("enter");768    queueContentIdle();769  }770771  function openList() {772    setView("list");773  }774775  function openHealth() {776    setEditEntry(undefined);777    setView("health");778  }779780  function handleOpenCJKImport() {781    setRestoreView("cjk");782  }783784  function handleOpenFileImport() {785    setRestoreView("file");786  }787788  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;789790  function handleFixEntry(entry: VaultEntry) {791    if (entry.type === "passkey") {792      setSelectedEntry(entry);793      setView("entry");794      return;795    }796    setEditEntry(entry);797    setView("add");798  }799800  const isLockedView = () => view() === "lock";801  const selectedPasskeyEntry = createMemo(() =>802    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null803  );804805  async function handleApprovePendingPasskey() {806    const pending = pendingPasskey();807    if (!pending) return;808    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });809    setPendingPasskey(null);810  }811812  async function handleDenyPendingPasskey() {813    const pending = pendingPasskey();814    if (!pending) return;815    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });816    setPendingPasskey(null);817  }818819  function queueContentIdle(after?: () => void) {820    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);821    contentPhaseTimer = window.setTimeout(() => {822      setContentPhase("idle");823      after?.();824    }, ENTRY_TRANSITION_SETTLE_MS);825  }826827  return (828    <div class="popup-root peach-theme">829      <Show when={bootState() === "ready"}>830        <Show831          when={!pendingPasskey()}832          fallback={833            <PasskeyApprovalView834              type={pendingPasskey()!.type}835              rpName={pendingPasskey()!.rpName}836              rpId={pendingPasskey()!.rpId}837              userName={pendingPasskey()!.userName}838              userDisplayName={pendingPasskey()!.userDisplayName}839              onApprove={handleApprovePendingPasskey}840              onDeny={handleDenyPendingPasskey}841            />842          }843        >844          <Show845            when={!isLockedView()}846            fallback={847              <LockView848                hasVault={hasVault()}849                ready={bootState() === "ready"}850                isPro={proStatus()}851                onUnlock={handleUnlock}852              />853            }854          >855            <Show856              when={view() !== "pro-celebration"}857              fallback={<ProCelebration />}858            >859            <section860              id="hd-shell"861              class="peach-shell peach-theme"862              data-surface="extension"863            >864            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>865              <PeachWordmark unlocked />866              <div class="peach-chrome-actions">867                <button868                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"869                  onClick={() => setView("settings")}870                  aria-label="Settings"871                >872                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">873                    <path874                      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"875                      stroke="currentColor"876                      stroke-width="0.9"877                    />878                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />879                  </svg>880                </button>881                <button882                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"883                  onClick={handleAdd}884                  aria-label="New entry"885                >886                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">887                    <path d="M5.5 1v9M1 5.5h9" />888                  </svg>889                </button>890                <button891                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"892                  onClick={() => void handleHeaderLock()}893                  aria-label="Lock vault"894                >895                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">896                    <path897                      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"898                      stroke="currentColor"899                      stroke-width="1"900                      stroke-linecap="round"901                    />902                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />903                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />904                  </svg>905                </button>906              </div>907            </header>908909            <Show when={pendingProNotice() !== null}>910              <div class="peach-pro-notice-banner">911                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>912                <button913                  class="peach-pro-notice-link"914                  onClick={() => {915                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);916                    setSettingsInitialSection("license");917                    setView("settings");918                    setPendingProNotice(null);919                  }}920                >921                  Learn more →922                </button>923                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>924              </div>925            </Show>926927            <div class="peach-shell-body">928              <div class="peach-shell-veil"></div>929              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>930                <Show931                  when={selectedPasskeyEntry()}932                  fallback={933                    <EntryView934                      entry={selectedEntry()}935                      onDelete={handleDelete}936                      onFieldSaved={() => void loadEntries()}937                      onEscape={() => { setView("list"); }}938                    />939                  }940                >941                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />942                </Show>943              </div>944945              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>946                <div class="peach-search-bar">947                  <span class="peach-search-icon">948                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">949                      <circle cx="5.5" cy="5.5" r="3.75" />950                      <path d="M8.75 8.75L11.5 11.5" />951                    </svg>952                  </span>953                  <input954                    class="peach-search-input"955                    placeholder="search vault..."956                    autocomplete="off"957                    aria-label="Search vault entries"958                    onClick={openList}959                    onFocus={openList}960                    readOnly961                  />962                  <div class="peach-search-meta">963                    <span class="peach-search-counter">964                      {entries().length > 0965                        ? `${selectedIndex() + 1} / ${entries().length}`966                        : "0 entries"}967                    </span>968                    <span class="peach-divider"></span>969                    <button970                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"971                      onClick={() => navigate(-1)}972                      aria-label="Previous entry"973                      disabled={entries().length === 0}974                    >975                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">976                        <path d="M5 1L1 5.5L5 10" />977                      </svg>978                    </button>979                    <button980                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"981                      onClick={() => navigate(1)}982                      aria-label="Next entry"983                      disabled={entries().length === 0}984                    >985                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">986                        <path d="M1 1L5 5.5L1 10" />987                      </svg>988                    </button>989                  </div>990                </div>991              </section>992            </div>993994            <Show when={view() === "list"}>995              <VaultView996                entries={entries()}997                selectedEntryId={selectedEntry()?.id}998                currentTabDomain={currentTabDomain()}999                sortOrder={sortOrder()}1000                searchSeed={listSearchSeed()}1001                searchSeedNonce={listSearchSeedNonce()}1002                onSelectEntry={handleSelectEntry}1003                onSortOrderChange={setSortOrder}1004                onAdd={handleAdd}1005                onClose={() => setView("entry")}1006                onOpenTrash={() => setView("trash")}1007                onDeleteMany={handleDeleteMany}1008              />1009            </Show>1010            <Show when={view() === "settings"}>1011              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1012                <SettingsView1013                  entriesCount={entries().length}1014                  breachMonitoring={breachMonitoring()}1015                  lockOnClose={lockOnClose()}1016                  autoLockMinutes={autoLockMinutes()}1017                  trashRetentionDays={trashRetentionDays()}1018                  theme={theme()}1019                  accentColor={accentColor()}1020                  displayFont={displayFont()}1021                  textSize={textSize()}1022                  reduceMotion={reduceMotion()}1023                  isPro={proStatus()}1024                  initialSection={settingsInitialSection()}1025                  upgradeHighlight={upgradeHighlight()}1026                  onBreachMonitoringChange={applyBreachMonitoring}1027                  onLockOnCloseChange={applyLockOnClose}1028                  onAutoLockMinutesChange={applyAutoLockMinutes}1029                  onTrashRetentionChange={applyTrashRetention}1030                  onThemeChange={applyTheme}1031                  onAccentChange={applyAccentColor}1032                  onDisplayFontChange={applyDisplayFont}1033                  onTextSizeChange={applyTextSize}1034                  onReduceMotionChange={applyReduceMotion}1035                  shortcuts={shortcutBindings()}1036                  onShortcutsChange={applyShortcuts}1037                  onLicenseChange={refreshProStatus}1038                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1039                  onImport={() => setView("import")}1040                  onExport={() => setView("export")}1041                  onBreachCheckDone={loadEntries}1042                  onOpenHealth={openHealth}1043                  onOpenCJKImport={handleOpenCJKImport}1044                  onOpenFileImport={handleOpenFileImport}1045                />1046              </Suspense>1047            </Show>1048            <Show when={view() === "add"}>1049              <AddView1050                entry={editEntry()}1051                entries={entries()}1052                onBack={handleBack}1053                onSave={handleSave}1054              />1055            </Show>1056            <Show when={view() === "import"}>1057              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1058                <ImportView onBack={handleBack} onImport={handleSave} />1059              </Suspense>1060            </Show>1061            <Show when={view() === "export"}>1062              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1063                <ExportView onBack={handleBack} />1064              </Suspense>1065            </Show>1066            <Show when={view() === "health"}>1067              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1068                <HealthView1069                  entries={entries()}1070                  isPro={proStatus()}1071                  onFixEntry={handleFixEntry}1072                  onBack={() => setView("settings")}1073                  onRefresh={loadEntries}1074                />1075              </Suspense>1076            </Show>1077            <Show when={view() === "trash"}>1078              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1079                <TrashView1080                  onBack={() => setView("list")}1081                  onRestored={() => void loadEntries()}1082                />1083              </Suspense>1084            </Show>1085            <Show when={restoreView() === "cjk"}>1086              <CJKImport1087                mode="onboarding"1088                onBack={() => setRestoreView(null)}1089                onDone={async () => {1090                  setRestoreView(null);1091                  await handleUnlock();1092                }}1093              />1094            </Show>1095            <Show when={restoreView() === "file"}>1096              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1097                <ImportView1098                  mode="onboarding"1099                  replacesExistingVault={true}1100                  onBack={() => setRestoreView(null)}1101                  onImport={async () => {1102                    setRestoreView(null);1103                    await handleUnlock();1104                  }}1105                />1106              </Suspense>1107            </Show>1108            </section>1109            </Show>1110          </Show>1111        </Show>1112      </Show>1113      <Show when={showShortcutsHelp()}>1114        <ShortcutsHelpOverlay1115          bindings={shortcutBindings()}1116          onClose={() => setShowShortcutsHelp(false)}1117        />1118      </Show>1119    </div>1120  );1121}11221123function ShortcutsHelpOverlay(props: {1124  bindings: Record<ShortcutAction, Keybinding>;1125  onClose: () => void;1126}) {1127  return (1128    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1129      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1130        <div class="peach-shortcuts-help-head">1131          <h3>Keyboard shortcuts</h3>1132          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1133        </div>1134        <div class="peach-shortcuts-help-body">1135          <p class="peach-settings-sub-label">ENTRY VIEW</p>1136          <ul>1137            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1138              <li>1139                <span>{d.label}</span>1140                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1141              </li>1142            ))}1143          </ul>1144          <p class="peach-settings-sub-label">ANYWHERE</p>1145          <ul>1146            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1147              <li>1148                <span>{d.label}</span>1149                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1150              </li>1151            ))}1152          </ul>1153        </div>1154      </div>1155    </div>1156  );1157}