1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { ACCENT_COLORS } from "../shared/types";3import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";4import { LockView } from "./views/Lock";5import { ProCelebration } from "./views/ProCelebration";6import { VaultView, type SortOrder } from "./views/Vault";7import { EntryView } from "./views/Entry";8import { AddView } from "./views/Add";9import { PeachWordmark } from "./components/PeachMark";10import { PasskeyApprovalView } from "./views/PasskeyApproval";11import { PasskeyEntryView } from "./views/PasskeyEntry";12import { extractDomain } from "../shared/domain";13import { popupCopy } from "./copy";14import {15 DEFAULT_BINDINGS,16 formatKeybinding,17 getActiveBindings,18 matchKeybinding,19 SHORTCUT_DESCRIPTORS,20 type Keybinding,21 type ShortcutAction,22} from "../shared/shortcuts";23import { generateTOTP } from "../crypto/totp";2425const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));26const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));27const CJKImport = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));28const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));29const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));30const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3132type View =33 | "lock"34 | "pro-celebration"35 | "entry"36 | "list"37 | "add"38 | "settings"39 | "import"40 | "export"41 | "health"42 | "trash";43type BootState = "loading" | "ready";4445const canarySuppressMap = new Map<string, boolean>();46let shellRingAnimation: Animation | null = null;4748export function isCanarySuppressed(entryId: string): boolean {49 return canarySuppressMap.get(entryId) ?? false;50}5152export function setCanarySuppressed(entryId: string, suppressed: boolean): void {53 canarySuppressMap.set(entryId, suppressed);54}5556export function clearCanarySuppressions(): void {57 canarySuppressMap.clear();58}5960export function triggerRing() {61 const shell = document.getElementById("hd-shell");62 if (!shell) return;6364 shellRingAnimation?.cancel();65 shell.dataset.glowing = "true";66 const animation = shell.animate(67 [68 { offset: 0, boxShadow: "var(--copy-shadow)" },69 {70 offset: 0.3,71 boxShadow:72 "0 2px 8px rgba(0,0,0,0.5), 0 14px 48px rgba(0,0,0,0.6), 0 0 0 1.5px color-mix(in srgb, var(--peach-accent) 58%, transparent), 0 0 22px color-mix(in srgb, var(--peach-accent) 18%, transparent)",73 },74 { offset: 1, boxShadow: "var(--copy-shadow)" },75 ],76 {77 duration: 720,78 easing: "cubic-bezier(0.4, 0, 0.2, 1)",79 fill: "none",80 }81 );82 shellRingAnimation = animation;83 const clearGlow = () => {84 if (shellRingAnimation !== animation) return;85 shellRingAnimation = null;86 delete shell.dataset.glowing;87 };88 animation.onfinish = clearGlow;89 animation.oncancel = clearGlow;90}919293export function triggerPipPulse() {94 const pip = document.querySelector(".peach-status-pip, .pip");95 if (pip) {96 pip.classList.remove("pulse");97 void (pip as HTMLElement).offsetWidth;98 pip.classList.add("pulse");99 }100}101102function humanizeError(error: string | undefined): string | undefined {103 if (!error) return error;104 if (error === "STORAGE_QUOTA_EXCEEDED") {105 return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";106 }107 return error;108}109110async function sendMessage(msg: any): Promise<any> {111 const MESSAGE_TIMEOUT_MS = 5000;112113 return new Promise((resolve) => {114 let settled = false;115 const timeoutId = window.setTimeout(() => {116 if (settled) return;117 settled = true;118 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });119 }, MESSAGE_TIMEOUT_MS);120121 try {122 chrome.runtime.sendMessage(msg, (response) => {123 if (settled) return;124 settled = true;125 window.clearTimeout(timeoutId);126127 if (chrome.runtime.lastError) {128 resolve({129 ok: false,130 error:131 chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",132 });133 return;134 }135136 if (response === undefined) {137 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });138 return;139 }140141 if (response && response.ok === false) {142 response.error = humanizeError(response.error);143 }144 resolve(response);145 });146 } catch (error) {147 if (settled) return;148 settled = true;149 window.clearTimeout(timeoutId);150 resolve({151 ok: false,152 error:153 error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",154 });155 }156 });157}158159export { sendMessage };160161const ENTRY_TRANSITION_EXIT_MS = 95;162const ENTRY_TRANSITION_SETTLE_MS = 135;163164export default function App() {165 const [bootState, setBootState] = createSignal<BootState>("loading");166 const [view, setView] = createSignal<View>("lock");167 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(168 "enter"169 );170 const [contentTransitioning, setContentTransitioning] = createSignal(false);171 const [hasVault, setHasVault] = createSignal(false);172 const [entries, setEntries] = createSignal<VaultEntry[]>([]);173 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);174 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);175 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");176 const [theme, setTheme] = createSignal<"dark" | "light">("dark");177 const [lockOnClose, setLockOnClose] = createSignal(true);178 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);179 const [breachMonitoring, setBreachMonitoring] = createSignal(true);180 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);181 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);182 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);183 const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);184 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);185 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");186 187 188 189 190 const [proStatus, setProStatus] = createSignal(false);191 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);192 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);193 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");194 const [listSearchSeed, setListSearchSeed] = createSignal("");195 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);196 const [pendingCelebration, setPendingCelebration] = createSignal(false);197 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);198199 let contentPhaseTimer: number | undefined;200201 function applyAccent(name: AccentColorName) {202 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;203 const hex = color.hex;204 document.documentElement.style.setProperty("--peach-accent", hex);205 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 if (chrome.storage.session) {343 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as344 Record<string, { feature?: string } | undefined>;345 if (session.pendingProGate?.feature) {346 await chrome.storage.session.remove("pendingProGate");347 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);348 setSettingsInitialSection("license");349 setView("settings");350 } else if (session.pendingProNotice?.feature) {351 await chrome.storage.session.remove("pendingProNotice");352 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });353 }354 }355356 357 358 359 360 void Promise.all([361 import("./views/Settings").catch(() => {}),362 import("./views/Import").catch(() => {}),363 import("./views/Export").catch(() => {}),364 import("./views/Health").catch(() => {}),365 import("./views/TrashView").catch(() => {}),366 import("./views/CJKImport").catch(() => {}),367 ]);368369 370 371 372 let popupPort: chrome.runtime.Port | null = null;373 try {374 popupPort = chrome.runtime.connect({ name: "popup" });375 } catch { }376 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });377378 379 380 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {381 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();382 if (msg?.type === "PRO_GATE_TRIGGERED") {383 384 385 setUpgradeHighlight(msg.payload?.feature ?? null);386 setSettingsInitialSection("license");387 setView("settings");388 }389 };390 chrome.runtime.onMessage.addListener(handleRuntimeMessage);391 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));392393 394 395 396 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {397 copy_password: () => {398 const entry = selectedEntry();399 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });400 },401 copy_username: () => {402 const entry = selectedEntry();403 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });404 },405 copy_totp: () => {406 const entry = selectedEntry();407 if (!entry?.totp) return;408 try {409 const { code } = generateTOTP(entry.totp);410 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });411 } catch { }412 },413 new_entry: () => handleAdd(),414 edit_entry: () => {415 const entry = selectedEntry();416 if (!entry || entry.type === "passkey") return;417 setEditEntry(entry);418 setView("add");419 },420 delete_entry: () => {421 if (selectedEntry()) void handleDelete();422 },423 lock_vault: () => { void handleHeaderLock(); },424 search: () => openList(),425 nav_prev: () => navigate(-1),426 nav_next: () => navigate(1),427 jump_vault: () => setView("entry"),428 jump_settings: () => setView("settings"),429 show_help: () => setShowShortcutsHelp(true),430 };431432 433 434 435 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([436 "copy_password", "copy_username", "copy_totp",437 "edit_entry", "delete_entry", "nav_prev", "nav_next",438 ]);439440 const handleGlobalKeydown = (e: KeyboardEvent) => {441 const v = view();442 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"443 || (e.target as HTMLElement)?.tagName === "TEXTAREA"444 || (e.target as HTMLElement)?.isContentEditable;445 const isPrintableSearchKey =446 e.key.length === 1 &&447 !e.ctrlKey &&448 !e.metaKey &&449 !e.altKey;450451 452 if (v === "pro-celebration") return;453454 455 456 if (showShortcutsHelp()) {457 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }458 return;459 }460461 462 if (e.key === "Escape") {463 if (v === "list") { setView("entry"); return; }464 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }465 }466467 468 469 470 const bindings = shortcutBindings();471 for (const action of Object.keys(bindings) as ShortcutAction[]) {472 const binding = bindings[action];473 if (!matchKeybinding(e, binding)) continue;474 475 476 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;477 if (!hasMod && binding.key.length === 1 && inInput) return;478 if (ENTRY_SCOPED.has(action) && v !== "entry") return;479 e.preventDefault();480 SHORTCUT_HANDLERS[action]?.();481 return;482 }483484 485 486 487 488 if (v === "entry" && !inInput && isPrintableSearchKey) {489 e.preventDefault();490 primeListSearch(e.key);491 return;492 }493 };494 window.addEventListener("keydown", handleGlobalKeydown);495 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));496497 const handleStorageChanged = (498 changes: Record<string, chrome.storage.StorageChange>,499 areaName: string500 ) => {501 if (areaName !== "session") return;502 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {503 return;504 }505 void refreshPendingPasskey();506 };507508 chrome.storage.onChanged.addListener(handleStorageChanged);509 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));510 });511512 async function refreshCurrentTab() {513 try {514 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });515 const url = tab?.url ?? tab?.pendingUrl ?? "";516 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");517 } catch {518 setCurrentTabDomain("");519 }520 }521522 async function refreshPendingPasskey() {523 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });524 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);525 }526527 async function refreshProStatus() {528 const res = await sendMessage({ type: "GET_LICENSE" });529 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;530 setProStatus(Boolean(data?.valid && data?.plan === "pro"));531 }532533 async function restoreSettings() {534 const res = await sendMessage({ type: "GET_SETTINGS" });535 const s = (res as { ok: boolean; data?: any }).data ?? {};536 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {537 setAccentColor(s.accentColor as AccentColorName);538 }539 setTheme(s.theme === "light" ? "light" : "dark");540 setLockOnClose(s.lockOnClose !== false);541 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);542 setBreachMonitoring(s.breachMonitoring !== false);543 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);544 setShortcutBindings(getActiveBindings(s.shortcuts));545 }546547 async function applyTrashRetention(days: number) {548 setTrashRetentionDays(days);549 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });550 }551552 function primeListSearch(seed: string) {553 setListSearchSeed(seed);554 setListSearchSeedNonce((value) => value + 1);555 openList();556 }557558 559 560 561 async function loadEntries(): Promise<VaultEntry[] | null> {562 const response = await sendMessage({ type: "GET_ENTRIES" });563 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };564565 566 567 568 569 if (!res.ok) {570 if (571 res.error === "vault locked" ||572 res.error === "SERVICE_WORKER_UNAVAILABLE" ||573 (typeof res.error === "string" && res.error.includes("locked"))574 ) {575 handleLock();576 return null; 577 }578 return [];579 }580581 const loadedEntries = res.data ?? [];582 setEntries(loadedEntries);583584 const currentId = selectedEntry()?.id;585 if (currentId) {586 setSelectedEntry(587 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null588 );589 }590591 return loadedEntries;592 }593594 async function openVault(preferredEntryId?: string, skipViewSet = false) {595 596 const retDays = trashRetentionDays();597 if (retDays > 0) {598 void sendMessage({599 type: "PURGE_EXPIRED_TRASH",600 payload: { retentionMs: retDays * 86_400_000 },601 });602 }603604 const loadedEntries = await loadEntries();605606 607 608 if (loadedEntries === null) return;609610 611 let nextSelectedEntry: VaultEntry | null = null;612 if (preferredEntryId) {613 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;614 }615 if (!nextSelectedEntry) {616 const tabDomain = currentTabDomain();617 if (tabDomain) {618 nextSelectedEntry = loadedEntries.find((e) => {619 if (e.type === "login") {620 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;621 for (const extra of e.additionalUrls ?? []) {622 if ((extractDomain(extra) ?? "") === tabDomain) return true;623 }624 return false;625 }626 if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;627 return false;628 }) ?? null;629 }630 }631 nextSelectedEntry ??= loadedEntries[0] ?? null;632633 setSelectedEntry(nextSelectedEntry);634 setEditEntry(undefined);635 setContentTransitioning(false);636 setContentPhase("enter");637 queueContentIdle();638 if (!skipViewSet) setView("entry");639 }640641 async function handleUnlock() {642 setHasVault(true);643 triggerPipPulse();644 await restoreSettings();645 const celebrating = pendingCelebration();646 if (celebrating) {647 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });648 setPendingCelebration(false);649 }650 await openVault(undefined, celebrating);651 if (celebrating) {652 setView("pro-celebration");653 window.setTimeout(() => setView("entry"), 2500);654 }655 }656657 function handleSelectEntry(entry: VaultEntry) {658 setSelectedEntry(entry);659 setContentTransitioning(false);660 setContentPhase("enter");661 queueContentIdle();662 setView("entry");663 }664665 function handleAdd() {666 setEditEntry(undefined);667 setView("add");668 }669670 async function handleDelete() {671 const entry = selectedEntry();672 if (!entry) return;673 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });674 const loadedEntries = await loadEntries() ?? [];675 setSelectedEntry(loadedEntries[0] ?? null);676 setView("entry");677 }678679 async function handleDeleteMany(ids: string[]) {680 for (const id of ids) {681 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });682 }683 const loadedEntries = await loadEntries() ?? [];684 685 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);686 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);687 }688689 async function handleSave(savedEntry?: VaultEntry) {690 await openVault(savedEntry?.id);691 }692693 function handleBack() {694 setEditEntry(undefined);695 setView("entry");696 }697698 function handleLock() {699 clearCanarySuppressions();700 setEntries([]);701 setSelectedEntry(null);702 setEditEntry(undefined);703 setContentPhase("idle");704 setView("lock");705 }706707 function handleHeaderLock() {708 709 710 711 handleLock();712 void sendMessage({ type: "LOCK" });713 }714715 function navigate(dir: number) {716 const allEntries = entries();717 if (allEntries.length === 0) return;718 const currentIndex = selectedIndex();719 const baseIndex = currentIndex >= 0 ? currentIndex : 0;720 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;721 setSelectedEntry(allEntries[nextIndex]);722 setContentPhase("enter");723 queueContentIdle();724 }725726 function openList() {727 setView("list");728 }729730 function openHealth() {731 setEditEntry(undefined);732 setView("health");733 }734735 function handleOpenCJKImport() {736 setRestoreView("cjk");737 }738739 function handleOpenFileImport() {740 setRestoreView("file");741 }742743 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;744745 function handleFixEntry(entry: VaultEntry) {746 if (entry.type === "passkey") {747 setSelectedEntry(entry);748 setView("entry");749 return;750 }751 setEditEntry(entry);752 setView("add");753 }754755 const isLockedView = () => view() === "lock";756 const selectedPasskeyEntry = createMemo(() =>757 selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null758 );759760 async function handleApprovePendingPasskey() {761 const pending = pendingPasskey();762 if (!pending) return;763 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });764 setPendingPasskey(null);765 }766767 async function handleDenyPendingPasskey() {768 const pending = pendingPasskey();769 if (!pending) return;770 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });771 setPendingPasskey(null);772 }773774 function queueContentIdle(after?: () => void) {775 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);776 contentPhaseTimer = window.setTimeout(() => {777 setContentPhase("idle");778 after?.();779 }, ENTRY_TRANSITION_SETTLE_MS);780 }781782 return (783 <div class="popup-root peach-theme">784 <Show when={bootState() === "ready"}>785 <Show786 when={!pendingPasskey()}787 fallback={788 <PasskeyApprovalView789 type={pendingPasskey()!.type}790 rpName={pendingPasskey()!.rpName}791 rpId={pendingPasskey()!.rpId}792 userName={pendingPasskey()!.userName}793 userDisplayName={pendingPasskey()!.userDisplayName}794 onApprove={handleApprovePendingPasskey}795 onDeny={handleDenyPendingPasskey}796 />797 }798 >799 <Show800 when={!isLockedView()}801 fallback={802 <LockView803 hasVault={hasVault()}804 ready={bootState() === "ready"}805 isPro={proStatus()}806 onUnlock={handleUnlock}807 />808 }809 >810 <Show811 when={view() !== "pro-celebration"}812 fallback={<ProCelebration />}813 >814 <section815 id="hd-shell"816 class="peach-shell peach-theme"817 data-surface="extension"818 >819 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>820 <PeachWordmark unlocked />821 <div class="peach-chrome-actions">822 <button823 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"824 onClick={() => setView("settings")}825 aria-label="Settings"826 >827 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">828 <path829 d="M5.91 1.11C6.38-.02 7.62-.02 8.09 1.11L8.31 1.64C8.48 2.05 8.91 2.27 9.34 2.17L9.9 2.04C11.1 1.76 11.98 2.64 11.7 3.84L11.57 4.4C11.47 4.83 11.69 5.26 12.1 5.43L12.63 5.65C13.76 6.12 13.76 7.36 12.63 7.83L12.1 8.05C11.69 8.22 11.47 8.65 11.57 9.08L11.7 9.64C11.98 10.84 11.1 11.72 9.9 11.44L9.34 11.31C8.91 11.21 8.48 11.43 8.31 11.84L8.09 12.37C7.62 13.5 6.38 13.5 5.91 12.37L5.69 11.84C5.52 11.43 5.09 11.21 4.66 11.31L4.1 11.44C2.9 11.72 2.02 10.84 2.3 9.64L2.43 9.08C2.53 8.65 2.31 8.22 1.9 8.05L1.37 7.83C.24 7.36.24 6.12 1.37 5.65L1.9 5.43C2.31 5.26 2.53 4.83 2.43 4.4L2.3 3.84C2.02 2.64 2.9 1.76 4.1 2.04L4.66 2.17C5.09 2.27 5.52 2.05 5.69 1.64L5.91 1.11Z"830 stroke="currentColor"831 stroke-width="0.9"832 />833 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />834 </svg>835 </button>836 <button837 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"838 onClick={handleAdd}839 aria-label="New entry"840 >841 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">842 <path d="M5.5 1v9M1 5.5h9" />843 </svg>844 </button>845 <button846 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"847 onClick={() => void handleHeaderLock()}848 aria-label="Lock vault"849 >850 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">851 <path852 d="M3.75 6V4.75C3.75 2.96 5.21 1.5 7 1.5C8.79 1.5 10.25 2.96 10.25 4.75V6"853 stroke="currentColor"854 stroke-width="1"855 stroke-linecap="round"856 />857 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />858 <circle cx="7" cy="10.25" r="1" fill="currentColor" />859 </svg>860 </button>861 </div>862 </header>863864 <Show when={pendingProNotice() !== null}>865 <div class="peach-pro-notice-banner">866 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>867 <button868 class="peach-pro-notice-link"869 onClick={() => {870 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);871 setSettingsInitialSection("license");872 setView("settings");873 setPendingProNotice(null);874 }}875 >876 Learn more →877 </button>878 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>879 </div>880 </Show>881882 <div class="peach-shell-body">883 <div class="peach-shell-veil"></div>884 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>885 <Show886 when={selectedPasskeyEntry()}887 fallback={888 <EntryView889 entry={selectedEntry()}890 onDelete={handleDelete}891 onFieldSaved={() => void loadEntries()}892 onEscape={() => { setView("list"); }}893 />894 }895 >896 <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />897 </Show>898 </div>899900 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>901 <div class="peach-search-bar">902 <span class="peach-search-icon">903 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">904 <circle cx="5.5" cy="5.5" r="3.75" />905 <path d="M8.75 8.75L11.5 11.5" />906 </svg>907 </span>908 <input909 class="peach-search-input"910 placeholder="search vault..."911 autocomplete="off"912 aria-label="Search vault entries"913 onClick={openList}914 onFocus={openList}915 readOnly916 />917 <div class="peach-search-meta">918 <span class="peach-search-counter">919 {entries().length > 0920 ? `${selectedIndex() + 1} / ${entries().length}`921 : "0 entries"}922 </span>923 <span class="peach-divider"></span>924 <button925 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"926 onClick={() => navigate(-1)}927 aria-label="Previous entry"928 disabled={entries().length === 0}929 >930 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">931 <path d="M5 1L1 5.5L5 10" />932 </svg>933 </button>934 <button935 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"936 onClick={() => navigate(1)}937 aria-label="Next entry"938 disabled={entries().length === 0}939 >940 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">941 <path d="M1 1L5 5.5L1 10" />942 </svg>943 </button>944 </div>945 </div>946 </section>947 </div>948949 <Show when={view() === "list"}>950 <VaultView951 entries={entries()}952 selectedEntryId={selectedEntry()?.id}953 currentTabDomain={currentTabDomain()}954 sortOrder={sortOrder()}955 searchSeed={listSearchSeed()}956 searchSeedNonce={listSearchSeedNonce()}957 onSelectEntry={handleSelectEntry}958 onSortOrderChange={setSortOrder}959 onAdd={handleAdd}960 onClose={() => setView("entry")}961 onOpenTrash={() => setView("trash")}962 onDeleteMany={handleDeleteMany}963 />964 </Show>965 <Show when={view() === "settings"}>966 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>967 <SettingsView968 entriesCount={entries().length}969 breachMonitoring={breachMonitoring()}970 lockOnClose={lockOnClose()}971 autoLockMinutes={autoLockMinutes()}972 trashRetentionDays={trashRetentionDays()}973 theme={theme()}974 accentColor={accentColor()}975 isPro={proStatus()}976 initialSection={settingsInitialSection()}977 upgradeHighlight={upgradeHighlight()}978 onBreachMonitoringChange={applyBreachMonitoring}979 onLockOnCloseChange={applyLockOnClose}980 onAutoLockMinutesChange={applyAutoLockMinutes}981 onTrashRetentionChange={applyTrashRetention}982 onThemeChange={applyTheme}983 onAccentChange={applyAccentColor}984 shortcuts={shortcutBindings()}985 onShortcutsChange={applyShortcuts}986 onLicenseChange={refreshProStatus}987 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}988 onImport={() => setView("import")}989 onExport={() => setView("export")}990 onBreachCheckDone={loadEntries}991 onOpenHealth={openHealth}992 onOpenCJKImport={handleOpenCJKImport}993 onOpenFileImport={handleOpenFileImport}994 />995 </Suspense>996 </Show>997 <Show when={view() === "add"}>998 <AddView999 entry={editEntry()}1000 entries={entries()}1001 onBack={handleBack}1002 onSave={handleSave}1003 />1004 </Show>1005 <Show when={view() === "import"}>1006 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1007 <ImportView onBack={handleBack} onImport={handleSave} />1008 </Suspense>1009 </Show>1010 <Show when={view() === "export"}>1011 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1012 <ExportView onBack={handleBack} />1013 </Suspense>1014 </Show>1015 <Show when={view() === "health"}>1016 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1017 <HealthView1018 entries={entries()}1019 isPro={proStatus()}1020 onFixEntry={handleFixEntry}1021 onBack={() => setView("settings")}1022 onRefresh={loadEntries}1023 />1024 </Suspense>1025 </Show>1026 <Show when={view() === "trash"}>1027 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1028 <TrashView1029 onBack={() => setView("list")}1030 onRestored={() => void loadEntries()}1031 />1032 </Suspense>1033 </Show>1034 <Show when={restoreView() === "cjk"}>1035 <CJKImport1036 mode="onboarding"1037 onBack={() => setRestoreView(null)}1038 onDone={async () => {1039 setRestoreView(null);1040 await handleUnlock();1041 }}1042 />1043 </Show>1044 <Show when={restoreView() === "file"}>1045 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1046 <ImportView1047 mode="onboarding"1048 replacesExistingVault={true}1049 onBack={() => setRestoreView(null)}1050 onImport={async () => {1051 setRestoreView(null);1052 await handleUnlock();1053 }}1054 />1055 </Suspense>1056 </Show>1057 </section>1058 </Show>1059 </Show>1060 </Show>1061 </Show>1062 <Show when={showShortcutsHelp()}>1063 <ShortcutsHelpOverlay1064 bindings={shortcutBindings()}1065 onClose={() => setShowShortcutsHelp(false)}1066 />1067 </Show>1068 </div>1069 );1070}10711072function ShortcutsHelpOverlay(props: {1073 bindings: Record<ShortcutAction, Keybinding>;1074 onClose: () => void;1075}) {1076 return (1077 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1078 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1079 <div class="peach-shortcuts-help-head">1080 <h3>Keyboard shortcuts</h3>1081 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1082 </div>1083 <div class="peach-shortcuts-help-body">1084 <p class="peach-settings-sub-label">ENTRY VIEW</p>1085 <ul>1086 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1087 <li>1088 <span>{d.label}</span>1089 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1090 </li>1091 ))}1092 </ul>1093 <p class="peach-settings-sub-label">ANYWHERE</p>1094 <ul>1095 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1096 <li>1097 <span>{d.label}</span>1098 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1099 </li>1100 ))}1101 </ul>1102 </div>1103 </div>1104 </div>1105 );1106}