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 { ACCENT_COLORS } from "../shared/types";10import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";11import type { IncomingPairingRequest } from "../shared/sync-types";12import { isPasskeyOnly } from "../shared/types";13import { LockView } from "./views/Lock";14import { Onboarding } from "./views/Onboarding";15import { ProCelebration } from "./views/ProCelebration";16import { VaultView, type SortOrder } from "./views/Vault";17import { EntryView } from "./views/Entry";18import { AddView } from "./views/Add";19import { PeachWordmark } from "./components/PeachMark";20import { PasskeyApprovalView } from "./views/PasskeyApproval";21import { extractDomain } from "../shared/domain";22import { popupCopy } from "./copy";23import {24 DEFAULT_BINDINGS,25 formatKeybinding,26 getActiveBindings,27 matchKeybinding,28 SHORTCUT_DESCRIPTORS,29 type Keybinding,30 type ShortcutAction,31} from "../shared/shortcuts";32import { generateTOTP } from "../crypto/totp";3334export { sendMessage } from "../shared/bridge";3536const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));37const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));38const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));39const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));40const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));41const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));42const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));4344const Autofill = registerPlugin<{45 checkPendingSave(): Promise<{ pending: string | null }>;46 openAutofillSettings(): Promise<void>;47 isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;48}>("Autofill");4950type View =51 | "lock"52 | "pro-celebration"53 | "entry"54 | "list"55 | "add"56 | "settings"57 | "sync-pairing"58 | "import"59 | "export"60 | "health"61 | "trash"62 | "panic";63type BootState = "loading" | "ready";6465const canarySuppressMap = new Map<string, boolean>();66let shellRingAnimation: Animation | null = null;6768export function isCanarySuppressed(entryId: string): boolean {69 return canarySuppressMap.get(entryId) ?? false;70}7172export function setCanarySuppressed(entryId: string, suppressed: boolean): void {73 canarySuppressMap.set(entryId, suppressed);74}7576export function clearCanarySuppressions(): void {77 canarySuppressMap.clear();78}7980export function triggerRing() {81 const shell = document.getElementById("hd-shell");82 if (!shell) return;8384 shellRingAnimation?.cancel();85 shell.dataset.glowing = "true";86 const animation = shell.animate(87 [88 { offset: 0, boxShadow: "var(--copy-shadow)" },89 {90 offset: 0.3,91 boxShadow:92 "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)",93 },94 { offset: 1, boxShadow: "var(--copy-shadow)" },95 ],96 {97 duration: 720,98 easing: "cubic-bezier(0.4, 0, 0.2, 1)",99 fill: "none",100 }101 );102 shellRingAnimation = animation;103 const clearGlow = () => {104 if (shellRingAnimation !== animation) return;105 shellRingAnimation = null;106 delete shell.dataset.glowing;107 };108 animation.onfinish = clearGlow;109 animation.oncancel = clearGlow;110}111112113export function triggerPipPulse() {114 const pip = document.querySelector(".peach-status-pip, .pip");115 if (pip) {116 pip.classList.remove("pulse");117 void (pip as HTMLElement).offsetWidth;118 pip.classList.add("pulse");119 }120}121122const ENTRY_TRANSITION_EXIT_MS = 95;123const ENTRY_TRANSITION_SETTLE_MS = 135;124125export default function App() {126 const [bootState, setBootState] = createSignal<BootState>("loading");127 const [view, setView] = createSignal<View>("lock");128 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(129 "enter"130 );131 const [contentTransitioning, setContentTransitioning] = createSignal(false);132 const [hasVault, setHasVault] = createSignal(false);133 const [onboarded, setOnboarded] = createSignal(true);134 const [entries, setEntries] = createSignal<VaultEntry[]>([]);135 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);136 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);137 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");138 const [theme, setTheme] = createSignal<"dark" | "light">("dark");139 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");140 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");141 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);142 const [lockOnClose, setLockOnClose] = createSignal(true);143 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);144 const [breachMonitoring, setBreachMonitoring] = createSignal(true);145 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);146 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);147 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);148 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);149 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);150 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");151 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");152 153 154 155 156 const [proStatus, setProStatus] = createSignal(false);157 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);158 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);159 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");160 const [listSearchSeed, setListSearchSeed] = createSignal("");161 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);162 const [pendingCelebration, setPendingCelebration] = createSignal(false);163 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);164 const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{165 domain: string;166 username: string;167 password: string;168 } | null>(null);169 const [backExitNotice, setBackExitNotice] = createSignal(false);170 const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);171 const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);172 const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);173 const [incomingPairTick, setIncomingPairTick] = createSignal(0);174 const incomingPairSecondsLeft = () => {175 void incomingPairTick();176 const req = incomingPair();177 if (!req) return 0;178 return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));179 };180 181 182 183 const [returnToPanic, setReturnToPanic] = createSignal(false);184185 let contentPhaseTimer: number | undefined;186 let lastBackExitAt = 0;187188 function applyAccent(name: AccentColorName) {189 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;190 const hex = color.hex;191 document.documentElement.style.setProperty("--peach-accent", hex);192 193 194 document.documentElement.style.setProperty("--peach-glow", hex + "2A");195 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");196 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");197 }198199 createEffect(() => {200 applyAccent(accentColor());201 });202203 createEffect(() => {204 document.documentElement.classList.toggle("light", theme() === "light");205 document.body.classList.toggle("light", theme() === "light");206 if (Capacitor.isNativePlatform()) {207 void applyMobileStatusBar(theme());208 }209 });210211 createEffect(() => {212 document.documentElement.setAttribute("data-display-font", displayFont());213 });214215 createEffect(() => {216 document.documentElement.setAttribute("data-text-size", textSize());217 });218219 createEffect(() => {220 if (reduceMotion()) {221 document.documentElement.setAttribute("data-motion", "reduce");222 } else {223 document.documentElement.removeAttribute("data-motion");224 }225 });226227 const selectedIndex = createMemo(() => {228 const currentId = selectedEntry()?.id;229 if (!currentId) return entries().length > 0 ? 0 : -1;230 return entries().findIndex((entry) => entry.id === currentId);231 });232233 async function applyAccentColor(name: AccentColorName) {234 applyAccent(name);235 setAccentColor(name);236 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });237 }238239 async function applyTheme(t: "dark" | "light") {240 setTheme(t);241 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });242 }243244 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {245 setDisplayFont(f);246 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });247 }248249 async function applyTextSize(size: "small" | "medium" | "large") {250 setTextSize(size);251 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });252 }253254 async function applyReduceMotion(enabled: boolean) {255 setReduceMotion(enabled);256 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });257 }258259 260 261 262 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {263 setShortcutBindings(active);264 const overrides: Record<string, Keybinding> = {};265 for (const action of Object.keys(active) as ShortcutAction[]) {266 const a = active[action];267 const d = DEFAULT_BINDINGS[action];268 const same = a.key === d.key269 && Boolean(a.ctrl) === Boolean(d.ctrl)270 && Boolean(a.meta) === Boolean(d.meta)271 && Boolean(a.shift) === Boolean(d.shift)272 && Boolean(a.alt) === Boolean(d.alt);273 if (!same) overrides[action] = a;274 }275 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });276 }277278 async function applyLockOnClose(enabled: boolean) {279 setLockOnClose(enabled);280 await sendMessage({281 type: "UPDATE_SETTINGS",282 payload: { lockOnClose: enabled },283 });284 }285286 async function applyAutoLockMinutes(minutes: number) {287 setAutoLockMinutes(minutes);288 await sendMessage({289 type: "UPDATE_SETTINGS",290 payload: { autoLockMinutes: minutes },291 });292 }293294 async function applyBreachMonitoring(enabled: boolean) {295 setBreachMonitoring(enabled);296 await sendMessage({297 type: "UPDATE_SETTINGS",298 payload: { breachMonitoring: enabled },299 });300 }301302 async function applyMobileStatusBar(nextTheme: "dark" | "light") {303 try {304 await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });305 await StatusBar.setBackgroundColor({306 color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",307 });308 } catch {309 310 }311 }312313 function handleBackNavigation() {314 if (showShortcutsHelp()) {315 setShowShortcutsHelp(false);316 return;317 }318 if (restoreView() !== null) {319 setRestoreView(null);320 return;321 }322323 switch (view()) {324 case "list":325 setView("entry");326 return;327 case "settings":328 case "add":329 case "import":330 handleBack();331 return;332 case "export":333 if (returnToPanic()) {334 setReturnToPanic(false);335 setView("panic");336 } else {337 handleBack();338 }339 return;340 case "health":341 case "panic":342 case "sync-pairing":343 setView("settings");344 return;345 case "trash":346 setView("list");347 return;348 case "entry": {349 const now = Date.now();350 if (now - lastBackExitAt < 1600) {351 void CapacitorApp.exitApp();352 return;353 }354 lastBackExitAt = now;355 setBackExitNotice(true);356 window.setTimeout(() => setBackExitNotice(false), 1400);357 return;358 }359 default:360 void CapacitorApp.exitApp();361 }362 }363364 onMount(async () => {365 if (Capacitor.isNativePlatform()) {366 document.body.classList.add("peach-mobile");367 document.documentElement.classList.add("peach-mobile-root");368 void applyMobileStatusBar(theme());369370 371 372 const updateLayoutClass = () => {373 const w = window.innerWidth;374 const cls = document.body.classList;375 cls.remove("layout-compact", "layout-medium", "layout-expanded");376 if (w >= 840) cls.add("layout-expanded");377 else if (w >= 600) cls.add("layout-medium");378 else cls.add("layout-compact");379 };380 updateLayoutClass();381 window.addEventListener("resize", updateLayoutClass);382383 const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {384 document.body.style.paddingBottom = `${info.keyboardHeight}px`;385 });386 const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {387 document.body.style.paddingBottom = "0";388 });389 const backButton = await CapacitorApp.addListener("backButton", () => {390 handleBackNavigation();391 });392393 let touchStartX = 0;394 let touchStartY = 0;395 const handleTouchStart = (event: TouchEvent) => {396 const touch = event.touches[0];397 if (!touch) return;398 touchStartX = touch.clientX;399 touchStartY = touch.clientY;400 };401 const handleTouchEnd = (event: TouchEvent) => {402 const touch = event.changedTouches[0];403 if (!touch) return;404 const dx = touch.clientX - touchStartX;405 const dy = Math.abs(touch.clientY - touchStartY);406 if (touchStartX < 30 && dx > 60 && dy < 40) {407 handleBackNavigation();408 }409 };410 document.addEventListener("touchstart", handleTouchStart, { passive: true });411 document.addEventListener("touchend", handleTouchEnd, { passive: true });412413 onCleanup(() => {414 void keyboardShow.remove();415 void keyboardHide.remove();416 void backButton.remove();417 window.removeEventListener("resize", updateLayoutClass);418 document.removeEventListener("touchstart", handleTouchStart);419 document.removeEventListener("touchend", handleTouchEnd);420 document.body.style.paddingBottom = "0";421 });422 }423424 425 426 427 428 429 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([430 sendMessage({ type: "IS_UNLOCKED" }),431 sendMessage({ type: "HAS_VAULT" }),432 refreshCurrentTab(),433 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),434 ]);435 const unlocked = Boolean(436 (unlockResponse as { ok: boolean; data?: boolean }).data437 );438 const vaultExists = Boolean(439 (vaultResponse as { ok: boolean; data?: boolean }).data440 );441 const shouldCelebrate = Boolean(442 (celebrationResponse as { ok: boolean; data?: boolean }).data443 );444 setHasVault(vaultExists);445 446 447 448 try {449 const onboarded = await storageGet("peach_onboarded");450 const seen = onboarded === "true";451 if (vaultExists && !seen) {452 await storageSet("peach_onboarded", "true");453 setOnboarded(true);454 } else {455 setOnboarded(seen);456 }457 } catch {458 459 setOnboarded(true);460 }461 await Promise.all([restoreSettings(), refreshProStatus()]);462463 464 if (shouldCelebrate) {465 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });466 }467468 if (unlocked && vaultExists) {469 await openVault(undefined, shouldCelebrate);470 if (shouldCelebrate) {471 setView("pro-celebration");472 window.setTimeout(() => setView("entry"), 2500);473 }474 } else if (shouldCelebrate) {475 setPendingCelebration(true);476 }477478 await refreshPendingPasskey();479 await refreshIncomingPairing();480 await consumeRecentPairing();481482 483 if (!Capacitor.isNativePlatform()) {484 const handleTabActivated = () => { void refreshCurrentTab(); };485 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {486 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();487 };488 try {489 chrome.tabs.onActivated.addListener(handleTabActivated);490 chrome.tabs.onUpdated.addListener(handleTabUpdated);491 onCleanup(() => {492 chrome.tabs.onActivated.removeListener(handleTabActivated);493 chrome.tabs.onUpdated.removeListener(handleTabUpdated);494 });495 } catch {496 497 }498 }499500 setBootState("ready");501 document.getElementById("pre-lock")?.setAttribute("hidden", "true");502503 504 if (!Capacitor.isNativePlatform() && chrome.storage.session) {505 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as506 Record<string, { feature?: string } | undefined>;507 if (session.pendingProGate?.feature) {508 await chrome.storage.session.remove("pendingProGate");509 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);510 setSettingsInitialSection("license");511 setView("settings");512 } else if (session.pendingProNotice?.feature) {513 await chrome.storage.session.remove("pendingProNotice");514 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });515 }516 }517518 if (Capacitor.getPlatform() === "android") {519 try {520 const { pending } = await Autofill.checkPendingSave();521 if (pending) {522 const parsed = JSON.parse(pending) as {523 domain?: string;524 username?: string;525 password?: string;526 };527 if (parsed.username && parsed.password) {528 setPendingAutofillSave({529 domain: parsed.domain ?? "",530 username: parsed.username,531 password: parsed.password,532 });533 }534 }535 } catch {536 537 }538 }539540 541 542 543 544 void Promise.all([545 import("./views/Settings").catch(() => {}),546 import("./views/Import").catch(() => {}),547 import("./views/Export").catch(() => {}),548 import("./views/Health").catch(() => {}),549 import("./views/TrashView").catch(() => {}),550 ]);551552 553 554 555 if (!Capacitor.isNativePlatform()) {556 let popupPort: chrome.runtime.Port | null = null;557 try {558 popupPort = chrome.runtime.connect({ name: "popup" });559 } catch { }560 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });561562 563 564 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {565 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();566 if (msg?.type === "PRO_GATE_TRIGGERED") {567 setUpgradeHighlight(msg.payload?.feature ?? null);568 setSettingsInitialSection("license");569 setView("settings");570 }571 if (msg?.type === "INCOMING_PAIRING_REQUEST") {572 void refreshIncomingPairing();573 }574 };575 chrome.runtime.onMessage.addListener(handleRuntimeMessage);576 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));577 }578579 580 581 582 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {583 copy_password: () => {584 const entry = selectedEntry();585 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });586 },587 copy_username: () => {588 const entry = selectedEntry();589 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });590 },591 copy_totp: () => {592 const entry = selectedEntry();593 if (!entry?.totp) return;594 try {595 const { code } = generateTOTP(entry.totp);596 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });597 } catch { }598 },599 new_entry: () => handleAdd(),600 edit_entry: () => {601 const entry = selectedEntry();602 603 604 if (!entry || isPasskeyOnly(entry)) return;605 setEditEntry(entry);606 setView("add");607 },608 delete_entry: () => {609 if (selectedEntry()) void handleDelete();610 },611 lock_vault: () => { void handleHeaderLock(); },612 search: () => openList(),613 nav_prev: () => navigate(-1),614 nav_next: () => navigate(1),615 jump_vault: () => setView("entry"),616 jump_settings: () => setView("settings"),617 show_help: () => setShowShortcutsHelp(true),618 };619620 621 622 623 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([624 "copy_password", "copy_username", "copy_totp",625 "edit_entry", "delete_entry", "nav_prev", "nav_next",626 ]);627628 const handleGlobalKeydown = (e: KeyboardEvent) => {629 const v = view();630 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"631 || (e.target as HTMLElement)?.tagName === "TEXTAREA"632 || (e.target as HTMLElement)?.isContentEditable;633 const isPrintableSearchKey =634 e.key.length === 1 &&635 !e.ctrlKey &&636 !e.metaKey &&637 !e.altKey;638639 640 if (v === "pro-celebration") return;641642 643 644 if (showShortcutsHelp()) {645 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }646 return;647 }648649 650 if (e.key === "Escape") {651 if (v === "list") { setView("entry"); return; }652 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }653 if (v === "sync-pairing") { setView("settings"); return; }654 }655656 657 658 659 const bindings = shortcutBindings();660 for (const action of Object.keys(bindings) as ShortcutAction[]) {661 const binding = bindings[action];662 if (!matchKeybinding(e, binding)) continue;663 664 665 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;666 if (!hasMod && binding.key.length === 1 && inInput) return;667 if (ENTRY_SCOPED.has(action) && v !== "entry") return;668 e.preventDefault();669 SHORTCUT_HANDLERS[action]?.();670 return;671 }672673 674 675 676 677 if (v === "entry" && !inInput && isPrintableSearchKey) {678 e.preventDefault();679 primeListSearch(e.key);680 return;681 }682 };683 window.addEventListener("keydown", handleGlobalKeydown);684 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));685686 if (!Capacitor.isNativePlatform()) {687 const handleStorageChanged = (688 changes: Record<string, chrome.storage.StorageChange>,689 areaName: string690 ) => {691 if (areaName !== "session") return;692 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {693 return;694 }695 void refreshPendingPasskey();696 };697 chrome.storage.onChanged.addListener(handleStorageChanged);698 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));699 }700 });701702 async function refreshCurrentTab() {703 if (Capacitor.isNativePlatform()) return;704 try {705 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });706 const url = tab?.url ?? tab?.pendingUrl ?? "";707 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");708 709 710 const isWebUrl = /^https?:\/\//i.test(url);711 setCurrentTabUrl(isWebUrl ? url : "");712 } catch {713 setCurrentTabDomain("");714 setCurrentTabUrl("");715 }716 }717718 async function refreshPendingPasskey() {719 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });720 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);721 }722723 async function refreshIncomingPairing() {724 const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });725 const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;726 setIncomingPair(data);727 }728729 730 731 732 async function consumeRecentPairing() {733 const res = await sendMessage({ type: "GET_RECENT_PAIRING" });734 const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;735 if (data?.deviceName) {736 setRecentPairToast(data.deviceName);737 window.setTimeout(() => setRecentPairToast(null), 4000);738 }739 }740741 async function respondIncomingPairing(accept: boolean) {742 if (incomingPairResponding()) return;743 setIncomingPairResponding(true);744 await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });745 setIncomingPair(null);746 setIncomingPairResponding(false);747 }748749 750 751 752 createEffect(() => {753 const req = incomingPair();754 if (!req) return;755 const handle = window.setInterval(() => {756 setIncomingPairTick((tick) => tick + 1);757 if (Date.now() >= req.expiresAt) {758 setIncomingPair(null);759 window.clearInterval(handle);760 }761 }, 1000);762 onCleanup(() => window.clearInterval(handle));763 });764765 async function refreshProStatus() {766 const res = await sendMessage({ type: "GET_LICENSE" });767 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;768 setProStatus(Boolean(data?.valid && data?.plan === "pro"));769 }770771 async function restoreSettings() {772 const res = await sendMessage({ type: "GET_SETTINGS" });773 const s = (res as { ok: boolean; data?: any }).data ?? {};774 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {775 setAccentColor(s.accentColor as AccentColorName);776 }777 setTheme(s.theme === "light" ? "light" : "dark");778 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {779 setDisplayFont(s.displayFont);780 } else {781 setDisplayFont("cormorant-italic");782 }783 if (s.textSize === "small" || s.textSize === "large") {784 setTextSize(s.textSize);785 } else {786 setTextSize("medium");787 }788 setReduceMotion(s.reduceMotion === true);789 setLockOnClose(s.lockOnClose !== false);790 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);791 setBreachMonitoring(s.breachMonitoring !== false);792 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);793 setShortcutBindings(getActiveBindings(s.shortcuts));794 }795796 async function applyTrashRetention(days: number) {797 setTrashRetentionDays(days);798 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });799 }800801 function primeListSearch(seed: string) {802 setListSearchSeed(seed);803 setListSearchSeedNonce((value) => value + 1);804 openList();805 }806807 808 809 810 async function loadEntries(): Promise<VaultEntry[] | null> {811 const response = await sendMessage({ type: "GET_ENTRIES" });812 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };813814 815 816 817 818 if (!res.ok) {819 if (820 res.error === "vault locked" ||821 res.error === "SERVICE_WORKER_UNAVAILABLE" ||822 (typeof res.error === "string" && res.error.includes("locked"))823 ) {824 handleLock();825 return null; 826 }827 return [];828 }829830 const loadedEntries = res.data ?? [];831 setEntries(loadedEntries);832 void publishInMemoryVault(loadedEntries);833834 const currentId = selectedEntry()?.id;835 if (currentId) {836 setSelectedEntry(837 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null838 );839 }840841 return loadedEntries;842 }843844 async function openVault(preferredEntryId?: string, skipViewSet = false) {845 846 const retDays = trashRetentionDays();847 if (retDays > 0) {848 void sendMessage({849 type: "PURGE_EXPIRED_TRASH",850 payload: { retentionMs: retDays * 86_400_000 },851 });852 }853854 const loadedEntries = await loadEntries();855856 857 858 if (loadedEntries === null) return;859860 861 let nextSelectedEntry: VaultEntry | null = null;862 if (preferredEntryId) {863 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;864 }865 if (!nextSelectedEntry) {866 const tabDomain = currentTabDomain();867 if (tabDomain) {868 nextSelectedEntry = loadedEntries.find((e) => {869 if (e.type === "login") {870 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;871 for (const extra of e.additionalUrls ?? []) {872 if ((extractDomain(extra) ?? "") === tabDomain) return true;873 }874 return false;875 }876 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;877 return false;878 }) ?? null;879 }880 }881 nextSelectedEntry ??= loadedEntries[0] ?? null;882883 setSelectedEntry(nextSelectedEntry);884 setEditEntry(undefined);885 setContentTransitioning(false);886 setContentPhase("enter");887 queueContentIdle();888 if (!skipViewSet) setView("entry");889 }890891 async function handleUnlock() {892 setHasVault(true);893 triggerPipPulse();894 await restoreSettings();895 const celebrating = pendingCelebration();896 if (celebrating) {897 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });898 setPendingCelebration(false);899 }900 await openVault(undefined, celebrating);901 if (celebrating) {902 setView("pro-celebration");903 window.setTimeout(() => setView("entry"), 2500);904 }905 }906907 function handleSelectEntry(entry: VaultEntry) {908 setSelectedEntry(entry);909 setContentTransitioning(false);910 setContentPhase("enter");911 queueContentIdle();912 setView("entry");913 }914915 function handleAdd() {916 setEditEntry(undefined);917 setView("add");918 }919920 async function handleDelete() {921 const entry = selectedEntry();922 if (!entry) return;923 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });924 const loadedEntries = await loadEntries() ?? [];925 setSelectedEntry(loadedEntries[0] ?? null);926 setView("entry");927 }928929 async function handleDeleteMany(ids: string[]) {930 for (const id of ids) {931 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });932 }933 const loadedEntries = await loadEntries() ?? [];934 935 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);936 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);937 }938939 async function handleSave(savedEntry?: VaultEntry) {940 await openVault(savedEntry?.id);941 }942943 async function savePendingAutofill() {944 const pending = pendingAutofillSave();945 if (!pending) return;946 const domain = pending.domain.trim();947 const response = await sendMessage({948 type: "ADD_ENTRY",949 payload: {950 type: "login",951 title: domain || "Saved login",952 url: domain ? `https://${domain}` : "",953 username: pending.username,954 password: pending.password,955 },956 });957 if ((response as { ok: boolean }).ok) {958 setPendingAutofillSave(null);959 await openVault((response as { data?: VaultEntry }).data?.id);960 }961 }962963 function handleBack() {964 setEditEntry(undefined);965 setView("entry");966 }967968 function handleLock() {969 clearCanarySuppressions();970 setEntries([]);971 setSelectedEntry(null);972 setEditEntry(undefined);973 setContentPhase("idle");974 setView("lock");975 void clearInMemoryVault();976 }977978 function handleHeaderLock() {979 980 981 982 handleLock();983 void sendMessage({ type: "LOCK" });984 }985986 function navigate(dir: number) {987 const allEntries = entries();988 if (allEntries.length === 0) return;989 const currentIndex = selectedIndex();990 const baseIndex = currentIndex >= 0 ? currentIndex : 0;991 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;992 setSelectedEntry(allEntries[nextIndex]);993 setContentPhase("enter");994 queueContentIdle();995 }996997 function openList() {998 setView("list");999 }10001001 function openHealth() {1002 setEditEntry(undefined);1003 setView("health");1004 }10051006 function handleOpenFileImport() {1007 setRestoreView("file");1008 }10091010 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10111012 function handleFixEntry(entry: VaultEntry) {1013 1014 1015 if (isPasskeyOnly(entry)) {1016 setSelectedEntry(entry);1017 setView("entry");1018 return;1019 }1020 setEditEntry(entry);1021 setView("add");1022 }10231024 const isLockedView = () => view() === "lock";10251026 async function handleApprovePendingPasskey() {1027 const pending = pendingPasskey();1028 if (!pending) return;1029 if (pending.browser === "firefox" && pending.type === "create") {1030 1031 1032 1033 1034 const { createFirefoxPasskey } = await import("./firefox-passkey");1035 const result = await createFirefoxPasskey(pending);1036 if (!result.ok) {1037 console.warn("Peach Firefox passkey create failed:", result.error);1038 }1039 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1040 setPendingPasskey(null);1041 void loadEntries();1042 return;1043 }1044 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1045 setPendingPasskey(null);1046 }10471048 async function handleDenyPendingPasskey() {1049 const pending = pendingPasskey();1050 if (!pending) return;1051 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1052 setPendingPasskey(null);1053 }10541055 function queueContentIdle(after?: () => void) {1056 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1057 contentPhaseTimer = window.setTimeout(() => {1058 setContentPhase("idle");1059 after?.();1060 }, ENTRY_TRANSITION_SETTLE_MS);1061 }10621063 return (1064 <div class="popup-root peach-theme">1065 <Show when={bootState() === "ready"}>1066 <Show1067 when={!pendingPasskey()}1068 fallback={1069 <PasskeyApprovalView1070 type={pendingPasskey()!.type}1071 rpName={pendingPasskey()!.rpName}1072 rpId={pendingPasskey()!.rpId}1073 userName={pendingPasskey()!.userName}1074 userDisplayName={pendingPasskey()!.userDisplayName}1075 onApprove={handleApprovePendingPasskey}1076 onDeny={handleDenyPendingPasskey}1077 />1078 }1079 >1080 <Show1081 when={!isLockedView()}1082 fallback={1083 <Show1084 when={onboarded() || hasVault()}1085 fallback={1086 <Onboarding1087 onComplete={() => { setOnboarded(true); }}1088 />1089 }1090 >1091 <LockView1092 hasVault={hasVault()}1093 ready={bootState() === "ready"}1094 isPro={proStatus()}1095 onUnlock={handleUnlock}1096 onBackToOnboarding={1097 !hasVault()1098 ? () => setOnboarded(false)1099 : undefined1100 }1101 />1102 </Show>1103 }1104 >1105 <Show1106 when={view() !== "pro-celebration"}1107 fallback={<ProCelebration />}1108 >1109 <section1110 id="hd-shell"1111 class="peach-shell peach-theme"1112 data-surface="extension"1113 >1114 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1115 <PeachWordmark unlocked />1116 <div class="peach-chrome-actions">1117 <button1118 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1119 onClick={() => setView("settings")}1120 aria-label="Settings"1121 >1122 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1123 <path1124 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"1125 stroke="currentColor"1126 stroke-width="0.9"1127 />1128 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1129 </svg>1130 </button>1131 <button1132 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1133 onClick={handleAdd}1134 aria-label="New entry"1135 >1136 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1137 <path d="M5.5 1v9M1 5.5h9" />1138 </svg>1139 </button>1140 <button1141 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1142 onClick={() => void handleHeaderLock()}1143 aria-label="Lock vault"1144 >1145 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1146 <path1147 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"1148 stroke="currentColor"1149 stroke-width="1"1150 stroke-linecap="round"1151 />1152 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1153 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1154 </svg>1155 </button>1156 </div>1157 </header>11581159 <Show when={recentPairToast()}>1160 <div class="peach-pair-toast" role="status" aria-live="polite">1161 Paired with <strong>{recentPairToast()}</strong>1162 </div>1163 </Show>11641165 <Show when={incomingPair() !== null}>1166 <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1167 <div class="peach-pair-request-header">1168 <span class="peach-pair-request-mark">🍑</span>1169 <span class="peach-pair-request-title">Pairing request</span>1170 <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1171 </div>1172 <p class="peach-pair-request-body">1173 <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1174 </p>1175 <div class="peach-pair-request-actions">1176 <button1177 class="peach-btn peach-btn--ghost peach-btn--sm"1178 onClick={() => void respondIncomingPairing(false)}1179 disabled={incomingPairResponding()}1180 >1181 Reject1182 </button>1183 <button1184 class="peach-btn peach-btn--accent peach-btn--sm"1185 onClick={() => void respondIncomingPairing(true)}1186 disabled={incomingPairResponding()}1187 >1188 Accept1189 </button>1190 </div>1191 </div>1192 </Show>11931194 <Show when={pendingProNotice() !== null}>1195 <div class="peach-pro-notice-banner">1196 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1197 <button1198 class="peach-pro-notice-link"1199 onClick={() => {1200 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1201 setSettingsInitialSection("license");1202 setView("settings");1203 setPendingProNotice(null);1204 }}1205 >1206 Learn more →1207 </button>1208 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1209 </div>1210 </Show>12111212 <Show when={pendingAutofillSave() !== null && !isLockedView()}>1213 <div class="peach-pro-notice-banner peach-autofill-save-banner">1214 <span>1215 Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1216 </span>1217 <button1218 class="peach-pro-notice-link"1219 onClick={() => void savePendingAutofill()}1220 >1221 Save1222 </button>1223 <button1224 class="peach-pro-notice-close"1225 onClick={() => setPendingAutofillSave(null)}1226 aria-label="Dismiss"1227 >1228 x1229 </button>1230 </div>1231 </Show>12321233 <Show when={backExitNotice()}>1234 <div class="peach-pro-notice-banner peach-back-exit-banner">1235 <span>Tap back again to exit Peach</span>1236 </div>1237 </Show>12381239 <div class="peach-shell-body">1240 <div class="peach-shell-veil"></div>1241 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1242 <EntryView1243 entry={selectedEntry()}1244 currentTabUrl={currentTabUrl()}1245 onDelete={handleDelete}1246 onFieldSaved={() => void loadEntries()}1247 onEscape={() => { setView("list"); }}1248 />1249 </div>12501251 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1252 <div class="peach-search-bar">1253 <span class="peach-search-icon">1254 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1255 <circle cx="5.5" cy="5.5" r="3.75" />1256 <path d="M8.75 8.75L11.5 11.5" />1257 </svg>1258 </span>1259 <input1260 class="peach-search-input"1261 placeholder="search vault..."1262 autocomplete="off"1263 aria-label="Search vault entries"1264 onClick={openList}1265 onFocus={openList}1266 readOnly1267 />1268 <div class="peach-search-meta">1269 <span class="peach-search-counter">1270 {entries().length > 01271 ? `${selectedIndex() + 1} / ${entries().length}`1272 : "0 entries"}1273 </span>1274 <span class="peach-divider"></span>1275 <button1276 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1277 onClick={() => navigate(-1)}1278 aria-label="Previous entry"1279 disabled={entries().length === 0}1280 >1281 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1282 <path d="M5 1L1 5.5L5 10" />1283 </svg>1284 </button>1285 <button1286 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1287 onClick={() => navigate(1)}1288 aria-label="Next entry"1289 disabled={entries().length === 0}1290 >1291 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1292 <path d="M1 1L5 5.5L1 10" />1293 </svg>1294 </button>1295 </div>1296 </div>1297 </section>1298 </div>12991300 <Show when={view() === "list"}>1301 <VaultView1302 entries={entries()}1303 selectedEntryId={selectedEntry()?.id}1304 currentTabDomain={currentTabDomain()}1305 sortOrder={sortOrder()}1306 searchSeed={listSearchSeed()}1307 searchSeedNonce={listSearchSeedNonce()}1308 onSelectEntry={handleSelectEntry}1309 onSortOrderChange={setSortOrder}1310 onAdd={handleAdd}1311 onClose={() => setView("entry")}1312 onOpenTrash={() => setView("trash")}1313 onDeleteMany={handleDeleteMany}1314 />1315 </Show>1316 <Show when={view() === "settings"}>1317 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1318 <SettingsView1319 entriesCount={entries().length}1320 breachMonitoring={breachMonitoring()}1321 lockOnClose={lockOnClose()}1322 autoLockMinutes={autoLockMinutes()}1323 trashRetentionDays={trashRetentionDays()}1324 theme={theme()}1325 accentColor={accentColor()}1326 displayFont={displayFont()}1327 textSize={textSize()}1328 reduceMotion={reduceMotion()}1329 isPro={proStatus()}1330 initialSection={settingsInitialSection()}1331 upgradeHighlight={upgradeHighlight()}1332 onBreachMonitoringChange={applyBreachMonitoring}1333 onLockOnCloseChange={applyLockOnClose}1334 onAutoLockMinutesChange={applyAutoLockMinutes}1335 onTrashRetentionChange={applyTrashRetention}1336 onThemeChange={applyTheme}1337 onAccentChange={applyAccentColor}1338 onDisplayFontChange={applyDisplayFont}1339 onTextSizeChange={applyTextSize}1340 onReduceMotionChange={applyReduceMotion}1341 shortcuts={shortcutBindings()}1342 onShortcutsChange={applyShortcuts}1343 onLicenseChange={async () => {1344 1345 1346 1347 1348 1349 await refreshProStatus();1350 void loadEntries();1351 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1352 if ((should as { ok: boolean; data?: boolean }).data === true) {1353 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1354 setView("pro-celebration");1355 window.setTimeout(() => setView("settings"), 2500);1356 }1357 }}1358 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1359 onImport={() => setView("import")}1360 onExport={() => setView("export")}1361 onBreachCheckDone={loadEntries}1362 onOpenHealth={openHealth}1363 onOpenSyncPairing={() => setView("sync-pairing")}1364 onOpenFileImport={handleOpenFileImport}1365 onOpenPanic={() => setView("panic")}1366 />1367 </Suspense>1368 </Show>1369 <Show when={view() === "sync-pairing"}>1370 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1371 <SyncPairingView1372 onClose={() => setView("settings")}1373 onPaired={() => { }}1374 />1375 </Suspense>1376 </Show>1377 <Show when={view() === "add"}>1378 <AddView1379 entry={editEntry()}1380 entries={entries()}1381 onBack={handleBack}1382 onSave={handleSave}1383 />1384 </Show>1385 <Show when={view() === "import"}>1386 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1387 <ImportView onBack={handleBack} onImport={handleSave} />1388 </Suspense>1389 </Show>1390 <Show when={view() === "export"}>1391 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1392 <ExportView1393 onBack={() => {1394 if (returnToPanic()) {1395 setReturnToPanic(false);1396 setView("panic");1397 } else {1398 handleBack();1399 }1400 }}1401 />1402 </Suspense>1403 </Show>1404 <Show when={view() === "panic"}>1405 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1406 <PanicView1407 onCancel={() => setView("settings")}1408 onOpenExport={() => {1409 setReturnToPanic(true);1410 setView("export");1411 }}1412 onDestroyed={async () => {1413 clearCanarySuppressions();1414 setEntries([]);1415 setSelectedEntry(null);1416 setEditEntry(undefined);1417 setHasVault(false);1418 setOnboarded(false);1419 setProStatus(false);1420 setPendingPasskey(null);1421 setUpgradeHighlight(null);1422 setSettingsInitialSection(null);1423 setPendingProNotice(null);1424 setPendingCelebration(false);1425 setReturnToPanic(false);1426 setContentPhase("idle");1427 setView("lock");1428 void clearInMemoryVault();1429 }}1430 />1431 </Suspense>1432 </Show>1433 <Show when={view() === "health"}>1434 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1435 <HealthView1436 entries={entries()}1437 isPro={proStatus()}1438 onFixEntry={handleFixEntry}1439 onBack={() => setView("settings")}1440 onRefresh={loadEntries}1441 />1442 </Suspense>1443 </Show>1444 <Show when={view() === "trash"}>1445 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1446 <TrashView1447 onBack={() => setView("list")}1448 onRestored={() => void loadEntries()}1449 />1450 </Suspense>1451 </Show>1452 <Show when={restoreView() === "file"}>1453 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1454 <ImportView1455 mode="onboarding"1456 replacesExistingVault={true}1457 onBack={() => setRestoreView(null)}1458 onImport={async () => {1459 setRestoreView(null);1460 await handleUnlock();1461 }}1462 />1463 </Suspense>1464 </Show>1465 </section>1466 </Show>1467 </Show>1468 </Show>1469 </Show>1470 <Show when={showShortcutsHelp()}>1471 <ShortcutsHelpOverlay1472 bindings={shortcutBindings()}1473 onClose={() => setShowShortcutsHelp(false)}1474 />1475 </Show>1476 </div>1477 );1478}14791480function ShortcutsHelpOverlay(props: {1481 bindings: Record<ShortcutAction, Keybinding>;1482 onClose: () => void;1483}) {1484 return (1485 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1486 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1487 <div class="peach-shortcuts-help-head">1488 <h3>Keyboard shortcuts</h3>1489 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1490 </div>1491 <div class="peach-shortcuts-help-body">1492 <p class="peach-settings-sub-label">ENTRY VIEW</p>1493 <ul>1494 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1495 <li>1496 <span>{d.label}</span>1497 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1498 </li>1499 ))}1500 </ul>1501 <p class="peach-settings-sub-label">ANYWHERE</p>1502 <ul>1503 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1504 <li>1505 <span>{d.label}</span>1506 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1507 </li>1508 ))}1509 </ul>1510 </div>1511 </div>1512 </div>1513 );1514}