1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { Portal } from "solid-js/web";3import { storageGet, storageSet } from "../shared/storage";4import { sendMessage } from "../shared/bridge";5import { ACCENT_COLORS } from "../shared/types";6import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7import type { IncomingPairingRequest } from "../shared/sync-types";8import { isPasskeyOnly } from "../shared/types";9import { LockView } from "./views/Lock";10import { Onboarding } from "./views/Onboarding";11import { NotesMode } from "./views/NotesMode";12import { ProCelebration } from "./views/ProCelebration";13import { VaultView, type SortOrder } from "./views/Vault";14import { EntryView } from "./views/Entry";15import { AddView } from "./views/Add";16import { PeachMark, PeachWordmark } from "./components/PeachMark";17import { PasskeyApprovalView } from "./views/PasskeyApproval";18import { extractDomain } from "../shared/domain";19import { popupCopy } from "./copy";20import {21 DEFAULT_BINDINGS,22 formatKeybinding,23 getActiveBindings,24 matchKeybinding,25 SHORTCUT_DESCRIPTORS,26 type Keybinding,27 type ShortcutAction,28} from "../shared/shortcuts";29import { generateTOTP } from "../crypto/totp";30import { isNativePlatform, nativePlugin } from "../shared/native";3132export { sendMessage } from "../shared/bridge";3334const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));35const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));36const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));37const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));38const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));39const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));40const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));41const AddDeviceView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.AddDeviceView })));42const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));43const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4445type View =46 | "lock"47 | "pro-celebration"48 | "entry"49 | "list"50 | "add"51 | "settings"52 | "sync-pairing"53 | "sync-add-device"54 | "import"55 | "export"56 | "health"57 | "trash"58 | "panic"59 | "codex-generate"60 | "codex-restore";61type BootState = "loading" | "ready";6263const canarySuppressMap = new Map<string, boolean>();64let shellRingAnimation: Animation | null = null;6566export function isCanarySuppressed(entryId: string): boolean {67 return canarySuppressMap.get(entryId) ?? false;68}6970export function setCanarySuppressed(entryId: string, suppressed: boolean): void {71 canarySuppressMap.set(entryId, suppressed);72}7374export function clearCanarySuppressions(): void {75 canarySuppressMap.clear();76}7778export function triggerRing() {79 const shell = document.getElementById("hd-shell");80 if (!shell) return;8182 shellRingAnimation?.cancel();83 shell.dataset.glowing = "true";84 const animation = shell.animate(85 [86 { offset: 0, boxShadow: "var(--copy-shadow)" },87 {88 offset: 0.3,89 boxShadow:90 "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)",91 },92 { offset: 1, boxShadow: "var(--copy-shadow)" },93 ],94 {95 duration: 720,96 easing: "cubic-bezier(0.4, 0, 0.2, 1)",97 fill: "none",98 }99 );100 shellRingAnimation = animation;101 const clearGlow = () => {102 if (shellRingAnimation !== animation) return;103 shellRingAnimation = null;104 delete shell.dataset.glowing;105 };106 animation.onfinish = clearGlow;107 animation.oncancel = clearGlow;108}109110111export function triggerPipPulse() {112 const pip = document.querySelector(".peach-status-pip, .pip");113 if (pip) {114 pip.classList.remove("pulse");115 void (pip as HTMLElement).offsetWidth;116 pip.classList.add("pulse");117 }118}119120const ENTRY_TRANSITION_SETTLE_MS = 135;121122export default function App() {123 const [bootState, setBootState] = createSignal<BootState>("loading");124 const [view, setView] = createSignal<View>("lock");125 126 127 const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);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 [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);165 const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);166 const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);167 const [incomingPairTick, setIncomingPairTick] = createSignal(0);168 const incomingPairSecondsLeft = () => {169 void incomingPairTick();170 const req = incomingPair();171 if (!req) return 0;172 return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));173 };174 175 176 177 const [returnToPanic, setReturnToPanic] = createSignal(false);178 const [showNotes, setShowNotes] = createSignal(false);179 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);180 181 182 183 184 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);185186 let contentPhaseTimer: number | undefined;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 });207208 createEffect(() => {209 document.documentElement.setAttribute("data-display-font", displayFont());210 });211212 createEffect(() => {213 document.documentElement.setAttribute("data-text-size", textSize());214 });215216 createEffect(() => {217 if (reduceMotion()) {218 document.documentElement.setAttribute("data-motion", "reduce");219 } else {220 document.documentElement.removeAttribute("data-motion");221 }222 });223224 const selectedIndex = createMemo(() => {225 const currentId = selectedEntry()?.id;226 if (!currentId) return entries().length > 0 ? 0 : -1;227 return entries().findIndex((entry) => entry.id === currentId);228 });229230 async function applyAccentColor(name: AccentColorName) {231 applyAccent(name);232 setAccentColor(name);233 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });234 }235236 async function applyTheme(t: "dark" | "light") {237 setTheme(t);238 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });239 }240241 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {242 setDisplayFont(f);243 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });244 }245246 async function applyTextSize(size: "small" | "medium" | "large") {247 setTextSize(size);248 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });249 }250251 async function applyReduceMotion(enabled: boolean) {252 setReduceMotion(enabled);253 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });254 }255256 257 258 259 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {260 setShortcutBindings(active);261 const overrides: Record<string, Keybinding> = {};262 for (const action of Object.keys(active) as ShortcutAction[]) {263 const a = active[action];264 const d = DEFAULT_BINDINGS[action];265 const same = a.key === d.key266 && Boolean(a.ctrl) === Boolean(d.ctrl)267 && Boolean(a.meta) === Boolean(d.meta)268 && Boolean(a.shift) === Boolean(d.shift)269 && Boolean(a.alt) === Boolean(d.alt);270 if (!same) overrides[action] = a;271 }272 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });273 }274275 async function applyLockOnClose(enabled: boolean) {276 setLockOnClose(enabled);277 await sendMessage({278 type: "UPDATE_SETTINGS",279 payload: { lockOnClose: enabled },280 });281 }282283 async function applyAutoLockMinutes(minutes: number) {284 setAutoLockMinutes(minutes);285 await sendMessage({286 type: "UPDATE_SETTINGS",287 payload: { autoLockMinutes: minutes },288 });289 }290291 async function applyBreachMonitoring(enabled: boolean) {292 setBreachMonitoring(enabled);293 await sendMessage({294 type: "UPDATE_SETTINGS",295 payload: { breachMonitoring: enabled },296 });297 }298299 onMount(async () => {300 301 302 303 304 305 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([306 sendMessage({ type: "IS_UNLOCKED" }),307 sendMessage({ type: "HAS_VAULT" }),308 refreshCurrentTab(),309 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),310 ]);311 const unlocked = Boolean(312 (unlockResponse as { ok: boolean; data?: boolean }).data313 );314 const vaultExists = Boolean(315 (vaultResponse as { ok: boolean; data?: boolean }).data316 );317 const shouldCelebrate = Boolean(318 (celebrationResponse as { ok: boolean; data?: boolean }).data319 );320 setHasVault(vaultExists);321 322 323 324 try {325 const onboarded = await storageGet("peach_onboarded");326 const seen = onboarded === "true";327 if (vaultExists && !seen) {328 await storageSet("peach_onboarded", "true");329 setOnboarded(true);330 } else {331 setOnboarded(seen);332 }333 } catch {334 335 setOnboarded(true);336 }337 await Promise.all([restoreSettings(), refreshProStatus()]);338339 340 if (shouldCelebrate) {341 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });342 }343344 345 346 347 348 349 350 let recoveryPending = false;351 try {352 const out = await chrome.storage.local?.get("peach_recovery_pending");353 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";354 } catch { }355356 357 358 359 360 361 if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {362 try {363 await chrome.storage.local?.remove([364 "peach_vault_a",365 "peach_vault_b",366 "peach_recovery_pending",367 "peach_onboarded",368 "peach_vault_history",369 ]);370 } catch { }371 setHasVault(false);372 setOnboarded(false);373 setView("lock");374 setBootState("ready");375 document.getElementById("pre-lock")?.setAttribute("hidden", "true");376 return;377 }378379 if (recoveryPending && unlocked) {380 381 382 setForceRecoveryWords(true);383 setView("lock");384 } else if (unlocked && vaultExists) {385 await openVault(undefined, shouldCelebrate);386 if (shouldCelebrate) {387 setView("pro-celebration");388 window.setTimeout(() => setView("entry"), 2500);389 }390 } else if (shouldCelebrate) {391 setPendingCelebration(true);392 }393394 await refreshPendingPasskey();395 await refreshIncomingPairing();396 await consumeRecentPairing();397398 399 const handleTabActivated = () => { void refreshCurrentTab(); };400 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {401 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();402 };403 try {404 chrome.tabs.onActivated.addListener(handleTabActivated);405 chrome.tabs.onUpdated.addListener(handleTabUpdated);406 onCleanup(() => {407 chrome.tabs.onActivated.removeListener(handleTabActivated);408 chrome.tabs.onUpdated.removeListener(handleTabUpdated);409 });410 } catch {411 412 }413414 setBootState("ready");415 document.getElementById("pre-lock")?.setAttribute("hidden", "true");416417 418 if (chrome.storage.session) {419 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as420 Record<string, { feature?: string } | undefined>;421 if (session.pendingProGate?.feature) {422 await chrome.storage.session.remove("pendingProGate");423 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);424 setSettingsInitialSection("license");425 setView("settings");426 } else if (session.pendingProNotice?.feature) {427 await chrome.storage.session.remove("pendingProNotice");428 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });429 }430 }431432 433 434 435 436 void Promise.all([437 import("./views/Settings").catch(() => {}),438 import("./views/Import").catch(() => {}),439 import("./views/Export").catch(() => {}),440 import("./views/Health").catch(() => {}),441 import("./views/TrashView").catch(() => {}),442 ]);443444 445 446 447 let popupPort: chrome.runtime.Port | null = null;448 try {449 popupPort = chrome.runtime.connect({ name: "popup" });450 } catch { }451 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });452453 454 455 456 if (isNativePlatform()) {457 let backgroundedAt: number | null = null;458 let syncPollInterval: ReturnType<typeof setInterval> | null = null;459460 function startSyncPolling() {461 if (syncPollInterval) return;462 syncPollInterval = setInterval(async () => {463 const res = await sendMessage({ type: 'TRIGGER_SYNC' }).catch(() => null);464 const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;465 if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {466 await sendMessage({ type: 'GET_ENTRIES' }).catch(() => null);467 }468 }, 60_000);469 }470471 function stopSyncPolling() {472 if (syncPollInterval) { clearInterval(syncPollInterval); syncPollInterval = null; }473 }474475 const CapApp = nativePlugin<{476 addListener: (477 ev: string,478 cb: (s: { isActive: boolean }) => void,479 ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };480 }>("App");481 if (CapApp?.addListener) {482 const sub = CapApp.addListener(483 "appStateChange",484 ({ isActive }: { isActive: boolean }) => {485 if (!isActive) {486 backgroundedAt = Date.now();487 stopSyncPolling();488 return;489 }490 if (backgroundedAt !== null && autoLockMinutes() > 0) {491 const elapsed = Date.now() - backgroundedAt;492 if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();493 }494 backgroundedAt = null;495 496 if (proStatus()) startSyncPolling();497 }498 );499 500 onCleanup(() => {501 Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});502 stopSyncPolling();503 });504 }505 }506507 508 509 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {510 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();511 if (msg?.type === "PRO_GATE_TRIGGERED") {512 setUpgradeHighlight(msg.payload?.feature ?? null);513 setSettingsInitialSection("license");514 setView("settings");515 }516 };517 chrome.runtime.onMessage.addListener(handleRuntimeMessage);518 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));519520 521 522 523 const handlePeachBack = () => {524 525 if (showNotes()) {526 setShowNotes(false);527 setOpenNoteId(undefined);528 return;529 }530 const v = view();531 if (v === "lock" && !hasVault() && onboarded()) {532 setOnboarded(false);533 return;534 }535 if (v === "list") { setView("entry"); return; }536 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") {537 handleBack();538 return;539 }540 if (v === "sync-pairing") { setView("settings"); return; }541 };542 window.addEventListener("peach:back", handlePeachBack);543 onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));544545 546 547 548 549 550 if (isNativePlatform()) {551 let touchStartY = 0;552 let touchStartX = 0;553 let lastY = 0;554 let lastT = 0;555 let dragging = false;556 let captured = false;557 const HORIZONTAL_TOLERANCE = 16;558 const ACTIVATION_DY = 8;559 const PEEK_PX = 60;560561 const onTouchStart = (e: TouchEvent) => {562 const t = e.touches[0];563 if (!t) return;564 const v = view();565 if (v !== "entry" && v !== "list") return;566 567 568 const inBand = v === "entry"569 ? t.clientY > window.innerHeight * 0.6570 : t.clientY < window.innerHeight * 0.35;571 if (!inBand) return;572 touchStartY = lastY = t.clientY;573 touchStartX = t.clientX;574 lastT = e.timeStamp;575 dragging = true;576 captured = false;577 };578579 const onTouchMove = (e: TouchEvent) => {580 if (!dragging) return;581 const t = e.touches[0];582 if (!t) return;583 const dy = t.clientY - touchStartY;584 const dx = Math.abs(t.clientX - touchStartX);585586 if (!captured) {587 if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {588 589 dragging = false;590 return;591 }592 if (Math.abs(dy) < ACTIVATION_DY) return;593 captured = true;594 }595596 597 598 const v = view();599 const restClosed = window.innerHeight - PEEK_PX;600 const baseTranslate = v === "list" ? 0 : restClosed;601 const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));602 setSheetTranslate(next);603 lastY = t.clientY;604 lastT = e.timeStamp;605 };606607 const onTouchEnd = (e: TouchEvent) => {608 if (!dragging) { return; }609 const t = e.changedTouches[0];610 const elapsed = Math.max(1, e.timeStamp - lastT);611 const velocity = t ? (t.clientY - lastY) / elapsed : 0; 612 const wasCaptured = captured;613 dragging = false;614 captured = false;615 const v = view();616617 if (!wasCaptured) {618 619 if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");620 setSheetTranslate(null);621 return;622 }623624 const restClosed = window.innerHeight - PEEK_PX;625 const current = sheetTranslate() ?? restClosed;626627 628 629 630 631 let nextOpen: boolean;632 if (velocity < -0.05) nextOpen = true;633 else if (velocity > 0.05) nextOpen = false;634 else nextOpen = current < restClosed * 0.5;635636 if (nextOpen && v !== "list") setView("list");637 if (!nextOpen && v !== "entry") setView("entry");638 639 setSheetTranslate(null);640 };641642 document.addEventListener("touchstart", onTouchStart, { passive: true });643 document.addEventListener("touchmove", onTouchMove, { passive: true });644 document.addEventListener("touchend", onTouchEnd, { passive: true });645 document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });646 onCleanup(() => {647 document.removeEventListener("touchstart", onTouchStart);648 document.removeEventListener("touchmove", onTouchMove);649 document.removeEventListener("touchend", onTouchEnd);650 });651 }652653 654 655 656 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {657 copy_password: () => {658 const entry = selectedEntry();659 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });660 },661 copy_username: () => {662 const entry = selectedEntry();663 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });664 },665 copy_totp: () => {666 const entry = selectedEntry();667 if (!entry?.totp) return;668 try {669 const { code } = generateTOTP(entry.totp);670 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });671 } catch { }672 },673 new_entry: () => handleAdd(),674 edit_entry: () => {675 const entry = selectedEntry();676 677 678 if (!entry || isPasskeyOnly(entry)) return;679 setEditEntry(entry);680 setView("add");681 },682 delete_entry: () => {683 if (selectedEntry()) void handleDelete();684 },685 lock_vault: () => { void handleHeaderLock(); },686 search: () => openList(),687 nav_prev: () => navigate(-1),688 nav_next: () => navigate(1),689 jump_vault: () => setView("entry"),690 jump_settings: () => setView("settings"),691 show_help: () => setShowShortcutsHelp(true),692 };693694 695 696 697 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([698 "copy_password", "copy_username", "copy_totp",699 "edit_entry", "delete_entry", "nav_prev", "nav_next",700 ]);701702 const handleGlobalKeydown = (e: KeyboardEvent) => {703 const v = view();704 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"705 || (e.target as HTMLElement)?.tagName === "TEXTAREA"706 || (e.target as HTMLElement)?.isContentEditable;707 const isPrintableSearchKey =708 e.key.length === 1 &&709 !e.ctrlKey &&710 !e.metaKey &&711 !e.altKey;712713 714 if (v === "pro-celebration") return;715716 717 718 if (showShortcutsHelp()) {719 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }720 return;721 }722723 724 if (e.key === "Escape") {725 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }726 if (v === "list") { setView("entry"); return; }727 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }728 if (v === "sync-pairing") { setView("settings"); return; }729 if (v === "sync-add-device") { setView("sync-pairing"); return; }730 }731732 733 734 735 const bindings = shortcutBindings();736 for (const action of Object.keys(bindings) as ShortcutAction[]) {737 const binding = bindings[action];738 if (!matchKeybinding(e, binding)) continue;739 740 741 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;742 if (!hasMod && binding.key.length === 1 && inInput) return;743 if (ENTRY_SCOPED.has(action) && v !== "entry") return;744 e.preventDefault();745 SHORTCUT_HANDLERS[action]?.();746 return;747 }748749 750 751 752 753 if (v === "entry" && !inInput && isPrintableSearchKey) {754 e.preventDefault();755 primeListSearch(e.key);756 return;757 }758 };759 window.addEventListener("keydown", handleGlobalKeydown);760 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));761762 const handleStorageChanged = (763 changes: Record<string, chrome.storage.StorageChange>,764 areaName: string765 ) => {766 if (areaName !== "session") return;767 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {768 return;769 }770 void refreshPendingPasskey();771 };772 chrome.storage.onChanged.addListener(handleStorageChanged);773 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));774 });775776 async function refreshCurrentTab() {777 try {778 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });779 const url = tab?.url ?? tab?.pendingUrl ?? "";780 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");781 782 783 const isWebUrl = /^https?:\/\//i.test(url);784 setCurrentTabUrl(isWebUrl ? url : "");785 } catch {786 setCurrentTabDomain("");787 setCurrentTabUrl("");788 }789 }790791 async function refreshPendingPasskey() {792 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });793 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);794 }795796 async function refreshIncomingPairing() {797 const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });798 const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;799 setIncomingPair(data);800 }801802 803 804 805 async function consumeRecentPairing() {806 const res = await sendMessage({ type: "GET_RECENT_PAIRING" });807 const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;808 if (data?.deviceName) {809 setRecentPairToast(data.deviceName);810 window.setTimeout(() => setRecentPairToast(null), 4000);811 }812 }813814 async function respondIncomingPairing(accept: boolean) {815 if (incomingPairResponding()) return;816 setIncomingPairResponding(true);817 await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });818 setIncomingPair(null);819 setIncomingPairResponding(false);820 }821822 823 824 825 createEffect(() => {826 const req = incomingPair();827 if (!req) return;828 const handle = window.setInterval(() => {829 setIncomingPairTick((tick) => tick + 1);830 if (Date.now() >= req.expiresAt) {831 setIncomingPair(null);832 window.clearInterval(handle);833 }834 }, 1000);835 onCleanup(() => window.clearInterval(handle));836 });837838 async function refreshProStatus() {839 const res = await sendMessage({ type: "GET_LICENSE" });840 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;841 setProStatus(Boolean(data?.valid && data?.plan === "pro"));842 }843844 async function restoreSettings() {845 const res = await sendMessage({ type: "GET_SETTINGS" });846 const s = (res as { ok: boolean; data?: any }).data ?? {};847 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {848 setAccentColor(s.accentColor as AccentColorName);849 }850 setTheme(s.theme === "light" ? "light" : "dark");851 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {852 setDisplayFont(s.displayFont);853 } else {854 setDisplayFont("cormorant-italic");855 }856 if (s.textSize === "small" || s.textSize === "large") {857 setTextSize(s.textSize);858 } else {859 setTextSize("medium");860 }861 setReduceMotion(s.reduceMotion === true);862 setLockOnClose(s.lockOnClose !== false);863 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);864 setBreachMonitoring(s.breachMonitoring !== false);865 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);866 setShortcutBindings(getActiveBindings(s.shortcuts));867 }868869 async function applyTrashRetention(days: number) {870 setTrashRetentionDays(days);871 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });872 }873874 function primeListSearch(seed: string) {875 setListSearchSeed(seed);876 setListSearchSeedNonce((value) => value + 1);877 openList();878 }879880 881 882 883 async function loadEntries(): Promise<VaultEntry[] | null> {884 const response = await sendMessage({ type: "GET_ENTRIES" });885 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };886887 888 889 890 891 if (!res.ok) {892 if (893 res.error === "vault_locked" ||894 res.error === "SERVICE_WORKER_UNAVAILABLE" ||895 (typeof res.error === "string" && res.error.includes("locked"))896 ) {897 handleLock();898 return null; 899 }900 return [];901 }902903 const loadedEntries = res.data ?? [];904 setEntries(loadedEntries);905906 const currentId = selectedEntry()?.id;907 if (currentId) {908 setSelectedEntry(909 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null910 );911 }912913 return loadedEntries;914 }915916 async function openVault(preferredEntryId?: string, skipViewSet = false) {917 918 919 if (!isNativePlatform() && proStatus()) {920 void sendMessage({ type: 'TRIGGER_SYNC' }).then((res) => {921 const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;922 if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {923 void loadEntries();924 }925 }).catch(() => {});926 }927928 929 const retDays = trashRetentionDays();930 if (retDays > 0) {931 void sendMessage({932 type: "PURGE_EXPIRED_TRASH",933 payload: { retentionMs: retDays * 86_400_000 },934 });935 }936937 const loadedEntries = await loadEntries();938939 940 941 if (loadedEntries === null) return;942943 944 let nextSelectedEntry: VaultEntry | null = null;945 if (preferredEntryId) {946 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;947 }948 if (!nextSelectedEntry) {949 const tabDomain = currentTabDomain();950 if (tabDomain) {951 nextSelectedEntry = loadedEntries.find((e) => {952 if (e.type === "login") {953 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;954 for (const extra of e.additionalUrls ?? []) {955 if ((extractDomain(extra) ?? "") === tabDomain) return true;956 }957 return false;958 }959 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;960 return false;961 }) ?? null;962 }963 }964 nextSelectedEntry ??= loadedEntries[0] ?? null;965966 setSelectedEntry(nextSelectedEntry);967 setEditEntry(undefined);968 setContentTransitioning(false);969 setContentPhase("enter");970 queueContentIdle();971 if (!skipViewSet) setView("entry");972 }973974 async function handleUnlock() {975 976 977 978 979 980 981 let recoveryPending = false;982 try {983 const out = await chrome.storage.local?.get("peach_recovery_pending");984 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";985 } catch { }986 if (recoveryPending) {987 setForceRecoveryWords(true);988 setView("lock");989 triggerPipPulse();990 await restoreSettings();991 return;992 }993 setHasVault(true);994 triggerPipPulse();995 await restoreSettings();996 const celebrating = pendingCelebration();997 if (celebrating) {998 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });999 setPendingCelebration(false);1000 }1001 await openVault(undefined, celebrating);1002 if (celebrating) {1003 setView("pro-celebration");1004 window.setTimeout(() => setView("entry"), 2500);1005 }1006 }10071008 function handleSelectEntry(entry: VaultEntry) {1009 if (entry.type === "note") {1010 setOpenNoteId(entry.id);1011 setShowNotes(true);1012 return;1013 }1014 setSelectedEntry(entry);1015 setContentTransitioning(false);1016 setContentPhase("enter");1017 queueContentIdle();1018 setView("entry");1019 }10201021 function handleAdd() {1022 setEditEntry(undefined);1023 setView("add");1024 }10251026 async function handleDelete() {1027 const entry = selectedEntry();1028 if (!entry) return;1029 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1030 const loadedEntries = await loadEntries() ?? [];1031 setSelectedEntry(loadedEntries[0] ?? null);1032 setView("entry");1033 }10341035 async function handleDeleteMany(ids: string[]) {1036 for (const id of ids) {1037 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1038 }1039 const loadedEntries = await loadEntries() ?? [];1040 1041 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1042 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1043 }10441045 async function handleSave(savedEntry?: VaultEntry) {1046 await openVault(savedEntry?.id);1047 }10481049 function handleBack() {1050 setEditEntry(undefined);1051 setView("entry");1052 }10531054 function handleLock() {1055 clearCanarySuppressions();1056 setEntries([]);1057 setSelectedEntry(null);1058 setEditEntry(undefined);1059 setContentPhase("idle");1060 setView("lock");1061 }10621063 function handleHeaderLock() {1064 1065 1066 1067 handleLock();1068 void sendMessage({ type: "LOCK" });1069 }10701071 function navigate(dir: number) {1072 const allEntries = entries();1073 if (allEntries.length === 0) return;1074 const currentIndex = selectedIndex();1075 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1076 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1077 setSelectedEntry(allEntries[nextIndex]);1078 setContentPhase("enter");1079 queueContentIdle();1080 }10811082 function openList() {1083 setView("list");1084 }10851086 function openHealth() {1087 setEditEntry(undefined);1088 setView("health");1089 }10901091 function handleOpenFileImport() {1092 setRestoreView("file");1093 }10941095 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;10961097 function handleFixEntry(entry: VaultEntry) {1098 1099 1100 if (isPasskeyOnly(entry)) {1101 setSelectedEntry(entry);1102 setView("entry");1103 return;1104 }1105 setEditEntry(entry);1106 setView("add");1107 }11081109 const isLockedView = () => view() === "lock";11101111 async function handleApprovePendingPasskey() {1112 const pending = pendingPasskey();1113 if (!pending) return;1114 if (pending.browser === "firefox" && pending.type === "create") {1115 1116 1117 1118 1119 const { createFirefoxPasskey } = await import("./firefox-passkey");1120 const result = await createFirefoxPasskey(pending);1121 if (!result.ok) {1122 console.warn("Peach Firefox passkey create failed:", result.error);1123 }1124 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1125 setPendingPasskey(null);1126 void loadEntries();1127 window.close();1128 return;1129 }1130 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1131 window.close();1132 }11331134 async function handleDenyPendingPasskey() {1135 const pending = pendingPasskey();1136 if (!pending) return;1137 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1138 window.close();1139 }11401141 function queueContentIdle(after?: () => void) {1142 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1143 contentPhaseTimer = window.setTimeout(() => {1144 setContentPhase("idle");1145 after?.();1146 }, ENTRY_TRANSITION_SETTLE_MS);1147 }11481149 return (1150 <div class="popup-root peach-theme">1151 <Show when={bootState() === "ready"}>1152 <Show1153 when={!pendingPasskey()}1154 fallback={1155 <PasskeyApprovalView1156 type={pendingPasskey()!.type}1157 rpName={pendingPasskey()!.rpName}1158 rpId={pendingPasskey()!.rpId}1159 userName={pendingPasskey()!.userName}1160 userDisplayName={pendingPasskey()!.userDisplayName}1161 onApprove={handleApprovePendingPasskey}1162 onDeny={handleDenyPendingPasskey}1163 />1164 }1165 >1166 <Show1167 when={!isLockedView()}1168 fallback={1169 <Show1170 when={onboarded() || hasVault()}1171 fallback={1172 <Onboarding1173 onComplete={() => { setOnboarded(true); }}1174 />1175 }1176 >1177 <LockView1178 hasVault={hasVault()}1179 ready={bootState() === "ready"}1180 isPro={proStatus()}1181 forceRecoveryWords={forceRecoveryWords()}1182 onUnlock={() => {1183 1184 1185 1186 if (forceRecoveryWords()) setForceRecoveryWords(false);1187 return handleUnlock();1188 }}1189 onBackToOnboarding={1190 !hasVault()1191 ? () => setOnboarded(false)1192 : undefined1193 }1194 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1195 />1196 </Show>1197 }1198 >1199 <Show1200 when={view() !== "pro-celebration"}1201 fallback={<ProCelebration />}1202 >1203 <section1204 id="hd-shell"1205 class="peach-shell peach-theme"1206 data-surface="extension"1207 >1208 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1209 <PeachWordmark unlocked />1210 <div class="peach-chrome-actions">1211 <button1212 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1213 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1214 aria-label="Notes"1215 title="Notes"1216 >1217 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1218 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1219 <line x1="4" y1="5" x2="10" y2="5" />1220 <line x1="4" y1="7.5" x2="10" y2="7.5" />1221 <line x1="4" y1="10" x2="7" y2="10" />1222 </svg>1223 </button>1224 <button1225 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1226 onClick={() => setView("settings")}1227 aria-label="Settings"1228 >1229 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1230 <path1231 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"1232 stroke="currentColor"1233 stroke-width="0.9"1234 />1235 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1236 </svg>1237 </button>1238 <button1239 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1240 onClick={handleAdd}1241 aria-label="New entry"1242 >1243 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1244 <path d="M5.5 1v9M1 5.5h9" />1245 </svg>1246 </button>1247 <button1248 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1249 onClick={() => void handleHeaderLock()}1250 aria-label="Lock vault"1251 >1252 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1253 <path1254 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"1255 stroke="currentColor"1256 stroke-width="1"1257 stroke-linecap="round"1258 />1259 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1260 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1261 </svg>1262 </button>1263 </div>1264 </header>12651266 <Show when={recentPairToast()}>1267 <div class="peach-pair-toast" role="status" aria-live="polite">1268 Paired with <strong>{recentPairToast()}</strong>1269 </div>1270 </Show>12711272 <Show when={incomingPair() !== null}>1273 <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1274 <div class="peach-pair-request-header">1275 <span class="peach-pair-request-mark"><PeachMark class="peach-pair-request-icon" /></span>1276 <span class="peach-pair-request-title">Pairing request</span>1277 <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1278 </div>1279 <p class="peach-pair-request-body">1280 <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1281 </p>1282 <div class="peach-pair-request-actions">1283 <button1284 class="peach-btn peach-btn--ghost peach-btn--sm"1285 onClick={() => void respondIncomingPairing(false)}1286 disabled={incomingPairResponding()}1287 >1288 Reject1289 </button>1290 <button1291 class="peach-btn peach-btn--accent peach-btn--sm"1292 onClick={() => void respondIncomingPairing(true)}1293 disabled={incomingPairResponding()}1294 >1295 Accept1296 </button>1297 </div>1298 </div>1299 </Show>13001301 <Show when={pendingProNotice() !== null}>1302 <div class="peach-pro-notice-banner">1303 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1304 <button1305 class="peach-pro-notice-link"1306 onClick={() => {1307 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1308 setSettingsInitialSection("license");1309 setView("settings");1310 setPendingProNotice(null);1311 }}1312 >1313 Learn more →1314 </button>1315 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1316 </div>1317 </Show>13181319 <div class="peach-shell-body">1320 <div class="peach-shell-veil"></div>1321 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1322 <EntryView1323 entry={selectedEntry()}1324 currentTabUrl={currentTabUrl()}1325 entries={entries()}1326 onDelete={handleDelete}1327 onFieldSaved={() => void loadEntries()}1328 onEscape={() => { setView("list"); }}1329 />1330 </div>13311332 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>1333 <div class="peach-search-bar">1334 <span class="peach-search-icon">1335 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1336 <circle cx="5.5" cy="5.5" r="3.75" />1337 <path d="M8.75 8.75L11.5 11.5" />1338 </svg>1339 </span>1340 <input1341 class="peach-search-input"1342 placeholder="search vault..."1343 autocomplete="off"1344 aria-label="Search vault entries"1345 onClick={openList}1346 onFocus={openList}1347 readOnly1348 />1349 <div class="peach-search-meta">1350 <span class="peach-search-counter">1351 {entries().length > 01352 ? `${selectedIndex() + 1} / ${entries().length}`1353 : "0 entries"}1354 </span>1355 <span class="peach-divider"></span>1356 <button1357 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1358 onClick={() => navigate(-1)}1359 aria-label="Previous entry"1360 disabled={entries().length === 0}1361 >1362 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1363 <path d="M5 1L1 5.5L5 10" />1364 </svg>1365 </button>1366 <button1367 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1368 onClick={() => navigate(1)}1369 aria-label="Next entry"1370 disabled={entries().length === 0}1371 >1372 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1373 <path d="M1 1L5 5.5L1 10" />1374 </svg>1375 </button>1376 </div>1377 </div>1378 </section>1379 </div>13801381 {13821383}1384 <Show when={!isNativePlatform() && view() === "list"}>1385 <VaultView1386 entries={entries()}1387 selectedEntryId={selectedEntry()?.id}1388 currentTabDomain={currentTabDomain()}1389 sortOrder={sortOrder()}1390 searchSeed={listSearchSeed()}1391 searchSeedNonce={listSearchSeedNonce()}1392 onSelectEntry={handleSelectEntry}1393 onSortOrderChange={setSortOrder}1394 onAdd={handleAdd}1395 onClose={() => setView("entry")}1396 onOpenTrash={() => setView("trash")}1397 onDeleteMany={handleDeleteMany}1398 />1399 </Show>1400 <Show when={isNativePlatform() && hasVault()}>1401 <Portal>1402 <div1403 class="peach-vault-sheet"1404 classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1405 style={(() => {1406 const live = sheetTranslate();1407 if (live === null) return undefined;1408 return { transform: `translateY(${live}px)` };1409 })()}1410 >1411 <VaultView1412 entries={entries()}1413 selectedEntryId={selectedEntry()?.id}1414 currentTabDomain={currentTabDomain()}1415 sortOrder={sortOrder()}1416 searchSeed={listSearchSeed()}1417 searchSeedNonce={listSearchSeedNonce()}1418 onSelectEntry={handleSelectEntry}1419 onSortOrderChange={setSortOrder}1420 onAdd={handleAdd}1421 onClose={() => setView("entry")}1422 onOpenTrash={() => setView("trash")}1423 onDeleteMany={handleDeleteMany}1424 />1425 </div>1426 </Portal>1427 </Show>1428 <Show when={view() === "settings"}>1429 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1430 <SettingsView1431 entriesCount={entries().length}1432 breachMonitoring={breachMonitoring()}1433 lockOnClose={lockOnClose()}1434 autoLockMinutes={autoLockMinutes()}1435 trashRetentionDays={trashRetentionDays()}1436 theme={theme()}1437 accentColor={accentColor()}1438 displayFont={displayFont()}1439 textSize={textSize()}1440 reduceMotion={reduceMotion()}1441 isPro={proStatus()}1442 initialSection={settingsInitialSection()}1443 upgradeHighlight={upgradeHighlight()}1444 onBreachMonitoringChange={applyBreachMonitoring}1445 onLockOnCloseChange={applyLockOnClose}1446 onAutoLockMinutesChange={applyAutoLockMinutes}1447 onTrashRetentionChange={applyTrashRetention}1448 onThemeChange={applyTheme}1449 onAccentChange={applyAccentColor}1450 onDisplayFontChange={applyDisplayFont}1451 onTextSizeChange={applyTextSize}1452 onReduceMotionChange={applyReduceMotion}1453 shortcuts={shortcutBindings()}1454 onShortcutsChange={applyShortcuts}1455 onLicenseChange={async () => {1456 1457 1458 1459 1460 1461 await refreshProStatus();1462 void loadEntries();1463 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1464 if ((should as { ok: boolean; data?: boolean }).data === true) {1465 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1466 setView("pro-celebration");1467 window.setTimeout(() => setView("settings"), 2500);1468 }1469 }}1470 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1471 onImport={() => setView("import")}1472 onExport={() => setView("export")}1473 onBreachCheckDone={loadEntries}1474 onOpenHealth={openHealth}1475 onOpenSyncPairing={() => setView("sync-pairing")}1476 onOpenFileImport={handleOpenFileImport}1477 onOpenPanic={() => setView("panic")}1478 onOpenCodexGenerate={() => setView("codex-generate")}1479 />1480 </Suspense>1481 </Show>1482 <Show when={view() === "codex-generate"}>1483 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1484 <CodexGenerateView1485 isPro={proStatus()}1486 onBack={() => setView("settings")}1487 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1488 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1489 />1490 </Suspense>1491 </Show>1492 <Show when={view() === "codex-restore"}>1493 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1494 <CodexRestoreView1495 onBack={() => setView("lock")}1496 onRestoreSuccess={async () => {1497 1498 1499 1500 await handleUnlock();1501 }}1502 />1503 </Suspense>1504 </Show>1505 <Show when={view() === "sync-pairing"}>1506 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1507 <SyncPairingView1508 isPro={proStatus()}1509 onAddDevice={() => setView("sync-add-device")}1510 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1511 onBack={() => setView("settings")}1512 />1513 </Suspense>1514 </Show>1515 <Show when={view() === "sync-add-device"}>1516 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1517 <AddDeviceView1518 onClose={() => setView("sync-pairing")}1519 onPaired={() => setView("sync-pairing")}1520 />1521 </Suspense>1522 </Show>1523 <Show when={view() === "add"}>1524 <AddView1525 entry={editEntry()}1526 entries={entries()}1527 onBack={handleBack}1528 onSave={handleSave}1529 />1530 </Show>1531 <Show when={view() === "import"}>1532 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1533 <ImportView onBack={handleBack} onImport={handleSave} />1534 </Suspense>1535 </Show>1536 <Show when={view() === "export"}>1537 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1538 <ExportView1539 onBack={() => {1540 if (returnToPanic()) {1541 setReturnToPanic(false);1542 setView("panic");1543 } else {1544 handleBack();1545 }1546 }}1547 />1548 </Suspense>1549 </Show>1550 <Show when={view() === "panic"}>1551 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1552 <PanicView1553 onCancel={() => setView("settings")}1554 onOpenExport={() => {1555 setReturnToPanic(true);1556 setView("export");1557 }}1558 onDestroyed={async () => {1559 clearCanarySuppressions();1560 setEntries([]);1561 setSelectedEntry(null);1562 setEditEntry(undefined);1563 setHasVault(false);1564 setOnboarded(false);1565 setProStatus(false);1566 setPendingPasskey(null);1567 setUpgradeHighlight(null);1568 setSettingsInitialSection(null);1569 setPendingProNotice(null);1570 setPendingCelebration(false);1571 setReturnToPanic(false);1572 setContentPhase("idle");1573 setView("lock");1574 }}1575 />1576 </Suspense>1577 </Show>1578 <Show when={view() === "health"}>1579 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1580 <HealthView1581 entries={entries()}1582 isPro={proStatus()}1583 onFixEntry={handleFixEntry}1584 onBack={() => setView("settings")}1585 onRefresh={loadEntries}1586 />1587 </Suspense>1588 </Show>1589 <Show when={view() === "trash"}>1590 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1591 <TrashView1592 onBack={() => setView("list")}1593 onRestored={() => void loadEntries()}1594 />1595 </Suspense>1596 </Show>1597 <Show when={showNotes()}>1598 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1599 <NotesMode1600 notes={entries().filter((e) => e.type === "note")}1601 initialNoteId={openNoteId()}1602 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1603 onNoteListChanged={() => void loadEntries()}1604 />1605 </div>1606 </Show>1607 <Show when={restoreView() === "file"}>1608 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1609 <ImportView1610 mode="onboarding"1611 replacesExistingVault={true}1612 onBack={() => setRestoreView(null)}1613 onImport={async () => {1614 setRestoreView(null);1615 await handleUnlock();1616 }}1617 />1618 </Suspense>1619 </Show>1620 </section>1621 </Show>1622 </Show>1623 </Show>1624 </Show>1625 <Show when={showShortcutsHelp()}>1626 <ShortcutsHelpOverlay1627 bindings={shortcutBindings()}1628 onClose={() => setShowShortcutsHelp(false)}1629 />1630 </Show>1631 </div>1632 );1633}16341635function ShortcutsHelpOverlay(props: {1636 bindings: Record<ShortcutAction, Keybinding>;1637 onClose: () => void;1638}) {1639 return (1640 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1641 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1642 <div class="peach-shortcuts-help-head">1643 <h3>Keyboard shortcuts</h3>1644 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1645 </div>1646 <div class="peach-shortcuts-help-body">1647 <p class="peach-settings-sub-label">ENTRY VIEW</p>1648 <ul>1649 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1650 <li>1651 <span>{d.label}</span>1652 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1653 </li>1654 ))}1655 </ul>1656 <p class="peach-settings-sub-label">ANYWHERE</p>1657 <ul>1658 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1659 <li>1660 <span>{d.label}</span>1661 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1662 </li>1663 ))}1664 </ul>1665 </div>1666 </div>1667 </div>1668 );1669}