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 { ProCelebration } from "./views/ProCelebration";6import { VaultView, type SortOrder } from "./views/Vault";7import { EntryView } from "./views/Entry";8import { AddView } from "./views/Add";9import { PeachWordmark } from "./components/PeachMark";10import { PasskeyApprovalView } from "./views/PasskeyApproval";11import { PasskeyEntryView } from "./views/PasskeyEntry";12import { extractDomain } from "../shared/domain";13import { popupCopy } from "./copy";14import {15 DEFAULT_BINDINGS,16 formatKeybinding,17 getActiveBindings,18 matchKeybinding,19 SHORTCUT_DESCRIPTORS,20 type Keybinding,21 type ShortcutAction,22} from "../shared/shortcuts";23import { generateTOTP } from "../crypto/totp";2425const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));26const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));27const CJKImport = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));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 [entries, setEntries] = createSignal<VaultEntry[]>([]);173 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);174 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);175 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");176 const [theme, setTheme] = createSignal<"dark" | "light">("dark");177 const [displayFont, setDisplayFont] = createSignal<"cormorant-italic" | "cormorant" | "inter" | "system">("cormorant-italic");178 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");179 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);180 const [lockOnClose, setLockOnClose] = createSignal(true);181 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);182 const [breachMonitoring, setBreachMonitoring] = createSignal(true);183 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);184 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);185 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);186 const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);187 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);188 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");189 190 191 192 193 const [proStatus, setProStatus] = createSignal(false);194 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);195 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);196 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");197 const [listSearchSeed, setListSearchSeed] = createSignal("");198 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);199 const [pendingCelebration, setPendingCelebration] = createSignal(false);200 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);201202 let contentPhaseTimer: number | undefined;203204 function applyAccent(name: AccentColorName) {205 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;206 const hex = color.hex;207 document.documentElement.style.setProperty("--peach-accent", hex);208 209 210 document.documentElement.style.setProperty("--peach-glow", hex + "2A");211 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");212 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");213 }214215 createEffect(() => {216 applyAccent(accentColor());217 });218219 createEffect(() => {220 document.documentElement.classList.toggle("light", theme() === "light");221 document.body.classList.toggle("light", theme() === "light");222 });223224 createEffect(() => {225 document.documentElement.setAttribute("data-display-font", displayFont());226 });227228 createEffect(() => {229 document.documentElement.setAttribute("data-text-size", textSize());230 });231232 createEffect(() => {233 if (reduceMotion()) {234 document.documentElement.setAttribute("data-motion", "reduce");235 } else {236 document.documentElement.removeAttribute("data-motion");237 }238 });239240 const selectedIndex = createMemo(() => {241 const currentId = selectedEntry()?.id;242 if (!currentId) return entries().length > 0 ? 0 : -1;243 return entries().findIndex((entry) => entry.id === currentId);244 });245246 async function applyAccentColor(name: AccentColorName) {247 applyAccent(name);248 setAccentColor(name);249 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });250 }251252 async function applyTheme(t: "dark" | "light") {253 setTheme(t);254 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });255 }256257 async function applyDisplayFont(f: "cormorant-italic" | "cormorant" | "inter" | "system") {258 setDisplayFont(f);259 await sendMessage({ type: "UPDATE_SETTINGS", payload: { displayFont: f } });260 }261262 async function applyTextSize(size: "small" | "medium" | "large") {263 setTextSize(size);264 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });265 }266267 async function applyReduceMotion(enabled: boolean) {268 setReduceMotion(enabled);269 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });270 }271272 273 274 275 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {276 setShortcutBindings(active);277 const overrides: Record<string, Keybinding> = {};278 for (const action of Object.keys(active) as ShortcutAction[]) {279 const a = active[action];280 const d = DEFAULT_BINDINGS[action];281 const same = a.key === d.key282 && Boolean(a.ctrl) === Boolean(d.ctrl)283 && Boolean(a.meta) === Boolean(d.meta)284 && Boolean(a.shift) === Boolean(d.shift)285 && Boolean(a.alt) === Boolean(d.alt);286 if (!same) overrides[action] = a;287 }288 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });289 }290291 async function applyLockOnClose(enabled: boolean) {292 setLockOnClose(enabled);293 await sendMessage({294 type: "UPDATE_SETTINGS",295 payload: { lockOnClose: enabled },296 });297 }298299 async function applyAutoLockMinutes(minutes: number) {300 setAutoLockMinutes(minutes);301 await sendMessage({302 type: "UPDATE_SETTINGS",303 payload: { autoLockMinutes: minutes },304 });305 }306307 async function applyBreachMonitoring(enabled: boolean) {308 setBreachMonitoring(enabled);309 await sendMessage({310 type: "UPDATE_SETTINGS",311 payload: { breachMonitoring: enabled },312 });313 }314315 onMount(async () => {316 317 318 319 320 321 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([322 sendMessage({ type: "IS_UNLOCKED" }),323 sendMessage({ type: "HAS_VAULT" }),324 refreshCurrentTab(),325 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),326 ]);327 const unlocked = Boolean(328 (unlockResponse as { ok: boolean; data?: boolean }).data329 );330 const vaultExists = Boolean(331 (vaultResponse as { ok: boolean; data?: boolean }).data332 );333 const shouldCelebrate = Boolean(334 (celebrationResponse as { ok: boolean; data?: boolean }).data335 );336 setHasVault(vaultExists);337 await Promise.all([restoreSettings(), refreshProStatus()]);338339 340 if (shouldCelebrate) {341 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });342 }343344 if (unlocked && vaultExists) {345 await openVault(undefined, shouldCelebrate);346 if (shouldCelebrate) {347 setView("pro-celebration");348 window.setTimeout(() => setView("entry"), 2500);349 }350 } else if (shouldCelebrate) {351 setPendingCelebration(true);352 }353354 await refreshPendingPasskey();355356 357 const handleTabActivated = () => { void refreshCurrentTab(); };358 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {359 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();360 };361 try {362 chrome.tabs.onActivated.addListener(handleTabActivated);363 chrome.tabs.onUpdated.addListener(handleTabUpdated);364 onCleanup(() => {365 chrome.tabs.onActivated.removeListener(handleTabActivated);366 chrome.tabs.onUpdated.removeListener(handleTabUpdated);367 });368 } catch {369 370 }371372 setBootState("ready");373 document.getElementById("pre-lock")?.setAttribute("hidden", "true");374375 376 if (chrome.storage.session) {377 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as378 Record<string, { feature?: string } | undefined>;379 if (session.pendingProGate?.feature) {380 await chrome.storage.session.remove("pendingProGate");381 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);382 setSettingsInitialSection("license");383 setView("settings");384 } else if (session.pendingProNotice?.feature) {385 await chrome.storage.session.remove("pendingProNotice");386 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });387 }388 }389390 391 392 393 394 void Promise.all([395 import("./views/Settings").catch(() => {}),396 import("./views/Import").catch(() => {}),397 import("./views/Export").catch(() => {}),398 import("./views/Health").catch(() => {}),399 import("./views/TrashView").catch(() => {}),400 import("./views/CJKImport").catch(() => {}),401 ]);402403 404 405 406 let popupPort: chrome.runtime.Port | null = null;407 try {408 popupPort = chrome.runtime.connect({ name: "popup" });409 } catch { }410 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });411412 413 414 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {415 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();416 if (msg?.type === "PRO_GATE_TRIGGERED") {417 418 419 setUpgradeHighlight(msg.payload?.feature ?? null);420 setSettingsInitialSection("license");421 setView("settings");422 }423 };424 chrome.runtime.onMessage.addListener(handleRuntimeMessage);425 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));426427 428 429 430 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {431 copy_password: () => {432 const entry = selectedEntry();433 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });434 },435 copy_username: () => {436 const entry = selectedEntry();437 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });438 },439 copy_totp: () => {440 const entry = selectedEntry();441 if (!entry?.totp) return;442 try {443 const { code } = generateTOTP(entry.totp);444 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });445 } catch { }446 },447 new_entry: () => handleAdd(),448 edit_entry: () => {449 const entry = selectedEntry();450 if (!entry || entry.type === "passkey") return;451 setEditEntry(entry);452 setView("add");453 },454 delete_entry: () => {455 if (selectedEntry()) void handleDelete();456 },457 lock_vault: () => { void handleHeaderLock(); },458 search: () => openList(),459 nav_prev: () => navigate(-1),460 nav_next: () => navigate(1),461 jump_vault: () => setView("entry"),462 jump_settings: () => setView("settings"),463 show_help: () => setShowShortcutsHelp(true),464 };465466 467 468 469 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([470 "copy_password", "copy_username", "copy_totp",471 "edit_entry", "delete_entry", "nav_prev", "nav_next",472 ]);473474 const handleGlobalKeydown = (e: KeyboardEvent) => {475 const v = view();476 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"477 || (e.target as HTMLElement)?.tagName === "TEXTAREA"478 || (e.target as HTMLElement)?.isContentEditable;479 const isPrintableSearchKey =480 e.key.length === 1 &&481 !e.ctrlKey &&482 !e.metaKey &&483 !e.altKey;484485 486 if (v === "pro-celebration") return;487488 489 490 if (showShortcutsHelp()) {491 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }492 return;493 }494495 496 if (e.key === "Escape") {497 if (v === "list") { setView("entry"); return; }498 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }499 }500501 502 503 504 const bindings = shortcutBindings();505 for (const action of Object.keys(bindings) as ShortcutAction[]) {506 const binding = bindings[action];507 if (!matchKeybinding(e, binding)) continue;508 509 510 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;511 if (!hasMod && binding.key.length === 1 && inInput) return;512 if (ENTRY_SCOPED.has(action) && v !== "entry") return;513 e.preventDefault();514 SHORTCUT_HANDLERS[action]?.();515 return;516 }517518 519 520 521 522 if (v === "entry" && !inInput && isPrintableSearchKey) {523 e.preventDefault();524 primeListSearch(e.key);525 return;526 }527 };528 window.addEventListener("keydown", handleGlobalKeydown);529 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));530531 const handleStorageChanged = (532 changes: Record<string, chrome.storage.StorageChange>,533 areaName: string534 ) => {535 if (areaName !== "session") return;536 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {537 return;538 }539 void refreshPendingPasskey();540 };541542 chrome.storage.onChanged.addListener(handleStorageChanged);543 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));544 });545546 async function refreshCurrentTab() {547 try {548 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });549 const url = tab?.url ?? tab?.pendingUrl ?? "";550 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");551 } catch {552 setCurrentTabDomain("");553 }554 }555556 async function refreshPendingPasskey() {557 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });558 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);559 }560561 async function refreshProStatus() {562 const res = await sendMessage({ type: "GET_LICENSE" });563 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;564 setProStatus(Boolean(data?.valid && data?.plan === "pro"));565 }566567 async function restoreSettings() {568 const res = await sendMessage({ type: "GET_SETTINGS" });569 const s = (res as { ok: boolean; data?: any }).data ?? {};570 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {571 setAccentColor(s.accentColor as AccentColorName);572 }573 setTheme(s.theme === "light" ? "light" : "dark");574 if (s.displayFont === "cormorant" || s.displayFont === "inter" || s.displayFont === "system") {575 setDisplayFont(s.displayFont);576 } else {577 setDisplayFont("cormorant-italic");578 }579 if (s.textSize === "small" || s.textSize === "large") {580 setTextSize(s.textSize);581 } else {582 setTextSize("medium");583 }584 setReduceMotion(s.reduceMotion === true);585 setLockOnClose(s.lockOnClose !== false);586 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);587 setBreachMonitoring(s.breachMonitoring !== false);588 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);589 setShortcutBindings(getActiveBindings(s.shortcuts));590 }591592 async function applyTrashRetention(days: number) {593 setTrashRetentionDays(days);594 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });595 }596597 function primeListSearch(seed: string) {598 setListSearchSeed(seed);599 setListSearchSeedNonce((value) => value + 1);600 openList();601 }602603 604 605 606 async function loadEntries(): Promise<VaultEntry[] | null> {607 const response = await sendMessage({ type: "GET_ENTRIES" });608 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };609610 611 612 613 614 if (!res.ok) {615 if (616 res.error === "vault locked" ||617 res.error === "SERVICE_WORKER_UNAVAILABLE" ||618 (typeof res.error === "string" && res.error.includes("locked"))619 ) {620 handleLock();621 return null; 622 }623 return [];624 }625626 const loadedEntries = res.data ?? [];627 setEntries(loadedEntries);628629 const currentId = selectedEntry()?.id;630 if (currentId) {631 setSelectedEntry(632 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null633 );634 }635636 return loadedEntries;637 }638639 async function openVault(preferredEntryId?: string, skipViewSet = false) {640 641 const retDays = trashRetentionDays();642 if (retDays > 0) {643 void sendMessage({644 type: "PURGE_EXPIRED_TRASH",645 payload: { retentionMs: retDays * 86_400_000 },646 });647 }648649 const loadedEntries = await loadEntries();650651 652 653 if (loadedEntries === null) return;654655 656 let nextSelectedEntry: VaultEntry | null = null;657 if (preferredEntryId) {658 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;659 }660 if (!nextSelectedEntry) {661 const tabDomain = currentTabDomain();662 if (tabDomain) {663 nextSelectedEntry = loadedEntries.find((e) => {664 if (e.type === "login") {665 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;666 for (const extra of e.additionalUrls ?? []) {667 if ((extractDomain(extra) ?? "") === tabDomain) return true;668 }669 return false;670 }671 if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;672 return false;673 }) ?? null;674 }675 }676 nextSelectedEntry ??= loadedEntries[0] ?? null;677678 setSelectedEntry(nextSelectedEntry);679 setEditEntry(undefined);680 setContentTransitioning(false);681 setContentPhase("enter");682 queueContentIdle();683 if (!skipViewSet) setView("entry");684 }685686 async function handleUnlock() {687 setHasVault(true);688 triggerPipPulse();689 await restoreSettings();690 const celebrating = pendingCelebration();691 if (celebrating) {692 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });693 setPendingCelebration(false);694 }695 await openVault(undefined, celebrating);696 if (celebrating) {697 setView("pro-celebration");698 window.setTimeout(() => setView("entry"), 2500);699 }700 }701702 function handleSelectEntry(entry: VaultEntry) {703 setSelectedEntry(entry);704 setContentTransitioning(false);705 setContentPhase("enter");706 queueContentIdle();707 setView("entry");708 }709710 function handleAdd() {711 setEditEntry(undefined);712 setView("add");713 }714715 async function handleDelete() {716 const entry = selectedEntry();717 if (!entry) return;718 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });719 const loadedEntries = await loadEntries() ?? [];720 setSelectedEntry(loadedEntries[0] ?? null);721 setView("entry");722 }723724 async function handleDeleteMany(ids: string[]) {725 for (const id of ids) {726 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });727 }728 const loadedEntries = await loadEntries() ?? [];729 730 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);731 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);732 }733734 async function handleSave(savedEntry?: VaultEntry) {735 await openVault(savedEntry?.id);736 }737738 function handleBack() {739 setEditEntry(undefined);740 setView("entry");741 }742743 function handleLock() {744 clearCanarySuppressions();745 setEntries([]);746 setSelectedEntry(null);747 setEditEntry(undefined);748 setContentPhase("idle");749 setView("lock");750 }751752 function handleHeaderLock() {753 754 755 756 handleLock();757 void sendMessage({ type: "LOCK" });758 }759760 function navigate(dir: number) {761 const allEntries = entries();762 if (allEntries.length === 0) return;763 const currentIndex = selectedIndex();764 const baseIndex = currentIndex >= 0 ? currentIndex : 0;765 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;766 setSelectedEntry(allEntries[nextIndex]);767 setContentPhase("enter");768 queueContentIdle();769 }770771 function openList() {772 setView("list");773 }774775 function openHealth() {776 setEditEntry(undefined);777 setView("health");778 }779780 function handleOpenCJKImport() {781 setRestoreView("cjk");782 }783784 function handleOpenFileImport() {785 setRestoreView("file");786 }787788 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;789790 function handleFixEntry(entry: VaultEntry) {791 if (entry.type === "passkey") {792 setSelectedEntry(entry);793 setView("entry");794 return;795 }796 setEditEntry(entry);797 setView("add");798 }799800 const isLockedView = () => view() === "lock";801 const selectedPasskeyEntry = createMemo(() =>802 selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null803 );804805 async function handleApprovePendingPasskey() {806 const pending = pendingPasskey();807 if (!pending) return;808 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });809 setPendingPasskey(null);810 }811812 async function handleDenyPendingPasskey() {813 const pending = pendingPasskey();814 if (!pending) return;815 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });816 setPendingPasskey(null);817 }818819 function queueContentIdle(after?: () => void) {820 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);821 contentPhaseTimer = window.setTimeout(() => {822 setContentPhase("idle");823 after?.();824 }, ENTRY_TRANSITION_SETTLE_MS);825 }826827 return (828 <div class="popup-root peach-theme">829 <Show when={bootState() === "ready"}>830 <Show831 when={!pendingPasskey()}832 fallback={833 <PasskeyApprovalView834 type={pendingPasskey()!.type}835 rpName={pendingPasskey()!.rpName}836 rpId={pendingPasskey()!.rpId}837 userName={pendingPasskey()!.userName}838 userDisplayName={pendingPasskey()!.userDisplayName}839 onApprove={handleApprovePendingPasskey}840 onDeny={handleDenyPendingPasskey}841 />842 }843 >844 <Show845 when={!isLockedView()}846 fallback={847 <LockView848 hasVault={hasVault()}849 ready={bootState() === "ready"}850 isPro={proStatus()}851 onUnlock={handleUnlock}852 />853 }854 >855 <Show856 when={view() !== "pro-celebration"}857 fallback={<ProCelebration />}858 >859 <section860 id="hd-shell"861 class="peach-shell peach-theme"862 data-surface="extension"863 >864 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>865 <PeachWordmark unlocked />866 <div class="peach-chrome-actions">867 <button868 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"869 onClick={() => setView("settings")}870 aria-label="Settings"871 >872 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">873 <path874 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"875 stroke="currentColor"876 stroke-width="0.9"877 />878 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />879 </svg>880 </button>881 <button882 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"883 onClick={handleAdd}884 aria-label="New entry"885 >886 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">887 <path d="M5.5 1v9M1 5.5h9" />888 </svg>889 </button>890 <button891 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"892 onClick={() => void handleHeaderLock()}893 aria-label="Lock vault"894 >895 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">896 <path897 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"898 stroke="currentColor"899 stroke-width="1"900 stroke-linecap="round"901 />902 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />903 <circle cx="7" cy="10.25" r="1" fill="currentColor" />904 </svg>905 </button>906 </div>907 </header>908909 <Show when={pendingProNotice() !== null}>910 <div class="peach-pro-notice-banner">911 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>912 <button913 class="peach-pro-notice-link"914 onClick={() => {915 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);916 setSettingsInitialSection("license");917 setView("settings");918 setPendingProNotice(null);919 }}920 >921 Learn more →922 </button>923 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>924 </div>925 </Show>926927 <div class="peach-shell-body">928 <div class="peach-shell-veil"></div>929 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>930 <Show931 when={selectedPasskeyEntry()}932 fallback={933 <EntryView934 entry={selectedEntry()}935 onDelete={handleDelete}936 onFieldSaved={() => void loadEntries()}937 onEscape={() => { setView("list"); }}938 />939 }940 >941 <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />942 </Show>943 </div>944945 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>946 <div class="peach-search-bar">947 <span class="peach-search-icon">948 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">949 <circle cx="5.5" cy="5.5" r="3.75" />950 <path d="M8.75 8.75L11.5 11.5" />951 </svg>952 </span>953 <input954 class="peach-search-input"955 placeholder="search vault..."956 autocomplete="off"957 aria-label="Search vault entries"958 onClick={openList}959 onFocus={openList}960 readOnly961 />962 <div class="peach-search-meta">963 <span class="peach-search-counter">964 {entries().length > 0965 ? `${selectedIndex() + 1} / ${entries().length}`966 : "0 entries"}967 </span>968 <span class="peach-divider"></span>969 <button970 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"971 onClick={() => navigate(-1)}972 aria-label="Previous entry"973 disabled={entries().length === 0}974 >975 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">976 <path d="M5 1L1 5.5L5 10" />977 </svg>978 </button>979 <button980 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"981 onClick={() => navigate(1)}982 aria-label="Next entry"983 disabled={entries().length === 0}984 >985 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">986 <path d="M1 1L5 5.5L1 10" />987 </svg>988 </button>989 </div>990 </div>991 </section>992 </div>993994 <Show when={view() === "list"}>995 <VaultView996 entries={entries()}997 selectedEntryId={selectedEntry()?.id}998 currentTabDomain={currentTabDomain()}999 sortOrder={sortOrder()}1000 searchSeed={listSearchSeed()}1001 searchSeedNonce={listSearchSeedNonce()}1002 onSelectEntry={handleSelectEntry}1003 onSortOrderChange={setSortOrder}1004 onAdd={handleAdd}1005 onClose={() => setView("entry")}1006 onOpenTrash={() => setView("trash")}1007 onDeleteMany={handleDeleteMany}1008 />1009 </Show>1010 <Show when={view() === "settings"}>1011 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1012 <SettingsView1013 entriesCount={entries().length}1014 breachMonitoring={breachMonitoring()}1015 lockOnClose={lockOnClose()}1016 autoLockMinutes={autoLockMinutes()}1017 trashRetentionDays={trashRetentionDays()}1018 theme={theme()}1019 accentColor={accentColor()}1020 displayFont={displayFont()}1021 textSize={textSize()}1022 reduceMotion={reduceMotion()}1023 isPro={proStatus()}1024 initialSection={settingsInitialSection()}1025 upgradeHighlight={upgradeHighlight()}1026 onBreachMonitoringChange={applyBreachMonitoring}1027 onLockOnCloseChange={applyLockOnClose}1028 onAutoLockMinutesChange={applyAutoLockMinutes}1029 onTrashRetentionChange={applyTrashRetention}1030 onThemeChange={applyTheme}1031 onAccentChange={applyAccentColor}1032 onDisplayFontChange={applyDisplayFont}1033 onTextSizeChange={applyTextSize}1034 onReduceMotionChange={applyReduceMotion}1035 shortcuts={shortcutBindings()}1036 onShortcutsChange={applyShortcuts}1037 onLicenseChange={refreshProStatus}1038 onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}1039 onImport={() => setView("import")}1040 onExport={() => setView("export")}1041 onBreachCheckDone={loadEntries}1042 onOpenHealth={openHealth}1043 onOpenCJKImport={handleOpenCJKImport}1044 onOpenFileImport={handleOpenFileImport}1045 />1046 </Suspense>1047 </Show>1048 <Show when={view() === "add"}>1049 <AddView1050 entry={editEntry()}1051 entries={entries()}1052 onBack={handleBack}1053 onSave={handleSave}1054 />1055 </Show>1056 <Show when={view() === "import"}>1057 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1058 <ImportView onBack={handleBack} onImport={handleSave} />1059 </Suspense>1060 </Show>1061 <Show when={view() === "export"}>1062 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1063 <ExportView onBack={handleBack} />1064 </Suspense>1065 </Show>1066 <Show when={view() === "health"}>1067 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1068 <HealthView1069 entries={entries()}1070 isPro={proStatus()}1071 onFixEntry={handleFixEntry}1072 onBack={() => setView("settings")}1073 onRefresh={loadEntries}1074 />1075 </Suspense>1076 </Show>1077 <Show when={view() === "trash"}>1078 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1079 <TrashView1080 onBack={() => setView("list")}1081 onRestored={() => void loadEntries()}1082 />1083 </Suspense>1084 </Show>1085 <Show when={restoreView() === "cjk"}>1086 <CJKImport1087 mode="onboarding"1088 onBack={() => setRestoreView(null)}1089 onDone={async () => {1090 setRestoreView(null);1091 await handleUnlock();1092 }}1093 />1094 </Show>1095 <Show when={restoreView() === "file"}>1096 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1097 <ImportView1098 mode="onboarding"1099 replacesExistingVault={true}1100 onBack={() => setRestoreView(null)}1101 onImport={async () => {1102 setRestoreView(null);1103 await handleUnlock();1104 }}1105 />1106 </Suspense>1107 </Show>1108 </section>1109 </Show>1110 </Show>1111 </Show>1112 </Show>1113 <Show when={showShortcutsHelp()}>1114 <ShortcutsHelpOverlay1115 bindings={shortcutBindings()}1116 onClose={() => setShowShortcutsHelp(false)}1117 />1118 </Show>1119 </div>1120 );1121}11221123function ShortcutsHelpOverlay(props: {1124 bindings: Record<ShortcutAction, Keybinding>;1125 onClose: () => void;1126}) {1127 return (1128 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1129 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1130 <div class="peach-shortcuts-help-head">1131 <h3>Keyboard shortcuts</h3>1132 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1133 </div>1134 <div class="peach-shortcuts-help-body">1135 <p class="peach-settings-sub-label">ENTRY VIEW</p>1136 <ul>1137 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1138 <li>1139 <span>{d.label}</span>1140 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1141 </li>1142 ))}1143 </ul>1144 <p class="peach-settings-sub-label">ANYWHERE</p>1145 <ul>1146 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1147 <li>1148 <span>{d.label}</span>1149 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1150 </li>1151 ))}1152 </ul>1153 </div>1154 </div>1155 </div>1156 );1157}