1import { createEffect, createMemo, createSignal, For, 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 { isPasskeyOnly } from "../shared/types";8import { LockView } from "./views/Lock";9import { Onboarding } from "./views/Onboarding";10import { NotesMode } from "./views/NotesMode";11import { ProCelebration } from "./views/ProCelebration";12import { VaultView, type SortOrder } from "./views/Vault";13import { EntryView } from "./views/Entry";14import { AddView } from "./views/Add";15import { PeachWordmark } from "./components/PeachMark";16import { PasskeyApprovalView } from "./views/PasskeyApproval";17import { extractDomain } from "../shared/domain";18import { popupCopy } from "./copy";19import {20 DEFAULT_BINDINGS,21 formatKeybinding,22 getActiveBindings,23 matchKeybinding,24 SHORTCUT_DESCRIPTORS,25 type Keybinding,26 type ShortcutAction,27} from "../shared/shortcuts";28import { generateTOTP } from "../crypto/totp";29import { isNativePlatform, nativePlugin } from "../shared/native";3031export { sendMessage } from "../shared/bridge";3233const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));34const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));35const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));36const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));37const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));38const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));39const ChirpSyncView = lazy(() => import("./views/ChirpSync").then(m => ({ default: m.ChirpSyncView })));40const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));41const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4243type View =44 | "lock"45 | "pro-celebration"46 | "entry"47 | "list"48 | "add"49 | "settings"50 | "chirp-sync"51 | "import"52 | "export"53 | "health"54 | "trash"55 | "panic"56 | "codex-generate"57 | "codex-restore";58type BootState = "loading" | "ready";5960const canarySuppressMap = new Map<string, boolean>();61let shellRingAnimation: Animation | null = null;6263export function isCanarySuppressed(entryId: string): boolean {64 return canarySuppressMap.get(entryId) ?? false;65}6667export function setCanarySuppressed(entryId: string, suppressed: boolean): void {68 canarySuppressMap.set(entryId, suppressed);69}7071export function clearCanarySuppressions(): void {72 canarySuppressMap.clear();73}7475export function triggerRing() {76 const shell = document.getElementById("hd-shell");77 if (!shell) return;7879 shellRingAnimation?.cancel();80 shell.dataset.glowing = "true";81 const animation = shell.animate(82 [83 { offset: 0, boxShadow: "var(--copy-shadow)" },84 {85 offset: 0.3,86 boxShadow:87 "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)",88 },89 { offset: 1, boxShadow: "var(--copy-shadow)" },90 ],91 {92 duration: 720,93 easing: "cubic-bezier(0.4, 0, 0.2, 1)",94 fill: "none",95 }96 );97 shellRingAnimation = animation;98 const clearGlow = () => {99 if (shellRingAnimation !== animation) return;100 shellRingAnimation = null;101 delete shell.dataset.glowing;102 };103 animation.onfinish = clearGlow;104 animation.oncancel = clearGlow;105}106107108export function triggerPipPulse() {109 const pip = document.querySelector(".peach-status-pip, .pip");110 if (pip) {111 pip.classList.remove("pulse");112 void (pip as HTMLElement).offsetWidth;113 pip.classList.add("pulse");114 }115}116117const ENTRY_TRANSITION_SETTLE_MS = 135;118119export default function App() {120 const [bootState, setBootState] = createSignal<BootState>("loading");121 const [view, setView] = createSignal<View>("lock");122 123 124 const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);125 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(126 "enter"127 );128 const [contentTransitioning, setContentTransitioning] = createSignal(false);129 const [hasVault, setHasVault] = createSignal(false);130 const [onboarded, setOnboarded] = createSignal(true);131 const [entries, setEntries] = createSignal<VaultEntry[]>([]);132 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);133 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);134135 136 137 interface SyncAlert {138 type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";139 entryId: string;140 entryTitle: string;141 deviceName: string;142 timestamp: number;143 }144 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);145 async function loadSyncAlerts() {146 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {147 ok: boolean;148 data?: SyncAlert[];149 };150 if (r.ok && (r.data?.length ?? 0) > 0) {151 setSyncAlerts(r.data ?? []);152 153 154 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });155 }156 }157 function dismissSyncAlert(index: number) {158 setSyncAlerts((arr) => arr.filter((_, i) => i !== index));159 }160 function syncAlertText(a: SyncAlert): string {161 if (a.type === "REMOTE_PASSWORD_CHANGE") {162 return `${a.deviceName} changed the password for ${a.entryTitle}`;163 }164 if (a.type === "REMOTE_DELETE") {165 return `${a.deviceName} deleted ${a.entryTitle}`;166 }167 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;168 }169 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");170 const [theme, setTheme] = createSignal<"dark" | "light">("dark");171 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");172 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);173 const [lockOnClose, setLockOnClose] = createSignal(true);174 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);175 const [breachMonitoring, setBreachMonitoring] = createSignal(true);176 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);177 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);178 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);179 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);180 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);181 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");182 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");183 184 185 186 187 const [proStatus, setProStatus] = createSignal(false);188 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);189 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");190 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);191 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");192 const [listSearchSeed, setListSearchSeed] = createSignal("");193 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);194 const [pendingCelebration, setPendingCelebration] = createSignal(false);195 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);196 197 198 199 const [returnToPanic, setReturnToPanic] = createSignal(false);200 201 202 const [editBackView, setEditBackView] = createSignal<View>("entry");203 const [settingsExiting, setSettingsExiting] = createSignal(false);204 const [showNotes, setShowNotes] = createSignal(false);205 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);206 207 208 209 210 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);211212 let contentPhaseTimer: number | undefined;213 214 215 let openedForInlineUnlock = false;216217 function applyAccent(name: AccentColorName) {218 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;219 const hex = color.hex;220 document.documentElement.style.setProperty("--peach-accent", hex);221 222 223 document.documentElement.style.setProperty("--peach-glow", hex + "2A");224 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");225 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");226 }227228 createEffect(() => {229 applyAccent(accentColor());230 });231232 createEffect(() => {233 document.documentElement.classList.toggle("light", theme() === "light");234 document.body.classList.toggle("light", theme() === "light");235 });236237 createEffect(() => {238 document.documentElement.setAttribute("data-text-size", textSize());239 });240241 createEffect(() => {242 if (reduceMotion()) {243 document.documentElement.setAttribute("data-motion", "reduce");244 } else {245 document.documentElement.removeAttribute("data-motion");246 }247 });248249 const selectedIndex = createMemo(() => {250 const currentId = selectedEntry()?.id;251 if (!currentId) return entries().length > 0 ? 0 : -1;252 return entries().findIndex((entry) => entry.id === currentId);253 });254255 async function applyAccentColor(name: AccentColorName) {256 applyAccent(name);257 setAccentColor(name);258 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });259 }260261 async function applyTheme(t: "dark" | "light") {262 setTheme(t);263 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });264 }265266 async function applyTextSize(size: "small" | "medium" | "large") {267 setTextSize(size);268 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });269 }270271 async function applyReduceMotion(enabled: boolean) {272 setReduceMotion(enabled);273 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });274 }275276 277 278 279 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {280 setShortcutBindings(active);281 const overrides: Record<string, Keybinding> = {};282 for (const action of Object.keys(active) as ShortcutAction[]) {283 const a = active[action];284 const d = DEFAULT_BINDINGS[action];285 const same = a.key === d.key286 && Boolean(a.ctrl) === Boolean(d.ctrl)287 && Boolean(a.meta) === Boolean(d.meta)288 && Boolean(a.shift) === Boolean(d.shift)289 && Boolean(a.alt) === Boolean(d.alt);290 if (!same) overrides[action] = a;291 }292 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });293 }294295 async function applyLockOnClose(enabled: boolean) {296 setLockOnClose(enabled);297 await sendMessage({298 type: "UPDATE_SETTINGS",299 payload: { lockOnClose: enabled },300 });301 }302303 async function applyAutoLockMinutes(minutes: number) {304 setAutoLockMinutes(minutes);305 await sendMessage({306 type: "UPDATE_SETTINGS",307 payload: { autoLockMinutes: minutes },308 });309 }310311 async function applyBreachMonitoring(enabled: boolean) {312 setBreachMonitoring(enabled);313 await sendMessage({314 type: "UPDATE_SETTINGS",315 payload: { breachMonitoring: enabled },316 });317 }318319 onMount(async () => {320 321 322 323 324 325 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([326 sendMessage({ type: "IS_UNLOCKED" }),327 sendMessage({ type: "HAS_VAULT" }),328 refreshCurrentTab(),329 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),330 ]);331 const unlocked = Boolean(332 (unlockResponse as { ok: boolean; data?: boolean }).data333 );334 const vaultExists = Boolean(335 (vaultResponse as { ok: boolean; data?: boolean }).data336 );337 const shouldCelebrate = Boolean(338 (celebrationResponse as { ok: boolean; data?: boolean }).data339 );340 setHasVault(vaultExists);341 342 343 344 try {345 const onboarded = await storageGet("peach_onboarded");346 const seen = onboarded === "true";347 if (vaultExists && !seen) {348 await storageSet("peach_onboarded", "true");349 setOnboarded(true);350 } else {351 setOnboarded(seen);352 }353 } catch {354 355 setOnboarded(true);356 }357 await Promise.all([restoreSettings(), refreshProStatus()]);358359 360 if (shouldCelebrate) {361 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });362 }363364 365 366 367 if (unlocked && vaultExists) {368 void sendMessage({369 type: "TRIGGER_SYNC",370 payload: { reason: "foreground" },371 });372 373 void loadSyncAlerts();374 }375376 377 378 379 380 381 382 let recoveryPending = false;383 try {384 const out = await chrome.storage.local?.get("peach_recovery_pending");385 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";386 } catch { }387388 389 390 391 if (chrome.storage.session) {392 try {393 const s = await chrome.storage.session.get("peach_inline_unlock");394 if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {395 openedForInlineUnlock = true;396 await chrome.storage.session.remove("peach_inline_unlock");397 }398 } catch { }399 }400401 402 403 404 405 406 if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {407 try {408 await chrome.storage.local?.remove([409 "peach_vault_a",410 "peach_vault_b",411 "peach_recovery_pending",412 "peach_onboarded",413 "peach_vault_history",414 ]);415 } catch { }416 setHasVault(false);417 setOnboarded(false);418 setView("lock");419 setBootState("ready");420 return;421 }422423 if (recoveryPending && unlocked) {424 425 426 setForceRecoveryWords(true);427 setView("lock");428 } else if (unlocked && vaultExists) {429 await openVault(undefined, shouldCelebrate);430 if (shouldCelebrate) {431 setView("pro-celebration");432 window.setTimeout(() => setView("entry"), 2500);433 }434 } else if (shouldCelebrate) {435 setPendingCelebration(true);436 }437438 await refreshPendingPasskey();439440 441 const handleTabActivated = () => { void refreshCurrentTab(); };442 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {443 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();444 };445 try {446 chrome.tabs.onActivated.addListener(handleTabActivated);447 chrome.tabs.onUpdated.addListener(handleTabUpdated);448 onCleanup(() => {449 chrome.tabs.onActivated.removeListener(handleTabActivated);450 chrome.tabs.onUpdated.removeListener(handleTabUpdated);451 });452 } catch {453 454 }455456 setBootState("ready");457458 459 460 461 462 try {463 const route = new URLSearchParams(window.location.search).get("codex");464 if (route === "pdf" || route === "restore") {465 setView("codex-restore");466 }467 } catch {468 469 }470471 472 if (chrome.storage.session) {473 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as474 Record<string, { feature?: string } | undefined>;475 if (session.pendingProGate?.feature) {476 await chrome.storage.session.remove("pendingProGate");477 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);478 setSettingsInitialSection("license");479 setView("settings");480 } else if (session.pendingProNotice?.feature) {481 await chrome.storage.session.remove("pendingProNotice");482 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });483 }484 }485486 487 488 489 490 void Promise.all([491 import("./views/Settings").catch(() => {}),492 import("./views/Import").catch(() => {}),493 import("./views/Export").catch(() => {}),494 import("./views/Health").catch(() => {}),495 import("./views/TrashView").catch(() => {}),496 ]);497498 499 500 501 let popupPort: chrome.runtime.Port | null = null;502 try {503 504 505 506 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });507 } catch { }508 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });509510 511 512 513 if (isNativePlatform()) {514 let backgroundedAt: number | null = null;515516 const CapApp = nativePlugin<{517 addListener: (518 ev: string,519 cb: (s: { isActive: boolean }) => void,520 ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };521 }>("App");522 if (CapApp?.addListener) {523 const sub = CapApp.addListener(524 "appStateChange",525 ({ isActive }: { isActive: boolean }) => {526 if (!isActive) {527 backgroundedAt = Date.now();528 return;529 }530 if (backgroundedAt !== null && autoLockMinutes() > 0) {531 const elapsed = Date.now() - backgroundedAt;532 if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();533 }534 backgroundedAt = null;535 }536 );537 538 onCleanup(() => {539 Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});540 });541 }542543 544 545 546 547 548 let lastUserActivityAt = Date.now();549 const bumpActivity = () => { lastUserActivityAt = Date.now(); };550 const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;551 for (const ev of ACTIVITY_EVENTS) {552 document.addEventListener(ev, bumpActivity, { passive: true, capture: true });553 }554 const idleLockInterval = setInterval(() => {555 const minutes = autoLockMinutes();556 if (!minutes || minutes <= 0) return;557 const elapsed = Date.now() - lastUserActivityAt;558 if (elapsed >= minutes * 60_000) {559 lastUserActivityAt = Date.now();560 handleHeaderLock();561 }562 }, 30_000);563 onCleanup(() => {564 clearInterval(idleLockInterval);565 for (const ev of ACTIVITY_EVENTS) {566 document.removeEventListener(ev, bumpActivity, { capture: true });567 }568 });569570 571 572 573 let stopMDNS: () => void = () => undefined;574 onCleanup(() => stopMDNS());575 if (isNativePlatform()) {576 577 578 579 void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>580 import("../background/chirp-sync-runner").then(({ productionSyncRunner }) => {581 setSyncRunner(productionSyncRunner);582 void hydratePersistedSyncState();583 })584 );585 }586 void import("./chirp-pair-capacitor").then(({ startMDNSListener }) => {587 stopMDNS = startMDNSListener();588 });589 }590591 592 593 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {594 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();595 if (msg?.type === "PRO_GATE_TRIGGERED") {596 setUpgradeHighlight(msg.payload?.feature ?? null);597 setSettingsInitialSection("license");598 setView("settings");599 }600 };601 chrome.runtime.onMessage.addListener(handleRuntimeMessage);602 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));603604 605 606 607 const handlePeachBack = () => {608 609 if (showNotes()) {610 setShowNotes(false);611 setOpenNoteId(undefined);612 return;613 }614 const v = view();615 if (v === "lock" && !hasVault() && onboarded()) {616 setOnboarded(false);617 return;618 }619 if (v === "list") { setView("entry"); return; }620 if (v === "settings") {621 window.dispatchEvent(new Event("peach:settings-back"));622 return;623 }624 if (v === "import" || v === "health") { setView("settings"); return; }625 if (v === "export") { handleExportBack(); return; }626 if (v === "add") { handleBack(); return; }627 if (v === "chirp-sync") { setView("settings"); return; }628 };629 window.addEventListener("peach:back", handlePeachBack);630 onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));631632 633 634 635 636 637 if (isNativePlatform()) {638 let touchStartY = 0;639 let touchStartX = 0;640 let lastY = 0;641 let lastT = 0;642 let dragging = false;643 let captured = false;644 const HORIZONTAL_TOLERANCE = 16;645 const ACTIVATION_DY = 8;646 const PEEK_PX = 60;647648 const onTouchStart = (e: TouchEvent) => {649 const t = e.touches[0];650 if (!t) return;651 const v = view();652 if (v !== "entry" && v !== "list") return;653 654 655 const inBand = v === "entry"656 ? t.clientY > window.innerHeight * 0.6657 : t.clientY < window.innerHeight * 0.35;658 if (!inBand) return;659 touchStartY = lastY = t.clientY;660 touchStartX = t.clientX;661 lastT = e.timeStamp;662 dragging = true;663 captured = false;664 };665666 const onTouchMove = (e: TouchEvent) => {667 if (!dragging) return;668 const t = e.touches[0];669 if (!t) return;670 const dy = t.clientY - touchStartY;671 const dx = Math.abs(t.clientX - touchStartX);672673 if (!captured) {674 if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {675 676 dragging = false;677 return;678 }679 if (Math.abs(dy) < ACTIVATION_DY) return;680 captured = true;681 }682683 684 685 const v = view();686 const restClosed = window.innerHeight - PEEK_PX;687 const baseTranslate = v === "list" ? 0 : restClosed;688 const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));689 setSheetTranslate(next);690 lastY = t.clientY;691 lastT = e.timeStamp;692 };693694 const onTouchEnd = (e: TouchEvent) => {695 if (!dragging) { return; }696 const t = e.changedTouches[0];697 const elapsed = Math.max(1, e.timeStamp - lastT);698 const velocity = t ? (t.clientY - lastY) / elapsed : 0; 699 const wasCaptured = captured;700 dragging = false;701 captured = false;702 const v = view();703704 if (!wasCaptured) {705 706 if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");707 setSheetTranslate(null);708 return;709 }710711 const restClosed = window.innerHeight - PEEK_PX;712 const current = sheetTranslate() ?? restClosed;713714 715 716 717 718 let nextOpen: boolean;719 if (velocity < -0.05) nextOpen = true;720 else if (velocity > 0.05) nextOpen = false;721 else nextOpen = current < restClosed * 0.5;722723 if (nextOpen && v !== "list") setView("list");724 if (!nextOpen && v !== "entry") setView("entry");725 726 setSheetTranslate(null);727 };728729 document.addEventListener("touchstart", onTouchStart, { passive: true });730 document.addEventListener("touchmove", onTouchMove, { passive: true });731 document.addEventListener("touchend", onTouchEnd, { passive: true });732 document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });733 onCleanup(() => {734 document.removeEventListener("touchstart", onTouchStart);735 document.removeEventListener("touchmove", onTouchMove);736 document.removeEventListener("touchend", onTouchEnd);737 });738 }739740 741 742 743 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {744 copy_password: () => {745 const entry = selectedEntry();746 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });747 },748 copy_username: () => {749 const entry = selectedEntry();750 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });751 },752 copy_totp: () => {753 const entry = selectedEntry();754 if (!entry?.totp) return;755 try {756 const { code } = generateTOTP(entry.totp);757 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });758 } catch { }759 },760 new_entry: () => handleAdd(),761 edit_entry: () => {762 const entry = selectedEntry();763 764 765 if (!entry || isPasskeyOnly(entry)) return;766 setEditEntry(entry);767 setView("add");768 },769 delete_entry: () => {770 if (selectedEntry()) void handleDelete();771 },772 lock_vault: () => { void handleHeaderLock(); },773 search: () => openList(),774 nav_prev: () => navigate(-1),775 nav_next: () => navigate(1),776 jump_vault: () => setView("entry"),777 jump_settings: () => setView("settings"),778 show_help: () => setShowShortcutsHelp(true),779 };780781 782 783 784 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([785 "copy_password", "copy_username", "copy_totp",786 "edit_entry", "delete_entry", "nav_prev", "nav_next",787 ]);788789 const handleGlobalKeydown = (e: KeyboardEvent) => {790 const v = view();791 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"792 || (e.target as HTMLElement)?.tagName === "TEXTAREA"793 || (e.target as HTMLElement)?.isContentEditable;794 const isPrintableSearchKey =795 e.key.length === 1 &&796 !e.ctrlKey &&797 !e.metaKey &&798 !e.altKey;799800 801 if (v === "pro-celebration") return;802803 804 805 if (showShortcutsHelp()) {806 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }807 return;808 }809810 811 if (e.key === "Escape") {812 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }813 if (v === "list") { setView("entry"); return; }814 if (v === "settings" || v === "add") { handleBack(); return; }815 if (v === "import" || v === "health") { setView("settings"); return; }816 if (v === "export") { handleExportBack(); return; }817 if (v === "chirp-sync") { setView("settings"); return; }818 }819820 821 822 823 const bindings = shortcutBindings();824 for (const action of Object.keys(bindings) as ShortcutAction[]) {825 const binding = bindings[action];826 if (!matchKeybinding(e, binding)) continue;827 828 829 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;830 if (!hasMod && binding.key.length === 1 && inInput) return;831 if (ENTRY_SCOPED.has(action) && v !== "entry") return;832 e.preventDefault();833 SHORTCUT_HANDLERS[action]?.();834 return;835 }836837 838 839 840 841 if (v === "entry" && !inInput && isPrintableSearchKey) {842 e.preventDefault();843 primeListSearch(e.key);844 return;845 }846 };847 window.addEventListener("keydown", handleGlobalKeydown);848 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));849850 const handleStorageChanged = (851 changes: Record<string, chrome.storage.StorageChange>,852 areaName: string853 ) => {854 if (areaName !== "session") return;855 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {856 return;857 }858 void refreshPendingPasskey();859 };860 chrome.storage.onChanged.addListener(handleStorageChanged);861 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));862 });863864 async function refreshCurrentTab() {865 try {866 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });867 const url = tab?.url ?? tab?.pendingUrl ?? "";868 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");869 870 871 const isWebUrl = /^https?:\/\//i.test(url);872 setCurrentTabUrl(isWebUrl ? url : "");873 } catch {874 setCurrentTabDomain("");875 setCurrentTabUrl("");876 }877 }878879 async function refreshPendingPasskey() {880 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });881 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);882 }883884 async function refreshProStatus() {885 const res = await sendMessage({ type: "GET_LICENSE" });886 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;887 setProStatus(Boolean(data?.valid && data?.plan === "pro"));888 }889890 async function restoreSettings() {891 const res = await sendMessage({ type: "GET_SETTINGS" });892 const s = (res as { ok: boolean; data?: any }).data ?? {};893 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {894 setAccentColor(s.accentColor as AccentColorName);895 }896 setTheme(s.theme === "light" ? "light" : "dark");897 if (s.textSize === "small" || s.textSize === "large") {898 setTextSize(s.textSize);899 } else {900 setTextSize("medium");901 }902 setReduceMotion(s.reduceMotion === true);903 setLockOnClose(s.lockOnClose !== false);904 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);905 setBreachMonitoring(s.breachMonitoring !== false);906 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);907 setShortcutBindings(getActiveBindings(s.shortcuts));908 }909910 async function applyTrashRetention(days: number) {911 setTrashRetentionDays(days);912 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });913 }914915 function primeListSearch(seed: string) {916 setListSearchSeed(seed);917 setListSearchSeedNonce((value) => value + 1);918 openList();919 }920921 922 923 924 async function loadEntries(): Promise<VaultEntry[] | null> {925 const response = await sendMessage({ type: "GET_ENTRIES" });926 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };927928 929 930 931 932 if (!res.ok) {933 if (934 res.error === "vault_locked" ||935 res.error === "SERVICE_WORKER_UNAVAILABLE" ||936 (typeof res.error === "string" && res.error.includes("locked"))937 ) {938 handleLock();939 return null; 940 }941 return [];942 }943944 const loadedEntries = res.data ?? [];945 setEntries(loadedEntries);946947 const currentId = selectedEntry()?.id;948 if (currentId) {949 setSelectedEntry(950 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null951 );952 }953954 return loadedEntries;955 }956957 async function openVault(preferredEntryId?: string, skipViewSet = false) {958 959 const retDays = trashRetentionDays();960 if (retDays > 0) {961 void sendMessage({962 type: "PURGE_EXPIRED_TRASH",963 payload: { retentionMs: retDays * 86_400_000 },964 });965 }966967 const loadedEntries = await loadEntries();968969 970 971 if (loadedEntries === null) return;972973 974 let nextSelectedEntry: VaultEntry | null = null;975 if (preferredEntryId) {976 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;977 }978 if (!nextSelectedEntry) {979 const tabDomain = currentTabDomain();980 if (tabDomain) {981 nextSelectedEntry = loadedEntries.find((e) => {982 if (e.type === "login") {983 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;984 for (const extra of e.additionalUrls ?? []) {985 if ((extractDomain(extra) ?? "") === tabDomain) return true;986 }987 return false;988 }989 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;990 return false;991 }) ?? null;992 }993 }994 nextSelectedEntry ??= loadedEntries[0] ?? null;995996 setSelectedEntry(nextSelectedEntry);997 setEditEntry(undefined);998 setContentTransitioning(false);999 setContentPhase("enter");1000 queueContentIdle();1001 if (!skipViewSet) setView("entry");1002 }10031004 async function handleUnlock() {1005 1006 1007 1008 1009 1010 1011 let recoveryPending = false;1012 try {1013 const out = await chrome.storage.local?.get("peach_recovery_pending");1014 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1015 } catch { }1016 if (recoveryPending) {1017 setForceRecoveryWords(true);1018 setView("lock");1019 triggerPipPulse();1020 await restoreSettings();1021 return;1022 }1023 setHasVault(true);1024 triggerPipPulse();1025 await restoreSettings();1026 const celebrating = pendingCelebration();1027 if (celebrating) {1028 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1029 setPendingCelebration(false);1030 }1031 await openVault(undefined, celebrating);1032 if (celebrating) {1033 setView("pro-celebration");1034 window.setTimeout(() => setView("entry"), 2500);1035 }1036 1037 1038 if (openedForInlineUnlock) {1039 openedForInlineUnlock = false;1040 window.close();1041 }1042 }10431044 function handleSelectEntry(entry: VaultEntry) {1045 if (entry.type === "note") {1046 setOpenNoteId(entry.id);1047 setShowNotes(true);1048 return;1049 }1050 setSelectedEntry(entry);1051 setContentTransitioning(false);1052 setContentPhase("enter");1053 queueContentIdle();1054 setView("entry");1055 }10561057 function handleAdd() {1058 setEditEntry(undefined);1059 setView("add");1060 }10611062 async function handleDelete() {1063 const entry = selectedEntry();1064 if (!entry) return;1065 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1066 const loadedEntries = await loadEntries() ?? [];1067 setSelectedEntry(loadedEntries[0] ?? null);1068 setView("entry");1069 }10701071 async function handleDeleteMany(ids: string[]) {1072 for (const id of ids) {1073 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1074 }1075 const loadedEntries = await loadEntries() ?? [];1076 1077 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1078 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1079 }10801081 async function handleSave(savedEntry?: VaultEntry) {1082 await openVault(savedEntry?.id);1083 }10841085 function handleBack() {1086 const dest = editBackView();1087 setEditBackView("entry");1088 setEditEntry(undefined);1089 setView(dest);1090 }10911092 function handleExportBack() {1093 if (returnToPanic()) {1094 setReturnToPanic(false);1095 setView("panic");1096 return;1097 }1098 setView("settings");1099 }11001101 function handleLock() {1102 clearCanarySuppressions();1103 setEntries([]);1104 setSelectedEntry(null);1105 setEditEntry(undefined);1106 setContentPhase("idle");1107 setView("lock");1108 }11091110 function handleHeaderLock() {1111 1112 1113 1114 handleLock();1115 void sendMessage({ type: "LOCK" });1116 }11171118 function navigate(dir: number) {1119 const allEntries = entries();1120 if (allEntries.length === 0) return;1121 const currentIndex = selectedIndex();1122 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1123 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1124 setSelectedEntry(allEntries[nextIndex]);1125 setContentPhase("enter");1126 queueContentIdle();1127 }11281129 function openList() {1130 setView("list");1131 }11321133 function openHealth() {1134 setEditEntry(undefined);1135 setView("health");1136 }11371138 function handleOpenFileImport() {1139 setRestoreView("file");1140 }11411142 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;11431144 function handleFixEntry(entry: VaultEntry) {1145 setSelectedEntry(entry);1146 1147 1148 if (isPasskeyOnly(entry)) {1149 setView("entry");1150 return;1151 }1152 setEditBackView(view()); 1153 setEditEntry(entry);1154 setView("add");1155 }11561157 const isLockedView = () => view() === "lock";11581159 async function handleApprovePendingPasskey() {1160 const pending = pendingPasskey();1161 if (!pending) return;1162 if (pending.browser === "firefox" && pending.type === "create") {1163 1164 1165 1166 1167 const { createFirefoxPasskey } = await import("./firefox-passkey");1168 const result = await createFirefoxPasskey(pending);1169 if (!result.ok) {1170 console.warn("Peach Firefox passkey create failed:", result.error);1171 }1172 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1173 setPendingPasskey(null);1174 void loadEntries();1175 window.close();1176 return;1177 }1178 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1179 window.close();1180 }11811182 async function handleDenyPendingPasskey() {1183 const pending = pendingPasskey();1184 if (!pending) return;1185 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1186 window.close();1187 }11881189 function queueContentIdle(after?: () => void) {1190 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1191 contentPhaseTimer = window.setTimeout(() => {1192 setContentPhase("idle");1193 after?.();1194 }, ENTRY_TRANSITION_SETTLE_MS);1195 }11961197 return (1198 <div class="popup-root peach-theme">1199 <Show when={bootState() === "ready"}>1200 <Show1201 when={!pendingPasskey()}1202 fallback={1203 <PasskeyApprovalView1204 type={pendingPasskey()!.type}1205 rpName={pendingPasskey()!.rpName}1206 rpId={pendingPasskey()!.rpId}1207 userName={pendingPasskey()!.userName}1208 userDisplayName={pendingPasskey()!.userDisplayName}1209 onApprove={handleApprovePendingPasskey}1210 onDeny={handleDenyPendingPasskey}1211 />1212 }1213 >1214 <Show1215 when={!isLockedView()}1216 fallback={1217 <Show1218 when={onboarded() || hasVault()}1219 fallback={1220 <Onboarding1221 onComplete={() => { setOnboarded(true); }}1222 />1223 }1224 >1225 <LockView1226 hasVault={hasVault()}1227 ready={bootState() === "ready"}1228 isPro={proStatus()}1229 forceRecoveryWords={forceRecoveryWords()}1230 onUnlock={() => {1231 1232 1233 1234 if (forceRecoveryWords()) setForceRecoveryWords(false);1235 return handleUnlock();1236 }}1237 onBackToOnboarding={1238 !hasVault()1239 ? () => setOnboarded(false)1240 : undefined1241 }1242 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1243 />1244 </Show>1245 }1246 >1247 <Show1248 when={view() !== "pro-celebration"}1249 fallback={<ProCelebration />}1250 >1251 <section1252 id="hd-shell"1253 class="peach-shell peach-theme"1254 data-surface="extension"1255 >1256 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "chirp-sync" }}>1257 <PeachWordmark unlocked />1258 <div class="peach-chrome-actions">1259 <button1260 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1261 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1262 aria-label="Notes"1263 title="Notes"1264 >1265 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1266 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1267 <line x1="4" y1="5" x2="10" y2="5" />1268 <line x1="4" y1="7.5" x2="10" y2="7.5" />1269 <line x1="4" y1="10" x2="7" y2="10" />1270 </svg>1271 </button>1272 <button1273 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1274 onClick={() => setView("settings")}1275 aria-label="Settings"1276 >1277 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1278 <path1279 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"1280 stroke="currentColor"1281 stroke-width="0.9"1282 />1283 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1284 </svg>1285 </button>1286 <button1287 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1288 onClick={handleAdd}1289 aria-label="New entry"1290 >1291 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1292 <path d="M5.5 1v9M1 5.5h9" />1293 </svg>1294 </button>1295 <button1296 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1297 onClick={() => void handleHeaderLock()}1298 aria-label="Lock vault"1299 >1300 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1301 <path1302 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"1303 stroke="currentColor"1304 stroke-width="1"1305 stroke-linecap="round"1306 />1307 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1308 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1309 </svg>1310 </button>1311 </div>1312 </header>13131314 <Show when={pendingProNotice() !== null}>1315 <div class="peach-pro-notice-banner">1316 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1317 <button1318 class="peach-pro-notice-link"1319 onClick={() => {1320 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1321 setSettingsInitialSection("license");1322 setView("settings");1323 setPendingProNotice(null);1324 }}1325 >1326 Learn more →1327 </button>1328 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1329 </div>1330 </Show>13311332 <div class="peach-shell-body">1333 <div class="peach-shell-veil"></div>1334 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1335 <EntryView1336 entry={selectedEntry()}1337 currentTabUrl={currentTabUrl()}1338 entries={entries()}1339 isPro={proStatus()}1340 onDelete={handleDelete}1341 onFieldSaved={() => void loadEntries()}1342 onEscape={() => { setView("list"); }}1343 onProGate={(feature) => {1344 setUpgradeHighlight(feature);1345 setSettingsInitialSection("license");1346 setView("settings");1347 }}1348 />1349 </div>13501351 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "chirp-sync" }}>1352 <div class="peach-search-bar">1353 <span class="peach-search-icon">1354 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1355 <circle cx="5.5" cy="5.5" r="3.75" />1356 <path d="M8.75 8.75L11.5 11.5" />1357 </svg>1358 </span>1359 <input1360 class="peach-search-input"1361 placeholder="search vault..."1362 autocomplete="off"1363 aria-label="Search vault entries"1364 onClick={openList}1365 onFocus={openList}1366 readOnly1367 />1368 <div class="peach-search-meta">1369 <span class="peach-search-counter">1370 {entries().length > 01371 ? `${selectedIndex() + 1} / ${entries().length}`1372 : "0 entries"}1373 </span>1374 <span class="peach-divider"></span>1375 <button1376 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1377 onClick={() => navigate(-1)}1378 aria-label="Previous entry"1379 disabled={entries().length === 0}1380 >1381 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1382 <path d="M5 1L1 5.5L5 10" />1383 </svg>1384 </button>1385 <button1386 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1387 onClick={() => navigate(1)}1388 aria-label="Next entry"1389 disabled={entries().length === 0}1390 >1391 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1392 <path d="M1 1L5 5.5L1 10" />1393 </svg>1394 </button>1395 </div>1396 </div>1397 </section>1398 </div>13991400 {14011402}1403 {}1404 <Show when={syncAlerts().length > 0}>1405 <div class="peach-sync-alerts">1406 <For each={syncAlerts()}>1407 {(alert, i) => (1408 <div class="peach-sync-alert">1409 <span class="peach-sync-alert-text">⚠ {syncAlertText(alert)}</span>1410 <button1411 class="peach-sync-alert-dismiss"1412 type="button"1413 aria-label="Dismiss"1414 onClick={() => dismissSyncAlert(i())}1415 >×</button>1416 </div>1417 )}1418 </For>1419 </div>1420 </Show>14211422 <Show when={!isNativePlatform() && view() === "list"}>1423 <VaultView1424 entries={entries()}1425 selectedEntryId={selectedEntry()?.id}1426 currentTabDomain={currentTabDomain()}1427 sortOrder={sortOrder()}1428 searchSeed={listSearchSeed()}1429 searchSeedNonce={listSearchSeedNonce()}1430 onSelectEntry={handleSelectEntry}1431 onSortOrderChange={setSortOrder}1432 onAdd={handleAdd}1433 onClose={() => setView("entry")}1434 onOpenTrash={() => setView("trash")}1435 onDeleteMany={handleDeleteMany}1436 />1437 </Show>1438 <Show when={isNativePlatform() && hasVault()}>1439 <Portal>1440 <div1441 class="peach-vault-sheet"1442 classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1443 style={(() => {1444 const live = sheetTranslate();1445 if (live === null) return undefined;1446 return { transform: `translateY(${live}px)` };1447 })()}1448 >1449 <VaultView1450 entries={entries()}1451 selectedEntryId={selectedEntry()?.id}1452 currentTabDomain={currentTabDomain()}1453 sortOrder={sortOrder()}1454 searchSeed={listSearchSeed()}1455 searchSeedNonce={listSearchSeedNonce()}1456 isNative={true}1457 onSelectEntry={handleSelectEntry}1458 onSortOrderChange={setSortOrder}1459 onAdd={handleAdd}1460 onClose={() => setView("entry")}1461 onOpenTrash={() => setView("trash")}1462 onDeleteMany={handleDeleteMany}1463 />1464 </div>1465 </Portal>1466 </Show>1467 <Show when={view() === "settings" || settingsExiting()}>1468 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1469 <SettingsView1470 entriesCount={entries().length}1471 breachMonitoring={breachMonitoring()}1472 lockOnClose={lockOnClose()}1473 autoLockMinutes={autoLockMinutes()}1474 trashRetentionDays={trashRetentionDays()}1475 theme={theme()}1476 accentColor={accentColor()}1477 textSize={textSize()}1478 reduceMotion={reduceMotion()}1479 isPro={proStatus()}1480 initialSection={settingsInitialSection()}1481 upgradeHighlight={upgradeHighlight()}1482 isExiting={settingsExiting()}1483 onBreachMonitoringChange={applyBreachMonitoring}1484 onLockOnCloseChange={applyLockOnClose}1485 onAutoLockMinutesChange={applyAutoLockMinutes}1486 onTrashRetentionChange={applyTrashRetention}1487 onThemeChange={applyTheme}1488 onAccentChange={applyAccentColor}1489 onTextSizeChange={applyTextSize}1490 onReduceMotionChange={applyReduceMotion}1491 shortcuts={shortcutBindings()}1492 onShortcutsChange={applyShortcuts}1493 onLicenseChange={async () => {1494 1495 1496 1497 1498 1499 await refreshProStatus();1500 void loadEntries();1501 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1502 if ((should as { ok: boolean; data?: boolean }).data === true) {1503 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1504 setView("pro-celebration");1505 window.setTimeout(() => setView("settings"), 2500);1506 }1507 }}1508 onBack={() => {1509 setSettingsExiting(true);1510 window.setTimeout(() => {1511 setSettingsExiting(false);1512 setSettingsInitialSection(null);1513 setUpgradeHighlight(null);1514 handleBack();1515 }, 200);1516 }}1517 onImport={() => setView("import")}1518 onExport={() => setView("export")}1519 onBreachCheckDone={loadEntries}1520 onOpenHealth={openHealth}1521 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("chirp-sync"); }}1522 onOpenFileImport={handleOpenFileImport}1523 onOpenPanic={() => setView("panic")}1524 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1525 onOpenCodexRestore={() => setView("codex-restore")}1526 onCardChange={setSettingsActiveCard}1527 onProGate={(feature) => {1528 setUpgradeHighlight(feature);1529 setSettingsInitialSection("license");1530 }}1531 />1532 </Suspense>1533 </Show>1534 <Show when={view() === "codex-generate"}>1535 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1536 <CodexGenerateView1537 isPro={proStatus()}1538 onBack={() => setView("settings")}1539 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1540 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1541 onVaultLocked={() => handleLock()}1542 />1543 </Suspense>1544 </Show>1545 <Show when={view() === "codex-restore"}>1546 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1547 <CodexRestoreView1548 onBack={() => setView("lock")}1549 onRestoreSuccess={async () => {1550 1551 1552 1553 await handleUnlock();1554 }}1555 />1556 </Suspense>1557 </Show>1558 <Show when={view() === "chirp-sync"}>1559 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1560 <ChirpSyncView1561 isPro={proStatus()}1562 onBack={() => setView("settings")}1563 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1564 />1565 </Suspense>1566 </Show>1567 <Show when={view() === "add"}>1568 <AddView1569 entry={editEntry()}1570 entries={entries()}1571 onBack={handleBack}1572 onSave={handleSave}1573 />1574 </Show>1575 <Show when={view() === "import"}>1576 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1577 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1578 </Suspense>1579 </Show>1580 <Show when={view() === "export"}>1581 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1582 <ExportView onBack={handleExportBack} />1583 </Suspense>1584 </Show>1585 <Show when={view() === "panic"}>1586 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1587 <PanicView1588 onCancel={() => setView("settings")}1589 onOpenExport={() => {1590 setReturnToPanic(true);1591 setView("export");1592 }}1593 onDestroyed={async () => {1594 clearCanarySuppressions();1595 setEntries([]);1596 setSelectedEntry(null);1597 setEditEntry(undefined);1598 setHasVault(false);1599 setOnboarded(false);1600 setProStatus(false);1601 setPendingPasskey(null);1602 setUpgradeHighlight(null);1603 setSettingsInitialSection(null);1604 setPendingProNotice(null);1605 setPendingCelebration(false);1606 setReturnToPanic(false);1607 setContentPhase("idle");1608 setView("lock");1609 }}1610 />1611 </Suspense>1612 </Show>1613 {1614}1615 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1616 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1617 <HealthView1618 entries={entries()}1619 isPro={proStatus()}1620 onFixEntry={handleFixEntry}1621 onBack={() => setView("settings")}1622 onRefresh={loadEntries}1623 hidden={view() !== "health"}1624 />1625 </Suspense>1626 </Show>1627 <Show when={view() === "trash"}>1628 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1629 <TrashView1630 onBack={() => setView("list")}1631 onRestored={() => void loadEntries()}1632 />1633 </Suspense>1634 </Show>1635 <Show when={showNotes()}>1636 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1637 <NotesMode1638 notes={entries().filter((e) => e.type === "note")}1639 initialNoteId={openNoteId()}1640 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1641 onNoteListChanged={() => void loadEntries()}1642 />1643 </div>1644 </Show>1645 <Show when={restoreView() === "file"}>1646 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1647 <ImportView1648 mode="onboarding"1649 replacesExistingVault={true}1650 onBack={() => setRestoreView(null)}1651 onImport={async () => {1652 setRestoreView(null);1653 await handleUnlock();1654 }}1655 />1656 </Suspense>1657 </Show>1658 </section>1659 </Show>1660 </Show>1661 </Show>1662 </Show>1663 <Show when={showShortcutsHelp()}>1664 <ShortcutsHelpOverlay1665 bindings={shortcutBindings()}1666 onClose={() => setShowShortcutsHelp(false)}1667 />1668 </Show>1669 </div>1670 );1671}16721673function ShortcutsHelpOverlay(props: {1674 bindings: Record<ShortcutAction, Keybinding>;1675 onClose: () => void;1676}) {1677 return (1678 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1679 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1680 <div class="peach-shortcuts-help-head">1681 <h3>Keyboard shortcuts</h3>1682 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1683 </div>1684 <div class="peach-shortcuts-help-body">1685 <p class="peach-settings-sub-label">ENTRY VIEW</p>1686 <ul>1687 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1688 <li>1689 <span>{d.label}</span>1690 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1691 </li>1692 ))}1693 </ul>1694 <p class="peach-settings-sub-label">ANYWHERE</p>1695 <ul>1696 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1697 <li>1698 <span>{d.label}</span>1699 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1700 </li>1701 ))}1702 </ul>1703 </div>1704 </div>1705 </div>1706 );1707}