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 CJKExport = lazy(() => import("./views/CJKExport").then(m => ({ default: m.CJKExportView })));28const CJKImport = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));29const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));30const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));31const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3233type View =34 | "lock"35 | "pro-celebration"36 | "entry"37 | "list"38 | "add"39 | "settings"40 | "import"41 | "export"42 | "health"43 | "trash";44type BootState = "loading" | "ready";4546const canarySuppressMap = new Map<string, boolean>();47let shellRingAnimation: Animation | null = null;4849export function isCanarySuppressed(entryId: string): boolean {50 return canarySuppressMap.get(entryId) ?? false;51}5253export function setCanarySuppressed(entryId: string, suppressed: boolean): void {54 canarySuppressMap.set(entryId, suppressed);55}5657export function clearCanarySuppressions(): void {58 canarySuppressMap.clear();59}6061export function triggerRing() {62 const shell = document.getElementById("hd-shell");63 if (!shell) return;6465 shellRingAnimation?.cancel();66 shell.dataset.glowing = "true";67 const animation = shell.animate(68 [69 { offset: 0, boxShadow: "var(--copy-shadow)" },70 {71 offset: 0.3,72 boxShadow:73 "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)",74 },75 { offset: 1, boxShadow: "var(--copy-shadow)" },76 ],77 {78 duration: 720,79 easing: "cubic-bezier(0.4, 0, 0.2, 1)",80 fill: "none",81 }82 );83 shellRingAnimation = animation;84 const clearGlow = () => {85 if (shellRingAnimation !== animation) return;86 shellRingAnimation = null;87 delete shell.dataset.glowing;88 };89 animation.onfinish = clearGlow;90 animation.oncancel = clearGlow;91}929394export function triggerPipPulse() {95 const pip = document.querySelector(".peach-status-pip, .pip");96 if (pip) {97 pip.classList.remove("pulse");98 void (pip as HTMLElement).offsetWidth;99 pip.classList.add("pulse");100 }101}102103function humanizeError(error: string | undefined): string | undefined {104 if (!error) return error;105 if (error === "STORAGE_QUOTA_EXCEEDED") {106 return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";107 }108 return error;109}110111async function sendMessage(msg: any): Promise<any> {112 const MESSAGE_TIMEOUT_MS = 5000;113114 return new Promise((resolve) => {115 let settled = false;116 const timeoutId = window.setTimeout(() => {117 if (settled) return;118 settled = true;119 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });120 }, MESSAGE_TIMEOUT_MS);121122 try {123 chrome.runtime.sendMessage(msg, (response) => {124 if (settled) return;125 settled = true;126 window.clearTimeout(timeoutId);127128 if (chrome.runtime.lastError) {129 resolve({130 ok: false,131 error:132 chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",133 });134 return;135 }136137 if (response === undefined) {138 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });139 return;140 }141142 if (response && response.ok === false) {143 response.error = humanizeError(response.error);144 }145 resolve(response);146 });147 } catch (error) {148 if (settled) return;149 settled = true;150 window.clearTimeout(timeoutId);151 resolve({152 ok: false,153 error:154 error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",155 });156 }157 });158}159160export { sendMessage };161162const ENTRY_TRANSITION_EXIT_MS = 95;163const ENTRY_TRANSITION_SETTLE_MS = 135;164165export default function App() {166 const [bootState, setBootState] = createSignal<BootState>("loading");167 const [view, setView] = createSignal<View>("lock");168 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(169 "enter"170 );171 const [contentTransitioning, setContentTransitioning] = createSignal(false);172 const [hasVault, setHasVault] = createSignal(false);173 const [entries, setEntries] = createSignal<VaultEntry[]>([]);174 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);175 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);176 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");177 const [theme, setTheme] = createSignal<"dark" | "light">("dark");178 const [lockOnClose, setLockOnClose] = createSignal(true);179 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);180 const [breachMonitoring, setBreachMonitoring] = createSignal(true);181 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);182 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);183 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);184 const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);185 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);186 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");187 188 189 190 191 const [proStatus, setProStatus] = createSignal(false);192 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);193 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);194 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");195 const [listSearchSeed, setListSearchSeed] = createSignal("");196 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);197 const [pendingCelebration, setPendingCelebration] = createSignal(false);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 206 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 239 240 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 283 284 285 286 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 306 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 323 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 336 }337338 setBootState("ready");339 document.getElementById("pre-lock")?.setAttribute("hidden", "true");340341 342 343 344 345 void Promise.all([346 import("./views/Settings").catch(() => {}),347 import("./views/Import").catch(() => {}),348 import("./views/Export").catch(() => {}),349 import("./views/Health").catch(() => {}),350 import("./views/TrashView").catch(() => {}),351 import("./views/CJKExport").catch(() => {}),352 import("./views/CJKImport").catch(() => {}),353 ]);354355 356 357 358 let popupPort: chrome.runtime.Port | null = null;359 try {360 popupPort = chrome.runtime.connect({ name: "popup" });361 } catch { }362 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });363364 365 366 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {367 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();368 if (msg?.type === "PRO_GATE_TRIGGERED") {369 370 371 setUpgradeHighlight(msg.payload?.feature ?? null);372 setSettingsInitialSection("license");373 setView("settings");374 }375 };376 chrome.runtime.onMessage.addListener(handleRuntimeMessage);377 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));378379 380 381 382 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {383 copy_password: () => {384 const entry = selectedEntry();385 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });386 },387 copy_username: () => {388 const entry = selectedEntry();389 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });390 },391 copy_totp: () => {392 const entry = selectedEntry();393 if (!entry?.totp) return;394 try {395 const { code } = generateTOTP(entry.totp);396 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });397 } catch { }398 },399 new_entry: () => handleAdd(),400 edit_entry: () => {401 const entry = selectedEntry();402 if (!entry || entry.type === "passkey") return;403 setEditEntry(entry);404 setView("add");405 },406 delete_entry: () => {407 if (selectedEntry()) void handleDelete();408 },409 lock_vault: () => { void handleHeaderLock(); },410 search: () => openList(),411 nav_prev: () => navigate(-1),412 nav_next: () => navigate(1),413 jump_vault: () => setView("entry"),414 jump_settings: () => setView("settings"),415 show_help: () => setShowShortcutsHelp(true),416 };417418 419 420 421 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([422 "copy_password", "copy_username", "copy_totp",423 "edit_entry", "delete_entry", "nav_prev", "nav_next",424 ]);425426 const handleGlobalKeydown = (e: KeyboardEvent) => {427 const v = view();428 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"429 || (e.target as HTMLElement)?.tagName === "TEXTAREA"430 || (e.target as HTMLElement)?.isContentEditable;431 const isPrintableSearchKey =432 e.key.length === 1 &&433 !e.ctrlKey &&434 !e.metaKey &&435 !e.altKey;436437 438 if (v === "pro-celebration") return;439440 441 442 if (showShortcutsHelp()) {443 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }444 return;445 }446447 448 if (e.key === "Escape") {449 if (v === "list") { setView("entry"); return; }450 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }451 }452453 454 455 456 const bindings = shortcutBindings();457 for (const action of Object.keys(bindings) as ShortcutAction[]) {458 const binding = bindings[action];459 if (!matchKeybinding(e, binding)) continue;460 461 462 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;463 if (!hasMod && binding.key.length === 1 && inInput) return;464 if (ENTRY_SCOPED.has(action) && v !== "entry") return;465 e.preventDefault();466 SHORTCUT_HANDLERS[action]?.();467 return;468 }469470 471 472 473 474 if (v === "entry" && !inInput && isPrintableSearchKey) {475 e.preventDefault();476 primeListSearch(e.key);477 return;478 }479 };480 window.addEventListener("keydown", handleGlobalKeydown);481 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));482483 const handleStorageChanged = (484 changes: Record<string, chrome.storage.StorageChange>,485 areaName: string486 ) => {487 if (areaName !== "session") return;488 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {489 return;490 }491 void refreshPendingPasskey();492 };493494 chrome.storage.onChanged.addListener(handleStorageChanged);495 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));496 });497498 async function refreshCurrentTab() {499 try {500 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });501 const url = tab?.url ?? tab?.pendingUrl ?? "";502 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");503 } catch {504 setCurrentTabDomain("");505 }506 }507508 async function refreshPendingPasskey() {509 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });510 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);511 }512513 async function refreshProStatus() {514 const res = await sendMessage({ type: "GET_LICENSE" });515 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;516 setProStatus(Boolean(data?.valid && data?.plan === "pro"));517 }518519 async function restoreSettings() {520 const res = await sendMessage({ type: "GET_SETTINGS" });521 const s = (res as { ok: boolean; data?: any }).data ?? {};522 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {523 setAccentColor(s.accentColor as AccentColorName);524 }525 setTheme(s.theme === "light" ? "light" : "dark");526 setLockOnClose(s.lockOnClose !== false);527 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);528 setBreachMonitoring(s.breachMonitoring !== false);529 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);530 setShortcutBindings(getActiveBindings(s.shortcuts));531 }532533 async function applyTrashRetention(days: number) {534 setTrashRetentionDays(days);535 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });536 }537538 function primeListSearch(seed: string) {539 setListSearchSeed(seed);540 setListSearchSeedNonce((value) => value + 1);541 openList();542 }543544 545 546 547 async function loadEntries(): Promise<VaultEntry[] | null> {548 const response = await sendMessage({ type: "GET_ENTRIES" });549 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };550551 552 553 554 555 if (!res.ok) {556 if (557 res.error === "vault locked" ||558 res.error === "SERVICE_WORKER_UNAVAILABLE" ||559 (typeof res.error === "string" && res.error.includes("locked"))560 ) {561 handleLock();562 return null; 563 }564 return [];565 }566567 const loadedEntries = res.data ?? [];568 setEntries(loadedEntries);569570 const currentId = selectedEntry()?.id;571 if (currentId) {572 setSelectedEntry(573 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null574 );575 }576577 return loadedEntries;578 }579580 async function openVault(preferredEntryId?: string, skipViewSet = false) {581 582 const retDays = trashRetentionDays();583 if (retDays > 0) {584 void sendMessage({585 type: "PURGE_EXPIRED_TRASH",586 payload: { retentionMs: retDays * 86_400_000 },587 });588 }589590 const loadedEntries = await loadEntries();591592 593 594 if (loadedEntries === null) return;595596 597 let nextSelectedEntry: VaultEntry | null = null;598 if (preferredEntryId) {599 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;600 }601 if (!nextSelectedEntry) {602 const tabDomain = currentTabDomain();603 if (tabDomain) {604 nextSelectedEntry = loadedEntries.find((e) => {605 if (e.type === "login") {606 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;607 for (const extra of e.additionalUrls ?? []) {608 if ((extractDomain(extra) ?? "") === tabDomain) return true;609 }610 return false;611 }612 if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;613 return false;614 }) ?? null;615 }616 }617 nextSelectedEntry ??= loadedEntries[0] ?? null;618619 setSelectedEntry(nextSelectedEntry);620 setEditEntry(undefined);621 setContentTransitioning(false);622 setContentPhase("enter");623 queueContentIdle();624 if (!skipViewSet) setView("entry");625 }626627 async function handleUnlock() {628 setHasVault(true);629 triggerPipPulse();630 await restoreSettings();631 const celebrating = pendingCelebration();632 if (celebrating) {633 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });634 setPendingCelebration(false);635 }636 await openVault(undefined, celebrating);637 if (celebrating) {638 setView("pro-celebration");639 window.setTimeout(() => setView("entry"), 2500);640 }641 }642643 function handleSelectEntry(entry: VaultEntry) {644 setSelectedEntry(entry);645 setContentTransitioning(false);646 setContentPhase("enter");647 queueContentIdle();648 setView("entry");649 }650651 function handleAdd() {652 setEditEntry(undefined);653 setView("add");654 }655656 async function handleDelete() {657 const entry = selectedEntry();658 if (!entry) return;659 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });660 const loadedEntries = await loadEntries() ?? [];661 setSelectedEntry(loadedEntries[0] ?? null);662 setView("entry");663 }664665 async function handleDeleteMany(ids: string[]) {666 for (const id of ids) {667 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });668 }669 const loadedEntries = await loadEntries() ?? [];670 671 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);672 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);673 }674675 async function handleSave(savedEntry?: VaultEntry) {676 await openVault(savedEntry?.id);677 }678679 function handleBack() {680 setEditEntry(undefined);681 setView("entry");682 }683684 function handleLock() {685 clearCanarySuppressions();686 setEntries([]);687 setSelectedEntry(null);688 setEditEntry(undefined);689 setContentPhase("idle");690 setView("lock");691 }692693 function handleHeaderLock() {694 695 696 697 handleLock();698 void sendMessage({ type: "LOCK" });699 }700701 function navigate(dir: number) {702 const allEntries = entries();703 if (allEntries.length === 0) return;704 const currentIndex = selectedIndex();705 const baseIndex = currentIndex >= 0 ? currentIndex : 0;706 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;707 setSelectedEntry(allEntries[nextIndex]);708 setContentPhase("enter");709 queueContentIdle();710 }711712 function openList() {713 setView("list");714 }715716 function openHealth() {717 setEditEntry(undefined);718 setView("health");719 }720721 function handleOpenCJKImport() {722 setRestoreView("cjk");723 }724725 function handleOpenFileImport() {726 setRestoreView("file");727 }728729 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;730731 function handleFixEntry(entry: VaultEntry) {732 if (entry.type === "passkey") {733 setSelectedEntry(entry);734 setView("entry");735 return;736 }737 setEditEntry(entry);738 setView("add");739 }740741 const isLockedView = () => view() === "lock";742 const selectedPasskeyEntry = createMemo(() =>743 selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null744 );745746 async function handleApprovePendingPasskey() {747 const pending = pendingPasskey();748 if (!pending) return;749 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });750 setPendingPasskey(null);751 }752753 async function handleDenyPendingPasskey() {754 const pending = pendingPasskey();755 if (!pending) return;756 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });757 setPendingPasskey(null);758 }759760 function queueContentIdle(after?: () => void) {761 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);762 contentPhaseTimer = window.setTimeout(() => {763 setContentPhase("idle");764 after?.();765 }, ENTRY_TRANSITION_SETTLE_MS);766 }767768 return (769 <div class="popup-root peach-theme">770 <Show when={bootState() === "ready"}>771 <Show772 when={!pendingPasskey()}773 fallback={774 <PasskeyApprovalView775 type={pendingPasskey()!.type}776 rpName={pendingPasskey()!.rpName}777 rpId={pendingPasskey()!.rpId}778 userName={pendingPasskey()!.userName}779 userDisplayName={pendingPasskey()!.userDisplayName}780 onApprove={handleApprovePendingPasskey}781 onDeny={handleDenyPendingPasskey}782 />783 }784 >785 <Show786 when={!isLockedView()}787 fallback={788 <LockView789 hasVault={hasVault()}790 ready={bootState() === "ready"}791 isPro={proStatus()}792 onUnlock={handleUnlock}793 />794 }795 >796 <Show797 when={view() !== "pro-celebration"}798 fallback={<ProCelebration />}799 >800 <section801 id="hd-shell"802 class="peach-shell peach-theme"803 data-surface="extension"804 >805 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>806 <PeachWordmark unlocked />807 <div class="peach-chrome-actions">808 <button809 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"810 onClick={() => setView("settings")}811 aria-label="Settings"812 >813 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">814 <path815 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"816 stroke="currentColor"817 stroke-width="0.9"818 />819 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />820 </svg>821 </button>822 <button823 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"824 onClick={handleAdd}825 aria-label="New entry"826 >827 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">828 <path d="M5.5 1v9M1 5.5h9" />829 </svg>830 </button>831 <button832 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"833 onClick={() => void handleHeaderLock()}834 aria-label="Lock vault"835 >836 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">837 <path838 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"839 stroke="currentColor"840 stroke-width="1"841 stroke-linecap="round"842 />843 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />844 <circle cx="7" cy="10.25" r="1" fill="currentColor" />845 </svg>846 </button>847 </div>848 </header>849850 <div class="peach-shell-body">851 <div class="peach-shell-veil"></div>852 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>853 <Show854 when={selectedPasskeyEntry()}855 fallback={856 <EntryView857 entry={selectedEntry()}858 onDelete={handleDelete}859 onFieldSaved={() => void loadEntries()}860 onEscape={() => { setView("list"); }}861 />862 }863 >864 <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />865 </Show>866 </div>867868 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>869 <div class="peach-search-bar">870 <span class="peach-search-icon">871 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">872 <circle cx="5.5" cy="5.5" r="3.75" />873 <path d="M8.75 8.75L11.5 11.5" />874 </svg>875 </span>876 <input877 class="peach-search-input"878 placeholder="search vault..."879 autocomplete="off"880 aria-label="Search vault entries"881 onClick={openList}882 onFocus={openList}883 readOnly884 />885 <div class="peach-search-meta">886 <span class="peach-search-counter">887 {entries().length > 0888 ? `${selectedIndex() + 1} / ${entries().length}`889 : "0 entries"}890 </span>891 <span class="peach-divider"></span>892 <button893 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"894 onClick={() => navigate(-1)}895 aria-label="Previous entry"896 disabled={entries().length === 0}897 >898 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">899 <path d="M5 1L1 5.5L5 10" />900 </svg>901 </button>902 <button903 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"904 onClick={() => navigate(1)}905 aria-label="Next entry"906 disabled={entries().length === 0}907 >908 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">909 <path d="M1 1L5 5.5L1 10" />910 </svg>911 </button>912 </div>913 </div>914 </section>915 </div>916917 <Show when={view() === "list"}>918 <VaultView919 entries={entries()}920 selectedEntryId={selectedEntry()?.id}921 currentTabDomain={currentTabDomain()}922 sortOrder={sortOrder()}923 searchSeed={listSearchSeed()}924 searchSeedNonce={listSearchSeedNonce()}925 onSelectEntry={handleSelectEntry}926 onSortOrderChange={setSortOrder}927 onAdd={handleAdd}928 onClose={() => setView("entry")}929 onOpenTrash={() => setView("trash")}930 onDeleteMany={handleDeleteMany}931 />932 </Show>933 <Show when={view() === "settings"}>934 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>935 <SettingsView936 entriesCount={entries().length}937 breachMonitoring={breachMonitoring()}938 lockOnClose={lockOnClose()}939 autoLockMinutes={autoLockMinutes()}940 trashRetentionDays={trashRetentionDays()}941 theme={theme()}942 accentColor={accentColor()}943 isPro={proStatus()}944 initialSection={settingsInitialSection()}945 upgradeHighlight={upgradeHighlight()}946 onBreachMonitoringChange={applyBreachMonitoring}947 onLockOnCloseChange={applyLockOnClose}948 onAutoLockMinutesChange={applyAutoLockMinutes}949 onTrashRetentionChange={applyTrashRetention}950 onThemeChange={applyTheme}951 onAccentChange={applyAccentColor}952 shortcuts={shortcutBindings()}953 onShortcutsChange={applyShortcuts}954 onLicenseChange={refreshProStatus}955 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}956 onImport={() => setView("import")}957 onExport={() => setView("export")}958 onBreachCheckDone={loadEntries}959 onOpenHealth={openHealth}960 onOpenCJKImport={handleOpenCJKImport}961 onOpenFileImport={handleOpenFileImport}962 />963 </Suspense>964 </Show>965 <Show when={view() === "add"}>966 <AddView967 entry={editEntry()}968 entries={entries()}969 onBack={handleBack}970 onSave={handleSave}971 />972 </Show>973 <Show when={view() === "import"}>974 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>975 <ImportView onBack={handleBack} onImport={handleSave} />976 </Suspense>977 </Show>978 <Show when={view() === "export"}>979 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>980 <ExportView onBack={handleBack} onRequiresUnlock={handleLock} />981 </Suspense>982 </Show>983 <Show when={view() === "health"}>984 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>985 <HealthView986 entries={entries()}987 isPro={proStatus()}988 onFixEntry={handleFixEntry}989 onBack={() => setView("settings")}990 onRefresh={loadEntries}991 />992 </Suspense>993 </Show>994 <Show when={view() === "trash"}>995 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>996 <TrashView997 onBack={() => setView("list")}998 onRestored={() => void loadEntries()}999 />1000 </Suspense>1001 </Show>1002 <Show when={restoreView() === "cjk"}>1003 <CJKImport1004 mode="onboarding"1005 onBack={() => setRestoreView(null)}1006 onDone={async () => {1007 setRestoreView(null);1008 await handleUnlock();1009 }}1010 />1011 </Show>1012 <Show when={restoreView() === "file"}>1013 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1014 <ImportView1015 mode="onboarding"1016 replacesExistingVault={true}1017 onBack={() => setRestoreView(null)}1018 onImport={async () => {1019 setRestoreView(null);1020 await handleUnlock();1021 }}1022 />1023 </Suspense>1024 </Show>1025 </section>1026 </Show>1027 </Show>1028 </Show>1029 </Show>1030 <Show when={showShortcutsHelp()}>1031 <ShortcutsHelpOverlay1032 bindings={shortcutBindings()}1033 onClose={() => setShowShortcutsHelp(false)}1034 />1035 </Show>1036 </div>1037 );1038}10391040function ShortcutsHelpOverlay(props: {1041 bindings: Record<ShortcutAction, Keybinding>;1042 onClose: () => void;1043}) {1044 return (1045 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1046 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1047 <div class="peach-shortcuts-help-head">1048 <h3>Keyboard shortcuts</h3>1049 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1050 </div>1051 <div class="peach-shortcuts-help-body">1052 <p class="peach-settings-sub-label">ENTRY VIEW</p>1053 <ul>1054 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1055 <li>1056 <span>{d.label}</span>1057 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1058 </li>1059 ))}1060 </ul>1061 <p class="peach-settings-sub-label">ANYWHERE</p>1062 <ul>1063 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1064 <li>1065 <span>{d.label}</span>1066 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1067 </li>1068 ))}1069 </ul>1070 </div>1071 </div>1072 </div>1073 );1074}