1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { App as CapacitorApp } from "@capacitor/app";3import { Capacitor, registerPlugin } from "@capacitor/core";4import { Keyboard } from "@capacitor/keyboard";5import { StatusBar, Style } from "@capacitor/status-bar";6import { storageGet, storageSet } from "../shared/storage";7import { sendMessage } from "../shared/bridge";8import { clearInMemoryVault, publishInMemoryVault } from "../shared/in-memory-vault";9import { startFoldMonitoring, type PostureChangedEvent } from "../shared/fold";10import { ACCENT_COLORS } from "../shared/types";11import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";12import type { IncomingPairingRequest } from "../shared/sync-types";13import { isPasskeyOnly } from "../shared/types";14import { LockView } from "./views/Lock";15import { Onboarding } from "./views/Onboarding";16import { ProCelebration } from "./views/ProCelebration";17import { VaultView, type SortOrder } from "./views/Vault";18import { EntryView } from "./views/Entry";19import { AddView } from "./views/Add";20import { PeachWordmark } from "./components/PeachMark";21import { PasskeyApprovalView } from "./views/PasskeyApproval";22import { extractDomain } from "../shared/domain";23import { popupCopy } from "./copy";24import {25 DEFAULT_BINDINGS,26 formatKeybinding,27 getActiveBindings,28 matchKeybinding,29 SHORTCUT_DESCRIPTORS,30 type Keybinding,31 type ShortcutAction,32} from "../shared/shortcuts";33import { generateTOTP } from "../crypto/totp";3435export { sendMessage } from "../shared/bridge";3637const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));38const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));39const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));40const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));41const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));42const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));43const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));4445const Autofill = registerPlugin<{46 checkPendingSave(): Promise<{ pending: string | null }>;47 openAutofillSettings(): Promise<void>;48 isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;49}>("Autofill");5051type View =52 | "lock"53 | "pro-celebration"54 | "entry"55 | "list"56 | "add"57 | "settings"58 | "sync-pairing"59 | "import"60 | "export"61 | "health"62 | "trash"63 | "panic";64type BootState = "loading" | "ready";6566const canarySuppressMap = new Map<string, boolean>();67let shellRingAnimation: Animation | null = null;6869export function isCanarySuppressed(entryId: string): boolean {70 return canarySuppressMap.get(entryId) ?? false;71}7273export function setCanarySuppressed(entryId: string, suppressed: boolean): void {74 canarySuppressMap.set(entryId, suppressed);75}7677export function clearCanarySuppressions(): void {78 canarySuppressMap.clear();79}8081export function triggerRing() {82 const shell = document.getElementById("hd-shell");83 if (!shell) return;8485 shellRingAnimation?.cancel();86 shell.dataset.glowing = "true";87 const animation = shell.animate(88 [89 { offset: 0, boxShadow: "var(--copy-shadow)" },90 {91 offset: 0.3,92 boxShadow:93 "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)",94 },95 { offset: 1, boxShadow: "var(--copy-shadow)" },96 ],97 {98 duration: 720,99 easing: "cubic-bezier(0.4, 0, 0.2, 1)",100 fill: "none",101 }102 );103 shellRingAnimation = animation;104 const clearGlow = () => {105 if (shellRingAnimation !== animation) return;106 shellRingAnimation = null;107 delete shell.dataset.glowing;108 };109 animation.onfinish = clearGlow;110 animation.oncancel = clearGlow;111}112113114export function triggerPipPulse() {115 const pip = document.querySelector(".peach-status-pip, .pip");116 if (pip) {117 pip.classList.remove("pulse");118 void (pip as HTMLElement).offsetWidth;119 pip.classList.add("pulse");120 }121}122123const ENTRY_TRANSITION_EXIT_MS = 95;124const ENTRY_TRANSITION_SETTLE_MS = 135;125126export default function App() {127 const [bootState, setBootState] = createSignal<BootState>("loading");128 const [view, setView] = createSignal<View>("lock");129 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(130 "enter"131 );132 const [contentTransitioning, setContentTransitioning] = createSignal(false);133 const [layoutClass, setLayoutClass] = createSignal<"compact" | "medium" | "expanded">("compact");134 const isTwoPaneLayout = () => layoutClass() === "medium" || layoutClass() === "expanded";135 const [hasVault, setHasVault] = createSignal(false);136 const [onboarded, setOnboarded] = createSignal(true);137 const [entries, setEntries] = createSignal<VaultEntry[]>([]);138 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);139 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);140 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");141 const [theme, setTheme] = createSignal<"dark" | "light">("dark");142 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");143 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");144 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);145 const [lockOnClose, setLockOnClose] = createSignal(true);146 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);147 const [breachMonitoring, setBreachMonitoring] = createSignal(true);148 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);149 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);150 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);151 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);152 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);153 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");154 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");155 156 157 158 159 const [proStatus, setProStatus] = createSignal(false);160 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);161 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);162 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");163 const [listSearchSeed, setListSearchSeed] = createSignal("");164 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);165 const [pendingCelebration, setPendingCelebration] = createSignal(false);166 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);167 const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{168 domain: string;169 username: string;170 password: string;171 } | null>(null);172 const [backExitNotice, setBackExitNotice] = createSignal(false);173 const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);174 const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);175 const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);176 const [incomingPairTick, setIncomingPairTick] = createSignal(0);177 const incomingPairSecondsLeft = () => {178 void incomingPairTick();179 const req = incomingPair();180 if (!req) return 0;181 return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));182 };183 184 185 186 const [returnToPanic, setReturnToPanic] = createSignal(false);187188 let contentPhaseTimer: number | undefined;189 let lastBackExitAt = 0;190191 function applyAccent(name: AccentColorName) {192 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;193 const hex = color.hex;194 document.documentElement.style.setProperty("--peach-accent", hex);195 196 197 document.documentElement.style.setProperty("--peach-glow", hex + "2A");198 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");199 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");200 }201202 createEffect(() => {203 applyAccent(accentColor());204 });205206 createEffect(() => {207 document.documentElement.classList.toggle("light", theme() === "light");208 document.body.classList.toggle("light", theme() === "light");209 if (Capacitor.isNativePlatform()) {210 void applyMobileStatusBar(theme());211 }212 });213214 createEffect(() => {215 document.documentElement.setAttribute("data-display-font", displayFont());216 });217218 createEffect(() => {219 document.documentElement.setAttribute("data-text-size", textSize());220 });221222 createEffect(() => {223 if (reduceMotion()) {224 document.documentElement.setAttribute("data-motion", "reduce");225 } else {226 document.documentElement.removeAttribute("data-motion");227 }228 });229230 const selectedIndex = createMemo(() => {231 const currentId = selectedEntry()?.id;232 if (!currentId) return entries().length > 0 ? 0 : -1;233 return entries().findIndex((entry) => entry.id === currentId);234 });235236 async function applyAccentColor(name: AccentColorName) {237 applyAccent(name);238 setAccentColor(name);239 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });240 }241242 async function applyTheme(t: "dark" | "light") {243 setTheme(t);244 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });245 }246247 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {248 setDisplayFont(f);249 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });250 }251252 async function applyTextSize(size: "small" | "medium" | "large") {253 setTextSize(size);254 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });255 }256257 async function applyReduceMotion(enabled: boolean) {258 setReduceMotion(enabled);259 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });260 }261262 263 264 265 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {266 setShortcutBindings(active);267 const overrides: Record<string, Keybinding> = {};268 for (const action of Object.keys(active) as ShortcutAction[]) {269 const a = active[action];270 const d = DEFAULT_BINDINGS[action];271 const same = a.key === d.key272 && Boolean(a.ctrl) === Boolean(d.ctrl)273 && Boolean(a.meta) === Boolean(d.meta)274 && Boolean(a.shift) === Boolean(d.shift)275 && Boolean(a.alt) === Boolean(d.alt);276 if (!same) overrides[action] = a;277 }278 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });279 }280281 async function applyLockOnClose(enabled: boolean) {282 setLockOnClose(enabled);283 await sendMessage({284 type: "UPDATE_SETTINGS",285 payload: { lockOnClose: enabled },286 });287 }288289 async function applyAutoLockMinutes(minutes: number) {290 setAutoLockMinutes(minutes);291 await sendMessage({292 type: "UPDATE_SETTINGS",293 payload: { autoLockMinutes: minutes },294 });295 }296297 async function applyBreachMonitoring(enabled: boolean) {298 setBreachMonitoring(enabled);299 await sendMessage({300 type: "UPDATE_SETTINGS",301 payload: { breachMonitoring: enabled },302 });303 }304305 async function applyMobileStatusBar(nextTheme: "dark" | "light") {306 try {307 await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });308 await StatusBar.setBackgroundColor({309 color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",310 });311 } catch {312 313 }314 }315316 function handleBackNavigation() {317 if (showShortcutsHelp()) {318 setShowShortcutsHelp(false);319 return;320 }321 if (restoreView() !== null) {322 setRestoreView(null);323 return;324 }325326 switch (view()) {327 case "list":328 setView("entry");329 return;330 case "settings":331 case "add":332 case "import":333 handleBack();334 return;335 case "export":336 if (returnToPanic()) {337 setReturnToPanic(false);338 setView("panic");339 } else {340 handleBack();341 }342 return;343 case "health":344 case "panic":345 case "sync-pairing":346 setView("settings");347 return;348 case "trash":349 setView("list");350 return;351 case "entry": {352 const now = Date.now();353 if (now - lastBackExitAt < 1600) {354 void CapacitorApp.exitApp();355 return;356 }357 lastBackExitAt = now;358 setBackExitNotice(true);359 window.setTimeout(() => setBackExitNotice(false), 1400);360 return;361 }362 default:363 void CapacitorApp.exitApp();364 }365 }366367 onMount(async () => {368 if (Capacitor.isNativePlatform()) {369 document.body.classList.add("peach-mobile");370 document.documentElement.classList.add("peach-mobile-root");371 void applyMobileStatusBar(theme());372373 374 375 const updateLayoutClass = () => {376 const w = window.innerWidth;377 const cls = document.body.classList;378 cls.remove("layout-compact", "layout-medium", "layout-expanded");379 const next: "compact" | "medium" | "expanded" =380 w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";381 cls.add(`layout-${next}`);382 setLayoutClass(next);383 };384 updateLayoutClass();385 window.addEventListener("resize", updateLayoutClass);386387 388 389 const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {390 document.body.setAttribute("data-posture", e.posture);391 document.documentElement.style.setProperty(392 "--peach-hinge-height",393 `${e.hingeHeight}px`,394 );395 });396397 const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {398 document.body.style.paddingBottom = `${info.keyboardHeight}px`;399 });400 const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {401 document.body.style.paddingBottom = "0";402 });403 const backButton = await CapacitorApp.addListener("backButton", () => {404 handleBackNavigation();405 });406407 let touchStartX = 0;408 let touchStartY = 0;409 const handleTouchStart = (event: TouchEvent) => {410 const touch = event.touches[0];411 if (!touch) return;412 touchStartX = touch.clientX;413 touchStartY = touch.clientY;414 };415 const handleTouchEnd = (event: TouchEvent) => {416 const touch = event.changedTouches[0];417 if (!touch) return;418 const dx = touch.clientX - touchStartX;419 const dy = Math.abs(touch.clientY - touchStartY);420 if (touchStartX < 30 && dx > 60 && dy < 40) {421 handleBackNavigation();422 }423 };424 document.addEventListener("touchstart", handleTouchStart, { passive: true });425 document.addEventListener("touchend", handleTouchEnd, { passive: true });426427 onCleanup(() => {428 void keyboardShow.remove();429 void keyboardHide.remove();430 void backButton.remove();431 stopFold();432 window.removeEventListener("resize", updateLayoutClass);433 document.removeEventListener("touchstart", handleTouchStart);434 document.removeEventListener("touchend", handleTouchEnd);435 document.body.style.paddingBottom = "0";436 });437 }438439 440 441 442 443 444 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([445 sendMessage({ type: "IS_UNLOCKED" }),446 sendMessage({ type: "HAS_VAULT" }),447 refreshCurrentTab(),448 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),449 ]);450 const unlocked = Boolean(451 (unlockResponse as { ok: boolean; data?: boolean }).data452 );453 const vaultExists = Boolean(454 (vaultResponse as { ok: boolean; data?: boolean }).data455 );456 const shouldCelebrate = Boolean(457 (celebrationResponse as { ok: boolean; data?: boolean }).data458 );459 setHasVault(vaultExists);460 461 462 463 try {464 const onboarded = await storageGet("peach_onboarded");465 const seen = onboarded === "true";466 if (vaultExists && !seen) {467 await storageSet("peach_onboarded", "true");468 setOnboarded(true);469 } else {470 setOnboarded(seen);471 }472 } catch {473 474 setOnboarded(true);475 }476 await Promise.all([restoreSettings(), refreshProStatus()]);477478 479 if (shouldCelebrate) {480 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });481 }482483 if (unlocked && vaultExists) {484 await openVault(undefined, shouldCelebrate);485 if (shouldCelebrate) {486 setView("pro-celebration");487 window.setTimeout(() => setView("entry"), 2500);488 }489 } else if (shouldCelebrate) {490 setPendingCelebration(true);491 }492493 await refreshPendingPasskey();494 await refreshIncomingPairing();495 await consumeRecentPairing();496497 498 if (!Capacitor.isNativePlatform()) {499 const handleTabActivated = () => { void refreshCurrentTab(); };500 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {501 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();502 };503 try {504 chrome.tabs.onActivated.addListener(handleTabActivated);505 chrome.tabs.onUpdated.addListener(handleTabUpdated);506 onCleanup(() => {507 chrome.tabs.onActivated.removeListener(handleTabActivated);508 chrome.tabs.onUpdated.removeListener(handleTabUpdated);509 });510 } catch {511 512 }513 }514515 setBootState("ready");516 document.getElementById("pre-lock")?.setAttribute("hidden", "true");517518 519 if (!Capacitor.isNativePlatform() && chrome.storage.session) {520 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as521 Record<string, { feature?: string } | undefined>;522 if (session.pendingProGate?.feature) {523 await chrome.storage.session.remove("pendingProGate");524 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);525 setSettingsInitialSection("license");526 setView("settings");527 } else if (session.pendingProNotice?.feature) {528 await chrome.storage.session.remove("pendingProNotice");529 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });530 }531 }532533 if (Capacitor.getPlatform() === "android") {534 try {535 const { pending } = await Autofill.checkPendingSave();536 if (pending) {537 const parsed = JSON.parse(pending) as {538 domain?: string;539 username?: string;540 password?: string;541 };542 if (parsed.username && parsed.password) {543 setPendingAutofillSave({544 domain: parsed.domain ?? "",545 username: parsed.username,546 password: parsed.password,547 });548 }549 }550 } catch {551 552 }553 }554555 556 557 558 559 void Promise.all([560 import("./views/Settings").catch(() => {}),561 import("./views/Import").catch(() => {}),562 import("./views/Export").catch(() => {}),563 import("./views/Health").catch(() => {}),564 import("./views/TrashView").catch(() => {}),565 ]);566567 568 569 570 if (!Capacitor.isNativePlatform()) {571 let popupPort: chrome.runtime.Port | null = null;572 try {573 popupPort = chrome.runtime.connect({ name: "popup" });574 } catch { }575 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });576577 578 579 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {580 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();581 if (msg?.type === "PRO_GATE_TRIGGERED") {582 setUpgradeHighlight(msg.payload?.feature ?? null);583 setSettingsInitialSection("license");584 setView("settings");585 }586 if (msg?.type === "INCOMING_PAIRING_REQUEST") {587 void refreshIncomingPairing();588 }589 };590 chrome.runtime.onMessage.addListener(handleRuntimeMessage);591 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));592 }593594 595 596 597 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {598 copy_password: () => {599 const entry = selectedEntry();600 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });601 },602 copy_username: () => {603 const entry = selectedEntry();604 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });605 },606 copy_totp: () => {607 const entry = selectedEntry();608 if (!entry?.totp) return;609 try {610 const { code } = generateTOTP(entry.totp);611 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });612 } catch { }613 },614 new_entry: () => handleAdd(),615 edit_entry: () => {616 const entry = selectedEntry();617 618 619 if (!entry || isPasskeyOnly(entry)) return;620 setEditEntry(entry);621 setView("add");622 },623 delete_entry: () => {624 if (selectedEntry()) void handleDelete();625 },626 lock_vault: () => { void handleHeaderLock(); },627 search: () => openList(),628 nav_prev: () => navigate(-1),629 nav_next: () => navigate(1),630 jump_vault: () => setView("entry"),631 jump_settings: () => setView("settings"),632 show_help: () => setShowShortcutsHelp(true),633 };634635 636 637 638 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([639 "copy_password", "copy_username", "copy_totp",640 "edit_entry", "delete_entry", "nav_prev", "nav_next",641 ]);642643 const handleGlobalKeydown = (e: KeyboardEvent) => {644 const v = view();645 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"646 || (e.target as HTMLElement)?.tagName === "TEXTAREA"647 || (e.target as HTMLElement)?.isContentEditable;648 const isPrintableSearchKey =649 e.key.length === 1 &&650 !e.ctrlKey &&651 !e.metaKey &&652 !e.altKey;653654 655 if (v === "pro-celebration") return;656657 658 659 if (showShortcutsHelp()) {660 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }661 return;662 }663664 665 if (e.key === "Escape") {666 if (v === "list") { setView("entry"); return; }667 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }668 if (v === "sync-pairing") { setView("settings"); return; }669 }670671 672 673 674 const bindings = shortcutBindings();675 for (const action of Object.keys(bindings) as ShortcutAction[]) {676 const binding = bindings[action];677 if (!matchKeybinding(e, binding)) continue;678 679 680 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;681 if (!hasMod && binding.key.length === 1 && inInput) return;682 if (ENTRY_SCOPED.has(action) && v !== "entry") return;683 e.preventDefault();684 SHORTCUT_HANDLERS[action]?.();685 return;686 }687688 689 690 691 692 if (v === "entry" && !inInput && isPrintableSearchKey) {693 e.preventDefault();694 primeListSearch(e.key);695 return;696 }697 };698 window.addEventListener("keydown", handleGlobalKeydown);699 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));700701 if (!Capacitor.isNativePlatform()) {702 const handleStorageChanged = (703 changes: Record<string, chrome.storage.StorageChange>,704 areaName: string705 ) => {706 if (areaName !== "session") return;707 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {708 return;709 }710 void refreshPendingPasskey();711 };712 chrome.storage.onChanged.addListener(handleStorageChanged);713 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));714 }715 });716717 async function refreshCurrentTab() {718 if (Capacitor.isNativePlatform()) return;719 try {720 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });721 const url = tab?.url ?? tab?.pendingUrl ?? "";722 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");723 724 725 const isWebUrl = /^https?:\/\//i.test(url);726 setCurrentTabUrl(isWebUrl ? url : "");727 } catch {728 setCurrentTabDomain("");729 setCurrentTabUrl("");730 }731 }732733 async function refreshPendingPasskey() {734 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });735 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);736 }737738 async function refreshIncomingPairing() {739 const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });740 const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;741 setIncomingPair(data);742 }743744 745 746 747 async function consumeRecentPairing() {748 const res = await sendMessage({ type: "GET_RECENT_PAIRING" });749 const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;750 if (data?.deviceName) {751 setRecentPairToast(data.deviceName);752 window.setTimeout(() => setRecentPairToast(null), 4000);753 }754 }755756 async function respondIncomingPairing(accept: boolean) {757 if (incomingPairResponding()) return;758 setIncomingPairResponding(true);759 await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });760 setIncomingPair(null);761 setIncomingPairResponding(false);762 }763764 765 766 767 createEffect(() => {768 const req = incomingPair();769 if (!req) return;770 const handle = window.setInterval(() => {771 setIncomingPairTick((tick) => tick + 1);772 if (Date.now() >= req.expiresAt) {773 setIncomingPair(null);774 window.clearInterval(handle);775 }776 }, 1000);777 onCleanup(() => window.clearInterval(handle));778 });779780 async function refreshProStatus() {781 const res = await sendMessage({ type: "GET_LICENSE" });782 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;783 setProStatus(Boolean(data?.valid && data?.plan === "pro"));784 }785786 async function restoreSettings() {787 const res = await sendMessage({ type: "GET_SETTINGS" });788 const s = (res as { ok: boolean; data?: any }).data ?? {};789 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {790 setAccentColor(s.accentColor as AccentColorName);791 }792 setTheme(s.theme === "light" ? "light" : "dark");793 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {794 setDisplayFont(s.displayFont);795 } else {796 setDisplayFont("cormorant-italic");797 }798 if (s.textSize === "small" || s.textSize === "large") {799 setTextSize(s.textSize);800 } else {801 setTextSize("medium");802 }803 setReduceMotion(s.reduceMotion === true);804 setLockOnClose(s.lockOnClose !== false);805 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);806 setBreachMonitoring(s.breachMonitoring !== false);807 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);808 setShortcutBindings(getActiveBindings(s.shortcuts));809 }810811 async function applyTrashRetention(days: number) {812 setTrashRetentionDays(days);813 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });814 }815816 function primeListSearch(seed: string) {817 setListSearchSeed(seed);818 setListSearchSeedNonce((value) => value + 1);819 openList();820 }821822 823 824 825 async function loadEntries(): Promise<VaultEntry[] | null> {826 const response = await sendMessage({ type: "GET_ENTRIES" });827 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };828829 830 831 832 833 if (!res.ok) {834 if (835 res.error === "vault locked" ||836 res.error === "SERVICE_WORKER_UNAVAILABLE" ||837 (typeof res.error === "string" && res.error.includes("locked"))838 ) {839 handleLock();840 return null; 841 }842 return [];843 }844845 const loadedEntries = res.data ?? [];846 setEntries(loadedEntries);847 void publishInMemoryVault(loadedEntries);848849 const currentId = selectedEntry()?.id;850 if (currentId) {851 setSelectedEntry(852 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null853 );854 }855856 return loadedEntries;857 }858859 async function openVault(preferredEntryId?: string, skipViewSet = false) {860 861 const retDays = trashRetentionDays();862 if (retDays > 0) {863 void sendMessage({864 type: "PURGE_EXPIRED_TRASH",865 payload: { retentionMs: retDays * 86_400_000 },866 });867 }868869 const loadedEntries = await loadEntries();870871 872 873 if (loadedEntries === null) return;874875 876 let nextSelectedEntry: VaultEntry | null = null;877 if (preferredEntryId) {878 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;879 }880 if (!nextSelectedEntry) {881 const tabDomain = currentTabDomain();882 if (tabDomain) {883 nextSelectedEntry = loadedEntries.find((e) => {884 if (e.type === "login") {885 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;886 for (const extra of e.additionalUrls ?? []) {887 if ((extractDomain(extra) ?? "") === tabDomain) return true;888 }889 return false;890 }891 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;892 return false;893 }) ?? null;894 }895 }896 nextSelectedEntry ??= loadedEntries[0] ?? null;897898 setSelectedEntry(nextSelectedEntry);899 setEditEntry(undefined);900 setContentTransitioning(false);901 setContentPhase("enter");902 queueContentIdle();903 if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");904 }905906 async function handleUnlock() {907 setHasVault(true);908 triggerPipPulse();909 await restoreSettings();910 const celebrating = pendingCelebration();911 if (celebrating) {912 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });913 setPendingCelebration(false);914 }915 await openVault(undefined, celebrating);916 if (celebrating) {917 setView("pro-celebration");918 window.setTimeout(() => setView("entry"), 2500);919 }920 }921922 function handleSelectEntry(entry: VaultEntry) {923 setSelectedEntry(entry);924 setContentTransitioning(false);925 setContentPhase("enter");926 queueContentIdle();927 928 929 if (!isTwoPaneLayout()) setView("entry");930 }931932 function handleAdd() {933 setEditEntry(undefined);934 setView("add");935 }936937 async function handleDelete() {938 const entry = selectedEntry();939 if (!entry) return;940 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });941 const loadedEntries = await loadEntries() ?? [];942 setSelectedEntry(loadedEntries[0] ?? null);943 setView("entry");944 }945946 async function handleDeleteMany(ids: string[]) {947 for (const id of ids) {948 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });949 }950 const loadedEntries = await loadEntries() ?? [];951 952 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);953 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);954 }955956 async function handleSave(savedEntry?: VaultEntry) {957 await openVault(savedEntry?.id);958 }959960 async function savePendingAutofill() {961 const pending = pendingAutofillSave();962 if (!pending) return;963 const domain = pending.domain.trim();964 const response = await sendMessage({965 type: "ADD_ENTRY",966 payload: {967 type: "login",968 title: domain || "Saved login",969 url: domain ? `https://${domain}` : "",970 username: pending.username,971 password: pending.password,972 },973 });974 if ((response as { ok: boolean }).ok) {975 setPendingAutofillSave(null);976 await openVault((response as { data?: VaultEntry }).data?.id);977 }978 }979980 function handleBack() {981 setEditEntry(undefined);982 setView("entry");983 }984985 function handleLock() {986 clearCanarySuppressions();987 setEntries([]);988 setSelectedEntry(null);989 setEditEntry(undefined);990 setContentPhase("idle");991 setView("lock");992 void clearInMemoryVault();993 }994995 function handleHeaderLock() {996 997 998 999 handleLock();1000 void sendMessage({ type: "LOCK" });1001 }10021003 function navigate(dir: number) {1004 const allEntries = entries();1005 if (allEntries.length === 0) return;1006 const currentIndex = selectedIndex();1007 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1008 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1009 setSelectedEntry(allEntries[nextIndex]);1010 setContentPhase("enter");1011 queueContentIdle();1012 }10131014 function openList() {1015 setView("list");1016 }10171018 function openHealth() {1019 setEditEntry(undefined);1020 setView("health");1021 }10221023 function handleOpenFileImport() {1024 setRestoreView("file");1025 }10261027 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10281029 function handleFixEntry(entry: VaultEntry) {1030 1031 1032 if (isPasskeyOnly(entry)) {1033 setSelectedEntry(entry);1034 setView("entry");1035 return;1036 }1037 setEditEntry(entry);1038 setView("add");1039 }10401041 const isLockedView = () => view() === "lock";10421043 async function handleApprovePendingPasskey() {1044 const pending = pendingPasskey();1045 if (!pending) return;1046 if (pending.browser === "firefox" && pending.type === "create") {1047 1048 1049 1050 1051 const { createFirefoxPasskey } = await import("./firefox-passkey");1052 const result = await createFirefoxPasskey(pending);1053 if (!result.ok) {1054 console.warn("Peach Firefox passkey create failed:", result.error);1055 }1056 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1057 setPendingPasskey(null);1058 void loadEntries();1059 return;1060 }1061 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1062 setPendingPasskey(null);1063 }10641065 async function handleDenyPendingPasskey() {1066 const pending = pendingPasskey();1067 if (!pending) return;1068 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1069 setPendingPasskey(null);1070 }10711072 function queueContentIdle(after?: () => void) {1073 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1074 contentPhaseTimer = window.setTimeout(() => {1075 setContentPhase("idle");1076 after?.();1077 }, ENTRY_TRANSITION_SETTLE_MS);1078 }10791080 return (1081 <div class="popup-root peach-theme">1082 <Show when={bootState() === "ready"}>1083 <Show1084 when={!pendingPasskey()}1085 fallback={1086 <PasskeyApprovalView1087 type={pendingPasskey()!.type}1088 rpName={pendingPasskey()!.rpName}1089 rpId={pendingPasskey()!.rpId}1090 userName={pendingPasskey()!.userName}1091 userDisplayName={pendingPasskey()!.userDisplayName}1092 onApprove={handleApprovePendingPasskey}1093 onDeny={handleDenyPendingPasskey}1094 />1095 }1096 >1097 <Show1098 when={!isLockedView()}1099 fallback={1100 <Show1101 when={onboarded() || hasVault()}1102 fallback={1103 <Onboarding1104 onComplete={() => { setOnboarded(true); }}1105 />1106 }1107 >1108 <LockView1109 hasVault={hasVault()}1110 ready={bootState() === "ready"}1111 isPro={proStatus()}1112 onUnlock={handleUnlock}1113 onBackToOnboarding={1114 !hasVault()1115 ? () => setOnboarded(false)1116 : undefined1117 }1118 />1119 </Show>1120 }1121 >1122 <Show1123 when={view() !== "pro-celebration"}1124 fallback={<ProCelebration />}1125 >1126 <section1127 id="hd-shell"1128 class="peach-shell peach-theme"1129 data-surface="extension"1130 >1131 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1132 <PeachWordmark unlocked />1133 <div class="peach-chrome-actions">1134 <button1135 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1136 onClick={() => setView("settings")}1137 aria-label="Settings"1138 >1139 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1140 <path1141 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"1142 stroke="currentColor"1143 stroke-width="0.9"1144 />1145 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1146 </svg>1147 </button>1148 <button1149 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1150 onClick={handleAdd}1151 aria-label="New entry"1152 >1153 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1154 <path d="M5.5 1v9M1 5.5h9" />1155 </svg>1156 </button>1157 <button1158 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1159 onClick={() => void handleHeaderLock()}1160 aria-label="Lock vault"1161 >1162 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1163 <path1164 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"1165 stroke="currentColor"1166 stroke-width="1"1167 stroke-linecap="round"1168 />1169 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1170 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1171 </svg>1172 </button>1173 </div>1174 </header>11751176 <Show when={recentPairToast()}>1177 <div class="peach-pair-toast" role="status" aria-live="polite">1178 Paired with <strong>{recentPairToast()}</strong>1179 </div>1180 </Show>11811182 <Show when={incomingPair() !== null}>1183 <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1184 <div class="peach-pair-request-header">1185 <span class="peach-pair-request-mark">🍑</span>1186 <span class="peach-pair-request-title">Pairing request</span>1187 <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1188 </div>1189 <p class="peach-pair-request-body">1190 <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1191 </p>1192 <div class="peach-pair-request-actions">1193 <button1194 class="peach-btn peach-btn--ghost peach-btn--sm"1195 onClick={() => void respondIncomingPairing(false)}1196 disabled={incomingPairResponding()}1197 >1198 Reject1199 </button>1200 <button1201 class="peach-btn peach-btn--accent peach-btn--sm"1202 onClick={() => void respondIncomingPairing(true)}1203 disabled={incomingPairResponding()}1204 >1205 Accept1206 </button>1207 </div>1208 </div>1209 </Show>12101211 <Show when={pendingProNotice() !== null}>1212 <div class="peach-pro-notice-banner">1213 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1214 <button1215 class="peach-pro-notice-link"1216 onClick={() => {1217 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1218 setSettingsInitialSection("license");1219 setView("settings");1220 setPendingProNotice(null);1221 }}1222 >1223 Learn more →1224 </button>1225 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1226 </div>1227 </Show>12281229 <Show when={pendingAutofillSave() !== null && !isLockedView()}>1230 <div class="peach-pro-notice-banner peach-autofill-save-banner">1231 <span>1232 Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1233 </span>1234 <button1235 class="peach-pro-notice-link"1236 onClick={() => void savePendingAutofill()}1237 >1238 Save1239 </button>1240 <button1241 class="peach-pro-notice-close"1242 onClick={() => setPendingAutofillSave(null)}1243 aria-label="Dismiss"1244 >1245 x1246 </button>1247 </div>1248 </Show>12491250 <Show when={backExitNotice()}>1251 <div class="peach-pro-notice-banner peach-back-exit-banner">1252 <span>Tap back again to exit Peach</span>1253 </div>1254 </Show>12551256 <div class="peach-shell-body">1257 <div class="peach-shell-veil"></div>1258 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1259 <EntryView1260 entry={selectedEntry()}1261 currentTabUrl={currentTabUrl()}1262 onDelete={handleDelete}1263 onFieldSaved={() => void loadEntries()}1264 onEscape={() => { setView("list"); }}1265 />1266 </div>12671268 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1269 <div class="peach-search-bar">1270 <span class="peach-search-icon">1271 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1272 <circle cx="5.5" cy="5.5" r="3.75" />1273 <path d="M8.75 8.75L11.5 11.5" />1274 </svg>1275 </span>1276 <input1277 class="peach-search-input"1278 placeholder="search vault..."1279 autocomplete="off"1280 aria-label="Search vault entries"1281 onClick={openList}1282 onFocus={openList}1283 readOnly1284 />1285 <div class="peach-search-meta">1286 <span class="peach-search-counter">1287 {entries().length > 01288 ? `${selectedIndex() + 1} / ${entries().length}`1289 : "0 entries"}1290 </span>1291 <span class="peach-divider"></span>1292 <button1293 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1294 onClick={() => navigate(-1)}1295 aria-label="Previous entry"1296 disabled={entries().length === 0}1297 >1298 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1299 <path d="M5 1L1 5.5L5 10" />1300 </svg>1301 </button>1302 <button1303 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1304 onClick={() => navigate(1)}1305 aria-label="Next entry"1306 disabled={entries().length === 0}1307 >1308 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1309 <path d="M1 1L5 5.5L1 10" />1310 </svg>1311 </button>1312 </div>1313 </div>1314 </section>1315 </div>13161317 <Show when={view() === "list"}>1318 <VaultView1319 entries={entries()}1320 selectedEntryId={selectedEntry()?.id}1321 currentTabDomain={currentTabDomain()}1322 sortOrder={sortOrder()}1323 searchSeed={listSearchSeed()}1324 searchSeedNonce={listSearchSeedNonce()}1325 onSelectEntry={handleSelectEntry}1326 onSortOrderChange={setSortOrder}1327 onAdd={handleAdd}1328 onClose={() => setView("entry")}1329 onOpenTrash={() => setView("trash")}1330 onDeleteMany={handleDeleteMany}1331 />1332 </Show>1333 <Show when={view() === "settings"}>1334 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1335 <SettingsView1336 entriesCount={entries().length}1337 breachMonitoring={breachMonitoring()}1338 lockOnClose={lockOnClose()}1339 autoLockMinutes={autoLockMinutes()}1340 trashRetentionDays={trashRetentionDays()}1341 theme={theme()}1342 accentColor={accentColor()}1343 displayFont={displayFont()}1344 textSize={textSize()}1345 reduceMotion={reduceMotion()}1346 isPro={proStatus()}1347 initialSection={settingsInitialSection()}1348 upgradeHighlight={upgradeHighlight()}1349 onBreachMonitoringChange={applyBreachMonitoring}1350 onLockOnCloseChange={applyLockOnClose}1351 onAutoLockMinutesChange={applyAutoLockMinutes}1352 onTrashRetentionChange={applyTrashRetention}1353 onThemeChange={applyTheme}1354 onAccentChange={applyAccentColor}1355 onDisplayFontChange={applyDisplayFont}1356 onTextSizeChange={applyTextSize}1357 onReduceMotionChange={applyReduceMotion}1358 shortcuts={shortcutBindings()}1359 onShortcutsChange={applyShortcuts}1360 onLicenseChange={async () => {1361 1362 1363 1364 1365 1366 await refreshProStatus();1367 void loadEntries();1368 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1369 if ((should as { ok: boolean; data?: boolean }).data === true) {1370 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1371 setView("pro-celebration");1372 window.setTimeout(() => setView("settings"), 2500);1373 }1374 }}1375 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1376 onImport={() => setView("import")}1377 onExport={() => setView("export")}1378 onBreachCheckDone={loadEntries}1379 onOpenHealth={openHealth}1380 onOpenSyncPairing={() => setView("sync-pairing")}1381 onOpenFileImport={handleOpenFileImport}1382 onOpenPanic={() => setView("panic")}1383 />1384 </Suspense>1385 </Show>1386 <Show when={view() === "sync-pairing"}>1387 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1388 <SyncPairingView1389 onClose={() => setView("settings")}1390 onPaired={() => { }}1391 />1392 </Suspense>1393 </Show>1394 <Show when={view() === "add"}>1395 <AddView1396 entry={editEntry()}1397 entries={entries()}1398 onBack={handleBack}1399 onSave={handleSave}1400 />1401 </Show>1402 <Show when={view() === "import"}>1403 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1404 <ImportView onBack={handleBack} onImport={handleSave} />1405 </Suspense>1406 </Show>1407 <Show when={view() === "export"}>1408 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1409 <ExportView1410 onBack={() => {1411 if (returnToPanic()) {1412 setReturnToPanic(false);1413 setView("panic");1414 } else {1415 handleBack();1416 }1417 }}1418 />1419 </Suspense>1420 </Show>1421 <Show when={view() === "panic"}>1422 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1423 <PanicView1424 onCancel={() => setView("settings")}1425 onOpenExport={() => {1426 setReturnToPanic(true);1427 setView("export");1428 }}1429 onDestroyed={async () => {1430 clearCanarySuppressions();1431 setEntries([]);1432 setSelectedEntry(null);1433 setEditEntry(undefined);1434 setHasVault(false);1435 setOnboarded(false);1436 setProStatus(false);1437 setPendingPasskey(null);1438 setUpgradeHighlight(null);1439 setSettingsInitialSection(null);1440 setPendingProNotice(null);1441 setPendingCelebration(false);1442 setReturnToPanic(false);1443 setContentPhase("idle");1444 setView("lock");1445 void clearInMemoryVault();1446 }}1447 />1448 </Suspense>1449 </Show>1450 <Show when={view() === "health"}>1451 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1452 <HealthView1453 entries={entries()}1454 isPro={proStatus()}1455 onFixEntry={handleFixEntry}1456 onBack={() => setView("settings")}1457 onRefresh={loadEntries}1458 />1459 </Suspense>1460 </Show>1461 <Show when={view() === "trash"}>1462 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1463 <TrashView1464 onBack={() => setView("list")}1465 onRestored={() => void loadEntries()}1466 />1467 </Suspense>1468 </Show>1469 <Show when={restoreView() === "file"}>1470 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1471 <ImportView1472 mode="onboarding"1473 replacesExistingVault={true}1474 onBack={() => setRestoreView(null)}1475 onImport={async () => {1476 setRestoreView(null);1477 await handleUnlock();1478 }}1479 />1480 </Suspense>1481 </Show>1482 </section>1483 </Show>1484 </Show>1485 </Show>1486 </Show>1487 <Show when={showShortcutsHelp()}>1488 <ShortcutsHelpOverlay1489 bindings={shortcutBindings()}1490 onClose={() => setShowShortcutsHelp(false)}1491 />1492 </Show>1493 </div>1494 );1495}14961497function ShortcutsHelpOverlay(props: {1498 bindings: Record<ShortcutAction, Keybinding>;1499 onClose: () => void;1500}) {1501 return (1502 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1503 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1504 <div class="peach-shortcuts-help-head">1505 <h3>Keyboard shortcuts</h3>1506 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1507 </div>1508 <div class="peach-shortcuts-help-body">1509 <p class="peach-settings-sub-label">ENTRY VIEW</p>1510 <ul>1511 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1512 <li>1513 <span>{d.label}</span>1514 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1515 </li>1516 ))}1517 </ul>1518 <p class="peach-settings-sub-label">ANYWHERE</p>1519 <ul>1520 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1521 <li>1522 <span>{d.label}</span>1523 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1524 </li>1525 ))}1526 </ul>1527 </div>1528 </div>1529 </div>1530 );1531}