App.tsx

32.0 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");190191  let contentPhaseTimer: number | undefined;192193  createEffect(() => {194    document.documentElement.style.setProperty("--ac", accentColor());195  });196197  createEffect(() => {198    document.documentElement.classList.toggle("light", theme() === "light");199    document.body.classList.toggle("light", theme() === "light");200  });201202  const selectedIndex = createMemo(() => {203    const currentId = selectedEntry()?.id;204    if (!currentId) return entries().length > 0 ? 0 : -1;205    return entries().findIndex((entry) => entry.id === currentId);206  });207208  async function applyTheme(t: "dark" | "light") {209    setTheme(t);210    await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });211  }212213  async function applyLockOnClose(enabled: boolean) {214    setLockOnClose(enabled);215    await sendMessage({216      type: "UPDATE_SETTINGS",217      payload: { lockOnClose: enabled },218    });219  }220221  async function applyAutoLockMinutes(minutes: number) {222    setAutoLockMinutes(minutes);223    await sendMessage({224      type: "UPDATE_SETTINGS",225      payload: { autoLockMinutes: minutes },226    });227  }228229  async function applyBreachMonitoring(enabled: boolean) {230    setBreachMonitoring(enabled);231    await sendMessage({232      type: "UPDATE_SETTINGS",233      payload: { breachMonitoring: enabled },234    });235  }236237  onMount(async () => {238    const [unlockResponse, vaultResponse] = await Promise.all([239      sendMessage({ type: "IS_UNLOCKED" }),240      sendMessage({ type: "HAS_VAULT" }),241    ]);242    const unlocked = Boolean(243      (unlockResponse as { ok: boolean; data?: boolean }).data244    );245    const vaultExists = Boolean(246      (vaultResponse as { ok: boolean; data?: boolean }).data247    );248    setHasVault(vaultExists);249    await restoreSettings();250251    if (unlocked && vaultExists) {252      await openVault();253    }254255    await refreshPendingPasskey();256    await refreshCurrentTab();257258    // Track tab changes so smart sort stays current259    const handleTabActivated = () => { void refreshCurrentTab(); };260    const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {261      if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();262    };263    try {264      chrome.tabs.onActivated.addListener(handleTabActivated);265      chrome.tabs.onUpdated.addListener(handleTabUpdated);266      onCleanup(() => {267        chrome.tabs.onActivated.removeListener(handleTabActivated);268        chrome.tabs.onUpdated.removeListener(handleTabUpdated);269      });270    } catch {271      // tabs permission may not be available in all contexts272    }273274    setBootState("ready");275    document.getElementById("pre-lock")?.setAttribute("hidden", "true");276277    // Warm the lazy-module cache so the Suspense fallback never shows for278    // locally-bundled extension views. Without this, the first navigation to279    // Settings/Import/Export/etc can briefly mount `.peach-loading`, which280    // covers the shell and absorbs clicks on the header.281    void Promise.all([282      import("./views/Settings").catch(() => {}),283      import("./views/Import").catch(() => {}),284      import("./views/Export").catch(() => {}),285      import("./views/Health").catch(() => {}),286      import("./views/TrashView").catch(() => {}),287      import("./views/CJKExport").catch(() => {}),288      import("./views/CJKImport").catch(() => {}),289    ]);290291    const handlePageHide = () => {292      if (!lockOnClose() || view() === "lock") return;293      chrome.runtime.sendMessage({ type: "LOCK" });294    };295296    window.addEventListener("pagehide", handlePageHide);297    onCleanup(() => window.removeEventListener("pagehide", handlePageHide));298299    // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale300    // entries instead of waiting for the next GET_ENTRIES round-trip.301    const handleRuntimeMessage = (msg: { type?: string }) => {302      if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();303    };304    chrome.runtime.onMessage.addListener(handleRuntimeMessage);305    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));306307    const handleGlobalKeydown = (e: KeyboardEvent) => {308      const v = view();309      const inInput = (e.target as HTMLElement)?.tagName === "INPUT"310        || (e.target as HTMLElement)?.tagName === "TEXTAREA"311        || (e.target as HTMLElement)?.isContentEditable;312313      // ── Escape ────────────────────────────────────────────────314      if (e.key === "Escape") {315        if (v === "list") { setView("entry"); return; }316        if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }317      }318319      // ── Focus search ─────────────────────────────────────────320      if ((e.key === "/" || (e.ctrlKey && e.key === "f")) && !inInput) {321        e.preventDefault();322        openList();323        return;324      }325326      // ── Lock ──────────────────────────────────────────────────327      if (e.ctrlKey && e.key === "l") {328        e.preventDefault();329        void handleHeaderLock();330        return;331      }332333      // ── Entry-view shortcuts (not in input fields) ────────────334      if (v === "entry" && !inInput) {335        // c → copy password, u → copy username336        if (e.key === "c") {337          e.preventDefault();338          const entry = selectedEntry();339          if (entry?.password) {340            void sendMessage({ type: "COPY", payload: { text: entry.password, label: "Password" } })341              .then(() => triggerRing());342          }343          return;344        }345        if (e.key === "u") {346          e.preventDefault();347          const entry = selectedEntry();348          if (entry?.username) {349            void sendMessage({ type: "COPY", payload: { text: entry.username, label: "Username" } })350              .then(() => triggerRing());351          }352          return;353        }354        // n → new entry355        if (e.key === "n") {356          e.preventDefault();357          handleAdd();358          return;359        }360        // Arrow keys → navigate entries361        if (e.key === "ArrowLeft" || e.key === "ArrowUp") {362          e.preventDefault();363          navigate(-1);364          return;365        }366        if (e.key === "ArrowRight" || e.key === "ArrowDown") {367          e.preventDefault();368          navigate(1);369          return;370        }371      }372    };373    window.addEventListener("keydown", handleGlobalKeydown);374    onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));375376    const handleStorageChanged = (377      changes: Record<string, chrome.storage.StorageChange>,378      areaName: string379    ) => {380      if (areaName !== "session") return;381      if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {382        return;383      }384      void refreshPendingPasskey();385    };386387    chrome.storage.onChanged.addListener(handleStorageChanged);388    onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));389  });390391  async function refreshCurrentTab() {392    try {393      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });394      const url = tab?.url ?? tab?.pendingUrl ?? "";395      setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");396    } catch {397      setCurrentTabDomain("");398    }399  }400401  async function refreshPendingPasskey() {402    const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });403    setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);404  }405406  async function restoreSettings() {407    const res = await sendMessage({ type: "GET_SETTINGS" });408    const s = (res as { ok: boolean; data?: any }).data ?? {};409    if (s.accentColor) setAccentColor(s.accentColor);410    setTheme(s.theme === "light" ? "light" : "dark");411    setLockOnClose(s.lockOnClose !== false);412    setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);413    setBreachMonitoring(s.breachMonitoring !== false);414    setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);415  }416417  async function applyTrashRetention(days: number) {418    setTrashRetentionDays(days);419    await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });420  }421422  // Returns null when it detected a dead service worker and triggered handleLock().423  // Callers that need to abort (e.g. openVault) check for null rather than relying424  // on view() state, which may already be "lock" during normal startup flows.425  async function loadEntries(): Promise<VaultEntry[] | null> {426    const response = await sendMessage({ type: "GET_ENTRIES" });427    const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };428429    // If the service worker was killed and restarted (MV3 workers sleep), the430    // vault will be locked again — or the worker may not respond at all.431    // In either case, send the user to the lock screen rather than silently432    // showing an empty vault (which looks like data loss).433    if (!res.ok) {434      if (435        res.error === "vault locked" ||436        res.error === "SERVICE_WORKER_UNAVAILABLE" ||437        (typeof res.error === "string" && res.error.includes("locked"))438      ) {439        handleLock();440        return null; // signal emergency lock to callers441      }442      return [];443    }444445    const loadedEntries = res.data ?? [];446    setEntries(loadedEntries);447448    const currentId = selectedEntry()?.id;449    if (currentId) {450      setSelectedEntry(451        loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null452      );453    }454455    return loadedEntries;456  }457458  async function openVault(preferredEntryId?: string) {459    // Purge expired trash entries on each vault open460    const retDays = trashRetentionDays();461    if (retDays > 0) {462      void sendMessage({463        type: "PURGE_EXPIRED_TRASH",464        payload: { retentionMs: retDays * 86_400_000 },465      });466    }467468    const loadedEntries = await loadEntries();469470    // null means loadEntries() triggered an emergency lock (service worker died).471    // Don't override it by setting view back to "entry" with an empty vault.472    if (loadedEntries === null) return;473474    // Smart selection: prefer explicitly requested ID, then a domain match, then first entry475    let nextSelectedEntry: VaultEntry | null = null;476    if (preferredEntryId) {477      nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;478    }479    if (!nextSelectedEntry) {480      const tabDomain = currentTabDomain();481      if (tabDomain) {482        nextSelectedEntry = loadedEntries.find((e) => {483          if (e.type === "login" && e.url) return (extractDomain(e.url) ?? "") === tabDomain;484          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;485          return false;486        }) ?? null;487      }488    }489    nextSelectedEntry ??= loadedEntries[0] ?? null;490491    setSelectedEntry(nextSelectedEntry);492    setEditEntry(undefined);493    setContentTransitioning(false);494    setContentPhase("enter");495    queueContentIdle();496    setView("entry");497  }498499  async function handleUnlock() {500    setHasVault(true);501    triggerPipPulse();502    await restoreSettings();503    await openVault();504  }505506  function handleSelectEntry(entry: VaultEntry) {507    setSelectedEntry(entry);508    setContentTransitioning(false);509    setContentPhase("enter");510    queueContentIdle();511    setView("entry");512  }513514  function handleAdd() {515    setEditEntry(undefined);516    setView("add");517  }518519  async function handleDelete() {520    const entry = selectedEntry();521    if (!entry) return;522    await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });523    const loadedEntries = await loadEntries() ?? [];524    setSelectedEntry(loadedEntries[0] ?? null);525    setView("entry");526  }527528  async function handleDeleteMany(ids: string[]) {529    for (const id of ids) {530      await sendMessage({ type: "DELETE_ENTRY", payload: { id } });531    }532    const loadedEntries = await loadEntries() ?? [];533    // If the currently selected entry was among the deleted, move to first534    const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);535    if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);536  }537538  async function handleSave(savedEntry?: VaultEntry) {539    await openVault(savedEntry?.id);540  }541542  function handleBack() {543    setEditEntry(undefined);544    setView("entry");545  }546547  function handleLock() {548    clearCanarySuppressions();549    setEntries([]);550    setSelectedEntry(null);551    setEditEntry(undefined);552    setContentPhase("idle");553    setView("lock");554  }555556  function handleHeaderLock() {557    // Lock the UI immediately — do not wait for the service worker. If the SW558    // is dead or slow, the 5s sendMessage timeout would create a dead-click559    // window. The SW will lock on its next wake regardless.560    handleLock();561    void sendMessage({ type: "LOCK" });562  }563564  function navigate(dir: number) {565    const allEntries = entries();566    if (allEntries.length === 0) return;567    const currentIndex = selectedIndex();568    const baseIndex = currentIndex >= 0 ? currentIndex : 0;569    const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;570    setSelectedEntry(allEntries[nextIndex]);571    setContentPhase("enter");572    queueContentIdle();573  }574575  function openList() {576    setView("list");577  }578579  function openHealth() {580    setEditEntry(undefined);581    setView("health");582  }583584  function handleOpenCJKImport() {585    setRestoreView("cjk");586  }587588  function handleOpenFileImport() {589    setRestoreView("file");590  }591592  const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;593594  function handleFixEntry(entry: VaultEntry) {595    if (entry.type === "passkey") {596      setSelectedEntry(entry);597      setView("entry");598      return;599    }600    setEditEntry(entry);601    setView("add");602  }603604  const isLockedView = () => view() === "lock";605  const selectedPasskeyEntry = createMemo(() =>606    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null607  );608609  async function handleApprovePendingPasskey() {610    const pending = pendingPasskey();611    if (!pending) return;612    await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });613    setPendingPasskey(null);614  }615616  async function handleDenyPendingPasskey() {617    const pending = pendingPasskey();618    if (!pending) return;619    await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });620    setPendingPasskey(null);621  }622623  function queueContentIdle(after?: () => void) {624    if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);625    contentPhaseTimer = window.setTimeout(() => {626      setContentPhase("idle");627      after?.();628    }, ENTRY_TRANSITION_SETTLE_MS);629  }630631  return (632    <div class="popup-root peach-theme">633      <Show when={bootState() === "ready"}>634        <Show635          when={!pendingPasskey()}636          fallback={637            <PasskeyApprovalView638              type={pendingPasskey()!.type}639              rpName={pendingPasskey()!.rpName}640              rpId={pendingPasskey()!.rpId}641              userName={pendingPasskey()!.userName}642              userDisplayName={pendingPasskey()!.userDisplayName}643              onApprove={handleApprovePendingPasskey}644              onDeny={handleDenyPendingPasskey}645            />646          }647        >648          <Show649            when={!isLockedView()}650            fallback={651              <LockView652                hasVault={hasVault()}653                ready={bootState() === "ready"}654                onUnlock={handleUnlock}655              />656            }657          >658            <section659              id="hd-shell"660              class="peach-shell peach-theme"661              data-surface="extension"662            >663            <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>664              <PeachWordmark unlocked />665              <div class="peach-chrome-actions">666                <button667                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"668                  onClick={() => setView("settings")}669                  aria-label="Settings"670                >671                  <svg width="14" height="14" viewBox="0 0 14 14" fill="none">672                    <path673                      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"674                      stroke="currentColor"675                      stroke-width="0.9"676                    />677                    <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />678                  </svg>679                </button>680                <button681                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"682                  onClick={handleAdd}683                  aria-label="New entry"684                >685                  <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">686                    <path d="M5.5 1v9M1 5.5h9" />687                  </svg>688                </button>689                <button690                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"691                  onClick={() => void handleHeaderLock()}692                  aria-label="Lock vault"693                >694                  <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">695                    <path696                      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"697                      stroke="currentColor"698                      stroke-width="1"699                      stroke-linecap="round"700                    />701                    <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />702                    <circle cx="7" cy="10.25" r="1" fill="currentColor" />703                  </svg>704                </button>705              </div>706            </header>707708            <div class="peach-shell-body">709              <div class="peach-shell-veil"></div>710              <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>711                <Show712                  when={selectedPasskeyEntry()}713                  fallback={714                    <EntryView715                      entry={selectedEntry()}716                      onDelete={handleDelete}717                      onFieldSaved={() => void loadEntries()}718                      onEscape={() => { setView("list"); }}719                    />720                  }721                >722                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />723                </Show>724              </div>725726              <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>727                <div class="peach-search-bar">728                  <span class="peach-search-icon">729                    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">730                      <circle cx="5.5" cy="5.5" r="3.75" />731                      <path d="M8.75 8.75L11.5 11.5" />732                    </svg>733                  </span>734                  <input735                    class="peach-search-input"736                    placeholder="search vault..."737                    autocomplete="off"738                    aria-label="Search vault entries"739                    onClick={openList}740                    onFocus={openList}741                    readOnly742                  />743                  <div class="peach-search-meta">744                    <span class="peach-search-counter">745                      {entries().length > 0746                        ? `${selectedIndex() + 1} / ${entries().length}`747                        : "0 entries"}748                    </span>749                    <span class="peach-divider"></span>750                    <button751                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"752                      onClick={() => navigate(-1)}753                      aria-label="Previous entry"754                      disabled={entries().length === 0}755                    >756                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">757                        <path d="M5 1L1 5.5L5 10" />758                      </svg>759                    </button>760                    <button761                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"762                      onClick={() => navigate(1)}763                      aria-label="Next entry"764                      disabled={entries().length === 0}765                    >766                      <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">767                        <path d="M1 1L5 5.5L1 10" />768                      </svg>769                    </button>770                  </div>771                </div>772              </section>773            </div>774775            <Show when={view() === "list"}>776              <VaultView777                entries={entries()}778                selectedEntryId={selectedEntry()?.id}779                currentTabDomain={currentTabDomain()}780                sortOrder={sortOrder()}781                onSelectEntry={handleSelectEntry}782                onSortOrderChange={setSortOrder}783                onAdd={handleAdd}784                onClose={() => setView("entry")}785                onOpenTrash={() => setView("trash")}786                onDeleteMany={handleDeleteMany}787              />788            </Show>789            <Show when={view() === "settings"}>790              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>791                <SettingsView792                  entriesCount={entries().length}793                  breachMonitoring={breachMonitoring()}794                  lockOnClose={lockOnClose()}795                  autoLockMinutes={autoLockMinutes()}796                  trashRetentionDays={trashRetentionDays()}797                  theme={theme()}798                  onBreachMonitoringChange={applyBreachMonitoring}799                  onLockOnCloseChange={applyLockOnClose}800                  onAutoLockMinutesChange={applyAutoLockMinutes}801                  onTrashRetentionChange={applyTrashRetention}802                  onThemeChange={applyTheme}803                  onBack={handleBack}804                  onImport={() => setView("import")}805                  onExport={() => setView("export")}806                  onBreachCheckDone={loadEntries}807                  onOpenHealth={openHealth}808                  onOpenCJKImport={handleOpenCJKImport}809                  onOpenFileImport={handleOpenFileImport}810                />811              </Suspense>812            </Show>813            <Show when={view() === "add"}>814              <AddView815                entry={editEntry()}816                entries={entries()}817                onBack={handleBack}818                onSave={handleSave}819              />820            </Show>821            <Show when={view() === "import"}>822              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>823                <ImportView onBack={handleBack} onImport={handleSave} />824              </Suspense>825            </Show>826            <Show when={view() === "export"}>827              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>828                <ExportView onBack={handleBack} onRequiresUnlock={handleLock} />829              </Suspense>830            </Show>831            <Show when={view() === "health"}>832              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>833                <HealthView834                  entries={entries()}835                  onFixEntry={handleFixEntry}836                  onBack={() => setView("settings")}837                  onRefresh={loadEntries}838                />839              </Suspense>840            </Show>841            <Show when={view() === "trash"}>842              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>843                <TrashView844                  onBack={() => setView("list")}845                  onRestored={() => void loadEntries()}846                />847              </Suspense>848            </Show>849            <Show when={restoreView() === "cjk"}>850              <CJKImport851                mode="onboarding"852                onBack={() => setRestoreView(null)}853                onDone={async () => {854                  setRestoreView(null);855                  await handleUnlock();856                }}857              />858            </Show>859            <Show when={restoreView() === "file"}>860              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>861                <ImportView862                  mode="onboarding"863                  replacesExistingVault={true}864                  onBack={() => setRestoreView(null)}865                  onImport={async () => {866                    setRestoreView(null);867                    await handleUnlock();868                  }}869                />870              </Suspense>871            </Show>872            </section>873          </Show>874        </Show>875      </Show>876    </div>877  );878}