1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { storageGet, storageRemove, storageSet } from "../shared/storage";3import { sendMessage } from "../shared/bridge";4import { ACCENT_COLORS } from "../shared/types";5import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";6import { isPasskeyOnly } from "../shared/types";7import {8 hasCompletedInitialSync,9 INITIAL_SYNC_PENDING_DEVICE_KEY,10 type InitialSyncDevice,11} from "../shared/initial-sync";12import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";13import {14 ONBOARDING_CARD_STORAGE_KEY,15 ONBOARDING_LAST_CARD,16 Onboarding,17} from "./views/Onboarding";1819const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";20import { NotesMode } from "./views/NotesMode";21import { ProCelebration } from "./views/ProCelebration";22import { InitialSyncView } from "./views/InitialSyncView";23import { VaultView, type SortOrder } from "./views/Vault";24import { EntryView } from "./views/Entry";25import { AddView } from "./views/Add";26import { PeachWordmark } from "./components/PeachMark";27import { PasskeyApprovalView } from "./views/PasskeyApproval";28import { extractDomain } from "../shared/domain";29import {30 syncIndicator,31 syncIndicatorLabel,32 type SyncOutcomeSummary,33} from "../shared/sync-outcome";34import { popupCopy } from "./copy";35import {36 DEFAULT_BINDINGS,37 formatKeybinding,38 getActiveBindings,39 matchKeybinding,40 SHORTCUT_DESCRIPTORS,41 type Keybinding,42 type ShortcutAction,43} from "../shared/shortcuts";44import { generateTOTP } from "../crypto/totp";4546export { sendMessage } from "../shared/bridge";4748const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));49const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));50const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));51const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));52const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));53const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));54const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));55const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));56const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));57const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));5859type View =60 | "lock"61 | "pro-celebration"62 | "entry"63 | "list"64 | "add"65 | "settings"66 | "pair-device"67 | "import"68 | "export"69 | "health"70 | "trash"71 | "panic"72 | "adopting"73 | "initial-sync"74 | "codex-generate"75 | "codex-restore";76type BootState = "loading" | "ready";7778const INITIAL_SYNC_POLL_MS = 500;79const INITIAL_SYNC_SLOW_MS = 15_000;8081let shellRingAnimation: Animation | null = null;8283export function triggerRing() {84 const shell = document.getElementById("hd-shell");85 if (!shell) return;8687 shellRingAnimation?.cancel();88 shell.dataset.glowing = "true";89 const animation = shell.animate(90 [91 { offset: 0, boxShadow: "var(--copy-shadow)" },92 {93 offset: 0.3,94 boxShadow:95 "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)",96 },97 { offset: 1, boxShadow: "var(--copy-shadow)" },98 ],99 {100 duration: 720,101 easing: "cubic-bezier(0.4, 0, 0.2, 1)",102 fill: "none",103 }104 );105 shellRingAnimation = animation;106 const clearGlow = () => {107 if (shellRingAnimation !== animation) return;108 shellRingAnimation = null;109 delete shell.dataset.glowing;110 };111 animation.onfinish = clearGlow;112 animation.oncancel = clearGlow;113}114115export function triggerPipPulse() {116 const pip = document.querySelector(".peach-status-pip, .pip");117 if (pip) {118 pip.classList.remove("pulse");119 void (pip as HTMLElement).offsetWidth;120 pip.classList.add("pulse");121 }122}123124const ENTRY_TRANSITION_SETTLE_MS = 135;125126export default function App() {127 const [bootState, setBootState] = createSignal<BootState>("loading");128 const [view, setView] = createSignal<View>("lock");129 const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");130 const [hasVault, setHasVault] = createSignal(false);131 const [onboarded, setOnboarded] = createSignal(true);132 const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);133 134 135 136 const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);137 138 139 140 const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);141 const [entries, setEntries] = createSignal<VaultEntry[]>([]);142 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);143 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);144145 146 interface SyncAlert {147 type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";148 entryId: string;149 entryTitle: string;150 deviceName: string;151 timestamp: number;152 }153 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);154 155 const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});156 async function loadSyncAlerts() {157 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {158 ok: boolean;159 data?: SyncAlert[];160 };161 if (r.ok && (r.data?.length ?? 0) > 0) {162 setSyncAlerts(r.data ?? []);163 164 165 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });166 }167 }168 function dismissAlertGroup(type: string) {169 setSyncAlerts((arr) => arr.filter((a) => a.type !== type));170 setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });171 }172 function flipAlert(type: string, dir: 1 | -1) {173 const group = syncAlerts().filter((a) => a.type === type);174 if (group.length <= 1) return;175 setAlertFlipIdx((m) => {176 const cur = m[type] ?? 0;177 return { ...m, [type]: (cur + dir + group.length) % group.length };178 });179 }180 181 const alertGroups = createMemo(() => {182 const map = new Map<string, SyncAlert[]>();183 for (const a of syncAlerts()) {184 const g = map.get(a.type);185 if (g) g.push(a); else map.set(a.type, [a]);186 }187 return [...map.entries()];188 });189 function syncAlertText(a: SyncAlert): string {190 if (a.type === "DEVICE_REVOKED") {191 return `${a.deviceName} revoked a paired device — review your devices in Settings`;192 }193 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;194 }195 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");196 const [theme, setTheme] = createSignal<"dark" | "light">("dark");197 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");198 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);199 const [lockOnClose, setLockOnClose] = createSignal(false);200 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);201 const [breachMonitoring, setBreachMonitoring] = createSignal(true);202 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);203 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);204 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);205 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);206 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);207 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");208 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");209 210 211 212 213 const [proStatus, setProStatus] = createSignal(false);214 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);215 216 217 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");218 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);219 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");220 const [listSearchSeed, setListSearchSeed] = createSignal("");221 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);222 const [pendingCelebration, setPendingCelebration] = createSignal(false);223 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);224 225 226 227 const [returnToPanic, setReturnToPanic] = createSignal(false);228 229 230 const [editBackView, setEditBackView] = createSignal<View>("entry");231 const [settingsExiting, setSettingsExiting] = createSignal(false);232 const [showNotes, setShowNotes] = createSignal(false);233 234 235 236 const [syncBusy, setSyncBusy] = createSignal(false);237 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);238 239 240 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});241 const [pairedCount, setPairedCount] = createSignal(0);242 const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);243 const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);244 let initialSyncWaitGeneration = 0;245 const syncLight = () =>246 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());247 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());248 async function loadSyncIndicator() {249 const [stateRes, devicesRes] = await Promise.all([250 sendMessage({ type: "GET_SYNC_STATE" }),251 sendMessage({ type: "LIST_PAIRED_DEVICES" }),252 ]);253 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };254 if (s.ok && s.data) setSyncSummary(s.data);255 const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };256 if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);257 }258259 interface InitialSyncPairedDevice extends InitialSyncDevice {260 pairedAt?: number;261 }262263 async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {264 const response = (await sendMessage({265 type: "LIST_PAIRED_DEVICES",266 })) as {267 ok: boolean;268 data?: { devices?: InitialSyncPairedDevice[] };269 };270 return response.ok ? response.data?.devices ?? [] : [];271 }272273 async function resolveInitialSyncDeviceId(): Promise<string | null> {274 const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(275 () => null,276 );277 const devices = await listInitialSyncDevices().catch(() => []);278 if (stored && devices.some((device) => device.deviceId === stored)) {279 return stored;280 }281282 283 284 const newest = [...devices].sort(285 (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),286 )[0];287 if (!newest) return stored;288 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(289 () => undefined,290 );291 return newest.deviceId;292 }293294 async function finishInitialSync(generation: number): Promise<void> {295 if (generation !== initialSyncWaitGeneration) return;296 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);297 setInitialSyncDeviceId(null);298 setInitialSyncSlow(false);299 await refreshProStatus();300301 302 303 304 const celebration = (await sendMessage({305 type: "SHOULD_PLAY_CELEBRATION",306 })) as { ok: boolean; data?: boolean };307 const shouldCelebrate = celebration.ok && celebration.data === true;308 if (shouldCelebrate) {309 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });310 }311312 const opened = await openVault(undefined, shouldCelebrate);313 if (!opened || generation !== initialSyncWaitGeneration) return;314 if (shouldCelebrate) {315 setView("pro-celebration");316 window.setTimeout(() => {317 if (generation === initialSyncWaitGeneration) setView("entry");318 }, 2500);319 }320 }321322 async function beginInitialSyncWait(deviceId: string): Promise<void> {323 const generation = ++initialSyncWaitGeneration;324 setInitialSyncDeviceId(deviceId);325 setInitialSyncSlow(false);326 setView("initial-sync");327 const startedAt = Date.now();328329 330 331 void sendMessage({332 type: "TRIGGER_SYNC",333 payload: { reason: "foreground" },334 }).catch(() => undefined);335336 while (337 generation === initialSyncWaitGeneration &&338 view() === "initial-sync"339 ) {340 try {341 const devices = await listInitialSyncDevices();342 if (hasCompletedInitialSync(devices, deviceId)) {343 await finishInitialSync(generation);344 return;345 }346 } catch {347 348 }349 if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {350 setInitialSyncSlow(true);351 }352 await new Promise((resolve) =>353 window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),354 );355 }356 }357358 async function retryInitialSync(): Promise<void> {359 const deviceId =360 initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());361 if (deviceId) {362 await beginInitialSyncWait(deviceId);363 } else {364 setInitialSyncSlow(true);365 }366 }367368 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);369 370 371 372 373 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);374375 let contentPhaseTimer: number | undefined;376 377 378 let openedForInlineUnlock = false;379380 onCleanup(() => {381 initialSyncWaitGeneration += 1;382 });383384 function applyAccent(name: AccentColorName) {385 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;386 const hex = color.hex;387 document.documentElement.style.setProperty("--peach-accent", hex);388 389 document.documentElement.style.setProperty("--peach-glow", hex + "2A");390 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");391 }392393 createEffect(() => {394 applyAccent(accentColor());395 });396397 createEffect(() => {398 document.documentElement.classList.toggle("light", theme() === "light");399 document.body.classList.toggle("light", theme() === "light");400 });401402 createEffect(() => {403 document.documentElement.setAttribute("data-text-size", textSize());404 });405406 createEffect(() => {407 if (reduceMotion()) {408 document.documentElement.setAttribute("data-motion", "reduce");409 } else {410 document.documentElement.removeAttribute("data-motion");411 }412 });413414 415 416 417 418 419 const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));420421 const selectedIndex = createMemo(() => {422 const currentId = selectedEntry()?.id;423 if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;424 return nonNoteEntries().findIndex((entry) => entry.id === currentId);425 });426427 async function applyAccentColor(name: AccentColorName) {428 applyAccent(name);429 setAccentColor(name);430 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });431 }432433 async function applyTheme(t: "dark" | "light") {434 setTheme(t);435 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });436 }437438 async function applyTextSize(size: "small" | "medium" | "large") {439 setTextSize(size);440 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });441 }442443 async function applyReduceMotion(enabled: boolean) {444 setReduceMotion(enabled);445 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });446 }447448 449 450 451 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {452 setShortcutBindings(active);453 const overrides: Record<string, Keybinding> = {};454 for (const action of Object.keys(active) as ShortcutAction[]) {455 const a = active[action];456 const d = DEFAULT_BINDINGS[action];457 const same = a.key === d.key458 && Boolean(a.ctrl) === Boolean(d.ctrl)459 && Boolean(a.meta) === Boolean(d.meta)460 && Boolean(a.shift) === Boolean(d.shift)461 && Boolean(a.alt) === Boolean(d.alt);462 if (!same) overrides[action] = a;463 }464 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });465 }466467 async function applyLockOnClose(enabled: boolean) {468 setLockOnClose(enabled);469 await sendMessage({470 type: "UPDATE_SETTINGS",471 payload: { lockOnClose: enabled },472 });473 }474475 async function applyAutoLockMinutes(minutes: number) {476 setAutoLockMinutes(minutes);477 await sendMessage({478 type: "UPDATE_SETTINGS",479 payload: { autoLockMinutes: minutes },480 });481 }482483 async function applyBreachMonitoring(enabled: boolean) {484 setBreachMonitoring(enabled);485 await sendMessage({486 type: "UPDATE_SETTINGS",487 payload: { breachMonitoring: enabled },488 });489 }490491 492 493 494 function markOnboardingExit(flow: "create" | "restore" | "adopt") {495 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);496 void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});497 498 499 void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});500 }501 function clearOnboardingExit() {502 void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});503 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});504 setResumeRestoreSource(null);505 }506507 onMount(async () => {508 509 510 511 512 513 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([514 sendMessage({ type: "IS_UNLOCKED" }),515 sendMessage({ type: "HAS_VAULT" }),516 refreshCurrentTab(),517 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),518 ]);519 const unlocked = Boolean(520 (unlockResponse as { ok: boolean; data?: boolean }).data521 );522 const vaultExists = Boolean(523 (vaultResponse as { ok: boolean; data?: boolean }).data524 );525 const shouldCelebrate = Boolean(526 (celebrationResponse as { ok: boolean; data?: boolean }).data527 );528 setHasVault(vaultExists);529 530 531 532 try {533 const onboarded = await storageGet("peach_onboarded");534 const seen = onboarded === "true";535 if (vaultExists && !seen) {536 await storageSet("peach_onboarded", "true");537 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});538 await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});539 await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});540 setOnboarded(true);541 } else {542 setOnboarded(seen);543 if (!seen) {544 545 546 547 const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);548 if (exit === "create" || exit === "restore" || exit === "adopt") {549 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);550 setOnboarded(true);551 setStartOnboardingRestore(exit === "restore");552 if (exit === "restore") {553 554 555 const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);556 if (source === "symbols" || source === "codex" || source === "csv") {557 setResumeRestoreSource(source);558 }559 }560 if (exit === "adopt") setView("adopting");561 } else {562 563 564 const stored = Number(565 await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),566 );567 if (568 Number.isInteger(stored) &&569 stored > 0 &&570 stored <= ONBOARDING_LAST_CARD571 ) {572 setOnboardingCardAtExit(stored);573 }574 }575 }576 }577 } catch {578 579 setOnboarded(true);580 }581582 583 584 585 586 587 try {588 const restoreRoute = new URLSearchParams(window.location.search).get("restore");589 if (restoreRoute === "peachscript" || restoreRoute === "csv") {590 setOnboarded(true);591 setStartOnboardingRestore(true);592 setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");593 }594 } catch {595 596 }597598 await Promise.all([restoreSettings(), refreshProStatus()]);599600 const pendingInitialSyncMarker =601 unlocked && vaultExists602 ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)603 : null;604 const pendingInitialSyncDeviceId = pendingInitialSyncMarker605 ? await resolveInitialSyncDeviceId()606 : null;607608 609 610 611 if (shouldCelebrate && !pendingInitialSyncDeviceId) {612 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });613 }614615 616 617 if (unlocked && vaultExists) {618 void sendMessage({619 type: "TRIGGER_SYNC",620 payload: { reason: "foreground" },621 });622 void loadSyncIndicator();623 624 void loadSyncAlerts();625 }626627 628 629 630 631 632 633 let recoveryPending = false;634 try {635 const out = await chrome.storage.local?.get("peach_recovery_pending");636 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";637 } catch { }638639 640 641 642 if (chrome.storage.session) {643 try {644 const s = await chrome.storage.session.get("peach_inline_unlock");645 if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {646 openedForInlineUnlock = true;647 await chrome.storage.session.remove("peach_inline_unlock");648 }649 } catch { }650 }651652 if (recoveryPending && unlocked) {653 654 655 setForceRecoveryWords(true);656 setView("lock");657 } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {658 void beginInitialSyncWait(pendingInitialSyncDeviceId);659 } else if (unlocked && vaultExists) {660 await openVault(undefined, shouldCelebrate);661 if (shouldCelebrate) {662 setView("pro-celebration");663 window.setTimeout(() => setView("entry"), 2500);664 }665 } else if (shouldCelebrate) {666 setPendingCelebration(true);667 }668669 await refreshPendingPasskey();670671 672 const handleTabActivated = () => { void refreshCurrentTab(); };673 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {674 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();675 };676 try {677 chrome.tabs.onActivated.addListener(handleTabActivated);678 chrome.tabs.onUpdated.addListener(handleTabUpdated);679 onCleanup(() => {680 chrome.tabs.onActivated.removeListener(handleTabActivated);681 chrome.tabs.onUpdated.removeListener(handleTabUpdated);682 });683 } catch {684 685 }686687 setBootState("ready");688689 690 691 692 693 try {694 const route = new URLSearchParams(window.location.search).get("codex");695 if (route === "pdf" || route === "restore") {696 setView("codex-restore");697 }698 } catch {699 700 }701702 703 if (chrome.storage.session) {704 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as705 Record<string, { feature?: string } | undefined>;706 if (session.pendingProGate?.feature) {707 await chrome.storage.session.remove("pendingProGate");708 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);709 setSettingsInitialSection("license");710 setView("settings");711 } else if (session.pendingProNotice?.feature) {712 await chrome.storage.session.remove("pendingProNotice");713 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });714 }715 }716717 718 719 720 721 void Promise.all([722 import("./views/Settings").catch(() => {}),723 import("./views/Import").catch(() => {}),724 import("./views/Export").catch(() => {}),725 import("./views/Health").catch(() => {}),726 import("./views/TrashView").catch(() => {}),727 ]);728729 730 731 732 let popupPort: chrome.runtime.Port | null = null;733 try {734 735 736 737 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });738 } catch { }739 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });740741 742 743 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {744 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();745 if (msg?.type === "SYNC_STATE_CHANGED") {746 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);747 }748 if (msg?.type === "PRO_GATE_TRIGGERED") {749 setUpgradeHighlight(msg.payload?.feature ?? null);750 setSettingsInitialSection("license");751 setView("settings");752 }753 };754 chrome.runtime.onMessage.addListener(handleRuntimeMessage);755 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));756757 758 759 760 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {761 copy_password: () => {762 const entry = selectedEntry();763 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });764 },765 copy_username: () => {766 const entry = selectedEntry();767 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });768 },769 copy_totp: () => {770 const entry = selectedEntry();771 if (!entry?.totp) return;772 try {773 const { code } = generateTOTP(entry.totp);774 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });775 } catch { }776 },777 new_entry: () => handleAdd(),778 edit_entry: () => {779 const entry = selectedEntry();780 781 782 if (!entry || isPasskeyOnly(entry)) return;783 setEditEntry(entry);784 setView("add");785 },786 delete_entry: () => {787 if (selectedEntry()) void handleDelete();788 },789 lock_vault: () => { void handleHeaderLock(); },790 search: () => openList(),791 nav_prev: () => navigate(-1),792 nav_next: () => navigate(1),793 jump_vault: () => setView("entry"),794 jump_settings: () => setView("settings"),795 show_help: () => setShowShortcutsHelp(true),796 };797798 799 800 801 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([802 "copy_password", "copy_username", "copy_totp",803 "edit_entry", "delete_entry", "nav_prev", "nav_next",804 ]);805806 const handleGlobalKeydown = (e: KeyboardEvent) => {807 const v = view();808 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"809 || (e.target as HTMLElement)?.tagName === "TEXTAREA"810 || (e.target as HTMLElement)?.isContentEditable;811 const isPrintableSearchKey =812 e.key.length === 1 &&813 !e.ctrlKey &&814 !e.metaKey &&815 !e.altKey;816817 818 819 820 821 822 if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;823824 825 826 if (showShortcutsHelp()) {827 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }828 return;829 }830831 832 if (e.key === "Escape") {833 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }834 if (v === "list") { setView("entry"); return; }835 if (v === "settings" || v === "add") { handleBack(); return; }836 if (v === "import" || v === "health") { setView("settings"); return; }837 if (v === "export") { handleExportBack(); return; }838 if (v === "pair-device") { setView("settings"); return; }839 }840841 842 843 844 const bindings = shortcutBindings();845 for (const action of Object.keys(bindings) as ShortcutAction[]) {846 const binding = bindings[action];847 if (!matchKeybinding(e, binding)) continue;848 849 850 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;851 if (!hasMod && binding.key.length === 1 && inInput) return;852 if (ENTRY_SCOPED.has(action) && v !== "entry") return;853 e.preventDefault();854 SHORTCUT_HANDLERS[action]?.();855 return;856 }857858 859 860 861 862 if (v === "entry" && !inInput && isPrintableSearchKey) {863 e.preventDefault();864 primeListSearch(e.key);865 return;866 }867 };868 window.addEventListener("keydown", handleGlobalKeydown);869 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));870871 const handleStorageChanged = (872 changes: Record<string, chrome.storage.StorageChange>,873 areaName: string874 ) => {875 if (areaName !== "session") return;876 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {877 return;878 }879 void refreshPendingPasskey();880 };881 chrome.storage.onChanged.addListener(handleStorageChanged);882 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));883 });884885 async function refreshCurrentTab() {886 try {887 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });888 const url = tab?.url ?? tab?.pendingUrl ?? "";889 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");890 891 892 const isWebUrl = /^https?:\/\//i.test(url);893 setCurrentTabUrl(isWebUrl ? url : "");894 } catch {895 setCurrentTabDomain("");896 setCurrentTabUrl("");897 }898 }899900 async function refreshPendingPasskey() {901 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });902 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);903 }904905 async function refreshProStatus() {906 const res = await sendMessage({ type: "GET_LICENSE" });907 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;908 setProStatus(Boolean(data?.valid && data?.plan === "pro"));909 }910911 async function restoreSettings() {912 const res = await sendMessage({ type: "GET_SETTINGS" });913 const s = (res as { ok: boolean; data?: any }).data ?? {};914 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {915 setAccentColor(s.accentColor as AccentColorName);916 }917 setTheme(s.theme === "light" ? "light" : "dark");918 if (s.textSize === "small" || s.textSize === "large") {919 setTextSize(s.textSize);920 } else {921 setTextSize("medium");922 }923 setReduceMotion(s.reduceMotion === true);924 setLockOnClose(s.lockOnClose === true);925 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);926 setBreachMonitoring(s.breachMonitoring !== false);927 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);928 setShortcutBindings(getActiveBindings(s.shortcuts));929 }930931 async function applyTrashRetention(days: number) {932 setTrashRetentionDays(days);933 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });934 }935936 function primeListSearch(seed: string) {937 setListSearchSeed(seed);938 setListSearchSeedNonce((value) => value + 1);939 openList();940 }941942 943 944 945 async function loadEntries(): Promise<VaultEntry[] | null> {946 const response = await sendMessage({ type: "GET_ENTRIES" });947 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };948949 950 951 952 953 if (!res.ok) {954 if (955 res.error === "vault_locked" ||956 res.error === "SERVICE_WORKER_UNAVAILABLE" ||957 (typeof res.error === "string" && res.error.includes("locked"))958 ) {959 handleLock();960 return null; 961 }962 return [];963 }964965 const loadedEntries = res.data ?? [];966 setEntries(loadedEntries);967968 const currentId = selectedEntry()?.id;969 if (currentId) {970 setSelectedEntry(971 loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)972 );973 }974975 return loadedEntries;976 }977978 async function openVault(preferredEntryId?: string, skipViewSet = false) {979 980 const retDays = trashRetentionDays();981 if (retDays > 0) {982 void sendMessage({983 type: "PURGE_EXPIRED_TRASH",984 payload: { retentionMs: retDays * 86_400_000 },985 });986 }987988 const loadedEntries = await loadEntries();989990 991 992 if (loadedEntries === null) return false;993994 995 let nextSelectedEntry: VaultEntry | null = null;996 if (preferredEntryId) {997 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;998 }999 if (!nextSelectedEntry) {1000 const tabDomain = currentTabDomain();1001 if (tabDomain) {1002 nextSelectedEntry = loadedEntries.find((e) => {1003 if (e.type === "login") {1004 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1005 for (const extra of e.additionalUrls ?? []) {1006 if ((extractDomain(extra) ?? "") === tabDomain) return true;1007 }1008 return false;1009 }1010 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1011 return false;1012 }) ?? null;1013 }1014 }1015 nextSelectedEntry ??= pickNavigable(loadedEntries);10161017 setSelectedEntry(nextSelectedEntry);1018 setEditEntry(undefined);1019 setContentPhase("enter");1020 queueContentIdle();1021 if (!skipViewSet) setView("entry");1022 return true;1023 }10241025 async function handleUnlock() {1026 1027 1028 1029 1030 1031 1032 let recoveryPending = false;1033 try {1034 const out = await chrome.storage.local?.get("peach_recovery_pending");1035 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1036 } catch { }1037 if (recoveryPending) {1038 setForceRecoveryWords(true);1039 setView("lock");1040 triggerPipPulse();1041 await restoreSettings();1042 return;1043 }1044 setHasVault(true);1045 triggerPipPulse();1046 await restoreSettings();1047 const celebrating = pendingCelebration();1048 if (celebrating) {1049 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1050 setPendingCelebration(false);1051 }1052 await openVault(undefined, celebrating);1053 if (celebrating) {1054 setView("pro-celebration");1055 window.setTimeout(() => setView("entry"), 2500);1056 }1057 1058 1059 if (openedForInlineUnlock) {1060 openedForInlineUnlock = false;1061 window.close();1062 }1063 }10641065 function handleSelectEntry(entry: VaultEntry) {1066 if (entry.type === "note") {1067 setOpenNoteId(entry.id);1068 setShowNotes(true);1069 return;1070 }1071 setSelectedEntry(entry);1072 setContentPhase("enter");1073 queueContentIdle();1074 setView("entry");1075 }10761077 function handleAdd() {1078 setEditEntry(undefined);1079 setView("add");1080 }10811082 async function handleDelete() {1083 const entry = selectedEntry();1084 if (!entry) return;1085 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1086 const loadedEntries = await loadEntries() ?? [];1087 setSelectedEntry(pickNavigable(loadedEntries));1088 setView("entry");1089 }10901091 async function handleDeleteMany(ids: string[]) {1092 for (const id of ids) {1093 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1094 }1095 const loadedEntries = await loadEntries() ?? [];1096 1097 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1098 if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1099 }11001101 async function handleSave(savedEntry?: VaultEntry) {1102 await openVault(savedEntry?.id);1103 }11041105 function handleBack() {1106 const dest = editBackView();1107 setEditBackView("entry");1108 setEditEntry(undefined);1109 setView(dest);1110 }11111112 function handleExportBack() {1113 if (returnToPanic()) {1114 setReturnToPanic(false);1115 setView("panic");1116 return;1117 }1118 setView("settings");1119 }11201121 function handleLock() {1122 setEntries([]);1123 setSelectedEntry(null);1124 setEditEntry(undefined);1125 setContentPhase("idle");1126 setView("lock");1127 }11281129 function handleHeaderLock() {1130 1131 1132 1133 handleLock();1134 void sendMessage({ type: "LOCK" });1135 }11361137 1138 1139 1140 async function handleHeaderSync(): Promise<void> {1141 if (syncBusy()) return;1142 setSyncBusy(true);1143 setSyncFlash(null);1144 try {1145 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1146 let state: SyncOutcomeSummary = { status: "syncing" };1147 for (let i = 0; i < 40 && state.status === "syncing"; i++) {1148 await new Promise((r) => setTimeout(r, 250));1149 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1150 ok: boolean;1151 data?: SyncOutcomeSummary;1152 };1153 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1154 }1155 setSyncSummary(state);1156 setSyncFlash(state.status === "error" ? "error" : "ok");1157 await loadSyncAlerts();1158 await loadEntries();1159 await loadSyncIndicator();1160 } catch {1161 setSyncSummary({ status: "error", outcome: "run_failed" });1162 setSyncFlash("error");1163 } finally {1164 setSyncBusy(false);1165 setTimeout(() => setSyncFlash(null), 2_000);1166 }1167 }11681169 function navigate(dir: number) {1170 const navigable = nonNoteEntries();1171 if (navigable.length === 0) return;1172 const currentIndex = selectedIndex();1173 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1174 const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1175 setSelectedEntry(navigable[nextIndex]);1176 setContentPhase("enter");1177 queueContentIdle();1178 }11791180 118111821183118411851186 function pickNavigable(list: VaultEntry[]): VaultEntry | null {1187 return list.find((e) => e.type !== "note") ?? null;1188 }11891190 function openList() {1191 setView("list");1192 }11931194 function openHealth() {1195 setEditEntry(undefined);1196 setView("health");1197 }11981199 function handleOpenFileImport() {1200 setRestoreView("file");1201 }12021203 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12041205 function handleFixEntry(entry: VaultEntry) {1206 setSelectedEntry(entry);1207 1208 1209 if (isPasskeyOnly(entry)) {1210 setView("entry");1211 return;1212 }1213 setEditBackView(view()); 1214 setEditEntry(entry);1215 setView("add");1216 }12171218 const isLockedView = () => view() === "lock" || view() === "adopting";12191220 async function handleAdoptionDone() {1221 await storageSet("peach_onboarded", "true").catch(() => {});1222 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1223 clearOnboardingExit();1224 setOnboarded(true);1225 setHasVault(true);1226 setView("initial-sync");1227 1228 1229 1230 await refreshProStatus();1231 const deviceId = await resolveInitialSyncDeviceId();1232 if (deviceId) {1233 void beginInitialSyncWait(deviceId);1234 } else {1235 1236 1237 1238 setInitialSyncSlow(true);1239 }1240 }12411242 async function handleApprovePendingPasskey() {1243 const pending = pendingPasskey();1244 if (!pending) return;1245 if (pending.browser === "firefox" && pending.type === "create") {1246 1247 1248 1249 1250 const { createFirefoxPasskey } = await import("./firefox-passkey");1251 const result = await createFirefoxPasskey(pending);1252 if (!result.ok) {1253 console.warn("Peach Firefox passkey create failed:", result.error);1254 }1255 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1256 setPendingPasskey(null);1257 void loadEntries();1258 window.close();1259 return;1260 }1261 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1262 window.close();1263 }12641265 async function handleDenyPendingPasskey() {1266 const pending = pendingPasskey();1267 if (!pending) return;1268 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1269 window.close();1270 }12711272 function queueContentIdle(after?: () => void) {1273 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1274 contentPhaseTimer = window.setTimeout(() => {1275 setContentPhase("idle");1276 after?.();1277 }, ENTRY_TRANSITION_SETTLE_MS);1278 }12791280 return (1281 <div class="popup-root peach-theme">1282 <Show when={bootState() === "ready"}>1283 <Show1284 when={!pendingPasskey()}1285 fallback={1286 <PasskeyApprovalView1287 type={pendingPasskey()!.type}1288 rpName={pendingPasskey()!.rpName}1289 rpId={pendingPasskey()!.rpId}1290 userName={pendingPasskey()!.userName}1291 userDisplayName={pendingPasskey()!.userDisplayName}1292 onApprove={handleApprovePendingPasskey}1293 onDeny={handleDenyPendingPasskey}1294 />1295 }1296 >1297 <Show1298 when={!isLockedView()}1299 fallback={1300 <Show1301 when={view() !== "adopting"}1302 fallback={1303 <Suspense fallback={<div class="peach-loading">loading...</div>}>1304 <AdoptVaultView1305 onCancel={() => {1306 clearOnboardingExit();1307 setOnboarded(false);1308 setView("lock");1309 }}1310 onDone={handleAdoptionDone}1311 />1312 </Suspense>1313 }1314 >1315 <Show1316 when={onboarded() || hasVault()}1317 fallback={1318 <Onboarding1319 initialCard={onboardingCardAtExit()}1320 onComplete={() => {1321 markOnboardingExit("create");1322 setStartOnboardingRestore(false);1323 setOnboarded(true);1324 }}1325 onRestore={() => {1326 markOnboardingExit("restore");1327 setStartOnboardingRestore(true);1328 setOnboarded(true);1329 }}1330 onAdopt={() => {1331 markOnboardingExit("adopt");1332 setStartOnboardingRestore(false);1333 setOnboarded(true);1334 setView("adopting");1335 }}1336 />1337 }1338 >1339 <LockView1340 hasVault={hasVault()}1341 ready={bootState() === "ready"}1342 isPro={proStatus()}1343 forceRecoveryWords={forceRecoveryWords()}1344 initialRestore={startOnboardingRestore()}1345 initialRestoreSource={resumeRestoreSource() ?? undefined}1346 onUnlock={() => {1347 1348 1349 1350 if (forceRecoveryWords()) setForceRecoveryWords(false);1351 1352 1353 1354 setStartOnboardingRestore(false);1355 setResumeRestoreSource(null);1356 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1357 return handleUnlock();1358 }}1359 onBackToOnboarding={1360 !hasVault()1361 ? () => {1362 1363 1364 clearOnboardingExit();1365 setStartOnboardingRestore(false);1366 setOnboarded(false);1367 }1368 : undefined1369 }1370 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1371 />1372 </Show>1373 </Show>1374 }1375 >1376 <Show1377 when={1378 view() !== "pro-celebration" &&1379 view() !== "initial-sync"1380 }1381 fallback={1382 <Show1383 when={view() === "initial-sync"}1384 fallback={<ProCelebration />}1385 >1386 <InitialSyncView1387 slow={initialSyncSlow()}1388 onRetry={() => void retryInitialSync()}1389 />1390 </Show>1391 }1392 >1393 <section1394 id="hd-shell"1395 class="peach-shell peach-theme"1396 data-surface="extension"1397 >1398 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1399 <PeachWordmark unlocked />1400 <div class="peach-chrome-actions">1401 <button1402 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1403 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1404 aria-label="Notes"1405 title="Notes"1406 >1407 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1408 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1409 <line x1="4" y1="5" x2="10" y2="5" />1410 <line x1="4" y1="7.5" x2="10" y2="7.5" />1411 <line x1="4" y1="10" x2="7" y2="10" />1412 </svg>1413 </button>1414 <button1415 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1416 classList={{1417 "is-syncing": syncBusy(),1418 "is-ok": syncFlash() === "ok",1419 "is-error": syncFlash() === "error",1420 }}1421 onClick={() => void handleHeaderSync()}1422 disabled={syncBusy()}1423 aria-label={`Sync now. ${syncLabel()}`}1424 title={syncLabel()}1425 >1426 <svg class="peach-sync-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1427 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1428 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1429 <path d="M2.6 1v2.4h2.4" />1430 <path d="M11.4 13v-2.4H9" />1431 </svg>1432 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1433 </button>1434 <button1435 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1436 onClick={() => setView("settings")}1437 aria-label="Settings"1438 >1439 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1440 <path1441 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"1442 stroke="currentColor"1443 stroke-width="0.9"1444 />1445 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1446 </svg>1447 </button>1448 <button1449 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1450 onClick={handleAdd}1451 aria-label="New entry"1452 >1453 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1454 <path d="M5.5 1v9M1 5.5h9" />1455 </svg>1456 </button>1457 <button1458 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1459 onClick={() => void handleHeaderLock()}1460 aria-label="Lock vault"1461 >1462 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1463 <path1464 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"1465 stroke="currentColor"1466 stroke-width="1"1467 stroke-linecap="round"1468 />1469 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1470 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1471 </svg>1472 </button>1473 </div>1474 </header>14751476 <Show when={pendingProNotice() !== null}>1477 <div class="peach-pro-notice-banner">1478 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1479 <button1480 class="peach-pro-notice-link"1481 onClick={() => {1482 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1483 setSettingsInitialSection("license");1484 setView("settings");1485 setPendingProNotice(null);1486 }}1487 >1488 Learn more →1489 </button>1490 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1491 </div>1492 </Show>14931494 <div class="peach-shell-body">1495 <div class="peach-shell-veil"></div>1496 <div class="peach-content-frame" data-content-phase={contentPhase()}>1497 <EntryView1498 entry={selectedEntry()}1499 currentTabUrl={currentTabUrl()}1500 entries={entries()}1501 isPro={proStatus()}1502 onDelete={handleDelete}1503 onFieldSaved={() => void loadEntries()}1504 onEscape={() => { setView("list"); }}1505 onProGate={(feature) => {1506 setUpgradeHighlight(feature);1507 setSettingsInitialSection("license");1508 setView("settings");1509 }}1510 />1511 </div>15121513 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1514 <div class="peach-search-bar">1515 <span class="peach-search-icon">1516 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1517 <circle cx="5.5" cy="5.5" r="3.75" />1518 <path d="M8.75 8.75L11.5 11.5" />1519 </svg>1520 </span>1521 <input1522 class="peach-search-input"1523 placeholder="search vault..."1524 autocomplete="off"1525 aria-label="Search vault entries"1526 onClick={openList}1527 onFocus={openList}1528 readOnly1529 />1530 <div class="peach-search-meta">1531 <span class="peach-search-counter">1532 {nonNoteEntries().length > 01533 ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1534 : "0 entries"}1535 </span>1536 <span class="peach-divider"></span>1537 <button1538 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1539 onClick={() => navigate(-1)}1540 aria-label="Previous entry"1541 disabled={nonNoteEntries().length === 0}1542 >1543 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1544 <path d="M5 1L1 5.5L5 10" />1545 </svg>1546 </button>1547 <button1548 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1549 onClick={() => navigate(1)}1550 aria-label="Next entry"1551 disabled={nonNoteEntries().length === 0}1552 >1553 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1554 <path d="M1 1L5 5.5L1 10" />1555 </svg>1556 </button>1557 </div>1558 </div>1559 </section>1560 </div>15611562 {}1563 <Show when={alertGroups().length > 0}>1564 <div class="peach-sync-alerts">1565 <For each={alertGroups()}>1566 {([type, group]) => {1567 const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1568 return (1569 <div class="peach-sync-alert">1570 <Show when={group.length > 1}>1571 <button1572 class="peach-sync-alert-flip"1573 type="button"1574 aria-label="Previous"1575 onClick={() => flipAlert(type, -1)}1576 >‹</button>1577 </Show>1578 <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1579 <Show when={group.length > 1}>1580 <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1581 <button1582 class="peach-sync-alert-flip"1583 type="button"1584 aria-label="Next"1585 onClick={() => flipAlert(type, 1)}1586 >›</button>1587 </Show>1588 <button1589 class="peach-sync-alert-dismiss"1590 type="button"1591 aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1592 onClick={() => dismissAlertGroup(type)}1593 >×</button>1594 </div>1595 );1596 }}1597 </For>1598 </div>1599 </Show>16001601 <Show when={view() === "list"}>1602 <VaultView1603 entries={entries()}1604 selectedEntryId={selectedEntry()?.id}1605 currentTabDomain={currentTabDomain()}1606 sortOrder={sortOrder()}1607 searchSeed={listSearchSeed()}1608 searchSeedNonce={listSearchSeedNonce()}1609 onSelectEntry={handleSelectEntry}1610 onSortOrderChange={setSortOrder}1611 onAdd={handleAdd}1612 onClose={() => setView("entry")}1613 onOpenTrash={() => setView("trash")}1614 onDeleteMany={handleDeleteMany}1615 />1616 </Show>1617 <Show when={view() === "settings" || settingsExiting()}>1618 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1619 <SettingsView1620 entriesCount={entries().length}1621 breachMonitoring={breachMonitoring()}1622 lockOnClose={lockOnClose()}1623 autoLockMinutes={autoLockMinutes()}1624 trashRetentionDays={trashRetentionDays()}1625 theme={theme()}1626 accentColor={accentColor()}1627 textSize={textSize()}1628 reduceMotion={reduceMotion()}1629 isPro={proStatus()}1630 initialSection={settingsInitialSection()}1631 upgradeHighlight={upgradeHighlight()}1632 isExiting={settingsExiting()}1633 onBreachMonitoringChange={applyBreachMonitoring}1634 onLockOnCloseChange={applyLockOnClose}1635 onAutoLockMinutesChange={applyAutoLockMinutes}1636 onTrashRetentionChange={applyTrashRetention}1637 onThemeChange={applyTheme}1638 onAccentChange={applyAccentColor}1639 onTextSizeChange={applyTextSize}1640 onReduceMotionChange={applyReduceMotion}1641 shortcuts={shortcutBindings()}1642 onShortcutsChange={applyShortcuts}1643 onLicenseChange={async () => {1644 1645 1646 1647 1648 1649 await refreshProStatus();1650 void loadEntries();1651 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1652 if ((should as { ok: boolean; data?: boolean }).data === true) {1653 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1654 setView("pro-celebration");1655 window.setTimeout(() => setView("settings"), 2500);1656 }1657 }}1658 onBack={() => {1659 setSettingsExiting(true);1660 window.setTimeout(() => {1661 setSettingsExiting(false);1662 setSettingsInitialSection(null);1663 setUpgradeHighlight(null);1664 handleBack();1665 }, 200);1666 }}1667 onImport={() => setView("import")}1668 onExport={() => setView("export")}1669 onBreachCheckDone={loadEntries}1670 onOpenHealth={openHealth}1671 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1672 onOpenFileImport={handleOpenFileImport}1673 onOpenPanic={() => setView("panic")}1674 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1675 onOpenCodexRestore={() => setView("codex-restore")}1676 onCardChange={setSettingsActiveCard}1677 onProGate={(feature) => {1678 setUpgradeHighlight(feature);1679 setSettingsInitialSection("license");1680 }}1681 />1682 </Suspense>1683 </Show>1684 <Show when={view() === "codex-generate"}>1685 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1686 <CodexGenerateView1687 isPro={proStatus()}1688 onBack={() => setView("settings")}1689 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1690 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1691 onVaultLocked={() => handleLock()}1692 />1693 </Suspense>1694 </Show>1695 <Show when={view() === "codex-restore"}>1696 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1697 <CodexRestoreView1698 onBack={() => setView("lock")}1699 onRestoreSuccess={async () => {1700 1701 1702 1703 await handleUnlock();1704 }}1705 />1706 </Suspense>1707 </Show>1708 <Show when={view() === "pair-device"}>1709 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1710 <PairDeviceView1711 isPro={proStatus()}1712 onProChanged={() => { void refreshProStatus(); }}1713 onBack={() => setView("settings")}1714 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1715 />1716 </Suspense>1717 </Show>1718 <Show when={view() === "add"}>1719 <AddView1720 entry={editEntry()}1721 entries={entries()}1722 onBack={handleBack}1723 onSave={handleSave}1724 />1725 </Show>1726 <Show when={view() === "import"}>1727 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1728 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1729 </Suspense>1730 </Show>1731 <Show when={view() === "export"}>1732 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1733 <ExportView onBack={handleExportBack} />1734 </Suspense>1735 </Show>1736 <Show when={view() === "panic"}>1737 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1738 <PanicView1739 onCancel={() => setView("settings")}1740 onOpenExport={() => {1741 setReturnToPanic(true);1742 setView("export");1743 }}1744 onDestroyed={async () => {1745 1746 1747 setOnboardingCardAtExit(0);1748 void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1749 clearOnboardingExit();1750 setEntries([]);1751 setSelectedEntry(null);1752 setEditEntry(undefined);1753 setHasVault(false);1754 setOnboarded(false);1755 setProStatus(false);1756 setPendingPasskey(null);1757 setUpgradeHighlight(null);1758 setSettingsInitialSection(null);1759 setPendingProNotice(null);1760 setPendingCelebration(false);1761 setReturnToPanic(false);1762 setContentPhase("idle");1763 setView("lock");1764 }}1765 />1766 </Suspense>1767 </Show>1768 {1769}1770 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1771 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1772 <HealthView1773 entries={entries()}1774 isPro={proStatus()}1775 onFixEntry={handleFixEntry}1776 onBack={() => setView("settings")}1777 onRefresh={loadEntries}1778 hidden={view() !== "health"}1779 />1780 </Suspense>1781 </Show>1782 <Show when={view() === "trash"}>1783 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1784 <TrashView1785 onBack={() => setView("list")}1786 onRestored={() => void loadEntries()}1787 />1788 </Suspense>1789 </Show>1790 <Show when={showNotes()}>1791 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1792 <NotesMode1793 notes={entries().filter((e) => e.type === "note")}1794 initialNoteId={openNoteId()}1795 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1796 onNoteListChanged={() => void loadEntries()}1797 />1798 </div>1799 </Show>1800 <Show when={restoreView() === "file"}>1801 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1802 <ImportView1803 mode="onboarding"1804 replacesExistingVault={true}1805 onBack={() => setRestoreView(null)}1806 onImport={async () => {1807 setRestoreView(null);1808 await handleUnlock();1809 }}1810 />1811 </Suspense>1812 </Show>1813 </section>1814 </Show>1815 </Show>1816 </Show>1817 </Show>1818 <Show when={showShortcutsHelp()}>1819 <ShortcutsHelpOverlay1820 bindings={shortcutBindings()}1821 onClose={() => setShowShortcutsHelp(false)}1822 />1823 </Show>1824 </div>1825 );1826}18271828function ShortcutsHelpOverlay(props: {1829 bindings: Record<ShortcutAction, Keybinding>;1830 onClose: () => void;1831}) {1832 return (1833 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1834 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1835 <div class="peach-shortcuts-help-head">1836 <h3>Keyboard shortcuts</h3>1837 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1838 </div>1839 <div class="peach-shortcuts-help-body">1840 <p class="peach-settings-sub-label">ENTRY VIEW</p>1841 <ul>1842 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1843 <li>1844 <span>{d.label}</span>1845 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1846 </li>1847 ))}1848 </ul>1849 <p class="peach-settings-sub-label">ANYWHERE</p>1850 <ul>1851 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1852 <li>1853 <span>{d.label}</span>1854 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1855 </li>1856 ))}1857 </ul>1858 </div>1859 </div>1860 </div>1861 );1862}