App.tsx

42.7 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 [lockOnClose, setLockOnClose] = createSignal(true);178  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);179  const [breachMonitoring, setBreachMonitoring] = createSignal(true);180  const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);181  const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);182  const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);183  const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);184  const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);185  const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");186  // Cached Pro status — synchronous for render paths. Background's187  // GET_LICENSE is async, but render-time reads of isPro() must not block.188  // Refreshed at boot, after activate/deactivate, and whenever the popup189  // re-opens onto a stored license that has changed via another window.190  const [proStatus, setProStatus] = createSignal(false);191  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);192  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);193  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");194  const [listSearchSeed, setListSearchSeed] = createSignal("");195  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);196  const [pendingCelebration, setPendingCelebration] = createSignal(false);197  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);198199  let contentPhaseTimer: number | undefined;200201  function applyAccent(name: AccentColorName) {202    const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;203    const hex = color.hex;204    document.documentElement.style.setProperty("--peach-accent", hex);205    // Ambient surface variables — derived from the accent so surfaces shift206    // subtly toward the accent hue rather than staying fixed warm-brown.207    document.documentElement.style.setProperty("--peach-glow", hex + "2A");208    document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");209    document.documentElement.style.setProperty("--peach-border-accent", hex + "24");210  }211212  createEffect(() => {213    applyAccent(accentColor());214  });215216  createEffect(() => {217    document.documentElement.classList.toggle("light", theme() === "light");218    document.body.classList.toggle("light", theme() === "light");219  });220221  const selectedIndex = createMemo(() => {222    const currentId = selectedEntry()?.id;223    if (!currentId) return entries().length > 0 ? 0 : -1;224    return entries().findIndex((entry) => entry.id === currentId);225  });226227  async function applyAccentColor(name: AccentColorName) {228    applyAccent(name);229    setAccentColor(name);230    await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });231  }232233  async function applyTheme(t: "dark" | "light") {234    setTheme(t);235    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });236  }237238  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload239  // small and lets future default updates land for actions the user hasn't240  // touched. Settings sends us the full active map; we filter and store.241  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {242    setShortcutBindings(active);243    const overrides: Record<string, Keybinding> = {};244    for (const action of Object.keys(active) as ShortcutAction[]) {245      const a = active[action];246      const d = DEFAULT_BINDINGS[action];247      const same = a.key === d.key248        && Boolean(a.ctrl)  === Boolean(d.ctrl)249        && Boolean(a.meta)  === Boolean(d.meta)250        && Boolean(a.shift) === Boolean(d.shift)251        && Boolean(a.alt)   === Boolean(d.alt);252      if (!same) overrides[action] = a;253    }254    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });255  }256257  async function applyLockOnClose(enabled: boolean) {258    setLockOnClose(enabled);259    await sendMessage({260      type: "UPDATE_SETTINGS",261      payload: { lockOnClose: enabled },262    });263  }264265  async function applyAutoLockMinutes(minutes: number) {266    setAutoLockMinutes(minutes);267    await sendMessage({268      type: "UPDATE_SETTINGS",269      payload: { autoLockMinutes: minutes },270    });271  }272273  async function applyBreachMonitoring(enabled: boolean) {274    setBreachMonitoring(enabled);275    await sendMessage({276      type: "UPDATE_SETTINGS",277      payload: { breachMonitoring: enabled },278    });279  }280281  onMount(async () => {282    // Resolve the active tab in parallel with the unlock/has-vault queries —283    // openVault()'s smart selection picks the entry matching currentTabDomain,284    // so it must be populated before openVault runs. Previously refreshCurrentTab285    // ran after openVault, leaving popups that booted already-unlocked falling286    // back to loadedEntries[0] instead of the domain match.287    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([288      sendMessage({ type: "IS_UNLOCKED" }),289      sendMessage({ type: "HAS_VAULT" }),290      refreshCurrentTab(),291      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),292    ]);293    const unlocked = Boolean(294      (unlockResponse as { ok: boolean; data?: boolean }).data295    );296    const vaultExists = Boolean(297      (vaultResponse as { ok: boolean; data?: boolean }).data298    );299    const shouldCelebrate = Boolean(300      (celebrationResponse as { ok: boolean; data?: boolean }).data301    );302    setHasVault(vaultExists);303    await Promise.all([restoreSettings(), refreshProStatus()]);304305    // Mark played before any animation so a crash mid-sequence never replays it306    if (shouldCelebrate) {307      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });308    }309310    if (unlocked && vaultExists) {311      await openVault(undefined, shouldCelebrate);312      if (shouldCelebrate) {313        setView("pro-celebration");314        window.setTimeout(() => setView("entry"), 2500);315      }316    } else if (shouldCelebrate) {317      setPendingCelebration(true);318    }319320    await refreshPendingPasskey();321322    // Track tab changes so smart sort stays current323    const handleTabActivated = () => { void refreshCurrentTab(); };324    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {325      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();326    };327    try {328      chrome.tabs.onActivated.addListener(handleTabActivated);329      chrome.tabs.onUpdated.addListener(handleTabUpdated);330      onCleanup(() => {331        chrome.tabs.onActivated.removeListener(handleTabActivated);332        chrome.tabs.onUpdated.removeListener(handleTabUpdated);333      });334    } catch {335      // tabs permission may not be available in all contexts336    }337338    setBootState("ready");339    document.getElementById("pre-lock")?.setAttribute("hidden", "true");340341    // Check for pending session-storage notices from the background.342    if (chrome.storage.session) {343      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as344        Record<string, { feature?: string } | undefined>;345      if (session.pendingProGate?.feature) {346        await chrome.storage.session.remove("pendingProGate");347        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);348        setSettingsInitialSection("license");349        setView("settings");350      } else if (session.pendingProNotice?.feature) {351        await chrome.storage.session.remove("pendingProNotice");352        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });353      }354    }355356    // Warm the lazy-module cache so the Suspense fallback never shows for357    // locally-bundled extension views. Without this, the first navigation to358    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which359    // covers the shell and absorbs clicks on the header.360    void Promise.all([361      import("./views/Settings").catch(() => {}),362      import("./views/Import").catch(() => {}),363      import("./views/Export").catch(() => {}),364      import("./views/Health").catch(() => {}),365      import("./views/TrashView").catch(() => {}),366      import("./views/CJKImport").catch(() => {}),367    ]);368369    // Lock-on-close via a persistent port. sendMessage in pagehide is370    // unreliable — the popup context tears down before the message lands.371    // The SW's onConnect listener locks when this port disconnects.372    let popupPort: chrome.runtime.Port | null = null;373    try {374      popupPort = chrome.runtime.connect({ name: "popup" });375    } catch { /* extension context invalidated — ignore */ }376    onCleanup(() => { try { popupPort?.disconnect(); } catch {} });377378    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale379    // entries instead of waiting for the next GET_ENTRIES round-trip.380    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {381      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();382      if (msg?.type === "PRO_GATE_TRIGGERED") {383        // Background never navigates UI directly — it asks; the popup decides.384        // Land on Settings → License with the triggering feature highlighted.385        setUpgradeHighlight(msg.payload?.feature ?? null);386        setSettingsInitialSection("license");387        setView("settings");388      }389    };390    chrome.runtime.onMessage.addListener(handleRuntimeMessage);391    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));392393    // Action handlers — keyed by ShortcutAction. The dispatcher below tests394    // each binding against the event and runs the matching handler. New395    // shortcuts only need an entry here; the binding is data, not code.396    const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {397      copy_password: () => {398        const entry = selectedEntry();399        if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });400      },401      copy_username: () => {402        const entry = selectedEntry();403        if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });404      },405      copy_totp: () => {406        const entry = selectedEntry();407        if (!entry?.totp) return;408        try {409          const { code } = generateTOTP(entry.totp);410          void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });411        } catch { /* invalid TOTP secret — silent */ }412      },413      new_entry: () => handleAdd(),414      edit_entry: () => {415        const entry = selectedEntry();416        if (!entry || entry.type === "passkey") return;417        setEditEntry(entry);418        setView("add");419      },420      delete_entry: () => {421        if (selectedEntry()) void handleDelete();422      },423      lock_vault: () => { void handleHeaderLock(); },424      search: () => openList(),425      nav_prev: () => navigate(-1),426      nav_next: () => navigate(1),427      jump_vault: () => setView("entry"),428      jump_settings: () => setView("settings"),429      show_help: () => setShowShortcutsHelp(true),430    };431432    // Actions that are MEANINGFUL only on the entry-detail view. Outside433    // that scope the binding is dormant — pressing C while on Settings434    // does nothing rather than awkwardly copying from a now-stale entry.435    const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([436      "copy_password", "copy_username", "copy_totp",437      "edit_entry", "delete_entry", "nav_prev", "nav_next",438    ]);439440    const handleGlobalKeydown = (e: KeyboardEvent) => {441      const v = view();442      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"443        || (e.target as HTMLElement)?.tagName === "TEXTAREA"444        || (e.target as HTMLElement)?.isContentEditable;445      const isPrintableSearchKey =446        e.key.length === 1 &&447        !e.ctrlKey &&448        !e.metaKey &&449        !e.altKey;450451      // No input during the pro celebration sequence452      if (v === "pro-celebration") return;453454      // Help dialog: Escape closes it; eat the keypress so it doesn't455      // also navigate away from the underlying view.456      if (showShortcutsHelp()) {457        if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }458        return;459      }460461      // ── Escape — fixed binding (not user-customizable) ───────462      if (e.key === "Escape") {463        if (v === "list") { setView("entry"); return; }464        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }465      }466467      // Bindings-driven dispatch. Single-key letter bindings without468      // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is469      // inside an input, so users can type into search/forms normally.470      const bindings = shortcutBindings();471      for (const action of Object.keys(bindings) as ShortcutAction[]) {472        const binding = bindings[action];473        if (!matchKeybinding(e, binding)) continue;474        // Single-key letter bindings need the input-focus guard. Bindings475        // with any modifier are safe — Ctrl+L doesn't conflict with typing.476        const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;477        if (!hasMod && binding.key.length === 1 && inInput) return;478        if (ENTRY_SCOPED.has(action) && v !== "entry") return;479        e.preventDefault();480        SHORTCUT_HANDLERS[action]?.();481        return;482      }483484      // Type-to-search: in entry view, any printable key not consumed by a485      // shortcut above primes the list search seed and opens the list.486      // Kept outside the bindings table because it's a behavior, not a487      // discrete action — every printable character can trigger it.488      if (v === "entry" && !inInput && isPrintableSearchKey) {489        e.preventDefault();490        primeListSearch(e.key);491        return;492      }493    };494    window.addEventListener("keydown", handleGlobalKeydown);495    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));496497    const handleStorageChanged = (498      changes: Record<string, chrome.storage.StorageChange>,499      areaName: string500    ) => {501      if (areaName !== "session") return;502      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {503        return;504      }505      void refreshPendingPasskey();506    };507508    chrome.storage.onChanged.addListener(handleStorageChanged);509    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));510  });511512  async function refreshCurrentTab() {513    try {514      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });515      const url = tab?.url ?? tab?.pendingUrl ?? "";516      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");517    } catch {518      setCurrentTabDomain("");519    }520  }521522  async function refreshPendingPasskey() {523    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });524    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);525  }526527  async function refreshProStatus() {528    const res = await sendMessage({ type: "GET_LICENSE" });529    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;530    setProStatus(Boolean(data?.valid && data?.plan === "pro"));531  }532533  async function restoreSettings() {534    const res = await sendMessage({ type: "GET_SETTINGS" });535    const s = (res as { ok: boolean; data?: any }).data ?? {};536    if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {537      setAccentColor(s.accentColor as AccentColorName);538    }539    setTheme(s.theme === "light" ? "light" : "dark");540    setLockOnClose(s.lockOnClose !== false);541    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);542    setBreachMonitoring(s.breachMonitoring !== false);543    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);544    setShortcutBindings(getActiveBindings(s.shortcuts));545  }546547  async function applyTrashRetention(days: number) {548    setTrashRetentionDays(days);549    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });550  }551552  function primeListSearch(seed: string) {553    setListSearchSeed(seed);554    setListSearchSeedNonce((value) => value + 1);555    openList();556  }557558  // Returns null when it detected a dead service worker and triggered handleLock().559  // Callers that need to abort (e.g. openVault) check for null rather than relying560  // on view() state, which may already be "lock" during normal startup flows.561  async function loadEntries(): Promise<VaultEntry[] | null> {562    const response = await sendMessage({ type: "GET_ENTRIES" });563    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };564565    // If the service worker was killed and restarted (MV3 workers sleep), the566    // vault will be locked again — or the worker may not respond at all.567    // In either case, send the user to the lock screen rather than silently568    // showing an empty vault (which looks like data loss).569    if (!res.ok) {570      if (571        res.error === "vault locked" ||572        res.error === "SERVICE_WORKER_UNAVAILABLE" ||573        (typeof res.error === "string" && res.error.includes("locked"))574      ) {575        handleLock();576        return null; // signal emergency lock to callers577      }578      return [];579    }580581    const loadedEntries = res.data ?? [];582    setEntries(loadedEntries);583584    const currentId = selectedEntry()?.id;585    if (currentId) {586      setSelectedEntry(587        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null588      );589    }590591    return loadedEntries;592  }593594  async function openVault(preferredEntryId?: string, skipViewSet = false) {595    // Purge expired trash entries on each vault open596    const retDays = trashRetentionDays();597    if (retDays > 0) {598      void sendMessage({599        type: "PURGE_EXPIRED_TRASH",600        payload: { retentionMs: retDays * 86_400_000 },601      });602    }603604    const loadedEntries = await loadEntries();605606    // null means loadEntries() triggered an emergency lock (service worker died).607    // Don't override it by setting view back to "entry" with an empty vault.608    if (loadedEntries === null) return;609610    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry611    let nextSelectedEntry: VaultEntry | null = null;612    if (preferredEntryId) {613      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;614    }615    if (!nextSelectedEntry) {616      const tabDomain = currentTabDomain();617      if (tabDomain) {618        nextSelectedEntry = loadedEntries.find((e) => {619          if (e.type === "login") {620            if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;621            for (const extra of e.additionalUrls ?? []) {622              if ((extractDomain(extra) ?? "") === tabDomain) return true;623            }624            return false;625          }626          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;627          return false;628        }) ?? null;629      }630    }631    nextSelectedEntry ??= loadedEntries[0] ?? null;632633    setSelectedEntry(nextSelectedEntry);634    setEditEntry(undefined);635    setContentTransitioning(false);636    setContentPhase("enter");637    queueContentIdle();638    if (!skipViewSet) setView("entry");639  }640641  async function handleUnlock() {642    setHasVault(true);643    triggerPipPulse();644    await restoreSettings();645    const celebrating = pendingCelebration();646    if (celebrating) {647      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });648      setPendingCelebration(false);649    }650    await openVault(undefined, celebrating);651    if (celebrating) {652      setView("pro-celebration");653      window.setTimeout(() => setView("entry"), 2500);654    }655  }656657  function handleSelectEntry(entry: VaultEntry) {658    setSelectedEntry(entry);659    setContentTransitioning(false);660    setContentPhase("enter");661    queueContentIdle();662    setView("entry");663  }664665  function handleAdd() {666    setEditEntry(undefined);667    setView("add");668  }669670  async function handleDelete() {671    const entry = selectedEntry();672    if (!entry) return;673    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });674    const loadedEntries = await loadEntries() ?? [];675    setSelectedEntry(loadedEntries[0] ?? null);676    setView("entry");677  }678679  async function handleDeleteMany(ids: string[]) {680    for (const id of ids) {681      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });682    }683    const loadedEntries = await loadEntries() ?? [];684    // If the currently selected entry was among the deleted, move to first685    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);686    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);687  }688689  async function handleSave(savedEntry?: VaultEntry) {690    await openVault(savedEntry?.id);691  }692693  function handleBack() {694    setEditEntry(undefined);695    setView("entry");696  }697698  function handleLock() {699    clearCanarySuppressions();700    setEntries([]);701    setSelectedEntry(null);702    setEditEntry(undefined);703    setContentPhase("idle");704    setView("lock");705  }706707  function handleHeaderLock() {708    // Lock the UI immediately — do not wait for the service worker. If the SW709    // is dead or slow, the 5s sendMessage timeout would create a dead-click710    // window. The SW will lock on its next wake regardless.711    handleLock();712    void sendMessage({ type: "LOCK" });713  }714715  function navigate(dir: number) {716    const allEntries = entries();717    if (allEntries.length === 0) return;718    const currentIndex = selectedIndex();719    const baseIndex = currentIndex >= 0 ? currentIndex : 0;720    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;721    setSelectedEntry(allEntries[nextIndex]);722    setContentPhase("enter");723    queueContentIdle();724  }725726  function openList() {727    setView("list");728  }729730  function openHealth() {731    setEditEntry(undefined);732    setView("health");733  }734735  function handleOpenCJKImport() {736    setRestoreView("cjk");737  }738739  function handleOpenFileImport() {740    setRestoreView("file");741  }742743  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;744745  function handleFixEntry(entry: VaultEntry) {746    if (entry.type === "passkey") {747      setSelectedEntry(entry);748      setView("entry");749      return;750    }751    setEditEntry(entry);752    setView("add");753  }754755  const isLockedView = () => view() === "lock";756  const selectedPasskeyEntry = createMemo(() =>757    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null758  );759760  async function handleApprovePendingPasskey() {761    const pending = pendingPasskey();762    if (!pending) return;763    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });764    setPendingPasskey(null);765  }766767  async function handleDenyPendingPasskey() {768    const pending = pendingPasskey();769    if (!pending) return;770    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });771    setPendingPasskey(null);772  }773774  function queueContentIdle(after?: () => void) {775    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);776    contentPhaseTimer = window.setTimeout(() => {777      setContentPhase("idle");778      after?.();779    }, ENTRY_TRANSITION_SETTLE_MS);780  }781782  return (783    <div class="popup-root peach-theme">784      <Show when={bootState() === "ready"}>785        <Show786          when={!pendingPasskey()}787          fallback={788            <PasskeyApprovalView789              type={pendingPasskey()!.type}790              rpName={pendingPasskey()!.rpName}791              rpId={pendingPasskey()!.rpId}792              userName={pendingPasskey()!.userName}793              userDisplayName={pendingPasskey()!.userDisplayName}794              onApprove={handleApprovePendingPasskey}795              onDeny={handleDenyPendingPasskey}796            />797          }798        >799          <Show800            when={!isLockedView()}801            fallback={802              <LockView803                hasVault={hasVault()}804                ready={bootState() === "ready"}805                isPro={proStatus()}806                onUnlock={handleUnlock}807              />808            }809          >810            <Show811              when={view() !== "pro-celebration"}812              fallback={<ProCelebration />}813            >814            <section815              id="hd-shell"816              class="peach-shell peach-theme"817              data-surface="extension"818            >819            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>820              <PeachWordmark unlocked />821              <div class="peach-chrome-actions">822                <button823                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"824                  onClick={() => setView("settings")}825                  aria-label="Settings"826                >827                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">828                    <path829                      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"830                      stroke="currentColor"831                      stroke-width="0.9"832                    />833                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />834                  </svg>835                </button>836                <button837                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"838                  onClick={handleAdd}839                  aria-label="New entry"840                >841                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">842                    <path d="M5.5 1v9M1 5.5h9" />843                  </svg>844                </button>845                <button846                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"847                  onClick={() => void handleHeaderLock()}848                  aria-label="Lock vault"849                >850                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">851                    <path852                      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"853                      stroke="currentColor"854                      stroke-width="1"855                      stroke-linecap="round"856                    />857                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />858                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />859                  </svg>860                </button>861              </div>862            </header>863864            <Show when={pendingProNotice() !== null}>865              <div class="peach-pro-notice-banner">866                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>867                <button868                  class="peach-pro-notice-link"869                  onClick={() => {870                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);871                    setSettingsInitialSection("license");872                    setView("settings");873                    setPendingProNotice(null);874                  }}875                >876                  Learn more →877                </button>878                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>879              </div>880            </Show>881882            <div class="peach-shell-body">883              <div class="peach-shell-veil"></div>884              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>885                <Show886                  when={selectedPasskeyEntry()}887                  fallback={888                    <EntryView889                      entry={selectedEntry()}890                      onDelete={handleDelete}891                      onFieldSaved={() => void loadEntries()}892                      onEscape={() => { setView("list"); }}893                    />894                  }895                >896                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />897                </Show>898              </div>899900              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>901                <div class="peach-search-bar">902                  <span class="peach-search-icon">903                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">904                      <circle cx="5.5" cy="5.5" r="3.75" />905                      <path d="M8.75 8.75L11.5 11.5" />906                    </svg>907                  </span>908                  <input909                    class="peach-search-input"910                    placeholder="search vault..."911                    autocomplete="off"912                    aria-label="Search vault entries"913                    onClick={openList}914                    onFocus={openList}915                    readOnly916                  />917                  <div class="peach-search-meta">918                    <span class="peach-search-counter">919                      {entries().length > 0920                        ? `${selectedIndex() + 1} / ${entries().length}`921                        : "0 entries"}922                    </span>923                    <span class="peach-divider"></span>924                    <button925                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"926                      onClick={() => navigate(-1)}927                      aria-label="Previous entry"928                      disabled={entries().length === 0}929                    >930                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">931                        <path d="M5 1L1 5.5L5 10" />932                      </svg>933                    </button>934                    <button935                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"936                      onClick={() => navigate(1)}937                      aria-label="Next entry"938                      disabled={entries().length === 0}939                    >940                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">941                        <path d="M1 1L5 5.5L1 10" />942                      </svg>943                    </button>944                  </div>945                </div>946              </section>947            </div>948949            <Show when={view() === "list"}>950              <VaultView951                entries={entries()}952                selectedEntryId={selectedEntry()?.id}953                currentTabDomain={currentTabDomain()}954                sortOrder={sortOrder()}955                searchSeed={listSearchSeed()}956                searchSeedNonce={listSearchSeedNonce()}957                onSelectEntry={handleSelectEntry}958                onSortOrderChange={setSortOrder}959                onAdd={handleAdd}960                onClose={() => setView("entry")}961                onOpenTrash={() => setView("trash")}962                onDeleteMany={handleDeleteMany}963              />964            </Show>965            <Show when={view() === "settings"}>966              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>967                <SettingsView968                  entriesCount={entries().length}969                  breachMonitoring={breachMonitoring()}970                  lockOnClose={lockOnClose()}971                  autoLockMinutes={autoLockMinutes()}972                  trashRetentionDays={trashRetentionDays()}973                  theme={theme()}974                  accentColor={accentColor()}975                  isPro={proStatus()}976                  initialSection={settingsInitialSection()}977                  upgradeHighlight={upgradeHighlight()}978                  onBreachMonitoringChange={applyBreachMonitoring}979                  onLockOnCloseChange={applyLockOnClose}980                  onAutoLockMinutesChange={applyAutoLockMinutes}981                  onTrashRetentionChange={applyTrashRetention}982                  onThemeChange={applyTheme}983                  onAccentChange={applyAccentColor}984                  shortcuts={shortcutBindings()}985                  onShortcutsChange={applyShortcuts}986                  onLicenseChange={refreshProStatus}987                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}988                  onImport={() => setView("import")}989                  onExport={() => setView("export")}990                  onBreachCheckDone={loadEntries}991                  onOpenHealth={openHealth}992                  onOpenCJKImport={handleOpenCJKImport}993                  onOpenFileImport={handleOpenFileImport}994                />995              </Suspense>996            </Show>997            <Show when={view() === "add"}>998              <AddView999                entry={editEntry()}1000                entries={entries()}1001                onBack={handleBack}1002                onSave={handleSave}1003              />1004            </Show>1005            <Show when={view() === "import"}>1006              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1007                <ImportView onBack={handleBack} onImport={handleSave} />1008              </Suspense>1009            </Show>1010            <Show when={view() === "export"}>1011              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1012                <ExportView onBack={handleBack} />1013              </Suspense>1014            </Show>1015            <Show when={view() === "health"}>1016              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1017                <HealthView1018                  entries={entries()}1019                  isPro={proStatus()}1020                  onFixEntry={handleFixEntry}1021                  onBack={() => setView("settings")}1022                  onRefresh={loadEntries}1023                />1024              </Suspense>1025            </Show>1026            <Show when={view() === "trash"}>1027              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1028                <TrashView1029                  onBack={() => setView("list")}1030                  onRestored={() => void loadEntries()}1031                />1032              </Suspense>1033            </Show>1034            <Show when={restoreView() === "cjk"}>1035              <CJKImport1036                mode="onboarding"1037                onBack={() => setRestoreView(null)}1038                onDone={async () => {1039                  setRestoreView(null);1040                  await handleUnlock();1041                }}1042              />1043            </Show>1044            <Show when={restoreView() === "file"}>1045              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1046                <ImportView1047                  mode="onboarding"1048                  replacesExistingVault={true}1049                  onBack={() => setRestoreView(null)}1050                  onImport={async () => {1051                    setRestoreView(null);1052                    await handleUnlock();1053                  }}1054                />1055              </Suspense>1056            </Show>1057            </section>1058            </Show>1059          </Show>1060        </Show>1061      </Show>1062      <Show when={showShortcutsHelp()}>1063        <ShortcutsHelpOverlay1064          bindings={shortcutBindings()}1065          onClose={() => setShowShortcutsHelp(false)}1066        />1067      </Show>1068    </div>1069  );1070}10711072function ShortcutsHelpOverlay(props: {1073  bindings: Record<ShortcutAction, Keybinding>;1074  onClose: () => void;1075}) {1076  return (1077    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1078      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1079        <div class="peach-shortcuts-help-head">1080          <h3>Keyboard shortcuts</h3>1081          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1082        </div>1083        <div class="peach-shortcuts-help-body">1084          <p class="peach-settings-sub-label">ENTRY VIEW</p>1085          <ul>1086            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1087              <li>1088                <span>{d.label}</span>1089                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1090              </li>1091            ))}1092          </ul>1093          <p class="peach-settings-sub-label">ANYWHERE</p>1094          <ul>1095            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1096              <li>1097                <span>{d.label}</span>1098                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1099              </li>1100            ))}1101          </ul>1102        </div>1103      </div>1104    </div>1105  );1106}