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 case "lock": {353 const now = Date.now();354 if (now - lastBackExitAt < 1600) {355 void CapacitorApp.exitApp();356 return;357 }358 lastBackExitAt = now;359 setBackExitNotice(true);360 window.setTimeout(() => setBackExitNotice(false), 1400);361 return;362 }363 default:364 void CapacitorApp.exitApp();365 }366 }367368 onMount(async () => {369 if (Capacitor.isNativePlatform()) {370 document.body.classList.add("peach-mobile");371 document.documentElement.classList.add("peach-mobile-root");372 void applyMobileStatusBar(theme());373374 375 376 const updateLayoutClass = () => {377 const w = window.innerWidth;378 const cls = document.body.classList;379 cls.remove("layout-compact", "layout-medium", "layout-expanded");380 const next: "compact" | "medium" | "expanded" =381 w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";382 cls.add(`layout-${next}`);383 setLayoutClass(next);384 };385 updateLayoutClass();386 window.addEventListener("resize", updateLayoutClass);387388 389 390 const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {391 document.body.setAttribute("data-posture", e.posture);392 document.documentElement.style.setProperty(393 "--peach-hinge-height",394 `${e.hingeHeight}px`,395 );396 });397398 const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {399 document.body.style.paddingBottom = `${info.keyboardHeight}px`;400 });401 const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {402 document.body.style.paddingBottom = "0";403 });404 const backButton = await CapacitorApp.addListener("backButton", () => {405 handleBackNavigation();406 });407408 let touchStartX = 0;409 let touchStartY = 0;410 const handleTouchStart = (event: TouchEvent) => {411 const touch = event.touches[0];412 if (!touch) return;413 touchStartX = touch.clientX;414 touchStartY = touch.clientY;415 };416 const handleTouchEnd = (event: TouchEvent) => {417 const touch = event.changedTouches[0];418 if (!touch) return;419 const dx = touch.clientX - touchStartX;420 const dy = Math.abs(touch.clientY - touchStartY);421 if (touchStartX < 30 && dx > 60 && dy < 40) {422 handleBackNavigation();423 }424 };425 document.addEventListener("touchstart", handleTouchStart, { passive: true });426 document.addEventListener("touchend", handleTouchEnd, { passive: true });427428 onCleanup(() => {429 void keyboardShow.remove();430 void keyboardHide.remove();431 void backButton.remove();432 stopFold();433 window.removeEventListener("resize", updateLayoutClass);434 document.removeEventListener("touchstart", handleTouchStart);435 document.removeEventListener("touchend", handleTouchEnd);436 document.body.style.paddingBottom = "0";437 });438 }439440 441 442 443 444 445 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([446 sendMessage({ type: "IS_UNLOCKED" }),447 sendMessage({ type: "HAS_VAULT" }),448 refreshCurrentTab(),449 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),450 ]);451 const unlocked = Boolean(452 (unlockResponse as { ok: boolean; data?: boolean }).data453 );454 const vaultExists = Boolean(455 (vaultResponse as { ok: boolean; data?: boolean }).data456 );457 const shouldCelebrate = Boolean(458 (celebrationResponse as { ok: boolean; data?: boolean }).data459 );460 setHasVault(vaultExists);461 462 463 464 try {465 const onboarded = await storageGet("peach_onboarded");466 const seen = onboarded === "true";467 if (vaultExists && !seen) {468 await storageSet("peach_onboarded", "true");469 setOnboarded(true);470 } else {471 setOnboarded(seen);472 }473 } catch {474 475 setOnboarded(true);476 }477 await Promise.all([restoreSettings(), refreshProStatus()]);478479 480 if (shouldCelebrate) {481 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });482 }483484 if (unlocked && vaultExists) {485 await openVault(undefined, shouldCelebrate);486 if (shouldCelebrate) {487 setView("pro-celebration");488 window.setTimeout(() => setView("entry"), 2500);489 }490 } else if (shouldCelebrate) {491 setPendingCelebration(true);492 }493494 await refreshPendingPasskey();495 await refreshIncomingPairing();496 await consumeRecentPairing();497498 499 if (!Capacitor.isNativePlatform()) {500 const handleTabActivated = () => { void refreshCurrentTab(); };501 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {502 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();503 };504 try {505 chrome.tabs.onActivated.addListener(handleTabActivated);506 chrome.tabs.onUpdated.addListener(handleTabUpdated);507 onCleanup(() => {508 chrome.tabs.onActivated.removeListener(handleTabActivated);509 chrome.tabs.onUpdated.removeListener(handleTabUpdated);510 });511 } catch {512 513 }514 }515516 setBootState("ready");517 document.getElementById("pre-lock")?.setAttribute("hidden", "true");518519 520 if (!Capacitor.isNativePlatform() && chrome.storage.session) {521 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as522 Record<string, { feature?: string } | undefined>;523 if (session.pendingProGate?.feature) {524 await chrome.storage.session.remove("pendingProGate");525 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);526 setSettingsInitialSection("license");527 setView("settings");528 } else if (session.pendingProNotice?.feature) {529 await chrome.storage.session.remove("pendingProNotice");530 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });531 }532 }533534 if (Capacitor.getPlatform() === "android") {535 try {536 const { pending } = await Autofill.checkPendingSave();537 if (pending) {538 const parsed = JSON.parse(pending) as {539 domain?: string;540 username?: string;541 password?: string;542 };543 if (parsed.username && parsed.password) {544 setPendingAutofillSave({545 domain: parsed.domain ?? "",546 username: parsed.username,547 password: parsed.password,548 });549 }550 }551 } catch {552 553 }554 }555556 557 558 559 560 void Promise.all([561 import("./views/Settings").catch(() => {}),562 import("./views/Import").catch(() => {}),563 import("./views/Export").catch(() => {}),564 import("./views/Health").catch(() => {}),565 import("./views/TrashView").catch(() => {}),566 ]);567568 569 570 571 if (!Capacitor.isNativePlatform()) {572 let popupPort: chrome.runtime.Port | null = null;573 try {574 popupPort = chrome.runtime.connect({ name: "popup" });575 } catch { }576 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });577578 579 580 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {581 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();582 if (msg?.type === "PRO_GATE_TRIGGERED") {583 setUpgradeHighlight(msg.payload?.feature ?? null);584 setSettingsInitialSection("license");585 setView("settings");586 }587 if (msg?.type === "INCOMING_PAIRING_REQUEST") {588 void refreshIncomingPairing();589 }590 };591 chrome.runtime.onMessage.addListener(handleRuntimeMessage);592 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));593 }594595 596 597 598 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {599 copy_password: () => {600 const entry = selectedEntry();601 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });602 },603 copy_username: () => {604 const entry = selectedEntry();605 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });606 },607 copy_totp: () => {608 const entry = selectedEntry();609 if (!entry?.totp) return;610 try {611 const { code } = generateTOTP(entry.totp);612 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });613 } catch { }614 },615 new_entry: () => handleAdd(),616 edit_entry: () => {617 const entry = selectedEntry();618 619 620 if (!entry || isPasskeyOnly(entry)) return;621 setEditEntry(entry);622 setView("add");623 },624 delete_entry: () => {625 if (selectedEntry()) void handleDelete();626 },627 lock_vault: () => { void handleHeaderLock(); },628 search: () => openList(),629 nav_prev: () => navigate(-1),630 nav_next: () => navigate(1),631 jump_vault: () => setView("entry"),632 jump_settings: () => setView("settings"),633 show_help: () => setShowShortcutsHelp(true),634 };635636 637 638 639 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([640 "copy_password", "copy_username", "copy_totp",641 "edit_entry", "delete_entry", "nav_prev", "nav_next",642 ]);643644 const handleGlobalKeydown = (e: KeyboardEvent) => {645 const v = view();646 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"647 || (e.target as HTMLElement)?.tagName === "TEXTAREA"648 || (e.target as HTMLElement)?.isContentEditable;649 const isPrintableSearchKey =650 e.key.length === 1 &&651 !e.ctrlKey &&652 !e.metaKey &&653 !e.altKey;654655 656 if (v === "pro-celebration") return;657658 659 660 if (showShortcutsHelp()) {661 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }662 return;663 }664665 666 if (e.key === "Escape") {667 if (v === "list") { setView("entry"); return; }668 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }669 if (v === "sync-pairing") { setView("settings"); return; }670 }671672 673 674 675 const bindings = shortcutBindings();676 for (const action of Object.keys(bindings) as ShortcutAction[]) {677 const binding = bindings[action];678 if (!matchKeybinding(e, binding)) continue;679 680 681 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;682 if (!hasMod && binding.key.length === 1 && inInput) return;683 if (ENTRY_SCOPED.has(action) && v !== "entry") return;684 e.preventDefault();685 SHORTCUT_HANDLERS[action]?.();686 return;687 }688689 690 691 692 693 if (v === "entry" && !inInput && isPrintableSearchKey) {694 e.preventDefault();695 primeListSearch(e.key);696 return;697 }698 };699 window.addEventListener("keydown", handleGlobalKeydown);700 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));701702 if (!Capacitor.isNativePlatform()) {703 const handleStorageChanged = (704 changes: Record<string, chrome.storage.StorageChange>,705 areaName: string706 ) => {707 if (areaName !== "session") return;708 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {709 return;710 }711 void refreshPendingPasskey();712 };713 chrome.storage.onChanged.addListener(handleStorageChanged);714 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));715 }716 });717718 async function refreshCurrentTab() {719 if (Capacitor.isNativePlatform()) return;720 try {721 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });722 const url = tab?.url ?? tab?.pendingUrl ?? "";723 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");724 725 726 const isWebUrl = /^https?:\/\//i.test(url);727 setCurrentTabUrl(isWebUrl ? url : "");728 } catch {729 setCurrentTabDomain("");730 setCurrentTabUrl("");731 }732 }733734 async function refreshPendingPasskey() {735 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });736 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);737 }738739 async function refreshIncomingPairing() {740 const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });741 const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;742 setIncomingPair(data);743 }744745 746 747 748 async function consumeRecentPairing() {749 const res = await sendMessage({ type: "GET_RECENT_PAIRING" });750 const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;751 if (data?.deviceName) {752 setRecentPairToast(data.deviceName);753 window.setTimeout(() => setRecentPairToast(null), 4000);754 }755 }756757 async function respondIncomingPairing(accept: boolean) {758 if (incomingPairResponding()) return;759 setIncomingPairResponding(true);760 await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });761 setIncomingPair(null);762 setIncomingPairResponding(false);763 }764765 766 767 768 createEffect(() => {769 const req = incomingPair();770 if (!req) return;771 const handle = window.setInterval(() => {772 setIncomingPairTick((tick) => tick + 1);773 if (Date.now() >= req.expiresAt) {774 setIncomingPair(null);775 window.clearInterval(handle);776 }777 }, 1000);778 onCleanup(() => window.clearInterval(handle));779 });780781 async function refreshProStatus() {782 const res = await sendMessage({ type: "GET_LICENSE" });783 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;784 setProStatus(Boolean(data?.valid && data?.plan === "pro"));785 }786787 async function restoreSettings() {788 const res = await sendMessage({ type: "GET_SETTINGS" });789 const s = (res as { ok: boolean; data?: any }).data ?? {};790 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {791 setAccentColor(s.accentColor as AccentColorName);792 }793 setTheme(s.theme === "light" ? "light" : "dark");794 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {795 setDisplayFont(s.displayFont);796 } else {797 setDisplayFont("cormorant-italic");798 }799 if (s.textSize === "small" || s.textSize === "large") {800 setTextSize(s.textSize);801 } else {802 setTextSize("medium");803 }804 setReduceMotion(s.reduceMotion === true);805 setLockOnClose(s.lockOnClose !== false);806 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);807 setBreachMonitoring(s.breachMonitoring !== false);808 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);809 setShortcutBindings(getActiveBindings(s.shortcuts));810 }811812 async function applyTrashRetention(days: number) {813 setTrashRetentionDays(days);814 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });815 }816817 function primeListSearch(seed: string) {818 setListSearchSeed(seed);819 setListSearchSeedNonce((value) => value + 1);820 openList();821 }822823 824 825 826 async function loadEntries(): Promise<VaultEntry[] | null> {827 const response = await sendMessage({ type: "GET_ENTRIES" });828 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };829830 831 832 833 834 if (!res.ok) {835 if (836 res.error === "vault locked" ||837 res.error === "SERVICE_WORKER_UNAVAILABLE" ||838 (typeof res.error === "string" && res.error.includes("locked"))839 ) {840 handleLock();841 return null; 842 }843 return [];844 }845846 const loadedEntries = res.data ?? [];847 setEntries(loadedEntries);848 void publishInMemoryVault(loadedEntries);849850 const currentId = selectedEntry()?.id;851 if (currentId) {852 setSelectedEntry(853 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null854 );855 }856857 return loadedEntries;858 }859860 async function openVault(preferredEntryId?: string, skipViewSet = false) {861 862 const retDays = trashRetentionDays();863 if (retDays > 0) {864 void sendMessage({865 type: "PURGE_EXPIRED_TRASH",866 payload: { retentionMs: retDays * 86_400_000 },867 });868 }869870 const loadedEntries = await loadEntries();871872 873 874 if (loadedEntries === null) return;875876 877 let nextSelectedEntry: VaultEntry | null = null;878 if (preferredEntryId) {879 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;880 }881 if (!nextSelectedEntry) {882 const tabDomain = currentTabDomain();883 if (tabDomain) {884 nextSelectedEntry = loadedEntries.find((e) => {885 if (e.type === "login") {886 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;887 for (const extra of e.additionalUrls ?? []) {888 if ((extractDomain(extra) ?? "") === tabDomain) return true;889 }890 return false;891 }892 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;893 return false;894 }) ?? null;895 }896 }897 nextSelectedEntry ??= loadedEntries[0] ?? null;898899 setSelectedEntry(nextSelectedEntry);900 setEditEntry(undefined);901 setContentTransitioning(false);902 setContentPhase("enter");903 queueContentIdle();904 if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");905 }906907 async function handleUnlock() {908 setHasVault(true);909 triggerPipPulse();910 await restoreSettings();911 const celebrating = pendingCelebration();912 if (celebrating) {913 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });914 setPendingCelebration(false);915 }916 await openVault(undefined, celebrating);917 if (celebrating) {918 setView("pro-celebration");919 window.setTimeout(() => setView("entry"), 2500);920 }921 }922923 function handleSelectEntry(entry: VaultEntry) {924 setSelectedEntry(entry);925 setContentTransitioning(false);926 setContentPhase("enter");927 queueContentIdle();928 929 930 if (!isTwoPaneLayout()) setView("entry");931 }932933 function handleAdd() {934 setEditEntry(undefined);935 setView("add");936 }937938 async function handleDelete() {939 const entry = selectedEntry();940 if (!entry) return;941 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });942 const loadedEntries = await loadEntries() ?? [];943 setSelectedEntry(loadedEntries[0] ?? null);944 setView("entry");945 }946947 async function handleDeleteMany(ids: string[]) {948 for (const id of ids) {949 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });950 }951 const loadedEntries = await loadEntries() ?? [];952 953 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);954 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);955 }956957 async function handleSave(savedEntry?: VaultEntry) {958 await openVault(savedEntry?.id);959 }960961 async function savePendingAutofill() {962 const pending = pendingAutofillSave();963 if (!pending) return;964 const domain = pending.domain.trim();965 const response = await sendMessage({966 type: "ADD_ENTRY",967 payload: {968 type: "login",969 title: domain || "Saved login",970 url: domain ? `https://${domain}` : "",971 username: pending.username,972 password: pending.password,973 },974 });975 if ((response as { ok: boolean }).ok) {976 setPendingAutofillSave(null);977 await openVault((response as { data?: VaultEntry }).data?.id);978 }979 }980981 function handleBack() {982 setEditEntry(undefined);983 setView("entry");984 }985986 function handleLock() {987 clearCanarySuppressions();988 setEntries([]);989 setSelectedEntry(null);990 setEditEntry(undefined);991 setContentPhase("idle");992 setView("lock");993 void clearInMemoryVault();994 }995996 function handleHeaderLock() {997 998 999 1000 handleLock();1001 void sendMessage({ type: "LOCK" });1002 }10031004 function navigate(dir: number) {1005 const allEntries = entries();1006 if (allEntries.length === 0) return;1007 const currentIndex = selectedIndex();1008 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1009 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1010 setSelectedEntry(allEntries[nextIndex]);1011 setContentPhase("enter");1012 queueContentIdle();1013 }10141015 function openList() {1016 setView("list");1017 }10181019 function openHealth() {1020 setEditEntry(undefined);1021 setView("health");1022 }10231024 function handleOpenFileImport() {1025 setRestoreView("file");1026 }10271028 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10291030 function handleFixEntry(entry: VaultEntry) {1031 1032 1033 if (isPasskeyOnly(entry)) {1034 setSelectedEntry(entry);1035 setView("entry");1036 return;1037 }1038 setEditEntry(entry);1039 setView("add");1040 }10411042 const isLockedView = () => view() === "lock";10431044 async function handleApprovePendingPasskey() {1045 const pending = pendingPasskey();1046 if (!pending) return;1047 if (pending.browser === "firefox" && pending.type === "create") {1048 1049 1050 1051 1052 const { createFirefoxPasskey } = await import("./firefox-passkey");1053 const result = await createFirefoxPasskey(pending);1054 if (!result.ok) {1055 console.warn("Peach Firefox passkey create failed:", result.error);1056 }1057 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1058 setPendingPasskey(null);1059 void loadEntries();1060 return;1061 }1062 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1063 setPendingPasskey(null);1064 }10651066 async function handleDenyPendingPasskey() {1067 const pending = pendingPasskey();1068 if (!pending) return;1069 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1070 setPendingPasskey(null);1071 }10721073 function queueContentIdle(after?: () => void) {1074 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1075 contentPhaseTimer = window.setTimeout(() => {1076 setContentPhase("idle");1077 after?.();1078 }, ENTRY_TRANSITION_SETTLE_MS);1079 }10801081 return (1082 <div class="popup-root peach-theme">1083 <Show when={bootState() === "ready"}>1084 <Show1085 when={!pendingPasskey()}1086 fallback={1087 <PasskeyApprovalView1088 type={pendingPasskey()!.type}1089 rpName={pendingPasskey()!.rpName}1090 rpId={pendingPasskey()!.rpId}1091 userName={pendingPasskey()!.userName}1092 userDisplayName={pendingPasskey()!.userDisplayName}1093 onApprove={handleApprovePendingPasskey}1094 onDeny={handleDenyPendingPasskey}1095 />1096 }1097 >1098 <Show1099 when={!isLockedView()}1100 fallback={1101 <Show1102 when={onboarded() || hasVault()}1103 fallback={1104 <Onboarding1105 onComplete={() => { setOnboarded(true); }}1106 />1107 }1108 >1109 <LockView1110 hasVault={hasVault()}1111 ready={bootState() === "ready"}1112 isPro={proStatus()}1113 onUnlock={handleUnlock}1114 onBackToOnboarding={1115 !hasVault()1116 ? () => setOnboarded(false)1117 : undefined1118 }1119 />1120 </Show>1121 }1122 >1123 <Show1124 when={view() !== "pro-celebration"}1125 fallback={<ProCelebration />}1126 >1127 <section1128 id="hd-shell"1129 class="peach-shell peach-theme"1130 data-surface="extension"1131 >1132 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1133 <PeachWordmark unlocked />1134 <div class="peach-chrome-actions">1135 <button1136 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1137 onClick={() => setView("settings")}1138 aria-label="Settings"1139 >1140 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1141 <path1142 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"1143 stroke="currentColor"1144 stroke-width="0.9"1145 />1146 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1147 </svg>1148 </button>1149 <button1150 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1151 onClick={handleAdd}1152 aria-label="New entry"1153 >1154 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1155 <path d="M5.5 1v9M1 5.5h9" />1156 </svg>1157 </button>1158 <button1159 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1160 onClick={() => void handleHeaderLock()}1161 aria-label="Lock vault"1162 >1163 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1164 <path1165 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"1166 stroke="currentColor"1167 stroke-width="1"1168 stroke-linecap="round"1169 />1170 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1171 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1172 </svg>1173 </button>1174 </div>1175 </header>11761177 <Show when={recentPairToast()}>1178 <div class="peach-pair-toast" role="status" aria-live="polite">1179 Paired with <strong>{recentPairToast()}</strong>1180 </div>1181 </Show>11821183 <Show when={incomingPair() !== null}>1184 <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1185 <div class="peach-pair-request-header">1186 <span class="peach-pair-request-mark">🍑</span>1187 <span class="peach-pair-request-title">Pairing request</span>1188 <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1189 </div>1190 <p class="peach-pair-request-body">1191 <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1192 </p>1193 <div class="peach-pair-request-actions">1194 <button1195 class="peach-btn peach-btn--ghost peach-btn--sm"1196 onClick={() => void respondIncomingPairing(false)}1197 disabled={incomingPairResponding()}1198 >1199 Reject1200 </button>1201 <button1202 class="peach-btn peach-btn--accent peach-btn--sm"1203 onClick={() => void respondIncomingPairing(true)}1204 disabled={incomingPairResponding()}1205 >1206 Accept1207 </button>1208 </div>1209 </div>1210 </Show>12111212 <Show when={pendingProNotice() !== null}>1213 <div class="peach-pro-notice-banner">1214 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1215 <button1216 class="peach-pro-notice-link"1217 onClick={() => {1218 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1219 setSettingsInitialSection("license");1220 setView("settings");1221 setPendingProNotice(null);1222 }}1223 >1224 Learn more →1225 </button>1226 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1227 </div>1228 </Show>12291230 <Show when={pendingAutofillSave() !== null && !isLockedView()}>1231 <div class="peach-pro-notice-banner peach-autofill-save-banner">1232 <span>1233 Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1234 </span>1235 <button1236 class="peach-pro-notice-link"1237 onClick={() => void savePendingAutofill()}1238 >1239 Save1240 </button>1241 <button1242 class="peach-pro-notice-close"1243 onClick={() => setPendingAutofillSave(null)}1244 aria-label="Dismiss"1245 >1246 x1247 </button>1248 </div>1249 </Show>12501251 <Show when={backExitNotice()}>1252 <div class="peach-pro-notice-banner peach-back-exit-banner">1253 <span>Tap back again to exit Peach</span>1254 </div>1255 </Show>12561257 <div class="peach-shell-body">1258 <div class="peach-shell-veil"></div>1259 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1260 <EntryView1261 entry={selectedEntry()}1262 currentTabUrl={currentTabUrl()}1263 onDelete={handleDelete}1264 onFieldSaved={() => void loadEntries()}1265 onEscape={() => { setView("list"); }}1266 />1267 </div>12681269 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1270 <div class="peach-search-bar">1271 <span class="peach-search-icon">1272 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1273 <circle cx="5.5" cy="5.5" r="3.75" />1274 <path d="M8.75 8.75L11.5 11.5" />1275 </svg>1276 </span>1277 <input1278 class="peach-search-input"1279 placeholder="search vault..."1280 autocomplete="off"1281 aria-label="Search vault entries"1282 onClick={openList}1283 onFocus={openList}1284 readOnly1285 />1286 <div class="peach-search-meta">1287 <span class="peach-search-counter">1288 {entries().length > 01289 ? `${selectedIndex() + 1} / ${entries().length}`1290 : "0 entries"}1291 </span>1292 <span class="peach-divider"></span>1293 <button1294 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1295 onClick={() => navigate(-1)}1296 aria-label="Previous entry"1297 disabled={entries().length === 0}1298 >1299 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1300 <path d="M5 1L1 5.5L5 10" />1301 </svg>1302 </button>1303 <button1304 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1305 onClick={() => navigate(1)}1306 aria-label="Next entry"1307 disabled={entries().length === 0}1308 >1309 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1310 <path d="M1 1L5 5.5L1 10" />1311 </svg>1312 </button>1313 </div>1314 </div>1315 </section>1316 </div>13171318 <Show when={view() === "list"}>1319 <VaultView1320 entries={entries()}1321 selectedEntryId={selectedEntry()?.id}1322 currentTabDomain={currentTabDomain()}1323 sortOrder={sortOrder()}1324 searchSeed={listSearchSeed()}1325 searchSeedNonce={listSearchSeedNonce()}1326 onSelectEntry={handleSelectEntry}1327 onSortOrderChange={setSortOrder}1328 onAdd={handleAdd}1329 onClose={() => setView("entry")}1330 onOpenTrash={() => setView("trash")}1331 onDeleteMany={handleDeleteMany}1332 />1333 </Show>1334 <Show when={view() === "settings"}>1335 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1336 <SettingsView1337 entriesCount={entries().length}1338 breachMonitoring={breachMonitoring()}1339 lockOnClose={lockOnClose()}1340 autoLockMinutes={autoLockMinutes()}1341 trashRetentionDays={trashRetentionDays()}1342 theme={theme()}1343 accentColor={accentColor()}1344 displayFont={displayFont()}1345 textSize={textSize()}1346 reduceMotion={reduceMotion()}1347 isPro={proStatus()}1348 initialSection={settingsInitialSection()}1349 upgradeHighlight={upgradeHighlight()}1350 onBreachMonitoringChange={applyBreachMonitoring}1351 onLockOnCloseChange={applyLockOnClose}1352 onAutoLockMinutesChange={applyAutoLockMinutes}1353 onTrashRetentionChange={applyTrashRetention}1354 onThemeChange={applyTheme}1355 onAccentChange={applyAccentColor}1356 onDisplayFontChange={applyDisplayFont}1357 onTextSizeChange={applyTextSize}1358 onReduceMotionChange={applyReduceMotion}1359 shortcuts={shortcutBindings()}1360 onShortcutsChange={applyShortcuts}1361 onLicenseChange={async () => {1362 1363 1364 1365 1366 1367 await refreshProStatus();1368 void loadEntries();1369 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1370 if ((should as { ok: boolean; data?: boolean }).data === true) {1371 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1372 setView("pro-celebration");1373 window.setTimeout(() => setView("settings"), 2500);1374 }1375 }}1376 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1377 onImport={() => setView("import")}1378 onExport={() => setView("export")}1379 onBreachCheckDone={loadEntries}1380 onOpenHealth={openHealth}1381 onOpenSyncPairing={() => setView("sync-pairing")}1382 onOpenFileImport={handleOpenFileImport}1383 onOpenPanic={() => setView("panic")}1384 />1385 </Suspense>1386 </Show>1387 <Show when={view() === "sync-pairing"}>1388 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1389 <SyncPairingView1390 onClose={() => setView("settings")}1391 onPaired={() => { }}1392 />1393 </Suspense>1394 </Show>1395 <Show when={view() === "add"}>1396 <AddView1397 entry={editEntry()}1398 entries={entries()}1399 onBack={handleBack}1400 onSave={handleSave}1401 />1402 </Show>1403 <Show when={view() === "import"}>1404 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1405 <ImportView onBack={handleBack} onImport={handleSave} />1406 </Suspense>1407 </Show>1408 <Show when={view() === "export"}>1409 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1410 <ExportView1411 onBack={() => {1412 if (returnToPanic()) {1413 setReturnToPanic(false);1414 setView("panic");1415 } else {1416 handleBack();1417 }1418 }}1419 />1420 </Suspense>1421 </Show>1422 <Show when={view() === "panic"}>1423 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1424 <PanicView1425 onCancel={() => setView("settings")}1426 onOpenExport={() => {1427 setReturnToPanic(true);1428 setView("export");1429 }}1430 onDestroyed={async () => {1431 clearCanarySuppressions();1432 setEntries([]);1433 setSelectedEntry(null);1434 setEditEntry(undefined);1435 setHasVault(false);1436 setOnboarded(false);1437 setProStatus(false);1438 setPendingPasskey(null);1439 setUpgradeHighlight(null);1440 setSettingsInitialSection(null);1441 setPendingProNotice(null);1442 setPendingCelebration(false);1443 setReturnToPanic(false);1444 setContentPhase("idle");1445 setView("lock");1446 void clearInMemoryVault();1447 }}1448 />1449 </Suspense>1450 </Show>1451 <Show when={view() === "health"}>1452 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1453 <HealthView1454 entries={entries()}1455 isPro={proStatus()}1456 onFixEntry={handleFixEntry}1457 onBack={() => setView("settings")}1458 onRefresh={loadEntries}1459 />1460 </Suspense>1461 </Show>1462 <Show when={view() === "trash"}>1463 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1464 <TrashView1465 onBack={() => setView("list")}1466 onRestored={() => void loadEntries()}1467 />1468 </Suspense>1469 </Show>1470 <Show when={restoreView() === "file"}>1471 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1472 <ImportView1473 mode="onboarding"1474 replacesExistingVault={true}1475 onBack={() => setRestoreView(null)}1476 onImport={async () => {1477 setRestoreView(null);1478 await handleUnlock();1479 }}1480 />1481 </Suspense>1482 </Show>1483 </section>1484 </Show>1485 </Show>1486 </Show>1487 </Show>1488 <Show when={showShortcutsHelp()}>1489 <ShortcutsHelpOverlay1490 bindings={shortcutBindings()}1491 onClose={() => setShowShortcutsHelp(false)}1492 />1493 </Show>1494 </div>1495 );1496}14971498function ShortcutsHelpOverlay(props: {1499 bindings: Record<ShortcutAction, Keybinding>;1500 onClose: () => void;1501}) {1502 return (1503 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1504 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1505 <div class="peach-shortcuts-help-head">1506 <h3>Keyboard shortcuts</h3>1507 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1508 </div>1509 <div class="peach-shortcuts-help-body">1510 <p class="peach-settings-sub-label">ENTRY VIEW</p>1511 <ul>1512 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1513 <li>1514 <span>{d.label}</span>1515 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1516 </li>1517 ))}1518 </ul>1519 <p class="peach-settings-sub-label">ANYWHERE</p>1520 <ul>1521 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1522 <li>1523 <span>{d.label}</span>1524 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1525 </li>1526 ))}1527 </ul>1528 </div>1529 </div>1530 </div>1531 );1532}