App.tsx

32.7 KB · raw

1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import type { PasskeyEntry, PendingPasskeyRequest, VaultEntry } from "../shared/types";3import { LockView } from "./views/Lock";4import { VaultView, type SortOrder } from "./views/Vault";5import { EntryView } from "./views/Entry";6import { AddView } from "./views/Add";7import { PeachWordmark } from "./components/PeachMark";8import { PasskeyApprovalView } from "./views/PasskeyApproval";9import { PasskeyEntryView } from "./views/PasskeyEntry";10import { extractDomain } from "../shared/domain";1112const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));13const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));14const CJKExport     = lazy(() => import("./views/CJKExport").then(m => ({ default: m.CJKExportView })));15const CJKImport     = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));16const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));17const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));18const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));1920type View =21  | "lock"22  | "entry"23  | "list"24  | "add"25  | "settings"26  | "import"27  | "export"28  | "health"29  | "trash";30type BootState = "loading" | "ready";3132const canarySuppressMap = new Map<string, boolean>();33let shellRingAnimation: Animation | null = null;34let totpRingResetTimer: number | null = null;3536export function isCanarySuppressed(entryId: string): boolean {37  return canarySuppressMap.get(entryId) ?? false;38}3940export function setCanarySuppressed(entryId: string, suppressed: boolean): void {41  canarySuppressMap.set(entryId, suppressed);42}4344export function clearCanarySuppressions(): void {45  canarySuppressMap.clear();46}4748export function triggerRing() {49  const shell = document.getElementById("hd-shell");50  if (!shell) return;5152  shellRingAnimation?.cancel();53  shell.dataset.glowing = "true";54  const animation = shell.animate(55    [56      { offset: 0, boxShadow: "var(--copy-shadow)" },57      {58        offset: 0.3,59        boxShadow:60          "0 2px 8px rgba(0,0,0,0.5), 0 14px 48px rgba(0,0,0,0.6), 0 0 0 1.5px rgba(217,104,48,0.58), 0 0 22px rgba(217,104,48,0.18)",61      },62      { offset: 1, boxShadow: "var(--copy-shadow)" },63    ],64    {65      duration: 720,66      easing: "cubic-bezier(0.4, 0, 0.2, 1)",67      fill: "none",68    }69  );70  shellRingAnimation = animation;71  const clearGlow = () => {72    if (shellRingAnimation !== animation) return;73    shellRingAnimation = null;74    delete shell.dataset.glowing;75  };76  animation.onfinish = clearGlow;77  animation.oncancel = clearGlow;78}7980export function triggerTotpRing() {81  const shell = document.getElementById("hd-shell");82  if (!shell) return;8384  shell.classList.remove("peach-shell--totp-ring");85  void shell.offsetWidth;86  shell.classList.add("peach-shell--totp-ring");8788  if (totpRingResetTimer !== null) {89    window.clearTimeout(totpRingResetTimer);90  }9192  totpRingResetTimer = window.setTimeout(() => {93    shell.classList.remove("peach-shell--totp-ring");94    totpRingResetTimer = null;95  }, 920);96}9798export function triggerPipPulse() {99  const pip = document.querySelector(".peach-status-pip, .pip");100  if (pip) {101    pip.classList.remove("pulse");102    void (pip as HTMLElement).offsetWidth;103    pip.classList.add("pulse");104  }105}106107function humanizeError(error: string | undefined): string | undefined {108  if (!error) return error;109  if (error === "STORAGE_QUOTA_EXCEEDED") {110    return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";111  }112  return error;113}114115async function sendMessage(msg: any): Promise<any> {116  const MESSAGE_TIMEOUT_MS = 5000;117118  return new Promise((resolve) => {119    let settled = false;120    const timeoutId = window.setTimeout(() => {121      if (settled) return;122      settled = true;123      resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });124    }, MESSAGE_TIMEOUT_MS);125126    try {127      chrome.runtime.sendMessage(msg, (response) => {128        if (settled) return;129        settled = true;130        window.clearTimeout(timeoutId);131132        if (chrome.runtime.lastError) {133          resolve({134            ok: false,135            error:136              chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",137          });138          return;139        }140141        if (response === undefined) {142          resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });143          return;144        }145146        if (response && response.ok === false) {147          response.error = humanizeError(response.error);148        }149        resolve(response);150      });151    } catch (error) {152      if (settled) return;153      settled = true;154      window.clearTimeout(timeoutId);155      resolve({156        ok: false,157        error:158          error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",159      });160    }161  });162}163164export { sendMessage };165166const ENTRY_TRANSITION_EXIT_MS = 95;167const ENTRY_TRANSITION_SETTLE_MS = 135;168169export default function App() {170  const [bootState, setBootState] = createSignal<BootState>("loading");171  const [view, setView] = createSignal<View>("lock");172  const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(173    "enter"174  );175  const [contentTransitioning, setContentTransitioning] = createSignal(false);176  const [hasVault, setHasVault] = createSignal(false);177  const [entries, setEntries] = createSignal<VaultEntry[]>([]);178  const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);179  const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);180  const [accentColor, setAccentColor] = createSignal("#D96830");181  const [theme, setTheme] = createSignal<"dark" | "light">("dark");182  const [lockOnClose, setLockOnClose] = createSignal(true);183  const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);184  const [breachMonitoring, setBreachMonitoring] = createSignal(true);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  const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");190  const [listSearchSeed, setListSearchSeed] = createSignal("");191  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);192193  let contentPhaseTimer: number | undefined;194195  createEffect(() => {196    document.documentElement.style.setProperty("--ac", accentColor());197  });198199  createEffect(() => {200    document.documentElement.classList.toggle("light", theme() === "light");201    document.body.classList.toggle("light", theme() === "light");202  });203204  const selectedIndex = createMemo(() => {205    const currentId = selectedEntry()?.id;206    if (!currentId) return entries().length > 0 ? 0 : -1;207    return entries().findIndex((entry) => entry.id === currentId);208  });209210  async function applyTheme(t: "dark" | "light") {211    setTheme(t);212    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });213  }214215  async function applyLockOnClose(enabled: boolean) {216    setLockOnClose(enabled);217    await sendMessage({218      type: "UPDATE_SETTINGS",219      payload: { lockOnClose: enabled },220    });221  }222223  async function applyAutoLockMinutes(minutes: number) {224    setAutoLockMinutes(minutes);225    await sendMessage({226      type: "UPDATE_SETTINGS",227      payload: { autoLockMinutes: minutes },228    });229  }230231  async function applyBreachMonitoring(enabled: boolean) {232    setBreachMonitoring(enabled);233    await sendMessage({234      type: "UPDATE_SETTINGS",235      payload: { breachMonitoring: enabled },236    });237  }238239  onMount(async () => {240    const [unlockResponse, vaultResponse] = await Promise.all([241      sendMessage({ type: "IS_UNLOCKED" }),242      sendMessage({ type: "HAS_VAULT" }),243    ]);244    const unlocked = Boolean(245      (unlockResponse as { ok: boolean; data?: boolean }).data246    );247    const vaultExists = Boolean(248      (vaultResponse as { ok: boolean; data?: boolean }).data249    );250    setHasVault(vaultExists);251    await restoreSettings();252253    if (unlocked && vaultExists) {254      await openVault();255    }256257    await refreshPendingPasskey();258    await refreshCurrentTab();259260    // Track tab changes so smart sort stays current261    const handleTabActivated = () => { void refreshCurrentTab(); };262    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {263      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();264    };265    try {266      chrome.tabs.onActivated.addListener(handleTabActivated);267      chrome.tabs.onUpdated.addListener(handleTabUpdated);268      onCleanup(() => {269        chrome.tabs.onActivated.removeListener(handleTabActivated);270        chrome.tabs.onUpdated.removeListener(handleTabUpdated);271      });272    } catch {273      // tabs permission may not be available in all contexts274    }275276    setBootState("ready");277    document.getElementById("pre-lock")?.setAttribute("hidden", "true");278279    // Warm the lazy-module cache so the Suspense fallback never shows for280    // locally-bundled extension views. Without this, the first navigation to281    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which282    // covers the shell and absorbs clicks on the header.283    void Promise.all([284      import("./views/Settings").catch(() => {}),285      import("./views/Import").catch(() => {}),286      import("./views/Export").catch(() => {}),287      import("./views/Health").catch(() => {}),288      import("./views/TrashView").catch(() => {}),289      import("./views/CJKExport").catch(() => {}),290      import("./views/CJKImport").catch(() => {}),291    ]);292293    const handlePageHide = () => {294      if (!lockOnClose() || view() === "lock") return;295      chrome.runtime.sendMessage({ type: "LOCK" });296    };297298    window.addEventListener("pagehide", handlePageHide);299    onCleanup(() => window.removeEventListener("pagehide", handlePageHide));300301    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale302    // entries instead of waiting for the next GET_ENTRIES round-trip.303    const handleRuntimeMessage = (msg: { type?: string }) => {304      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();305    };306    chrome.runtime.onMessage.addListener(handleRuntimeMessage);307    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));308309    const handleGlobalKeydown = (e: KeyboardEvent) => {310      const v = view();311      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"312        || (e.target as HTMLElement)?.tagName === "TEXTAREA"313        || (e.target as HTMLElement)?.isContentEditable;314      const isPrintableSearchKey =315        e.key.length === 1 &&316        !e.ctrlKey &&317        !e.metaKey &&318        !e.altKey;319320      // ── Escape ────────────────────────────────────────────────321      if (e.key === "Escape") {322        if (v === "list") { setView("entry"); return; }323        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }324      }325326      // ── Focus search ─────────────────────────────────────────327      if ((e.key === "/" || (e.ctrlKey && e.key === "f")) && !inInput) {328        e.preventDefault();329        openList();330        return;331      }332333      if (v === "entry" && !inInput && isPrintableSearchKey) {334        e.preventDefault();335        primeListSearch(e.key);336        return;337      }338339      // ── Lock ──────────────────────────────────────────────────340      if (e.ctrlKey && e.key === "l") {341        e.preventDefault();342        void handleHeaderLock();343        return;344      }345346      // ── Entry-view shortcuts (not in input fields) ────────────347      if (v === "entry" && !inInput) {348        // c → copy password, u → copy username349        if (e.key === "c") {350          e.preventDefault();351          const entry = selectedEntry();352          if (entry?.password) {353            void sendMessage({ type: "COPY", payload: { text: entry.password, label: "Password" } })354              .then(() => triggerRing());355          }356          return;357        }358        if (e.key === "u") {359          e.preventDefault();360          const entry = selectedEntry();361          if (entry?.username) {362            void sendMessage({ type: "COPY", payload: { text: entry.username, label: "Username" } })363              .then(() => triggerRing());364          }365          return;366        }367        // n → new entry368        if (e.key === "n") {369          e.preventDefault();370          handleAdd();371          return;372        }373        // Arrow keys → navigate entries374        if (e.key === "ArrowLeft" || e.key === "ArrowUp") {375          e.preventDefault();376          navigate(-1);377          return;378        }379        if (e.key === "ArrowRight" || e.key === "ArrowDown") {380          e.preventDefault();381          navigate(1);382          return;383        }384      }385    };386    window.addEventListener("keydown", handleGlobalKeydown);387    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));388389    const handleStorageChanged = (390      changes: Record<string, chrome.storage.StorageChange>,391      areaName: string392    ) => {393      if (areaName !== "session") return;394      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {395        return;396      }397      void refreshPendingPasskey();398    };399400    chrome.storage.onChanged.addListener(handleStorageChanged);401    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));402  });403404  async function refreshCurrentTab() {405    try {406      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });407      const url = tab?.url ?? tab?.pendingUrl ?? "";408      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");409    } catch {410      setCurrentTabDomain("");411    }412  }413414  async function refreshPendingPasskey() {415    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });416    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);417  }418419  async function restoreSettings() {420    const res = await sendMessage({ type: "GET_SETTINGS" });421    const s = (res as { ok: boolean; data?: any }).data ?? {};422    if (s.accentColor) setAccentColor(s.accentColor);423    setTheme(s.theme === "light" ? "light" : "dark");424    setLockOnClose(s.lockOnClose !== false);425    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);426    setBreachMonitoring(s.breachMonitoring !== false);427    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);428  }429430  async function applyTrashRetention(days: number) {431    setTrashRetentionDays(days);432    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });433  }434435  function primeListSearch(seed: string) {436    setListSearchSeed(seed);437    setListSearchSeedNonce((value) => value + 1);438    openList();439  }440441  // Returns null when it detected a dead service worker and triggered handleLock().442  // Callers that need to abort (e.g. openVault) check for null rather than relying443  // on view() state, which may already be "lock" during normal startup flows.444  async function loadEntries(): Promise<VaultEntry[] | null> {445    const response = await sendMessage({ type: "GET_ENTRIES" });446    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };447448    // If the service worker was killed and restarted (MV3 workers sleep), the449    // vault will be locked again — or the worker may not respond at all.450    // In either case, send the user to the lock screen rather than silently451    // showing an empty vault (which looks like data loss).452    if (!res.ok) {453      if (454        res.error === "vault locked" ||455        res.error === "SERVICE_WORKER_UNAVAILABLE" ||456        (typeof res.error === "string" && res.error.includes("locked"))457      ) {458        handleLock();459        return null; // signal emergency lock to callers460      }461      return [];462    }463464    const loadedEntries = res.data ?? [];465    setEntries(loadedEntries);466467    const currentId = selectedEntry()?.id;468    if (currentId) {469      setSelectedEntry(470        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null471      );472    }473474    return loadedEntries;475  }476477  async function openVault(preferredEntryId?: string) {478    // Purge expired trash entries on each vault open479    const retDays = trashRetentionDays();480    if (retDays > 0) {481      void sendMessage({482        type: "PURGE_EXPIRED_TRASH",483        payload: { retentionMs: retDays * 86_400_000 },484      });485    }486487    const loadedEntries = await loadEntries();488489    // null means loadEntries() triggered an emergency lock (service worker died).490    // Don't override it by setting view back to "entry" with an empty vault.491    if (loadedEntries === null) return;492493    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry494    let nextSelectedEntry: VaultEntry | null = null;495    if (preferredEntryId) {496      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;497    }498    if (!nextSelectedEntry) {499      const tabDomain = currentTabDomain();500      if (tabDomain) {501        nextSelectedEntry = loadedEntries.find((e) => {502          if (e.type === "login" && e.url) return (extractDomain(e.url) ?? "") === tabDomain;503          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;504          return false;505        }) ?? null;506      }507    }508    nextSelectedEntry ??= loadedEntries[0] ?? null;509510    setSelectedEntry(nextSelectedEntry);511    setEditEntry(undefined);512    setContentTransitioning(false);513    setContentPhase("enter");514    queueContentIdle();515    setView("entry");516  }517518  async function handleUnlock() {519    setHasVault(true);520    triggerPipPulse();521    await restoreSettings();522    await openVault();523  }524525  function handleSelectEntry(entry: VaultEntry) {526    setSelectedEntry(entry);527    setContentTransitioning(false);528    setContentPhase("enter");529    queueContentIdle();530    setView("entry");531  }532533  function handleAdd() {534    setEditEntry(undefined);535    setView("add");536  }537538  async function handleDelete() {539    const entry = selectedEntry();540    if (!entry) return;541    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });542    const loadedEntries = await loadEntries() ?? [];543    setSelectedEntry(loadedEntries[0] ?? null);544    setView("entry");545  }546547  async function handleDeleteMany(ids: string[]) {548    for (const id of ids) {549      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });550    }551    const loadedEntries = await loadEntries() ?? [];552    // If the currently selected entry was among the deleted, move to first553    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);554    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);555  }556557  async function handleSave(savedEntry?: VaultEntry) {558    await openVault(savedEntry?.id);559  }560561  function handleBack() {562    setEditEntry(undefined);563    setView("entry");564  }565566  function handleLock() {567    clearCanarySuppressions();568    setEntries([]);569    setSelectedEntry(null);570    setEditEntry(undefined);571    setContentPhase("idle");572    setView("lock");573  }574575  function handleHeaderLock() {576    // Lock the UI immediately — do not wait for the service worker. If the SW577    // is dead or slow, the 5s sendMessage timeout would create a dead-click578    // window. The SW will lock on its next wake regardless.579    handleLock();580    void sendMessage({ type: "LOCK" });581  }582583  function navigate(dir: number) {584    const allEntries = entries();585    if (allEntries.length === 0) return;586    const currentIndex = selectedIndex();587    const baseIndex = currentIndex >= 0 ? currentIndex : 0;588    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;589    setSelectedEntry(allEntries[nextIndex]);590    setContentPhase("enter");591    queueContentIdle();592  }593594  function openList() {595    setView("list");596  }597598  function openHealth() {599    setEditEntry(undefined);600    setView("health");601  }602603  function handleOpenCJKImport() {604    setRestoreView("cjk");605  }606607  function handleOpenFileImport() {608    setRestoreView("file");609  }610611  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;612613  function handleFixEntry(entry: VaultEntry) {614    if (entry.type === "passkey") {615      setSelectedEntry(entry);616      setView("entry");617      return;618    }619    setEditEntry(entry);620    setView("add");621  }622623  const isLockedView = () => view() === "lock";624  const selectedPasskeyEntry = createMemo(() =>625    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null626  );627628  async function handleApprovePendingPasskey() {629    const pending = pendingPasskey();630    if (!pending) return;631    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });632    setPendingPasskey(null);633  }634635  async function handleDenyPendingPasskey() {636    const pending = pendingPasskey();637    if (!pending) return;638    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });639    setPendingPasskey(null);640  }641642  function queueContentIdle(after?: () => void) {643    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);644    contentPhaseTimer = window.setTimeout(() => {645      setContentPhase("idle");646      after?.();647    }, ENTRY_TRANSITION_SETTLE_MS);648  }649650  return (651    <div class="popup-root peach-theme">652      <Show when={bootState() === "ready"}>653        <Show654          when={!pendingPasskey()}655          fallback={656            <PasskeyApprovalView657              type={pendingPasskey()!.type}658              rpName={pendingPasskey()!.rpName}659              rpId={pendingPasskey()!.rpId}660              userName={pendingPasskey()!.userName}661              userDisplayName={pendingPasskey()!.userDisplayName}662              onApprove={handleApprovePendingPasskey}663              onDeny={handleDenyPendingPasskey}664            />665          }666        >667          <Show668            when={!isLockedView()}669            fallback={670              <LockView671                hasVault={hasVault()}672                ready={bootState() === "ready"}673                onUnlock={handleUnlock}674              />675            }676          >677            <section678              id="hd-shell"679              class="peach-shell peach-theme"680              data-surface="extension"681            >682            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>683              <PeachWordmark unlocked />684              <div class="peach-chrome-actions">685                <button686                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"687                  onClick={() => setView("settings")}688                  aria-label="Settings"689                >690                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">691                    <path692                      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"693                      stroke="currentColor"694                      stroke-width="0.9"695                    />696                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />697                  </svg>698                </button>699                <button700                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"701                  onClick={handleAdd}702                  aria-label="New entry"703                >704                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">705                    <path d="M5.5 1v9M1 5.5h9" />706                  </svg>707                </button>708                <button709                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"710                  onClick={() => void handleHeaderLock()}711                  aria-label="Lock vault"712                >713                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">714                    <path715                      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"716                      stroke="currentColor"717                      stroke-width="1"718                      stroke-linecap="round"719                    />720                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />721                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />722                  </svg>723                </button>724              </div>725            </header>726727            <div class="peach-shell-body">728              <div class="peach-shell-veil"></div>729              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>730                <Show731                  when={selectedPasskeyEntry()}732                  fallback={733                    <EntryView734                      entry={selectedEntry()}735                      onDelete={handleDelete}736                      onFieldSaved={() => void loadEntries()}737                      onEscape={() => { setView("list"); }}738                    />739                  }740                >741                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />742                </Show>743              </div>744745              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>746                <div class="peach-search-bar">747                  <span class="peach-search-icon">748                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">749                      <circle cx="5.5" cy="5.5" r="3.75" />750                      <path d="M8.75 8.75L11.5 11.5" />751                    </svg>752                  </span>753                  <input754                    class="peach-search-input"755                    placeholder="search vault..."756                    autocomplete="off"757                    aria-label="Search vault entries"758                    onClick={openList}759                    onFocus={openList}760                    readOnly761                  />762                  <div class="peach-search-meta">763                    <span class="peach-search-counter">764                      {entries().length > 0765                        ? `${selectedIndex() + 1} / ${entries().length}`766                        : "0 entries"}767                    </span>768                    <span class="peach-divider"></span>769                    <button770                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"771                      onClick={() => navigate(-1)}772                      aria-label="Previous entry"773                      disabled={entries().length === 0}774                    >775                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">776                        <path d="M5 1L1 5.5L5 10" />777                      </svg>778                    </button>779                    <button780                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"781                      onClick={() => navigate(1)}782                      aria-label="Next entry"783                      disabled={entries().length === 0}784                    >785                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">786                        <path d="M1 1L5 5.5L1 10" />787                      </svg>788                    </button>789                  </div>790                </div>791              </section>792            </div>793794            <Show when={view() === "list"}>795              <VaultView796                entries={entries()}797                selectedEntryId={selectedEntry()?.id}798                currentTabDomain={currentTabDomain()}799                sortOrder={sortOrder()}800                searchSeed={listSearchSeed()}801                searchSeedNonce={listSearchSeedNonce()}802                onSelectEntry={handleSelectEntry}803                onSortOrderChange={setSortOrder}804                onAdd={handleAdd}805                onClose={() => setView("entry")}806                onOpenTrash={() => setView("trash")}807                onDeleteMany={handleDeleteMany}808              />809            </Show>810            <Show when={view() === "settings"}>811              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>812                <SettingsView813                  entriesCount={entries().length}814                  breachMonitoring={breachMonitoring()}815                  lockOnClose={lockOnClose()}816                  autoLockMinutes={autoLockMinutes()}817                  trashRetentionDays={trashRetentionDays()}818                  theme={theme()}819                  onBreachMonitoringChange={applyBreachMonitoring}820                  onLockOnCloseChange={applyLockOnClose}821                  onAutoLockMinutesChange={applyAutoLockMinutes}822                  onTrashRetentionChange={applyTrashRetention}823                  onThemeChange={applyTheme}824                  onBack={handleBack}825                  onImport={() => setView("import")}826                  onExport={() => setView("export")}827                  onBreachCheckDone={loadEntries}828                  onOpenHealth={openHealth}829                  onOpenCJKImport={handleOpenCJKImport}830                  onOpenFileImport={handleOpenFileImport}831                />832              </Suspense>833            </Show>834            <Show when={view() === "add"}>835              <AddView836                entry={editEntry()}837                entries={entries()}838                onBack={handleBack}839                onSave={handleSave}840              />841            </Show>842            <Show when={view() === "import"}>843              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>844                <ImportView onBack={handleBack} onImport={handleSave} />845              </Suspense>846            </Show>847            <Show when={view() === "export"}>848              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>849                <ExportView onBack={handleBack} onRequiresUnlock={handleLock} />850              </Suspense>851            </Show>852            <Show when={view() === "health"}>853              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>854                <HealthView855                  entries={entries()}856                  onFixEntry={handleFixEntry}857                  onBack={() => setView("settings")}858                  onRefresh={loadEntries}859                />860              </Suspense>861            </Show>862            <Show when={view() === "trash"}>863              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>864                <TrashView865                  onBack={() => setView("list")}866                  onRestored={() => void loadEntries()}867                />868              </Suspense>869            </Show>870            <Show when={restoreView() === "cjk"}>871              <CJKImport872                mode="onboarding"873                onBack={() => setRestoreView(null)}874                onDone={async () => {875                  setRestoreView(null);876                  await handleUnlock();877                }}878              />879            </Show>880            <Show when={restoreView() === "file"}>881              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>882                <ImportView883                  mode="onboarding"884                  replacesExistingVault={true}885                  onBack={() => setRestoreView(null)}886                  onImport={async () => {887                    setRestoreView(null);888                    await handleUnlock();889                  }}890                />891              </Suspense>892            </Show>893            </section>894          </Show>895        </Show>896      </Show>897    </div>898  );899}