1import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { ACCENT_COLORS } from "../shared/types";3import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";4import { LockView } from "./views/Lock";5import { Onboarding } from "./views/Onboarding";6import { ProCelebration } from "./views/ProCelebration";7import { VaultView, type SortOrder } from "./views/Vault";8import { EntryView } from "./views/Entry";9import { AddView } from "./views/Add";10import { PeachWordmark } from "./components/PeachMark";11import { PasskeyApprovalView } from "./views/PasskeyApproval";12import { PasskeyEntryView } from "./views/PasskeyEntry";13import { extractDomain } from "../shared/domain";14import { popupCopy } from "./copy";15import {16 DEFAULT_BINDINGS,17 formatKeybinding,18 getActiveBindings,19 matchKeybinding,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../shared/shortcuts";24import { generateTOTP } from "../crypto/totp";2526const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));27const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));28const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));29const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));30const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3132type View =33 | "lock"34 | "pro-celebration"35 | "entry"36 | "list"37 | "add"38 | "settings"39 | "import"40 | "export"41 | "health"42 | "trash";43type BootState = "loading" | "ready";4445const canarySuppressMap = new Map<string, boolean>();46let shellRingAnimation: Animation | null = null;4748export function isCanarySuppressed(entryId: string): boolean {49 return canarySuppressMap.get(entryId) ?? false;50}5152export function setCanarySuppressed(entryId: string, suppressed: boolean): void {53 canarySuppressMap.set(entryId, suppressed);54}5556export function clearCanarySuppressions(): void {57 canarySuppressMap.clear();58}5960export function triggerRing() {61 const shell = document.getElementById("hd-shell");62 if (!shell) return;6364 shellRingAnimation?.cancel();65 shell.dataset.glowing = "true";66 const animation = shell.animate(67 [68 { offset: 0, boxShadow: "var(--copy-shadow)" },69 {70 offset: 0.3,71 boxShadow:72 "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)",73 },74 { offset: 1, boxShadow: "var(--copy-shadow)" },75 ],76 {77 duration: 720,78 easing: "cubic-bezier(0.4, 0, 0.2, 1)",79 fill: "none",80 }81 );82 shellRingAnimation = animation;83 const clearGlow = () => {84 if (shellRingAnimation !== animation) return;85 shellRingAnimation = null;86 delete shell.dataset.glowing;87 };88 animation.onfinish = clearGlow;89 animation.oncancel = clearGlow;90}919293export function triggerPipPulse() {94 const pip = document.querySelector(".peach-status-pip, .pip");95 if (pip) {96 pip.classList.remove("pulse");97 void (pip as HTMLElement).offsetWidth;98 pip.classList.add("pulse");99 }100}101102function humanizeError(error: string | undefined): string | undefined {103 if (!error) return error;104 if (error === "STORAGE_QUOTA_EXCEEDED") {105 return "Peach has run out of browser storage. Delete some entries or clear browser storage to continue.";106 }107 return error;108}109110async function sendMessage(msg: any): Promise<any> {111 const MESSAGE_TIMEOUT_MS = 5000;112113 return new Promise((resolve) => {114 let settled = false;115 const timeoutId = window.setTimeout(() => {116 if (settled) return;117 settled = true;118 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });119 }, MESSAGE_TIMEOUT_MS);120121 try {122 chrome.runtime.sendMessage(msg, (response) => {123 if (settled) return;124 settled = true;125 window.clearTimeout(timeoutId);126127 if (chrome.runtime.lastError) {128 resolve({129 ok: false,130 error:131 chrome.runtime.lastError.message ?? "SERVICE_WORKER_UNAVAILABLE",132 });133 return;134 }135136 if (response === undefined) {137 resolve({ ok: false, error: "SERVICE_WORKER_UNAVAILABLE" });138 return;139 }140141 if (response && response.ok === false) {142 response.error = humanizeError(response.error);143 }144 resolve(response);145 });146 } catch (error) {147 if (settled) return;148 settled = true;149 window.clearTimeout(timeoutId);150 resolve({151 ok: false,152 error:153 error instanceof Error ? error.message : "SERVICE_WORKER_UNAVAILABLE",154 });155 }156 });157}158159export { sendMessage };160161const ENTRY_TRANSITION_EXIT_MS = 95;162const ENTRY_TRANSITION_SETTLE_MS = 135;163164export default function App() {165 const [bootState, setBootState] = createSignal<BootState>("loading");166 const [view, setView] = createSignal<View>("lock");167 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(168 "enter"169 );170 const [contentTransitioning, setContentTransitioning] = createSignal(false);171 const [hasVault, setHasVault] = createSignal(false);172 const [onboarded, setOnboarded] = createSignal(true);173 const [entries, setEntries] = createSignal<VaultEntry[]>([]);174 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);175 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);176 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");177 const [theme, setTheme] = createSignal<"dark" | "light">("dark");178 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");179 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");180 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);181 const [lockOnClose, setLockOnClose] = createSignal(true);182 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);183 const [breachMonitoring, setBreachMonitoring] = createSignal(true);184 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);185 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);186 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);187 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);188 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);189 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");190 191 192 193 194 const [proStatus, setProStatus] = createSignal(false);195 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);196 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);197 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");198 const [listSearchSeed, setListSearchSeed] = createSignal("");199 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);200 const [pendingCelebration, setPendingCelebration] = createSignal(false);201 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);202203 let contentPhaseTimer: number | undefined;204205 function applyAccent(name: AccentColorName) {206 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;207 const hex = color.hex;208 document.documentElement.style.setProperty("--peach-accent", hex);209 210 211 document.documentElement.style.setProperty("--peach-glow", hex + "2A");212 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");213 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");214 }215216 createEffect(() => {217 applyAccent(accentColor());218 });219220 createEffect(() => {221 document.documentElement.classList.toggle("light", theme() === "light");222 document.body.classList.toggle("light", theme() === "light");223 });224225 createEffect(() => {226 document.documentElement.setAttribute("data-display-font", displayFont());227 });228229 createEffect(() => {230 document.documentElement.setAttribute("data-text-size", textSize());231 });232233 createEffect(() => {234 if (reduceMotion()) {235 document.documentElement.setAttribute("data-motion", "reduce");236 } else {237 document.documentElement.removeAttribute("data-motion");238 }239 });240241 const selectedIndex = createMemo(() => {242 const currentId = selectedEntry()?.id;243 if (!currentId) return entries().length > 0 ? 0 : -1;244 return entries().findIndex((entry) => entry.id === currentId);245 });246247 async function applyAccentColor(name: AccentColorName) {248 applyAccent(name);249 setAccentColor(name);250 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });251 }252253 async function applyTheme(t: "dark" | "light") {254 setTheme(t);255 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });256 }257258 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {259 setDisplayFont(f);260 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });261 }262263 async function applyTextSize(size: "small" | "medium" | "large") {264 setTextSize(size);265 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });266 }267268 async function applyReduceMotion(enabled: boolean) {269 setReduceMotion(enabled);270 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });271 }272273 274 275 276 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {277 setShortcutBindings(active);278 const overrides: Record<string, Keybinding> = {};279 for (const action of Object.keys(active) as ShortcutAction[]) {280 const a = active[action];281 const d = DEFAULT_BINDINGS[action];282 const same = a.key === d.key283 && Boolean(a.ctrl) === Boolean(d.ctrl)284 && Boolean(a.meta) === Boolean(d.meta)285 && Boolean(a.shift) === Boolean(d.shift)286 && Boolean(a.alt) === Boolean(d.alt);287 if (!same) overrides[action] = a;288 }289 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });290 }291292 async function applyLockOnClose(enabled: boolean) {293 setLockOnClose(enabled);294 await sendMessage({295 type: "UPDATE_SETTINGS",296 payload: { lockOnClose: enabled },297 });298 }299300 async function applyAutoLockMinutes(minutes: number) {301 setAutoLockMinutes(minutes);302 await sendMessage({303 type: "UPDATE_SETTINGS",304 payload: { autoLockMinutes: minutes },305 });306 }307308 async function applyBreachMonitoring(enabled: boolean) {309 setBreachMonitoring(enabled);310 await sendMessage({311 type: "UPDATE_SETTINGS",312 payload: { breachMonitoring: enabled },313 });314 }315316 onMount(async () => {317 318 319 320 321 322 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([323 sendMessage({ type: "IS_UNLOCKED" }),324 sendMessage({ type: "HAS_VAULT" }),325 refreshCurrentTab(),326 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),327 ]);328 const unlocked = Boolean(329 (unlockResponse as { ok: boolean; data?: boolean }).data330 );331 const vaultExists = Boolean(332 (vaultResponse as { ok: boolean; data?: boolean }).data333 );334 const shouldCelebrate = Boolean(335 (celebrationResponse as { ok: boolean; data?: boolean }).data336 );337 setHasVault(vaultExists);338 339 340 341 try {342 const onboardingState = await chrome.storage.local.get("peach_onboarded");343 const seen = onboardingState.peach_onboarded === true;344 if (vaultExists && !seen) {345 await chrome.storage.local.set({ peach_onboarded: true });346 setOnboarded(true);347 } else {348 setOnboarded(seen);349 }350 } catch {351 352 setOnboarded(true);353 }354 await Promise.all([restoreSettings(), refreshProStatus()]);355356 357 if (shouldCelebrate) {358 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });359 }360361 if (unlocked && vaultExists) {362 await openVault(undefined, shouldCelebrate);363 if (shouldCelebrate) {364 setView("pro-celebration");365 window.setTimeout(() => setView("entry"), 2500);366 }367 } else if (shouldCelebrate) {368 setPendingCelebration(true);369 }370371 await refreshPendingPasskey();372373 374 const handleTabActivated = () => { void refreshCurrentTab(); };375 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {376 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();377 };378 try {379 chrome.tabs.onActivated.addListener(handleTabActivated);380 chrome.tabs.onUpdated.addListener(handleTabUpdated);381 onCleanup(() => {382 chrome.tabs.onActivated.removeListener(handleTabActivated);383 chrome.tabs.onUpdated.removeListener(handleTabUpdated);384 });385 } catch {386 387 }388389 setBootState("ready");390 document.getElementById("pre-lock")?.setAttribute("hidden", "true");391392 393 if (chrome.storage.session) {394 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as395 Record<string, { feature?: string } | undefined>;396 if (session.pendingProGate?.feature) {397 await chrome.storage.session.remove("pendingProGate");398 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);399 setSettingsInitialSection("license");400 setView("settings");401 } else if (session.pendingProNotice?.feature) {402 await chrome.storage.session.remove("pendingProNotice");403 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });404 }405 }406407 408 409 410 411 void Promise.all([412 import("./views/Settings").catch(() => {}),413 import("./views/Import").catch(() => {}),414 import("./views/Export").catch(() => {}),415 import("./views/Health").catch(() => {}),416 import("./views/TrashView").catch(() => {}),417 ]);418419 420 421 422 let popupPort: chrome.runtime.Port | null = null;423 try {424 popupPort = chrome.runtime.connect({ name: "popup" });425 } catch { }426 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });427428 429 430 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {431 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();432 if (msg?.type === "PRO_GATE_TRIGGERED") {433 434 435 setUpgradeHighlight(msg.payload?.feature ?? null);436 setSettingsInitialSection("license");437 setView("settings");438 }439 };440 chrome.runtime.onMessage.addListener(handleRuntimeMessage);441 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));442443 444 445 446 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {447 copy_password: () => {448 const entry = selectedEntry();449 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });450 },451 copy_username: () => {452 const entry = selectedEntry();453 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });454 },455 copy_totp: () => {456 const entry = selectedEntry();457 if (!entry?.totp) return;458 try {459 const { code } = generateTOTP(entry.totp);460 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });461 } catch { }462 },463 new_entry: () => handleAdd(),464 edit_entry: () => {465 const entry = selectedEntry();466 if (!entry || entry.type === "passkey") return;467 setEditEntry(entry);468 setView("add");469 },470 delete_entry: () => {471 if (selectedEntry()) void handleDelete();472 },473 lock_vault: () => { void handleHeaderLock(); },474 search: () => openList(),475 nav_prev: () => navigate(-1),476 nav_next: () => navigate(1),477 jump_vault: () => setView("entry"),478 jump_settings: () => setView("settings"),479 show_help: () => setShowShortcutsHelp(true),480 };481482 483 484 485 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([486 "copy_password", "copy_username", "copy_totp",487 "edit_entry", "delete_entry", "nav_prev", "nav_next",488 ]);489490 const handleGlobalKeydown = (e: KeyboardEvent) => {491 const v = view();492 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"493 || (e.target as HTMLElement)?.tagName === "TEXTAREA"494 || (e.target as HTMLElement)?.isContentEditable;495 const isPrintableSearchKey =496 e.key.length === 1 &&497 !e.ctrlKey &&498 !e.metaKey &&499 !e.altKey;500501 502 if (v === "pro-celebration") return;503504 505 506 if (showShortcutsHelp()) {507 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }508 return;509 }510511 512 if (e.key === "Escape") {513 if (v === "list") { setView("entry"); return; }514 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }515 }516517 518 519 520 const bindings = shortcutBindings();521 for (const action of Object.keys(bindings) as ShortcutAction[]) {522 const binding = bindings[action];523 if (!matchKeybinding(e, binding)) continue;524 525 526 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;527 if (!hasMod && binding.key.length === 1 && inInput) return;528 if (ENTRY_SCOPED.has(action) && v !== "entry") return;529 e.preventDefault();530 SHORTCUT_HANDLERS[action]?.();531 return;532 }533534 535 536 537 538 if (v === "entry" && !inInput && isPrintableSearchKey) {539 e.preventDefault();540 primeListSearch(e.key);541 return;542 }543 };544 window.addEventListener("keydown", handleGlobalKeydown);545 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));546547 const handleStorageChanged = (548 changes: Record<string, chrome.storage.StorageChange>,549 areaName: string550 ) => {551 if (areaName !== "session") return;552 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {553 return;554 }555 void refreshPendingPasskey();556 };557558 chrome.storage.onChanged.addListener(handleStorageChanged);559 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));560 });561562 async function refreshCurrentTab() {563 try {564 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });565 const url = tab?.url ?? tab?.pendingUrl ?? "";566 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");567 } catch {568 setCurrentTabDomain("");569 }570 }571572 async function refreshPendingPasskey() {573 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });574 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);575 }576577 async function refreshProStatus() {578 const res = await sendMessage({ type: "GET_LICENSE" });579 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;580 setProStatus(Boolean(data?.valid && data?.plan === "pro"));581 }582583 async function restoreSettings() {584 const res = await sendMessage({ type: "GET_SETTINGS" });585 const s = (res as { ok: boolean; data?: any }).data ?? {};586 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {587 setAccentColor(s.accentColor as AccentColorName);588 }589 setTheme(s.theme === "light" ? "light" : "dark");590 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {591 setDisplayFont(s.displayFont);592 } else {593 setDisplayFont("cormorant-italic");594 }595 if (s.textSize === "small" || s.textSize === "large") {596 setTextSize(s.textSize);597 } else {598 setTextSize("medium");599 }600 setReduceMotion(s.reduceMotion === true);601 setLockOnClose(s.lockOnClose !== false);602 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);603 setBreachMonitoring(s.breachMonitoring !== false);604 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);605 setShortcutBindings(getActiveBindings(s.shortcuts));606 }607608 async function applyTrashRetention(days: number) {609 setTrashRetentionDays(days);610 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });611 }612613 function primeListSearch(seed: string) {614 setListSearchSeed(seed);615 setListSearchSeedNonce((value) => value + 1);616 openList();617 }618619 620 621 622 async function loadEntries(): Promise<VaultEntry[] | null> {623 const response = await sendMessage({ type: "GET_ENTRIES" });624 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };625626 627 628 629 630 if (!res.ok) {631 if (632 res.error === "vault locked" ||633 res.error === "SERVICE_WORKER_UNAVAILABLE" ||634 (typeof res.error === "string" && res.error.includes("locked"))635 ) {636 handleLock();637 return null; 638 }639 return [];640 }641642 const loadedEntries = res.data ?? [];643 setEntries(loadedEntries);644645 const currentId = selectedEntry()?.id;646 if (currentId) {647 setSelectedEntry(648 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null649 );650 }651652 return loadedEntries;653 }654655 async function openVault(preferredEntryId?: string, skipViewSet = false) {656 657 const retDays = trashRetentionDays();658 if (retDays > 0) {659 void sendMessage({660 type: "PURGE_EXPIRED_TRASH",661 payload: { retentionMs: retDays * 86_400_000 },662 });663 }664665 const loadedEntries = await loadEntries();666667 668 669 if (loadedEntries === null) return;670671 672 let nextSelectedEntry: VaultEntry | null = null;673 if (preferredEntryId) {674 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;675 }676 if (!nextSelectedEntry) {677 const tabDomain = currentTabDomain();678 if (tabDomain) {679 nextSelectedEntry = loadedEntries.find((e) => {680 if (e.type === "login") {681 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;682 for (const extra of e.additionalUrls ?? []) {683 if ((extractDomain(extra) ?? "") === tabDomain) return true;684 }685 return false;686 }687 if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;688 return false;689 }) ?? null;690 }691 }692 nextSelectedEntry ??= loadedEntries[0] ?? null;693694 setSelectedEntry(nextSelectedEntry);695 setEditEntry(undefined);696 setContentTransitioning(false);697 setContentPhase("enter");698 queueContentIdle();699 if (!skipViewSet) setView("entry");700 }701702 async function handleUnlock() {703 setHasVault(true);704 triggerPipPulse();705 await restoreSettings();706 const celebrating = pendingCelebration();707 if (celebrating) {708 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });709 setPendingCelebration(false);710 }711 await openVault(undefined, celebrating);712 if (celebrating) {713 setView("pro-celebration");714 window.setTimeout(() => setView("entry"), 2500);715 }716 }717718 function handleSelectEntry(entry: VaultEntry) {719 setSelectedEntry(entry);720 setContentTransitioning(false);721 setContentPhase("enter");722 queueContentIdle();723 setView("entry");724 }725726 function handleAdd() {727 setEditEntry(undefined);728 setView("add");729 }730731 async function handleDelete() {732 const entry = selectedEntry();733 if (!entry) return;734 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });735 const loadedEntries = await loadEntries() ?? [];736 setSelectedEntry(loadedEntries[0] ?? null);737 setView("entry");738 }739740 async function handleDeleteMany(ids: string[]) {741 for (const id of ids) {742 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });743 }744 const loadedEntries = await loadEntries() ?? [];745 746 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);747 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);748 }749750 async function handleSave(savedEntry?: VaultEntry) {751 await openVault(savedEntry?.id);752 }753754 function handleBack() {755 setEditEntry(undefined);756 setView("entry");757 }758759 function handleLock() {760 clearCanarySuppressions();761 setEntries([]);762 setSelectedEntry(null);763 setEditEntry(undefined);764 setContentPhase("idle");765 setView("lock");766 }767768 function handleHeaderLock() {769 770 771 772 handleLock();773 void sendMessage({ type: "LOCK" });774 }775776 function navigate(dir: number) {777 const allEntries = entries();778 if (allEntries.length === 0) return;779 const currentIndex = selectedIndex();780 const baseIndex = currentIndex >= 0 ? currentIndex : 0;781 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;782 setSelectedEntry(allEntries[nextIndex]);783 setContentPhase("enter");784 queueContentIdle();785 }786787 function openList() {788 setView("list");789 }790791 function openHealth() {792 setEditEntry(undefined);793 setView("health");794 }795796 function handleOpenFileImport() {797 setRestoreView("file");798 }799800 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;801802 function handleFixEntry(entry: VaultEntry) {803 if (entry.type === "passkey") {804 setSelectedEntry(entry);805 setView("entry");806 return;807 }808 setEditEntry(entry);809 setView("add");810 }811812 const isLockedView = () => view() === "lock";813 const selectedPasskeyEntry = createMemo(() =>814 selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null815 );816817 async function handleApprovePendingPasskey() {818 const pending = pendingPasskey();819 if (!pending) return;820 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });821 setPendingPasskey(null);822 }823824 async function handleDenyPendingPasskey() {825 const pending = pendingPasskey();826 if (!pending) return;827 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });828 setPendingPasskey(null);829 }830831 function queueContentIdle(after?: () => void) {832 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);833 contentPhaseTimer = window.setTimeout(() => {834 setContentPhase("idle");835 after?.();836 }, ENTRY_TRANSITION_SETTLE_MS);837 }838839 return (840 <div class="popup-root peach-theme">841 <Show when={bootState() === "ready"}>842 <Show843 when={!pendingPasskey()}844 fallback={845 <PasskeyApprovalView846 type={pendingPasskey()!.type}847 rpName={pendingPasskey()!.rpName}848 rpId={pendingPasskey()!.rpId}849 userName={pendingPasskey()!.userName}850 userDisplayName={pendingPasskey()!.userDisplayName}851 onApprove={handleApprovePendingPasskey}852 onDeny={handleDenyPendingPasskey}853 />854 }855 >856 <Show857 when={!isLockedView()}858 fallback={859 <Show860 when={onboarded() || hasVault()}861 fallback={862 <Onboarding863 onComplete={() => setOnboarded(true)}864 />865 }866 >867 <LockView868 hasVault={hasVault()}869 ready={bootState() === "ready"}870 isPro={proStatus()}871 onUnlock={handleUnlock}872 />873 </Show>874 }875 >876 <Show877 when={view() !== "pro-celebration"}878 fallback={<ProCelebration />}879 >880 <section881 id="hd-shell"882 class="peach-shell peach-theme"883 data-surface="extension"884 >885 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>886 <PeachWordmark unlocked />887 <div class="peach-chrome-actions">888 <button889 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"890 onClick={() => setView("settings")}891 aria-label="Settings"892 >893 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">894 <path895 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"896 stroke="currentColor"897 stroke-width="0.9"898 />899 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />900 </svg>901 </button>902 <button903 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"904 onClick={handleAdd}905 aria-label="New entry"906 >907 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">908 <path d="M5.5 1v9M1 5.5h9" />909 </svg>910 </button>911 <button912 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"913 onClick={() => void handleHeaderLock()}914 aria-label="Lock vault"915 >916 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">917 <path918 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"919 stroke="currentColor"920 stroke-width="1"921 stroke-linecap="round"922 />923 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />924 <circle cx="7" cy="10.25" r="1" fill="currentColor" />925 </svg>926 </button>927 </div>928 </header>929930 <Show when={pendingProNotice() !== null}>931 <div class="peach-pro-notice-banner">932 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>933 <button934 class="peach-pro-notice-link"935 onClick={() => {936 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);937 setSettingsInitialSection("license");938 setView("settings");939 setPendingProNotice(null);940 }}941 >942 Learn more →943 </button>944 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>945 </div>946 </Show>947948 <div class="peach-shell-body">949 <div class="peach-shell-veil"></div>950 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>951 <Show952 when={selectedPasskeyEntry()}953 fallback={954 <EntryView955 entry={selectedEntry()}956 onDelete={handleDelete}957 onFieldSaved={() => void loadEntries()}958 onEscape={() => { setView("list"); }}959 />960 }961 >962 <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />963 </Show>964 </div>965966 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>967 <div class="peach-search-bar">968 <span class="peach-search-icon">969 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">970 <circle cx="5.5" cy="5.5" r="3.75" />971 <path d="M8.75 8.75L11.5 11.5" />972 </svg>973 </span>974 <input975 class="peach-search-input"976 placeholder="search vault..."977 autocomplete="off"978 aria-label="Search vault entries"979 onClick={openList}980 onFocus={openList}981 readOnly982 />983 <div class="peach-search-meta">984 <span class="peach-search-counter">985 {entries().length > 0986 ? `${selectedIndex() + 1} / ${entries().length}`987 : "0 entries"}988 </span>989 <span class="peach-divider"></span>990 <button991 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"992 onClick={() => navigate(-1)}993 aria-label="Previous entry"994 disabled={entries().length === 0}995 >996 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">997 <path d="M5 1L1 5.5L5 10" />998 </svg>999 </button>1000 <button1001 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1002 onClick={() => navigate(1)}1003 aria-label="Next entry"1004 disabled={entries().length === 0}1005 >1006 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1007 <path d="M1 1L5 5.5L1 10" />1008 </svg>1009 </button>1010 </div>1011 </div>1012 </section>1013 </div>10141015 <Show when={view() === "list"}>1016 <VaultView1017 entries={entries()}1018 selectedEntryId={selectedEntry()?.id}1019 currentTabDomain={currentTabDomain()}1020 sortOrder={sortOrder()}1021 searchSeed={listSearchSeed()}1022 searchSeedNonce={listSearchSeedNonce()}1023 onSelectEntry={handleSelectEntry}1024 onSortOrderChange={setSortOrder}1025 onAdd={handleAdd}1026 onClose={() => setView("entry")}1027 onOpenTrash={() => setView("trash")}1028 onDeleteMany={handleDeleteMany}1029 />1030 </Show>1031 <Show when={view() === "settings"}>1032 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1033 <SettingsView1034 entriesCount={entries().length}1035 breachMonitoring={breachMonitoring()}1036 lockOnClose={lockOnClose()}1037 autoLockMinutes={autoLockMinutes()}1038 trashRetentionDays={trashRetentionDays()}1039 theme={theme()}1040 accentColor={accentColor()}1041 displayFont={displayFont()}1042 textSize={textSize()}1043 reduceMotion={reduceMotion()}1044 isPro={proStatus()}1045 initialSection={settingsInitialSection()}1046 upgradeHighlight={upgradeHighlight()}1047 onBreachMonitoringChange={applyBreachMonitoring}1048 onLockOnCloseChange={applyLockOnClose}1049 onAutoLockMinutesChange={applyAutoLockMinutes}1050 onTrashRetentionChange={applyTrashRetention}1051 onThemeChange={applyTheme}1052 onAccentChange={applyAccentColor}1053 onDisplayFontChange={applyDisplayFont}1054 onTextSizeChange={applyTextSize}1055 onReduceMotionChange={applyReduceMotion}1056 shortcuts={shortcutBindings()}1057 onShortcutsChange={applyShortcuts}1058 onLicenseChange={refreshProStatus}1059 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1060 onImport={() => setView("import")}1061 onExport={() => setView("export")}1062 onBreachCheckDone={loadEntries}1063 onOpenHealth={openHealth}1064 onOpenFileImport={handleOpenFileImport}1065 />1066 </Suspense>1067 </Show>1068 <Show when={view() === "add"}>1069 <AddView1070 entry={editEntry()}1071 entries={entries()}1072 onBack={handleBack}1073 onSave={handleSave}1074 />1075 </Show>1076 <Show when={view() === "import"}>1077 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1078 <ImportView onBack={handleBack} onImport={handleSave} />1079 </Suspense>1080 </Show>1081 <Show when={view() === "export"}>1082 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1083 <ExportView onBack={handleBack} />1084 </Suspense>1085 </Show>1086 <Show when={view() === "health"}>1087 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1088 <HealthView1089 entries={entries()}1090 isPro={proStatus()}1091 onFixEntry={handleFixEntry}1092 onBack={() => setView("settings")}1093 onRefresh={loadEntries}1094 />1095 </Suspense>1096 </Show>1097 <Show when={view() === "trash"}>1098 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1099 <TrashView1100 onBack={() => setView("list")}1101 onRestored={() => void loadEntries()}1102 />1103 </Suspense>1104 </Show>1105 <Show when={restoreView() === "file"}>1106 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1107 <ImportView1108 mode="onboarding"1109 replacesExistingVault={true}1110 onBack={() => setRestoreView(null)}1111 onImport={async () => {1112 setRestoreView(null);1113 await handleUnlock();1114 }}1115 />1116 </Suspense>1117 </Show>1118 </section>1119 </Show>1120 </Show>1121 </Show>1122 </Show>1123 <Show when={showShortcutsHelp()}>1124 <ShortcutsHelpOverlay1125 bindings={shortcutBindings()}1126 onClose={() => setShowShortcutsHelp(false)}1127 />1128 </Show>1129 </div>1130 );1131}11321133function ShortcutsHelpOverlay(props: {1134 bindings: Record<ShortcutAction, Keybinding>;1135 onClose: () => void;1136}) {1137 return (1138 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1139 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1140 <div class="peach-shortcuts-help-head">1141 <h3>Keyboard shortcuts</h3>1142 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1143 </div>1144 <div class="peach-shortcuts-help-body">1145 <p class="peach-settings-sub-label">ENTRY VIEW</p>1146 <ul>1147 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1148 <li>1149 <span>{d.label}</span>1150 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1151 </li>1152 ))}1153 </ul>1154 <p class="peach-settings-sub-label">ANYWHERE</p>1155 <ul>1156 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1157 <li>1158 <span>{d.label}</span>1159 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1160 </li>1161 ))}1162 </ul>1163 </div>1164 </div>1165 </div>1166 );1167}