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;8081const canarySuppressMap = new Map<string, boolean>();82let shellRingAnimation: Animation | null = null;8384export function isCanarySuppressed(entryId: string): boolean {85 return canarySuppressMap.get(entryId) ?? false;86}8788export function setCanarySuppressed(entryId: string, suppressed: boolean): void {89 canarySuppressMap.set(entryId, suppressed);90}9192export function clearCanarySuppressions(): void {93 canarySuppressMap.clear();94}9596export function triggerRing() {97 const shell = document.getElementById("hd-shell");98 if (!shell) return;99100 shellRingAnimation?.cancel();101 shell.dataset.glowing = "true";102 const animation = shell.animate(103 [104 { offset: 0, boxShadow: "var(--copy-shadow)" },105 {106 offset: 0.3,107 boxShadow:108 "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)",109 },110 { offset: 1, boxShadow: "var(--copy-shadow)" },111 ],112 {113 duration: 720,114 easing: "cubic-bezier(0.4, 0, 0.2, 1)",115 fill: "none",116 }117 );118 shellRingAnimation = animation;119 const clearGlow = () => {120 if (shellRingAnimation !== animation) return;121 shellRingAnimation = null;122 delete shell.dataset.glowing;123 };124 animation.onfinish = clearGlow;125 animation.oncancel = clearGlow;126}127128129export function triggerPipPulse() {130 const pip = document.querySelector(".peach-status-pip, .pip");131 if (pip) {132 pip.classList.remove("pulse");133 void (pip as HTMLElement).offsetWidth;134 pip.classList.add("pulse");135 }136}137138const ENTRY_TRANSITION_SETTLE_MS = 135;139140export default function App() {141 const [bootState, setBootState] = createSignal<BootState>("loading");142 const [view, setView] = createSignal<View>("lock");143 144 145 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(146 "enter"147 );148 const [contentTransitioning, setContentTransitioning] = createSignal(false);149 const [hasVault, setHasVault] = createSignal(false);150 const [onboarded, setOnboarded] = createSignal(true);151 const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);152 153 154 155 const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);156 157 158 159 const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);160 const [entries, setEntries] = createSignal<VaultEntry[]>([]);161 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);162 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);163164 165 interface SyncAlert {166 type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";167 entryId: string;168 entryTitle: string;169 deviceName: string;170 timestamp: number;171 }172 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);173 174 const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});175 async function loadSyncAlerts() {176 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {177 ok: boolean;178 data?: SyncAlert[];179 };180 if (r.ok && (r.data?.length ?? 0) > 0) {181 setSyncAlerts(r.data ?? []);182 183 184 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });185 }186 }187 function dismissAlertGroup(type: string) {188 setSyncAlerts((arr) => arr.filter((a) => a.type !== type));189 setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });190 }191 function flipAlert(type: string, dir: 1 | -1) {192 const group = syncAlerts().filter((a) => a.type === type);193 if (group.length <= 1) return;194 setAlertFlipIdx((m) => {195 const cur = m[type] ?? 0;196 return { ...m, [type]: (cur + dir + group.length) % group.length };197 });198 }199 200 const alertGroups = createMemo(() => {201 const map = new Map<string, SyncAlert[]>();202 for (const a of syncAlerts()) {203 const g = map.get(a.type);204 if (g) g.push(a); else map.set(a.type, [a]);205 }206 return [...map.entries()];207 });208 function syncAlertText(a: SyncAlert): string {209 if (a.type === "DEVICE_REVOKED") {210 return `${a.deviceName} revoked a paired device — review your devices in Settings`;211 }212 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;213 }214 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");215 const [theme, setTheme] = createSignal<"dark" | "light">("dark");216 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");217 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);218 const [lockOnClose, setLockOnClose] = createSignal(false);219 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);220 const [breachMonitoring, setBreachMonitoring] = createSignal(true);221 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);222 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);223 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);224 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);225 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);226 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");227 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");228 229 230 231 232 const [proStatus, setProStatus] = createSignal(false);233 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);234 235 236 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");237 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);238 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");239 const [listSearchSeed, setListSearchSeed] = createSignal("");240 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);241 const [pendingCelebration, setPendingCelebration] = createSignal(false);242 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);243 244 245 246 const [returnToPanic, setReturnToPanic] = createSignal(false);247 248 249 const [editBackView, setEditBackView] = createSignal<View>("entry");250 const [settingsExiting, setSettingsExiting] = createSignal(false);251 const [showNotes, setShowNotes] = createSignal(false);252 253 254 255 const [syncBusy, setSyncBusy] = createSignal(false);256 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);257 258 259 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});260 const [pairedCount, setPairedCount] = createSignal(0);261 const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);262 const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);263 let initialSyncWaitGeneration = 0;264 const syncLight = () =>265 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());266 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());267 async function loadSyncIndicator() {268 const [stateRes, devicesRes] = await Promise.all([269 sendMessage({ type: "GET_SYNC_STATE" }),270 sendMessage({ type: "LIST_PAIRED_DEVICES" }),271 ]);272 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };273 if (s.ok && s.data) setSyncSummary(s.data);274 const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };275 if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);276 }277278 interface InitialSyncPairedDevice extends InitialSyncDevice {279 pairedAt?: number;280 }281282 async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {283 const response = (await sendMessage({284 type: "LIST_PAIRED_DEVICES",285 })) as {286 ok: boolean;287 data?: { devices?: InitialSyncPairedDevice[] };288 };289 return response.ok ? response.data?.devices ?? [] : [];290 }291292 async function resolveInitialSyncDeviceId(): Promise<string | null> {293 const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(294 () => null,295 );296 const devices = await listInitialSyncDevices().catch(() => []);297 if (stored && devices.some((device) => device.deviceId === stored)) {298 return stored;299 }300301 302 303 const newest = [...devices].sort(304 (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),305 )[0];306 if (!newest) return stored;307 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(308 () => undefined,309 );310 return newest.deviceId;311 }312313 async function finishInitialSync(generation: number): Promise<void> {314 if (generation !== initialSyncWaitGeneration) return;315 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);316 setInitialSyncDeviceId(null);317 setInitialSyncSlow(false);318 await refreshProStatus();319320 321 322 323 const celebration = (await sendMessage({324 type: "SHOULD_PLAY_CELEBRATION",325 })) as { ok: boolean; data?: boolean };326 const shouldCelebrate = celebration.ok && celebration.data === true;327 if (shouldCelebrate) {328 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });329 }330331 const opened = await openVault(undefined, shouldCelebrate);332 if (!opened || generation !== initialSyncWaitGeneration) return;333 if (shouldCelebrate) {334 setView("pro-celebration");335 window.setTimeout(() => {336 if (generation === initialSyncWaitGeneration) setView("entry");337 }, 2500);338 }339 }340341 async function beginInitialSyncWait(deviceId: string): Promise<void> {342 const generation = ++initialSyncWaitGeneration;343 setInitialSyncDeviceId(deviceId);344 setInitialSyncSlow(false);345 setView("initial-sync");346 const startedAt = Date.now();347348 349 350 void sendMessage({351 type: "TRIGGER_SYNC",352 payload: { reason: "foreground" },353 }).catch(() => undefined);354355 while (356 generation === initialSyncWaitGeneration &&357 view() === "initial-sync"358 ) {359 try {360 const devices = await listInitialSyncDevices();361 if (hasCompletedInitialSync(devices, deviceId)) {362 await finishInitialSync(generation);363 return;364 }365 } catch {366 367 }368 if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {369 setInitialSyncSlow(true);370 }371 await new Promise((resolve) =>372 window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),373 );374 }375 }376377 async function retryInitialSync(): Promise<void> {378 const deviceId =379 initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());380 if (deviceId) {381 await beginInitialSyncWait(deviceId);382 } else {383 setInitialSyncSlow(true);384 }385 }386387 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);388 389 390 391 392 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);393394 let contentPhaseTimer: number | undefined;395 396 397 let openedForInlineUnlock = false;398399 onCleanup(() => {400 initialSyncWaitGeneration += 1;401 });402403 function applyAccent(name: AccentColorName) {404 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;405 const hex = color.hex;406 document.documentElement.style.setProperty("--peach-accent", hex);407 408 409 document.documentElement.style.setProperty("--peach-glow", hex + "2A");410 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");411 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");412 }413414 createEffect(() => {415 applyAccent(accentColor());416 });417418 createEffect(() => {419 document.documentElement.classList.toggle("light", theme() === "light");420 document.body.classList.toggle("light", theme() === "light");421 });422423 createEffect(() => {424 document.documentElement.setAttribute("data-text-size", textSize());425 });426427 createEffect(() => {428 if (reduceMotion()) {429 document.documentElement.setAttribute("data-motion", "reduce");430 } else {431 document.documentElement.removeAttribute("data-motion");432 }433 });434435 436 437 438 439 440 const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));441442 const selectedIndex = createMemo(() => {443 const currentId = selectedEntry()?.id;444 if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;445 return nonNoteEntries().findIndex((entry) => entry.id === currentId);446 });447448 async function applyAccentColor(name: AccentColorName) {449 applyAccent(name);450 setAccentColor(name);451 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });452 }453454 async function applyTheme(t: "dark" | "light") {455 setTheme(t);456 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });457 }458459 async function applyTextSize(size: "small" | "medium" | "large") {460 setTextSize(size);461 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });462 }463464 async function applyReduceMotion(enabled: boolean) {465 setReduceMotion(enabled);466 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });467 }468469 470 471 472 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {473 setShortcutBindings(active);474 const overrides: Record<string, Keybinding> = {};475 for (const action of Object.keys(active) as ShortcutAction[]) {476 const a = active[action];477 const d = DEFAULT_BINDINGS[action];478 const same = a.key === d.key479 && Boolean(a.ctrl) === Boolean(d.ctrl)480 && Boolean(a.meta) === Boolean(d.meta)481 && Boolean(a.shift) === Boolean(d.shift)482 && Boolean(a.alt) === Boolean(d.alt);483 if (!same) overrides[action] = a;484 }485 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });486 }487488 async function applyLockOnClose(enabled: boolean) {489 setLockOnClose(enabled);490 await sendMessage({491 type: "UPDATE_SETTINGS",492 payload: { lockOnClose: enabled },493 });494 }495496 async function applyAutoLockMinutes(minutes: number) {497 setAutoLockMinutes(minutes);498 await sendMessage({499 type: "UPDATE_SETTINGS",500 payload: { autoLockMinutes: minutes },501 });502 }503504 async function applyBreachMonitoring(enabled: boolean) {505 setBreachMonitoring(enabled);506 await sendMessage({507 type: "UPDATE_SETTINGS",508 payload: { breachMonitoring: enabled },509 });510 }511512 513 514 515 function markOnboardingExit(flow: "create" | "restore" | "adopt") {516 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);517 void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});518 519 520 void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});521 }522 function clearOnboardingExit() {523 void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});524 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});525 setResumeRestoreSource(null);526 }527528 onMount(async () => {529 530 531 532 533 534 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([535 sendMessage({ type: "IS_UNLOCKED" }),536 sendMessage({ type: "HAS_VAULT" }),537 refreshCurrentTab(),538 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),539 ]);540 const unlocked = Boolean(541 (unlockResponse as { ok: boolean; data?: boolean }).data542 );543 const vaultExists = Boolean(544 (vaultResponse as { ok: boolean; data?: boolean }).data545 );546 const shouldCelebrate = Boolean(547 (celebrationResponse as { ok: boolean; data?: boolean }).data548 );549 setHasVault(vaultExists);550 551 552 553 try {554 const onboarded = await storageGet("peach_onboarded");555 const seen = onboarded === "true";556 if (vaultExists && !seen) {557 await storageSet("peach_onboarded", "true");558 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});559 await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});560 await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});561 setOnboarded(true);562 } else {563 setOnboarded(seen);564 if (!seen) {565 566 567 568 const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);569 if (exit === "create" || exit === "restore" || exit === "adopt") {570 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);571 setOnboarded(true);572 setStartOnboardingRestore(exit === "restore");573 if (exit === "restore") {574 575 576 const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);577 if (source === "symbols" || source === "codex" || source === "csv") {578 setResumeRestoreSource(source);579 }580 }581 if (exit === "adopt") setView("adopting");582 } else {583 584 585 const stored = Number(586 await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),587 );588 if (589 Number.isInteger(stored) &&590 stored > 0 &&591 stored <= ONBOARDING_LAST_CARD592 ) {593 setOnboardingCardAtExit(stored);594 }595 }596 }597 }598 } catch {599 600 setOnboarded(true);601 }602603 604 605 606 607 608 try {609 const restoreRoute = new URLSearchParams(window.location.search).get("restore");610 if (restoreRoute === "peachscript" || restoreRoute === "csv") {611 setOnboarded(true);612 setStartOnboardingRestore(true);613 setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");614 }615 } catch {616 617 }618619 await Promise.all([restoreSettings(), refreshProStatus()]);620621 const pendingInitialSyncMarker =622 unlocked && vaultExists623 ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)624 : null;625 const pendingInitialSyncDeviceId = pendingInitialSyncMarker626 ? await resolveInitialSyncDeviceId()627 : null;628629 630 631 632 if (shouldCelebrate && !pendingInitialSyncDeviceId) {633 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });634 }635636 637 638 if (unlocked && vaultExists) {639 void sendMessage({640 type: "TRIGGER_SYNC",641 payload: { reason: "foreground" },642 });643 void loadSyncIndicator();644 645 void loadSyncAlerts();646 }647648 649 650 651 652 653 654 let recoveryPending = false;655 try {656 const out = await chrome.storage.local?.get("peach_recovery_pending");657 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";658 } catch { }659660 661 662 663 if (chrome.storage.session) {664 try {665 const s = await chrome.storage.session.get("peach_inline_unlock");666 if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {667 openedForInlineUnlock = true;668 await chrome.storage.session.remove("peach_inline_unlock");669 }670 } catch { }671 }672673 if (recoveryPending && unlocked) {674 675 676 setForceRecoveryWords(true);677 setView("lock");678 } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {679 void beginInitialSyncWait(pendingInitialSyncDeviceId);680 } else if (unlocked && vaultExists) {681 await openVault(undefined, shouldCelebrate);682 if (shouldCelebrate) {683 setView("pro-celebration");684 window.setTimeout(() => setView("entry"), 2500);685 }686 } else if (shouldCelebrate) {687 setPendingCelebration(true);688 }689690 await refreshPendingPasskey();691692 693 const handleTabActivated = () => { void refreshCurrentTab(); };694 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {695 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();696 };697 try {698 chrome.tabs.onActivated.addListener(handleTabActivated);699 chrome.tabs.onUpdated.addListener(handleTabUpdated);700 onCleanup(() => {701 chrome.tabs.onActivated.removeListener(handleTabActivated);702 chrome.tabs.onUpdated.removeListener(handleTabUpdated);703 });704 } catch {705 706 }707708 setBootState("ready");709710 711 712 713 714 try {715 const route = new URLSearchParams(window.location.search).get("codex");716 if (route === "pdf" || route === "restore") {717 setView("codex-restore");718 }719 } catch {720 721 }722723 724 if (chrome.storage.session) {725 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as726 Record<string, { feature?: string } | undefined>;727 if (session.pendingProGate?.feature) {728 await chrome.storage.session.remove("pendingProGate");729 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);730 setSettingsInitialSection("license");731 setView("settings");732 } else if (session.pendingProNotice?.feature) {733 await chrome.storage.session.remove("pendingProNotice");734 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });735 }736 }737738 739 740 741 742 void Promise.all([743 import("./views/Settings").catch(() => {}),744 import("./views/Import").catch(() => {}),745 import("./views/Export").catch(() => {}),746 import("./views/Health").catch(() => {}),747 import("./views/TrashView").catch(() => {}),748 ]);749750 751 752 753 let popupPort: chrome.runtime.Port | null = null;754 try {755 756 757 758 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });759 } catch { }760 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });761762 763 764 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {765 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();766 if (msg?.type === "SYNC_STATE_CHANGED") {767 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);768 }769 if (msg?.type === "PRO_GATE_TRIGGERED") {770 setUpgradeHighlight(msg.payload?.feature ?? null);771 setSettingsInitialSection("license");772 setView("settings");773 }774 };775 chrome.runtime.onMessage.addListener(handleRuntimeMessage);776 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));777778 779 780 781 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {782 copy_password: () => {783 const entry = selectedEntry();784 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });785 },786 copy_username: () => {787 const entry = selectedEntry();788 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });789 },790 copy_totp: () => {791 const entry = selectedEntry();792 if (!entry?.totp) return;793 try {794 const { code } = generateTOTP(entry.totp);795 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });796 } catch { }797 },798 new_entry: () => handleAdd(),799 edit_entry: () => {800 const entry = selectedEntry();801 802 803 if (!entry || isPasskeyOnly(entry)) return;804 setEditEntry(entry);805 setView("add");806 },807 delete_entry: () => {808 if (selectedEntry()) void handleDelete();809 },810 lock_vault: () => { void handleHeaderLock(); },811 search: () => openList(),812 nav_prev: () => navigate(-1),813 nav_next: () => navigate(1),814 jump_vault: () => setView("entry"),815 jump_settings: () => setView("settings"),816 show_help: () => setShowShortcutsHelp(true),817 };818819 820 821 822 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([823 "copy_password", "copy_username", "copy_totp",824 "edit_entry", "delete_entry", "nav_prev", "nav_next",825 ]);826827 const handleGlobalKeydown = (e: KeyboardEvent) => {828 const v = view();829 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"830 || (e.target as HTMLElement)?.tagName === "TEXTAREA"831 || (e.target as HTMLElement)?.isContentEditable;832 const isPrintableSearchKey =833 e.key.length === 1 &&834 !e.ctrlKey &&835 !e.metaKey &&836 !e.altKey;837838 839 840 841 842 843 if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;844845 846 847 if (showShortcutsHelp()) {848 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }849 return;850 }851852 853 if (e.key === "Escape") {854 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }855 if (v === "list") { setView("entry"); return; }856 if (v === "settings" || v === "add") { handleBack(); return; }857 if (v === "import" || v === "health") { setView("settings"); return; }858 if (v === "export") { handleExportBack(); return; }859 if (v === "pair-device") { setView("settings"); return; }860 }861862 863 864 865 const bindings = shortcutBindings();866 for (const action of Object.keys(bindings) as ShortcutAction[]) {867 const binding = bindings[action];868 if (!matchKeybinding(e, binding)) continue;869 870 871 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;872 if (!hasMod && binding.key.length === 1 && inInput) return;873 if (ENTRY_SCOPED.has(action) && v !== "entry") return;874 e.preventDefault();875 SHORTCUT_HANDLERS[action]?.();876 return;877 }878879 880 881 882 883 if (v === "entry" && !inInput && isPrintableSearchKey) {884 e.preventDefault();885 primeListSearch(e.key);886 return;887 }888 };889 window.addEventListener("keydown", handleGlobalKeydown);890 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));891892 const handleStorageChanged = (893 changes: Record<string, chrome.storage.StorageChange>,894 areaName: string895 ) => {896 if (areaName !== "session") return;897 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {898 return;899 }900 void refreshPendingPasskey();901 };902 chrome.storage.onChanged.addListener(handleStorageChanged);903 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));904 });905906 async function refreshCurrentTab() {907 try {908 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });909 const url = tab?.url ?? tab?.pendingUrl ?? "";910 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");911 912 913 const isWebUrl = /^https?:\/\//i.test(url);914 setCurrentTabUrl(isWebUrl ? url : "");915 } catch {916 setCurrentTabDomain("");917 setCurrentTabUrl("");918 }919 }920921 async function refreshPendingPasskey() {922 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });923 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);924 }925926 async function refreshProStatus() {927 const res = await sendMessage({ type: "GET_LICENSE" });928 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;929 setProStatus(Boolean(data?.valid && data?.plan === "pro"));930 }931932 async function restoreSettings() {933 const res = await sendMessage({ type: "GET_SETTINGS" });934 const s = (res as { ok: boolean; data?: any }).data ?? {};935 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {936 setAccentColor(s.accentColor as AccentColorName);937 }938 setTheme(s.theme === "light" ? "light" : "dark");939 if (s.textSize === "small" || s.textSize === "large") {940 setTextSize(s.textSize);941 } else {942 setTextSize("medium");943 }944 setReduceMotion(s.reduceMotion === true);945 setLockOnClose(s.lockOnClose === true);946 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);947 setBreachMonitoring(s.breachMonitoring !== false);948 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);949 setShortcutBindings(getActiveBindings(s.shortcuts));950 }951952 async function applyTrashRetention(days: number) {953 setTrashRetentionDays(days);954 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });955 }956957 function primeListSearch(seed: string) {958 setListSearchSeed(seed);959 setListSearchSeedNonce((value) => value + 1);960 openList();961 }962963 964 965 966 async function loadEntries(): Promise<VaultEntry[] | null> {967 const response = await sendMessage({ type: "GET_ENTRIES" });968 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };969970 971 972 973 974 if (!res.ok) {975 if (976 res.error === "vault_locked" ||977 res.error === "SERVICE_WORKER_UNAVAILABLE" ||978 (typeof res.error === "string" && res.error.includes("locked"))979 ) {980 handleLock();981 return null; 982 }983 return [];984 }985986 const loadedEntries = res.data ?? [];987 setEntries(loadedEntries);988989 const currentId = selectedEntry()?.id;990 if (currentId) {991 setSelectedEntry(992 loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)993 );994 }995996 return loadedEntries;997 }998999 async function openVault(preferredEntryId?: string, skipViewSet = false) {1000 1001 const retDays = trashRetentionDays();1002 if (retDays > 0) {1003 void sendMessage({1004 type: "PURGE_EXPIRED_TRASH",1005 payload: { retentionMs: retDays * 86_400_000 },1006 });1007 }10081009 const loadedEntries = await loadEntries();10101011 1012 1013 if (loadedEntries === null) return false;10141015 1016 let nextSelectedEntry: VaultEntry | null = null;1017 if (preferredEntryId) {1018 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1019 }1020 if (!nextSelectedEntry) {1021 const tabDomain = currentTabDomain();1022 if (tabDomain) {1023 nextSelectedEntry = loadedEntries.find((e) => {1024 if (e.type === "login") {1025 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1026 for (const extra of e.additionalUrls ?? []) {1027 if ((extractDomain(extra) ?? "") === tabDomain) return true;1028 }1029 return false;1030 }1031 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1032 return false;1033 }) ?? null;1034 }1035 }1036 nextSelectedEntry ??= pickNavigable(loadedEntries);10371038 setSelectedEntry(nextSelectedEntry);1039 setEditEntry(undefined);1040 setContentTransitioning(false);1041 setContentPhase("enter");1042 queueContentIdle();1043 if (!skipViewSet) setView("entry");1044 return true;1045 }10461047 async function handleUnlock() {1048 1049 1050 1051 1052 1053 1054 let recoveryPending = false;1055 try {1056 const out = await chrome.storage.local?.get("peach_recovery_pending");1057 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1058 } catch { }1059 if (recoveryPending) {1060 setForceRecoveryWords(true);1061 setView("lock");1062 triggerPipPulse();1063 await restoreSettings();1064 return;1065 }1066 setHasVault(true);1067 triggerPipPulse();1068 await restoreSettings();1069 const celebrating = pendingCelebration();1070 if (celebrating) {1071 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1072 setPendingCelebration(false);1073 }1074 await openVault(undefined, celebrating);1075 if (celebrating) {1076 setView("pro-celebration");1077 window.setTimeout(() => setView("entry"), 2500);1078 }1079 1080 1081 if (openedForInlineUnlock) {1082 openedForInlineUnlock = false;1083 window.close();1084 }1085 }10861087 function handleSelectEntry(entry: VaultEntry) {1088 if (entry.type === "note") {1089 setOpenNoteId(entry.id);1090 setShowNotes(true);1091 return;1092 }1093 setSelectedEntry(entry);1094 setContentTransitioning(false);1095 setContentPhase("enter");1096 queueContentIdle();1097 setView("entry");1098 }10991100 function handleAdd() {1101 setEditEntry(undefined);1102 setView("add");1103 }11041105 async function handleDelete() {1106 const entry = selectedEntry();1107 if (!entry) return;1108 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1109 const loadedEntries = await loadEntries() ?? [];1110 setSelectedEntry(pickNavigable(loadedEntries));1111 setView("entry");1112 }11131114 async function handleDeleteMany(ids: string[]) {1115 for (const id of ids) {1116 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1117 }1118 const loadedEntries = await loadEntries() ?? [];1119 1120 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1121 if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1122 }11231124 async function handleSave(savedEntry?: VaultEntry) {1125 await openVault(savedEntry?.id);1126 }11271128 function handleBack() {1129 const dest = editBackView();1130 setEditBackView("entry");1131 setEditEntry(undefined);1132 setView(dest);1133 }11341135 function handleExportBack() {1136 if (returnToPanic()) {1137 setReturnToPanic(false);1138 setView("panic");1139 return;1140 }1141 setView("settings");1142 }11431144 function handleLock() {1145 clearCanarySuppressions();1146 setEntries([]);1147 setSelectedEntry(null);1148 setEditEntry(undefined);1149 setContentPhase("idle");1150 setView("lock");1151 }11521153 function handleHeaderLock() {1154 1155 1156 1157 handleLock();1158 void sendMessage({ type: "LOCK" });1159 }11601161 1162 1163 1164 async function handleHeaderSync(): Promise<void> {1165 if (syncBusy()) return;1166 setSyncBusy(true);1167 setSyncFlash(null);1168 try {1169 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1170 let state: SyncOutcomeSummary = { status: "syncing" };1171 for (let i = 0; i < 40 && state.status === "syncing"; i++) {1172 await new Promise((r) => setTimeout(r, 250));1173 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1174 ok: boolean;1175 data?: SyncOutcomeSummary;1176 };1177 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1178 }1179 setSyncSummary(state);1180 setSyncFlash(state.status === "error" ? "error" : "ok");1181 await loadSyncAlerts();1182 await loadEntries();1183 await loadSyncIndicator();1184 } catch {1185 setSyncSummary({ status: "error", outcome: "run_failed" });1186 setSyncFlash("error");1187 } finally {1188 setSyncBusy(false);1189 setTimeout(() => setSyncFlash(null), 2_000);1190 }1191 }11921193 function navigate(dir: number) {1194 const navigable = nonNoteEntries();1195 if (navigable.length === 0) return;1196 const currentIndex = selectedIndex();1197 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1198 const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1199 setSelectedEntry(navigable[nextIndex]);1200 setContentPhase("enter");1201 queueContentIdle();1202 }12031204 120512061207120812091210 function pickNavigable(list: VaultEntry[]): VaultEntry | null {1211 return list.find((e) => e.type !== "note") ?? null;1212 }12131214 function openList() {1215 setView("list");1216 }12171218 function openHealth() {1219 setEditEntry(undefined);1220 setView("health");1221 }12221223 function handleOpenFileImport() {1224 setRestoreView("file");1225 }12261227 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12281229 function handleFixEntry(entry: VaultEntry) {1230 setSelectedEntry(entry);1231 1232 1233 if (isPasskeyOnly(entry)) {1234 setView("entry");1235 return;1236 }1237 setEditBackView(view()); 1238 setEditEntry(entry);1239 setView("add");1240 }12411242 const isLockedView = () => view() === "lock" || view() === "adopting";12431244 async function handleAdoptionDone() {1245 await storageSet("peach_onboarded", "true").catch(() => {});1246 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1247 clearOnboardingExit();1248 setOnboarded(true);1249 setHasVault(true);1250 setView("initial-sync");1251 1252 1253 1254 await refreshProStatus();1255 const deviceId = await resolveInitialSyncDeviceId();1256 if (deviceId) {1257 void beginInitialSyncWait(deviceId);1258 } else {1259 1260 1261 1262 setInitialSyncSlow(true);1263 }1264 }12651266 async function handleApprovePendingPasskey() {1267 const pending = pendingPasskey();1268 if (!pending) return;1269 if (pending.browser === "firefox" && pending.type === "create") {1270 1271 1272 1273 1274 const { createFirefoxPasskey } = await import("./firefox-passkey");1275 const result = await createFirefoxPasskey(pending);1276 if (!result.ok) {1277 console.warn("Peach Firefox passkey create failed:", result.error);1278 }1279 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1280 setPendingPasskey(null);1281 void loadEntries();1282 window.close();1283 return;1284 }1285 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1286 window.close();1287 }12881289 async function handleDenyPendingPasskey() {1290 const pending = pendingPasskey();1291 if (!pending) return;1292 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1293 window.close();1294 }12951296 function queueContentIdle(after?: () => void) {1297 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1298 contentPhaseTimer = window.setTimeout(() => {1299 setContentPhase("idle");1300 after?.();1301 }, ENTRY_TRANSITION_SETTLE_MS);1302 }13031304 return (1305 <div class="popup-root peach-theme">1306 <Show when={bootState() === "ready"}>1307 <Show1308 when={!pendingPasskey()}1309 fallback={1310 <PasskeyApprovalView1311 type={pendingPasskey()!.type}1312 rpName={pendingPasskey()!.rpName}1313 rpId={pendingPasskey()!.rpId}1314 userName={pendingPasskey()!.userName}1315 userDisplayName={pendingPasskey()!.userDisplayName}1316 onApprove={handleApprovePendingPasskey}1317 onDeny={handleDenyPendingPasskey}1318 />1319 }1320 >1321 <Show1322 when={!isLockedView()}1323 fallback={1324 <Show1325 when={view() !== "adopting"}1326 fallback={1327 <Suspense fallback={<div class="peach-loading">loading...</div>}>1328 <AdoptVaultView1329 onCancel={() => {1330 clearOnboardingExit();1331 setOnboarded(false);1332 setView("lock");1333 }}1334 onDone={handleAdoptionDone}1335 />1336 </Suspense>1337 }1338 >1339 <Show1340 when={onboarded() || hasVault()}1341 fallback={1342 <Onboarding1343 initialCard={onboardingCardAtExit()}1344 onComplete={() => {1345 markOnboardingExit("create");1346 setStartOnboardingRestore(false);1347 setOnboarded(true);1348 }}1349 onRestore={() => {1350 markOnboardingExit("restore");1351 setStartOnboardingRestore(true);1352 setOnboarded(true);1353 }}1354 onAdopt={() => {1355 markOnboardingExit("adopt");1356 setStartOnboardingRestore(false);1357 setOnboarded(true);1358 setView("adopting");1359 }}1360 />1361 }1362 >1363 <LockView1364 hasVault={hasVault()}1365 ready={bootState() === "ready"}1366 isPro={proStatus()}1367 forceRecoveryWords={forceRecoveryWords()}1368 initialRestore={startOnboardingRestore()}1369 initialRestoreSource={resumeRestoreSource() ?? undefined}1370 onUnlock={() => {1371 1372 1373 1374 if (forceRecoveryWords()) setForceRecoveryWords(false);1375 1376 1377 1378 setStartOnboardingRestore(false);1379 setResumeRestoreSource(null);1380 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1381 return handleUnlock();1382 }}1383 onBackToOnboarding={1384 !hasVault()1385 ? () => {1386 1387 1388 clearOnboardingExit();1389 setStartOnboardingRestore(false);1390 setOnboarded(false);1391 }1392 : undefined1393 }1394 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1395 />1396 </Show>1397 </Show>1398 }1399 >1400 <Show1401 when={1402 view() !== "pro-celebration" &&1403 view() !== "initial-sync"1404 }1405 fallback={1406 <Show1407 when={view() === "initial-sync"}1408 fallback={<ProCelebration />}1409 >1410 <InitialSyncView1411 slow={initialSyncSlow()}1412 onRetry={() => void retryInitialSync()}1413 />1414 </Show>1415 }1416 >1417 <section1418 id="hd-shell"1419 class="peach-shell peach-theme"1420 data-surface="extension"1421 >1422 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1423 <PeachWordmark unlocked />1424 <div class="peach-chrome-actions">1425 <button1426 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1427 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1428 aria-label="Notes"1429 title="Notes"1430 >1431 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1432 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1433 <line x1="4" y1="5" x2="10" y2="5" />1434 <line x1="4" y1="7.5" x2="10" y2="7.5" />1435 <line x1="4" y1="10" x2="7" y2="10" />1436 </svg>1437 </button>1438 <button1439 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1440 classList={{1441 "is-syncing": syncBusy(),1442 "is-ok": syncFlash() === "ok",1443 "is-error": syncFlash() === "error",1444 }}1445 onClick={() => void handleHeaderSync()}1446 disabled={syncBusy()}1447 aria-label={`Sync now. ${syncLabel()}`}1448 title={syncLabel()}1449 >1450 <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">1451 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1452 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1453 <path d="M2.6 1v2.4h2.4" />1454 <path d="M11.4 13v-2.4H9" />1455 </svg>1456 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1457 </button>1458 <button1459 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1460 onClick={() => setView("settings")}1461 aria-label="Settings"1462 >1463 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1464 <path1465 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"1466 stroke="currentColor"1467 stroke-width="0.9"1468 />1469 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1470 </svg>1471 </button>1472 <button1473 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1474 onClick={handleAdd}1475 aria-label="New entry"1476 >1477 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1478 <path d="M5.5 1v9M1 5.5h9" />1479 </svg>1480 </button>1481 <button1482 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1483 onClick={() => void handleHeaderLock()}1484 aria-label="Lock vault"1485 >1486 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1487 <path1488 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"1489 stroke="currentColor"1490 stroke-width="1"1491 stroke-linecap="round"1492 />1493 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1494 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1495 </svg>1496 </button>1497 </div>1498 </header>14991500 <Show when={pendingProNotice() !== null}>1501 <div class="peach-pro-notice-banner">1502 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1503 <button1504 class="peach-pro-notice-link"1505 onClick={() => {1506 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1507 setSettingsInitialSection("license");1508 setView("settings");1509 setPendingProNotice(null);1510 }}1511 >1512 Learn more →1513 </button>1514 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1515 </div>1516 </Show>15171518 <div class="peach-shell-body">1519 <div class="peach-shell-veil"></div>1520 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1521 <EntryView1522 entry={selectedEntry()}1523 currentTabUrl={currentTabUrl()}1524 entries={entries()}1525 isPro={proStatus()}1526 onDelete={handleDelete}1527 onFieldSaved={() => void loadEntries()}1528 onEscape={() => { setView("list"); }}1529 onProGate={(feature) => {1530 setUpgradeHighlight(feature);1531 setSettingsInitialSection("license");1532 setView("settings");1533 }}1534 />1535 </div>15361537 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1538 <div class="peach-search-bar">1539 <span class="peach-search-icon">1540 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1541 <circle cx="5.5" cy="5.5" r="3.75" />1542 <path d="M8.75 8.75L11.5 11.5" />1543 </svg>1544 </span>1545 <input1546 class="peach-search-input"1547 placeholder="search vault..."1548 autocomplete="off"1549 aria-label="Search vault entries"1550 onClick={openList}1551 onFocus={openList}1552 readOnly1553 />1554 <div class="peach-search-meta">1555 <span class="peach-search-counter">1556 {nonNoteEntries().length > 01557 ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1558 : "0 entries"}1559 </span>1560 <span class="peach-divider"></span>1561 <button1562 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1563 onClick={() => navigate(-1)}1564 aria-label="Previous entry"1565 disabled={nonNoteEntries().length === 0}1566 >1567 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1568 <path d="M5 1L1 5.5L5 10" />1569 </svg>1570 </button>1571 <button1572 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1573 onClick={() => navigate(1)}1574 aria-label="Next entry"1575 disabled={nonNoteEntries().length === 0}1576 >1577 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1578 <path d="M1 1L5 5.5L1 10" />1579 </svg>1580 </button>1581 </div>1582 </div>1583 </section>1584 </div>15851586 {}1587 <Show when={alertGroups().length > 0}>1588 <div class="peach-sync-alerts">1589 <For each={alertGroups()}>1590 {([type, group]) => {1591 const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1592 return (1593 <div class="peach-sync-alert">1594 <Show when={group.length > 1}>1595 <button1596 class="peach-sync-alert-flip"1597 type="button"1598 aria-label="Previous"1599 onClick={() => flipAlert(type, -1)}1600 >‹</button>1601 </Show>1602 <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1603 <Show when={group.length > 1}>1604 <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1605 <button1606 class="peach-sync-alert-flip"1607 type="button"1608 aria-label="Next"1609 onClick={() => flipAlert(type, 1)}1610 >›</button>1611 </Show>1612 <button1613 class="peach-sync-alert-dismiss"1614 type="button"1615 aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1616 onClick={() => dismissAlertGroup(type)}1617 >×</button>1618 </div>1619 );1620 }}1621 </For>1622 </div>1623 </Show>16241625 <Show when={view() === "list"}>1626 <VaultView1627 entries={entries()}1628 selectedEntryId={selectedEntry()?.id}1629 currentTabDomain={currentTabDomain()}1630 sortOrder={sortOrder()}1631 searchSeed={listSearchSeed()}1632 searchSeedNonce={listSearchSeedNonce()}1633 onSelectEntry={handleSelectEntry}1634 onSortOrderChange={setSortOrder}1635 onAdd={handleAdd}1636 onClose={() => setView("entry")}1637 onOpenTrash={() => setView("trash")}1638 onDeleteMany={handleDeleteMany}1639 />1640 </Show>1641 <Show when={view() === "settings" || settingsExiting()}>1642 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1643 <SettingsView1644 entriesCount={entries().length}1645 breachMonitoring={breachMonitoring()}1646 lockOnClose={lockOnClose()}1647 autoLockMinutes={autoLockMinutes()}1648 trashRetentionDays={trashRetentionDays()}1649 theme={theme()}1650 accentColor={accentColor()}1651 textSize={textSize()}1652 reduceMotion={reduceMotion()}1653 isPro={proStatus()}1654 initialSection={settingsInitialSection()}1655 upgradeHighlight={upgradeHighlight()}1656 isExiting={settingsExiting()}1657 onBreachMonitoringChange={applyBreachMonitoring}1658 onLockOnCloseChange={applyLockOnClose}1659 onAutoLockMinutesChange={applyAutoLockMinutes}1660 onTrashRetentionChange={applyTrashRetention}1661 onThemeChange={applyTheme}1662 onAccentChange={applyAccentColor}1663 onTextSizeChange={applyTextSize}1664 onReduceMotionChange={applyReduceMotion}1665 shortcuts={shortcutBindings()}1666 onShortcutsChange={applyShortcuts}1667 onLicenseChange={async () => {1668 1669 1670 1671 1672 1673 await refreshProStatus();1674 void loadEntries();1675 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1676 if ((should as { ok: boolean; data?: boolean }).data === true) {1677 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1678 setView("pro-celebration");1679 window.setTimeout(() => setView("settings"), 2500);1680 }1681 }}1682 onBack={() => {1683 setSettingsExiting(true);1684 window.setTimeout(() => {1685 setSettingsExiting(false);1686 setSettingsInitialSection(null);1687 setUpgradeHighlight(null);1688 handleBack();1689 }, 200);1690 }}1691 onImport={() => setView("import")}1692 onExport={() => setView("export")}1693 onBreachCheckDone={loadEntries}1694 onOpenHealth={openHealth}1695 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1696 onOpenFileImport={handleOpenFileImport}1697 onOpenPanic={() => setView("panic")}1698 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1699 onOpenCodexRestore={() => setView("codex-restore")}1700 onCardChange={setSettingsActiveCard}1701 onProGate={(feature) => {1702 setUpgradeHighlight(feature);1703 setSettingsInitialSection("license");1704 }}1705 />1706 </Suspense>1707 </Show>1708 <Show when={view() === "codex-generate"}>1709 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1710 <CodexGenerateView1711 isPro={proStatus()}1712 onBack={() => setView("settings")}1713 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1714 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1715 onVaultLocked={() => handleLock()}1716 />1717 </Suspense>1718 </Show>1719 <Show when={view() === "codex-restore"}>1720 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1721 <CodexRestoreView1722 onBack={() => setView("lock")}1723 onRestoreSuccess={async () => {1724 1725 1726 1727 await handleUnlock();1728 }}1729 />1730 </Suspense>1731 </Show>1732 <Show when={view() === "pair-device"}>1733 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1734 <PairDeviceView1735 isPro={proStatus()}1736 onProChanged={() => { void refreshProStatus(); }}1737 onBack={() => setView("settings")}1738 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1739 />1740 </Suspense>1741 </Show>1742 <Show when={view() === "add"}>1743 <AddView1744 entry={editEntry()}1745 entries={entries()}1746 onBack={handleBack}1747 onSave={handleSave}1748 />1749 </Show>1750 <Show when={view() === "import"}>1751 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1752 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1753 </Suspense>1754 </Show>1755 <Show when={view() === "export"}>1756 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1757 <ExportView onBack={handleExportBack} />1758 </Suspense>1759 </Show>1760 <Show when={view() === "panic"}>1761 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1762 <PanicView1763 onCancel={() => setView("settings")}1764 onOpenExport={() => {1765 setReturnToPanic(true);1766 setView("export");1767 }}1768 onDestroyed={async () => {1769 clearCanarySuppressions();1770 1771 1772 setOnboardingCardAtExit(0);1773 void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1774 clearOnboardingExit();1775 setEntries([]);1776 setSelectedEntry(null);1777 setEditEntry(undefined);1778 setHasVault(false);1779 setOnboarded(false);1780 setProStatus(false);1781 setPendingPasskey(null);1782 setUpgradeHighlight(null);1783 setSettingsInitialSection(null);1784 setPendingProNotice(null);1785 setPendingCelebration(false);1786 setReturnToPanic(false);1787 setContentPhase("idle");1788 setView("lock");1789 }}1790 />1791 </Suspense>1792 </Show>1793 {1794}1795 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1796 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1797 <HealthView1798 entries={entries()}1799 isPro={proStatus()}1800 onFixEntry={handleFixEntry}1801 onBack={() => setView("settings")}1802 onRefresh={loadEntries}1803 hidden={view() !== "health"}1804 />1805 </Suspense>1806 </Show>1807 <Show when={view() === "trash"}>1808 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1809 <TrashView1810 onBack={() => setView("list")}1811 onRestored={() => void loadEntries()}1812 />1813 </Suspense>1814 </Show>1815 <Show when={showNotes()}>1816 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1817 <NotesMode1818 notes={entries().filter((e) => e.type === "note")}1819 initialNoteId={openNoteId()}1820 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1821 onNoteListChanged={() => void loadEntries()}1822 />1823 </div>1824 </Show>1825 <Show when={restoreView() === "file"}>1826 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1827 <ImportView1828 mode="onboarding"1829 replacesExistingVault={true}1830 onBack={() => setRestoreView(null)}1831 onImport={async () => {1832 setRestoreView(null);1833 await handleUnlock();1834 }}1835 />1836 </Suspense>1837 </Show>1838 </section>1839 </Show>1840 </Show>1841 </Show>1842 </Show>1843 <Show when={showShortcutsHelp()}>1844 <ShortcutsHelpOverlay1845 bindings={shortcutBindings()}1846 onClose={() => setShowShortcutsHelp(false)}1847 />1848 </Show>1849 </div>1850 );1851}18521853function ShortcutsHelpOverlay(props: {1854 bindings: Record<ShortcutAction, Keybinding>;1855 onClose: () => void;1856}) {1857 return (1858 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1859 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1860 <div class="peach-shortcuts-help-head">1861 <h3>Keyboard shortcuts</h3>1862 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1863 </div>1864 <div class="peach-shortcuts-help-body">1865 <p class="peach-settings-sub-label">ENTRY VIEW</p>1866 <ul>1867 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1868 <li>1869 <span>{d.label}</span>1870 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1871 </li>1872 ))}1873 </ul>1874 <p class="peach-settings-sub-label">ANYWHERE</p>1875 <ul>1876 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1877 <li>1878 <span>{d.label}</span>1879 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1880 </li>1881 ))}1882 </ul>1883 </div>1884 </div>1885 </div>1886 );1887}