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 { PeachMark, 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 consumePendingAutofillRequest(): Promise<{50 pending: { domain: string; package: string } | null;51 }>;52 completeAutofillAuthentication(options: { domain: string }): Promise<void>;53}>("Autofill");5455type View =56 | "lock"57 | "pro-celebration"58 | "entry"59 | "list"60 | "add"61 | "settings"62 | "sync-pairing"63 | "import"64 | "export"65 | "health"66 | "trash"67 | "panic";68type BootState = "loading" | "ready";6970const canarySuppressMap = new Map<string, boolean>();71let shellRingAnimation: Animation | null = null;7273export function isCanarySuppressed(entryId: string): boolean {74 return canarySuppressMap.get(entryId) ?? false;75}7677export function setCanarySuppressed(entryId: string, suppressed: boolean): void {78 canarySuppressMap.set(entryId, suppressed);79}8081export function clearCanarySuppressions(): void {82 canarySuppressMap.clear();83}8485export function triggerRing() {86 const shell = document.getElementById("hd-shell");87 if (!shell) return;8889 shellRingAnimation?.cancel();90 shell.dataset.glowing = "true";91 const animation = shell.animate(92 [93 { offset: 0, boxShadow: "var(--copy-shadow)" },94 {95 offset: 0.3,96 boxShadow:97 "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)",98 },99 { offset: 1, boxShadow: "var(--copy-shadow)" },100 ],101 {102 duration: 720,103 easing: "cubic-bezier(0.4, 0, 0.2, 1)",104 fill: "none",105 }106 );107 shellRingAnimation = animation;108 const clearGlow = () => {109 if (shellRingAnimation !== animation) return;110 shellRingAnimation = null;111 delete shell.dataset.glowing;112 };113 animation.onfinish = clearGlow;114 animation.oncancel = clearGlow;115}116117118export function triggerPipPulse() {119 const pip = document.querySelector(".peach-status-pip, .pip");120 if (pip) {121 pip.classList.remove("pulse");122 void (pip as HTMLElement).offsetWidth;123 pip.classList.add("pulse");124 }125}126127const ENTRY_TRANSITION_EXIT_MS = 95;128const ENTRY_TRANSITION_SETTLE_MS = 135;129130export default function App() {131 const [bootState, setBootState] = createSignal<BootState>("loading");132 const [view, setView] = createSignal<View>("lock");133 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(134 "enter"135 );136 const [contentTransitioning, setContentTransitioning] = createSignal(false);137 const [layoutClass, setLayoutClass] = createSignal<"compact" | "medium" | "expanded">("compact");138 const isTwoPaneLayout = () => layoutClass() === "medium" || layoutClass() === "expanded";139 const [hasVault, setHasVault] = createSignal(false);140 const [onboarded, setOnboarded] = createSignal(true);141 const [entries, setEntries] = createSignal<VaultEntry[]>([]);142 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);143 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);144 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");145 const [theme, setTheme] = createSignal<"dark" | "light">("dark");146 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");147 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");148 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);149 const [lockOnClose, setLockOnClose] = createSignal(true);150 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);151 const [breachMonitoring, setBreachMonitoring] = createSignal(true);152 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);153 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);154 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);155 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);156 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);157 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");158 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");159 160 161 162 163 const [proStatus, setProStatus] = createSignal(false);164 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);165 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);166 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");167 const [listSearchSeed, setListSearchSeed] = createSignal("");168 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);169 const [pendingCelebration, setPendingCelebration] = createSignal(false);170 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);171 const [pendingAutofillSave, setPendingAutofillSave] = createSignal<{172 domain: string;173 username: string;174 password: string;175 } | null>(null);176 const [pendingAutofillRequest, setPendingAutofillRequest] = createSignal<{177 domain: string;178 pkg: string;179 } | null>(null);180 const [backExitNotice, setBackExitNotice] = createSignal(false);181 const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);182 const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);183 const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);184 const [incomingPairTick, setIncomingPairTick] = createSignal(0);185 const incomingPairSecondsLeft = () => {186 void incomingPairTick();187 const req = incomingPair();188 if (!req) return 0;189 return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));190 };191 192 193 194 const [returnToPanic, setReturnToPanic] = createSignal(false);195 const [showGestureHints, setShowGestureHints] = createSignal(false);196197 let contentPhaseTimer: number | undefined;198 let lastBackExitAt = 0;199200 function applyAccent(name: AccentColorName) {201 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;202 const hex = color.hex;203 document.documentElement.style.setProperty("--peach-accent", hex);204 205 206 document.documentElement.style.setProperty("--peach-glow", hex + "2A");207 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");208 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");209 }210211 createEffect(() => {212 applyAccent(accentColor());213 });214215 createEffect(() => {216 document.documentElement.classList.toggle("light", theme() === "light");217 document.body.classList.toggle("light", theme() === "light");218 if (Capacitor.isNativePlatform()) {219 void applyMobileStatusBar(theme());220 }221 });222223 createEffect(() => {224 document.documentElement.setAttribute("data-display-font", displayFont());225 });226227 createEffect(() => {228 document.documentElement.setAttribute("data-text-size", textSize());229 });230231 createEffect(() => {232 if (reduceMotion()) {233 document.documentElement.setAttribute("data-motion", "reduce");234 } else {235 document.documentElement.removeAttribute("data-motion");236 }237 });238239 const selectedIndex = createMemo(() => {240 const currentId = selectedEntry()?.id;241 if (!currentId) return entries().length > 0 ? 0 : -1;242 return entries().findIndex((entry) => entry.id === currentId);243 });244245 async function applyAccentColor(name: AccentColorName) {246 applyAccent(name);247 setAccentColor(name);248 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });249 }250251 async function applyTheme(t: "dark" | "light") {252 setTheme(t);253 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });254 }255256 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {257 setDisplayFont(f);258 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });259 }260261 async function applyTextSize(size: "small" | "medium" | "large") {262 setTextSize(size);263 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });264 }265266 async function applyReduceMotion(enabled: boolean) {267 setReduceMotion(enabled);268 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });269 }270271 272 273 274 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {275 setShortcutBindings(active);276 const overrides: Record<string, Keybinding> = {};277 for (const action of Object.keys(active) as ShortcutAction[]) {278 const a = active[action];279 const d = DEFAULT_BINDINGS[action];280 const same = a.key === d.key281 && Boolean(a.ctrl) === Boolean(d.ctrl)282 && Boolean(a.meta) === Boolean(d.meta)283 && Boolean(a.shift) === Boolean(d.shift)284 && Boolean(a.alt) === Boolean(d.alt);285 if (!same) overrides[action] = a;286 }287 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });288 }289290 async function applyLockOnClose(enabled: boolean) {291 setLockOnClose(enabled);292 await sendMessage({293 type: "UPDATE_SETTINGS",294 payload: { lockOnClose: enabled },295 });296 }297298 async function applyAutoLockMinutes(minutes: number) {299 setAutoLockMinutes(minutes);300 await sendMessage({301 type: "UPDATE_SETTINGS",302 payload: { autoLockMinutes: minutes },303 });304 }305306 async function applyBreachMonitoring(enabled: boolean) {307 setBreachMonitoring(enabled);308 await sendMessage({309 type: "UPDATE_SETTINGS",310 payload: { breachMonitoring: enabled },311 });312 }313314 async function applyMobileStatusBar(nextTheme: "dark" | "light") {315 try {316 await StatusBar.setStyle({ style: nextTheme === "light" ? Style.Light : Style.Dark });317 await StatusBar.setBackgroundColor({318 color: nextTheme === "light" ? "#EEE3CC" : "#0C0A06",319 });320 } catch {321 322 }323 }324325 function handleBackNavigation() {326 if (showShortcutsHelp()) {327 setShowShortcutsHelp(false);328 return;329 }330 if (restoreView() !== null) {331 setRestoreView(null);332 return;333 }334335 switch (view()) {336 case "list":337 setView("entry");338 return;339 case "settings":340 case "add":341 case "import":342 handleBack();343 return;344 case "export":345 if (returnToPanic()) {346 setReturnToPanic(false);347 setView("panic");348 } else {349 handleBack();350 }351 return;352 case "health":353 case "panic":354 case "sync-pairing":355 setView("settings");356 return;357 case "trash":358 setView("list");359 return;360 case "entry":361 case "lock": {362 const now = Date.now();363 if (now - lastBackExitAt < 1600) {364 void CapacitorApp.exitApp();365 return;366 }367 lastBackExitAt = now;368 setBackExitNotice(true);369 window.setTimeout(() => setBackExitNotice(false), 1400);370 return;371 }372 default:373 void CapacitorApp.exitApp();374 }375 }376377 onMount(async () => {378 if (Capacitor.isNativePlatform()) {379 document.body.classList.add("peach-mobile");380 document.documentElement.classList.add("peach-mobile-root");381 void applyMobileStatusBar(theme());382383 384 385 const updateLayoutClass = () => {386 const w = window.innerWidth;387 const cls = document.body.classList;388 cls.remove("layout-compact", "layout-medium", "layout-expanded");389 const next: "compact" | "medium" | "expanded" =390 w >= 840 ? "expanded" : w >= 600 ? "medium" : "compact";391 cls.add(`layout-${next}`);392 setLayoutClass(next);393 };394 updateLayoutClass();395 window.addEventListener("resize", updateLayoutClass);396397 398 399 400 const onAutofillRequested = () => {401 void (async () => {402 try {403 const { pending } = await Autofill.consumePendingAutofillRequest();404 if (pending && (pending.domain || pending.package)) {405 setPendingAutofillRequest({406 domain: pending.domain || pending.package,407 pkg: pending.package || "",408 });409 }410 } catch { }411 })();412 };413 window.addEventListener("peach:autofill-requested", onAutofillRequested);414415 416 417 418 const appState = await CapacitorApp.addListener("appStateChange", (state) => {419 if (!state.isActive) return;420 void (async () => {421 try {422 const { pending } = await Autofill.checkPendingSave();423 if (pending) {424 const parsed = JSON.parse(pending) as {425 domain?: string;426 username?: string;427 password?: string;428 };429 if (parsed.username && parsed.password) {430 setPendingAutofillSave({431 domain: parsed.domain ?? "",432 username: parsed.username,433 password: parsed.password,434 });435 }436 }437 } catch { }438 onAutofillRequested();439 })();440 });441442 443 444 const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {445 document.body.setAttribute("data-posture", e.posture);446 document.documentElement.style.setProperty(447 "--peach-hinge-height",448 `${e.hingeHeight}px`,449 );450 });451452 const keyboardShow = await Keyboard.addListener("keyboardWillShow", (info) => {453 document.body.style.paddingBottom = `${info.keyboardHeight}px`;454 });455 const keyboardHide = await Keyboard.addListener("keyboardWillHide", () => {456 document.body.style.paddingBottom = "0";457 });458 const backButton = await CapacitorApp.addListener("backButton", () => {459 handleBackNavigation();460 });461462 let touchStartX = 0;463 let touchStartY = 0;464 let touchStartTime = 0;465 const handleTouchStart = (event: TouchEvent) => {466 const touch = event.touches[0];467 if (!touch) return;468 touchStartX = touch.clientX;469 touchStartY = touch.clientY;470 touchStartTime = Date.now();471 };472 const handleTouchEnd = (event: TouchEvent) => {473 const touch = event.changedTouches[0];474 if (!touch) return;475 const dx = touch.clientX - touchStartX;476 const rawDy = touch.clientY - touchStartY;477 const absDx = Math.abs(dx);478 const absDy = Math.abs(rawDy);479 const dt = Date.now() - touchStartTime;480481 482 if (touchStartX < 30 && dx > 60 && absDy < 40) {483 handleBackNavigation();484 return;485 }486487 488 if (view() === "entry" && !isShellOverlayOpen() && !showGestureHints()) {489 490 if (rawDy < -80 && absDx < 60 && dt < 500491 && touchStartY > window.innerHeight * 0.4) {492 openList();493 return;494 }495 496 if (absDx > 60 && absDy < 40 && dt < 400 && touchStartX >= 30) {497 navigate(dx < 0 ? 1 : -1);498 return;499 }500 }501 };502 document.addEventListener("touchstart", handleTouchStart, { passive: true });503 document.addEventListener("touchend", handleTouchEnd, { passive: true });504505 onCleanup(() => {506 void keyboardShow.remove();507 void keyboardHide.remove();508 void backButton.remove();509 void appState.remove();510 stopFold();511 window.removeEventListener("peach:autofill-requested", onAutofillRequested);512 window.removeEventListener("resize", updateLayoutClass);513 document.removeEventListener("touchstart", handleTouchStart);514 document.removeEventListener("touchend", handleTouchEnd);515 document.body.style.paddingBottom = "0";516 });517 }518519 520 521 522 523 524 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([525 sendMessage({ type: "IS_UNLOCKED" }),526 sendMessage({ type: "HAS_VAULT" }),527 refreshCurrentTab(),528 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),529 ]);530 const unlocked = Boolean(531 (unlockResponse as { ok: boolean; data?: boolean }).data532 );533 const vaultExists = Boolean(534 (vaultResponse as { ok: boolean; data?: boolean }).data535 );536 const shouldCelebrate = Boolean(537 (celebrationResponse as { ok: boolean; data?: boolean }).data538 );539 setHasVault(vaultExists);540 541 542 543 try {544 const onboarded = await storageGet("peach_onboarded");545 const seen = onboarded === "true";546 if (vaultExists && !seen) {547 await storageSet("peach_onboarded", "true");548 setOnboarded(true);549 } else {550 setOnboarded(seen);551 }552 } catch {553 554 setOnboarded(true);555 }556 await Promise.all([restoreSettings(), refreshProStatus()]);557558 559 if (shouldCelebrate) {560 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });561 }562563 if (unlocked && vaultExists) {564 await openVault(undefined, shouldCelebrate);565 if (shouldCelebrate) {566 setView("pro-celebration");567 window.setTimeout(() => setView("entry"), 2500);568 }569 } else if (shouldCelebrate) {570 setPendingCelebration(true);571 }572573 await refreshPendingPasskey();574 await refreshIncomingPairing();575 await consumeRecentPairing();576577 578 if (!Capacitor.isNativePlatform()) {579 const handleTabActivated = () => { void refreshCurrentTab(); };580 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {581 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();582 };583 try {584 chrome.tabs.onActivated.addListener(handleTabActivated);585 chrome.tabs.onUpdated.addListener(handleTabUpdated);586 onCleanup(() => {587 chrome.tabs.onActivated.removeListener(handleTabActivated);588 chrome.tabs.onUpdated.removeListener(handleTabUpdated);589 });590 } catch {591 592 }593 }594595 setBootState("ready");596 document.getElementById("pre-lock")?.setAttribute("hidden", "true");597598 599 if (!Capacitor.isNativePlatform() && chrome.storage.session) {600 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as601 Record<string, { feature?: string } | undefined>;602 if (session.pendingProGate?.feature) {603 await chrome.storage.session.remove("pendingProGate");604 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);605 setSettingsInitialSection("license");606 setView("settings");607 } else if (session.pendingProNotice?.feature) {608 await chrome.storage.session.remove("pendingProNotice");609 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });610 }611 }612613 if (Capacitor.getPlatform() === "android") {614 try {615 const { pending } = await Autofill.checkPendingSave();616 if (pending) {617 const parsed = JSON.parse(pending) as {618 domain?: string;619 username?: string;620 password?: string;621 };622 if (parsed.username && parsed.password) {623 setPendingAutofillSave({624 domain: parsed.domain ?? "",625 username: parsed.username,626 password: parsed.password,627 });628 }629 }630 } catch {631 632 }633634 635 636 637 try {638 const { pending } = await Autofill.consumePendingAutofillRequest();639 if (pending && (pending.domain || pending.package)) {640 setPendingAutofillRequest({641 domain: pending.domain || pending.package,642 pkg: pending.package || "",643 });644 }645 } catch { }646 }647648 649 650 651 652 void Promise.all([653 import("./views/Settings").catch(() => {}),654 import("./views/Import").catch(() => {}),655 import("./views/Export").catch(() => {}),656 import("./views/Health").catch(() => {}),657 import("./views/TrashView").catch(() => {}),658 ]);659660 661 662 663 if (!Capacitor.isNativePlatform()) {664 let popupPort: chrome.runtime.Port | null = null;665 try {666 popupPort = chrome.runtime.connect({ name: "popup" });667 } catch { }668 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });669670 671 672 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {673 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();674 if (msg?.type === "PRO_GATE_TRIGGERED") {675 setUpgradeHighlight(msg.payload?.feature ?? null);676 setSettingsInitialSection("license");677 setView("settings");678 }679 if (msg?.type === "INCOMING_PAIRING_REQUEST") {680 void refreshIncomingPairing();681 }682 };683 chrome.runtime.onMessage.addListener(handleRuntimeMessage);684 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));685 }686687 688 689 690 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {691 copy_password: () => {692 const entry = selectedEntry();693 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });694 },695 copy_username: () => {696 const entry = selectedEntry();697 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });698 },699 copy_totp: () => {700 const entry = selectedEntry();701 if (!entry?.totp) return;702 try {703 const { code } = generateTOTP(entry.totp);704 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });705 } catch { }706 },707 new_entry: () => handleAdd(),708 edit_entry: () => {709 const entry = selectedEntry();710 711 712 if (!entry || isPasskeyOnly(entry)) return;713 setEditEntry(entry);714 setView("add");715 },716 delete_entry: () => {717 if (selectedEntry()) void handleDelete();718 },719 lock_vault: () => { void handleHeaderLock(); },720 search: () => openList(),721 nav_prev: () => navigate(-1),722 nav_next: () => navigate(1),723 jump_vault: () => setView("entry"),724 jump_settings: () => setView("settings"),725 show_help: () => setShowShortcutsHelp(true),726 };727728 729 730 731 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([732 "copy_password", "copy_username", "copy_totp",733 "edit_entry", "delete_entry", "nav_prev", "nav_next",734 ]);735736 const handleGlobalKeydown = (e: KeyboardEvent) => {737 const v = view();738 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"739 || (e.target as HTMLElement)?.tagName === "TEXTAREA"740 || (e.target as HTMLElement)?.isContentEditable;741 const isPrintableSearchKey =742 e.key.length === 1 &&743 !e.ctrlKey &&744 !e.metaKey &&745 !e.altKey;746747 748 if (v === "pro-celebration") return;749750 751 752 if (showShortcutsHelp()) {753 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }754 return;755 }756757 758 if (e.key === "Escape") {759 if (v === "list") { setView("entry"); return; }760 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }761 if (v === "sync-pairing") { setView("settings"); return; }762 }763764 765 766 767 const bindings = shortcutBindings();768 for (const action of Object.keys(bindings) as ShortcutAction[]) {769 const binding = bindings[action];770 if (!matchKeybinding(e, binding)) continue;771 772 773 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;774 if (!hasMod && binding.key.length === 1 && inInput) return;775 if (ENTRY_SCOPED.has(action) && v !== "entry") return;776 e.preventDefault();777 SHORTCUT_HANDLERS[action]?.();778 return;779 }780781 782 783 784 785 if (v === "entry" && !inInput && isPrintableSearchKey) {786 e.preventDefault();787 primeListSearch(e.key);788 return;789 }790 };791 window.addEventListener("keydown", handleGlobalKeydown);792 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));793794 if (!Capacitor.isNativePlatform()) {795 const handleStorageChanged = (796 changes: Record<string, chrome.storage.StorageChange>,797 areaName: string798 ) => {799 if (areaName !== "session") return;800 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {801 return;802 }803 void refreshPendingPasskey();804 };805 chrome.storage.onChanged.addListener(handleStorageChanged);806 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));807 }808 });809810 async function refreshCurrentTab() {811 if (Capacitor.isNativePlatform()) return;812 try {813 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });814 const url = tab?.url ?? tab?.pendingUrl ?? "";815 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");816 817 818 const isWebUrl = /^https?:\/\//i.test(url);819 setCurrentTabUrl(isWebUrl ? url : "");820 } catch {821 setCurrentTabDomain("");822 setCurrentTabUrl("");823 }824 }825826 async function refreshPendingPasskey() {827 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });828 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);829 }830831 async function refreshIncomingPairing() {832 const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });833 const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;834 setIncomingPair(data);835 }836837 838 839 840 async function consumeRecentPairing() {841 const res = await sendMessage({ type: "GET_RECENT_PAIRING" });842 const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;843 if (data?.deviceName) {844 setRecentPairToast(data.deviceName);845 window.setTimeout(() => setRecentPairToast(null), 4000);846 }847 }848849 async function respondIncomingPairing(accept: boolean) {850 if (incomingPairResponding()) return;851 setIncomingPairResponding(true);852 await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });853 setIncomingPair(null);854 setIncomingPairResponding(false);855 }856857 858 859 860 createEffect(() => {861 const req = incomingPair();862 if (!req) return;863 const handle = window.setInterval(() => {864 setIncomingPairTick((tick) => tick + 1);865 if (Date.now() >= req.expiresAt) {866 setIncomingPair(null);867 window.clearInterval(handle);868 }869 }, 1000);870 onCleanup(() => window.clearInterval(handle));871 });872873 async function refreshProStatus() {874 const res = await sendMessage({ type: "GET_LICENSE" });875 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;876 setProStatus(Boolean(data?.valid && data?.plan === "pro"));877 }878879 async function restoreSettings() {880 const res = await sendMessage({ type: "GET_SETTINGS" });881 const s = (res as { ok: boolean; data?: any }).data ?? {};882 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {883 setAccentColor(s.accentColor as AccentColorName);884 }885 setTheme(s.theme === "light" ? "light" : "dark");886 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {887 setDisplayFont(s.displayFont);888 } else {889 setDisplayFont("cormorant-italic");890 }891 if (s.textSize === "small" || s.textSize === "large") {892 setTextSize(s.textSize);893 } else {894 setTextSize("medium");895 }896 setReduceMotion(s.reduceMotion === true);897 setLockOnClose(s.lockOnClose !== false);898 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);899 setBreachMonitoring(s.breachMonitoring !== false);900 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);901 setShortcutBindings(getActiveBindings(s.shortcuts));902 }903904 async function applyTrashRetention(days: number) {905 setTrashRetentionDays(days);906 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });907 }908909 function primeListSearch(seed: string) {910 setListSearchSeed(seed);911 setListSearchSeedNonce((value) => value + 1);912 openList();913 }914915 916 917 918 async function loadEntries(): Promise<VaultEntry[] | null> {919 const response = await sendMessage({ type: "GET_ENTRIES" });920 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };921922 923 924 925 926 if (!res.ok) {927 if (928 res.error === "vault locked" ||929 res.error === "SERVICE_WORKER_UNAVAILABLE" ||930 (typeof res.error === "string" && res.error.includes("locked"))931 ) {932 handleLock();933 return null; 934 }935 return [];936 }937938 const loadedEntries = res.data ?? [];939 setEntries(loadedEntries);940 void publishInMemoryVault(loadedEntries).then(() => {941 942 943 944 const req = pendingAutofillRequest();945 if (req) {946 setPendingAutofillRequest(null);947 void Autofill.completeAutofillAuthentication({ domain: req.domain });948 }949 });950951 const currentId = selectedEntry()?.id;952 if (currentId) {953 setSelectedEntry(954 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null955 );956 }957958 return loadedEntries;959 }960961 async function openVault(preferredEntryId?: string, skipViewSet = false) {962 963 const retDays = trashRetentionDays();964 if (retDays > 0) {965 void sendMessage({966 type: "PURGE_EXPIRED_TRASH",967 payload: { retentionMs: retDays * 86_400_000 },968 });969 }970971 const loadedEntries = await loadEntries();972973 974 975 if (loadedEntries === null) return;976977 978 let nextSelectedEntry: VaultEntry | null = null;979 if (preferredEntryId) {980 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;981 }982 if (!nextSelectedEntry) {983 const tabDomain = currentTabDomain();984 if (tabDomain) {985 nextSelectedEntry = loadedEntries.find((e) => {986 if (e.type === "login") {987 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;988 for (const extra of e.additionalUrls ?? []) {989 if ((extractDomain(extra) ?? "") === tabDomain) return true;990 }991 return false;992 }993 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;994 return false;995 }) ?? null;996 }997 }998 nextSelectedEntry ??= loadedEntries[0] ?? null;9991000 setSelectedEntry(nextSelectedEntry);1001 setEditEntry(undefined);1002 setContentTransitioning(false);1003 setContentPhase("enter");1004 queueContentIdle();1005 if (!skipViewSet) setView(isTwoPaneLayout() ? "list" : "entry");1006 }10071008 async function handleUnlock() {1009 setHasVault(true);1010 triggerPipPulse();1011 await restoreSettings();1012 const celebrating = pendingCelebration();1013 if (celebrating) {1014 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1015 setPendingCelebration(false);1016 }1017 await openVault(undefined, celebrating);1018 if (celebrating) {1019 setView("pro-celebration");1020 window.setTimeout(() => setView("entry"), 2500);1021 }1022 if (Capacitor.isNativePlatform()) {1023 const shown = await storageGet("peach_mobile_gestures_shown");1024 if (!shown) {1025 await storageSet("peach_mobile_gestures_shown", "1");1026 setShowGestureHints(true);1027 }1028 }1029 }10301031 function handleSelectEntry(entry: VaultEntry) {1032 setSelectedEntry(entry);1033 setContentTransitioning(false);1034 setContentPhase("enter");1035 queueContentIdle();1036 1037 1038 if (!isTwoPaneLayout()) setView("entry");1039 }10401041 function handleAdd() {1042 setEditEntry(undefined);1043 setView("add");1044 }10451046 async function handleDelete() {1047 const entry = selectedEntry();1048 if (!entry) return;1049 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1050 const loadedEntries = await loadEntries() ?? [];1051 setSelectedEntry(loadedEntries[0] ?? null);1052 setView("entry");1053 }10541055 async function handleDeleteMany(ids: string[]) {1056 for (const id of ids) {1057 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1058 }1059 const loadedEntries = await loadEntries() ?? [];1060 1061 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1062 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1063 }10641065 async function handleSave(savedEntry?: VaultEntry) {1066 await openVault(savedEntry?.id);1067 }10681069 async function savePendingAutofill() {1070 const pending = pendingAutofillSave();1071 if (!pending) return;1072 const domain = pending.domain.trim();1073 const response = await sendMessage({1074 type: "ADD_ENTRY",1075 payload: {1076 type: "login",1077 title: domain || "Saved login",1078 url: domain ? `https://${domain}` : "",1079 username: pending.username,1080 password: pending.password,1081 },1082 });1083 if ((response as { ok: boolean }).ok) {1084 setPendingAutofillSave(null);1085 await openVault((response as { data?: VaultEntry }).data?.id);1086 }1087 }10881089 function handleBack() {1090 setEditEntry(undefined);1091 setView("entry");1092 }10931094 function handleLock() {1095 clearCanarySuppressions();1096 setEntries([]);1097 setSelectedEntry(null);1098 setEditEntry(undefined);1099 setContentPhase("idle");1100 setView("lock");1101 void clearInMemoryVault();1102 }11031104 function handleHeaderLock() {1105 1106 1107 1108 handleLock();1109 void sendMessage({ type: "LOCK" });1110 }11111112 function navigate(dir: number) {1113 const allEntries = entries();1114 if (allEntries.length === 0) return;1115 const currentIndex = selectedIndex();1116 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1117 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1118 setSelectedEntry(allEntries[nextIndex]);1119 setContentPhase("enter");1120 queueContentIdle();1121 }11221123 function openList() {1124 setView("list");1125 }11261127 function openHealth() {1128 setEditEntry(undefined);1129 setView("health");1130 }11311132 function handleOpenFileImport() {1133 setRestoreView("file");1134 }11351136 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;11371138 function handleFixEntry(entry: VaultEntry) {1139 1140 1141 if (isPasskeyOnly(entry)) {1142 setSelectedEntry(entry);1143 setView("entry");1144 return;1145 }1146 setEditEntry(entry);1147 setView("add");1148 }11491150 const isLockedView = () => view() === "lock";11511152 async function handleApprovePendingPasskey() {1153 const pending = pendingPasskey();1154 if (!pending) return;1155 if (pending.browser === "firefox" && pending.type === "create") {1156 1157 1158 1159 1160 const { createFirefoxPasskey } = await import("./firefox-passkey");1161 const result = await createFirefoxPasskey(pending);1162 if (!result.ok) {1163 console.warn("Peach Firefox passkey create failed:", result.error);1164 }1165 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1166 setPendingPasskey(null);1167 void loadEntries();1168 return;1169 }1170 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1171 setPendingPasskey(null);1172 }11731174 async function handleDenyPendingPasskey() {1175 const pending = pendingPasskey();1176 if (!pending) return;1177 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1178 setPendingPasskey(null);1179 }11801181 function queueContentIdle(after?: () => void) {1182 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1183 contentPhaseTimer = window.setTimeout(() => {1184 setContentPhase("idle");1185 after?.();1186 }, ENTRY_TRANSITION_SETTLE_MS);1187 }11881189 return (1190 <div class="popup-root peach-theme">1191 <Show when={bootState() === "ready"}>1192 <Show1193 when={!pendingPasskey()}1194 fallback={1195 <PasskeyApprovalView1196 type={pendingPasskey()!.type}1197 rpName={pendingPasskey()!.rpName}1198 rpId={pendingPasskey()!.rpId}1199 userName={pendingPasskey()!.userName}1200 userDisplayName={pendingPasskey()!.userDisplayName}1201 onApprove={handleApprovePendingPasskey}1202 onDeny={handleDenyPendingPasskey}1203 />1204 }1205 >1206 <Show1207 when={!isLockedView()}1208 fallback={1209 <Show1210 when={onboarded() || hasVault()}1211 fallback={1212 <Onboarding1213 onComplete={() => { setOnboarded(true); }}1214 />1215 }1216 >1217 <LockView1218 hasVault={hasVault()}1219 ready={bootState() === "ready"}1220 isPro={proStatus()}1221 onUnlock={handleUnlock}1222 onBackToOnboarding={1223 !hasVault()1224 ? () => setOnboarded(false)1225 : undefined1226 }1227 />1228 </Show>1229 }1230 >1231 <Show1232 when={view() !== "pro-celebration"}1233 fallback={<ProCelebration />}1234 >1235 <section1236 id="hd-shell"1237 class="peach-shell peach-theme"1238 data-surface="extension"1239 >1240 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1241 <Show1242 when={Capacitor.isNativePlatform()}1243 fallback={<PeachWordmark unlocked />}1244 >1245 <button1246 class="peach-wordmark-btn"1247 onClick={() => setView("settings")}1248 aria-label="Settings"1249 >1250 <PeachWordmark unlocked />1251 </button>1252 </Show>1253 <div class="peach-chrome-actions">1254 <button1255 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1256 onClick={() => setView("settings")}1257 aria-label="Settings"1258 >1259 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1260 <path1261 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"1262 stroke="currentColor"1263 stroke-width="0.9"1264 />1265 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1266 </svg>1267 </button>1268 <button1269 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1270 onClick={handleAdd}1271 aria-label="New entry"1272 >1273 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1274 <path d="M5.5 1v9M1 5.5h9" />1275 </svg>1276 </button>1277 <button1278 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1279 onClick={() => void handleHeaderLock()}1280 aria-label="Lock vault"1281 >1282 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1283 <path1284 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"1285 stroke="currentColor"1286 stroke-width="1"1287 stroke-linecap="round"1288 />1289 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1290 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1291 </svg>1292 </button>1293 </div>1294 </header>12951296 <Show when={recentPairToast()}>1297 <div class="peach-pair-toast" role="status" aria-live="polite">1298 Paired with <strong>{recentPairToast()}</strong>1299 </div>1300 </Show>13011302 <Show when={incomingPair() !== null}>1303 <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1304 <div class="peach-pair-request-header">1305 <span class="peach-pair-request-mark"><PeachMark class="peach-pair-request-icon" /></span>1306 <span class="peach-pair-request-title">Pairing request</span>1307 <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1308 </div>1309 <p class="peach-pair-request-body">1310 <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1311 </p>1312 <div class="peach-pair-request-actions">1313 <button1314 class="peach-btn peach-btn--ghost peach-btn--sm"1315 onClick={() => void respondIncomingPairing(false)}1316 disabled={incomingPairResponding()}1317 >1318 Reject1319 </button>1320 <button1321 class="peach-btn peach-btn--accent peach-btn--sm"1322 onClick={() => void respondIncomingPairing(true)}1323 disabled={incomingPairResponding()}1324 >1325 Accept1326 </button>1327 </div>1328 </div>1329 </Show>13301331 <Show when={pendingProNotice() !== null}>1332 <div class="peach-pro-notice-banner">1333 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1334 <button1335 class="peach-pro-notice-link"1336 onClick={() => {1337 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1338 setSettingsInitialSection("license");1339 setView("settings");1340 setPendingProNotice(null);1341 }}1342 >1343 Learn more →1344 </button>1345 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1346 </div>1347 </Show>13481349 <Show when={pendingAutofillSave() !== null && !isLockedView()}>1350 <div class="peach-pro-notice-banner peach-autofill-save-banner">1351 <span>1352 Save login{pendingAutofillSave()?.domain ? ` for ${pendingAutofillSave()!.domain}` : ""}?1353 </span>1354 <button1355 class="peach-pro-notice-link"1356 onClick={() => void savePendingAutofill()}1357 >1358 Save1359 </button>1360 <button1361 class="peach-pro-notice-close"1362 onClick={() => setPendingAutofillSave(null)}1363 aria-label="Dismiss"1364 >1365 x1366 </button>1367 </div>1368 </Show>13691370 <Show when={pendingAutofillRequest() !== null}>1371 <div class="peach-pro-notice-banner peach-autofill-request-banner">1372 <span>1373 Unlock to autofill{pendingAutofillRequest()?.domain ? ` ${pendingAutofillRequest()!.domain}` : ""}1374 </span>1375 <button1376 class="peach-pro-notice-close"1377 onClick={() => setPendingAutofillRequest(null)}1378 aria-label="Dismiss"1379 >1380 x1381 </button>1382 </div>1383 </Show>13841385 <Show when={backExitNotice()}>1386 <div class="peach-pro-notice-banner peach-back-exit-banner">1387 <span>Tap back again to exit Peach</span>1388 </div>1389 </Show>13901391 <Show when={showGestureHints()}>1392 <div class="peach-gesture-hints-overlay" onClick={() => setShowGestureHints(false)}>1393 <div class="peach-gesture-hints-card" onClick={(e: MouseEvent) => e.stopPropagation()}>1394 <p class="peach-gesture-hints-title">welcome to peach</p>1395 <ul class="peach-gesture-hints-list">1396 <li>1397 <span class="peach-gesture-glyph">↑</span>1398 <span>swipe up — open vault list</span>1399 </li>1400 <li>1401 <span class="peach-gesture-glyph">← →</span>1402 <span>swipe — switch entries</span>1403 </li>1404 <li>1405 <span class="peach-gesture-glyph">⬡</span>1406 <span>tap peach mark — settings</span>1407 </li>1408 </ul>1409 <button1410 class="peach-btn peach-btn--accent peach-btn--sm"1411 onClick={() => setShowGestureHints(false)}1412 >1413 got it1414 </button>1415 </div>1416 </div>1417 </Show>14181419 <div class="peach-shell-body">1420 <div class="peach-shell-veil"></div>1421 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1422 <EntryView1423 entry={selectedEntry()}1424 currentTabUrl={currentTabUrl()}1425 onDelete={handleDelete}1426 onFieldSaved={() => void loadEntries()}1427 onEscape={() => { setView("list"); }}1428 />1429 </div>14301431 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1432 <div class="peach-search-bar">1433 <span class="peach-search-icon">1434 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1435 <circle cx="5.5" cy="5.5" r="3.75" />1436 <path d="M8.75 8.75L11.5 11.5" />1437 </svg>1438 </span>1439 <input1440 class="peach-search-input"1441 placeholder="search vault..."1442 autocomplete="off"1443 aria-label="Search vault entries"1444 onClick={openList}1445 onFocus={openList}1446 readOnly1447 />1448 <div class="peach-search-meta">1449 <span class="peach-search-counter">1450 {entries().length > 01451 ? `${selectedIndex() + 1} / ${entries().length}`1452 : "0 entries"}1453 </span>1454 <span class="peach-divider"></span>1455 <button1456 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1457 onClick={() => navigate(-1)}1458 aria-label="Previous entry"1459 disabled={entries().length === 0}1460 >1461 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1462 <path d="M5 1L1 5.5L5 10" />1463 </svg>1464 </button>1465 <button1466 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1467 onClick={() => navigate(1)}1468 aria-label="Next entry"1469 disabled={entries().length === 0}1470 >1471 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1472 <path d="M1 1L5 5.5L1 10" />1473 </svg>1474 </button>1475 </div>1476 </div>1477 </section>1478 </div>14791480 <Show when={view() === "list"}>1481 <VaultView1482 entries={entries()}1483 selectedEntryId={selectedEntry()?.id}1484 currentTabDomain={currentTabDomain()}1485 sortOrder={sortOrder()}1486 searchSeed={listSearchSeed()}1487 searchSeedNonce={listSearchSeedNonce()}1488 onSelectEntry={handleSelectEntry}1489 onSortOrderChange={setSortOrder}1490 onAdd={handleAdd}1491 onClose={() => setView("entry")}1492 onOpenTrash={() => setView("trash")}1493 onDeleteMany={handleDeleteMany}1494 />1495 </Show>1496 <Show when={view() === "settings"}>1497 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1498 <SettingsView1499 entriesCount={entries().length}1500 breachMonitoring={breachMonitoring()}1501 lockOnClose={lockOnClose()}1502 autoLockMinutes={autoLockMinutes()}1503 trashRetentionDays={trashRetentionDays()}1504 theme={theme()}1505 accentColor={accentColor()}1506 displayFont={displayFont()}1507 textSize={textSize()}1508 reduceMotion={reduceMotion()}1509 isPro={proStatus()}1510 initialSection={settingsInitialSection()}1511 upgradeHighlight={upgradeHighlight()}1512 onBreachMonitoringChange={applyBreachMonitoring}1513 onLockOnCloseChange={applyLockOnClose}1514 onAutoLockMinutesChange={applyAutoLockMinutes}1515 onTrashRetentionChange={applyTrashRetention}1516 onThemeChange={applyTheme}1517 onAccentChange={applyAccentColor}1518 onDisplayFontChange={applyDisplayFont}1519 onTextSizeChange={applyTextSize}1520 onReduceMotionChange={applyReduceMotion}1521 shortcuts={shortcutBindings()}1522 onShortcutsChange={applyShortcuts}1523 onLicenseChange={async () => {1524 1525 1526 1527 1528 1529 await refreshProStatus();1530 void loadEntries();1531 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1532 if ((should as { ok: boolean; data?: boolean }).data === true) {1533 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1534 setView("pro-celebration");1535 window.setTimeout(() => setView("settings"), 2500);1536 }1537 }}1538 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1539 onImport={() => setView("import")}1540 onExport={() => setView("export")}1541 onBreachCheckDone={loadEntries}1542 onOpenHealth={openHealth}1543 onOpenSyncPairing={() => setView("sync-pairing")}1544 onOpenFileImport={handleOpenFileImport}1545 onOpenPanic={() => setView("panic")}1546 />1547 </Suspense>1548 </Show>1549 <Show when={view() === "sync-pairing"}>1550 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1551 <SyncPairingView1552 onClose={() => setView("settings")}1553 onPaired={() => { }}1554 />1555 </Suspense>1556 </Show>1557 <Show when={view() === "add"}>1558 <AddView1559 entry={editEntry()}1560 entries={entries()}1561 onBack={handleBack}1562 onSave={handleSave}1563 />1564 </Show>1565 <Show when={view() === "import"}>1566 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1567 <ImportView onBack={handleBack} onImport={handleSave} />1568 </Suspense>1569 </Show>1570 <Show when={view() === "export"}>1571 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1572 <ExportView1573 onBack={() => {1574 if (returnToPanic()) {1575 setReturnToPanic(false);1576 setView("panic");1577 } else {1578 handleBack();1579 }1580 }}1581 />1582 </Suspense>1583 </Show>1584 <Show when={view() === "panic"}>1585 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1586 <PanicView1587 onCancel={() => setView("settings")}1588 onOpenExport={() => {1589 setReturnToPanic(true);1590 setView("export");1591 }}1592 onDestroyed={async () => {1593 clearCanarySuppressions();1594 setEntries([]);1595 setSelectedEntry(null);1596 setEditEntry(undefined);1597 setHasVault(false);1598 setOnboarded(false);1599 setProStatus(false);1600 setPendingPasskey(null);1601 setUpgradeHighlight(null);1602 setSettingsInitialSection(null);1603 setPendingProNotice(null);1604 setPendingCelebration(false);1605 setReturnToPanic(false);1606 setContentPhase("idle");1607 setView("lock");1608 void clearInMemoryVault();1609 }}1610 />1611 </Suspense>1612 </Show>1613 <Show when={view() === "health"}>1614 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1615 <HealthView1616 entries={entries()}1617 isPro={proStatus()}1618 onFixEntry={handleFixEntry}1619 onBack={() => setView("settings")}1620 onRefresh={loadEntries}1621 />1622 </Suspense>1623 </Show>1624 <Show when={view() === "trash"}>1625 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1626 <TrashView1627 onBack={() => setView("list")}1628 onRestored={() => void loadEntries()}1629 />1630 </Suspense>1631 </Show>1632 <Show when={restoreView() === "file"}>1633 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1634 <ImportView1635 mode="onboarding"1636 replacesExistingVault={true}1637 onBack={() => setRestoreView(null)}1638 onImport={async () => {1639 setRestoreView(null);1640 await handleUnlock();1641 }}1642 />1643 </Suspense>1644 </Show>1645 </section>1646 </Show>1647 </Show>1648 </Show>1649 </Show>1650 <Show when={showShortcutsHelp()}>1651 <ShortcutsHelpOverlay1652 bindings={shortcutBindings()}1653 onClose={() => setShowShortcutsHelp(false)}1654 />1655 </Show>1656 </div>1657 );1658}16591660function ShortcutsHelpOverlay(props: {1661 bindings: Record<ShortcutAction, Keybinding>;1662 onClose: () => void;1663}) {1664 return (1665 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1666 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1667 <div class="peach-shortcuts-help-head">1668 <h3>Keyboard shortcuts</h3>1669 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1670 </div>1671 <div class="peach-shortcuts-help-body">1672 <p class="peach-settings-sub-label">ENTRY VIEW</p>1673 <ul>1674 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1675 <li>1676 <span>{d.label}</span>1677 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1678 </li>1679 ))}1680 </ul>1681 <p class="peach-settings-sub-label">ANYWHERE</p>1682 <ul>1683 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1684 <li>1685 <span>{d.label}</span>1686 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1687 </li>1688 ))}1689 </ul>1690 </div>1691 </div>1692 </div>1693 );1694}