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 { WhatsNewView } from "./views/WhatsNew";14import { shouldShowWhatsNew } from "../shared/release-notes";15import {16 ONBOARDING_CARD_STORAGE_KEY,17 ONBOARDING_LAST_CARD,18 Onboarding,19} from "./views/Onboarding";2021const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";22import { NotesMode } from "./views/NotesMode";23import { ProCelebration } from "./views/ProCelebration";24import { InitialSyncView } from "./views/InitialSyncView";25import { VaultView, type SortOrder } from "./views/Vault";26import { EntryView } from "./views/Entry";27import { AddView } from "./views/Add";28import { PeachWordmark } from "./components/PeachMark";29import { PasskeyApprovalView } from "./views/PasskeyApproval";30import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";31import {32 syncIndicator,33 syncIndicatorLabel,34 type SyncOutcomeSummary,35} from "../shared/sync-outcome";36import { popupCopy } from "./copy";37import {38 DEFAULT_BINDINGS,39 formatKeybinding,40 getActiveBindings,41 matchKeybinding,42 SHORTCUT_DESCRIPTORS,43 type Keybinding,44 type ShortcutAction,45} from "../shared/shortcuts";46import { generateTOTP } from "../crypto/totp";4748export { sendMessage } from "../shared/bridge";4950const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));51const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));52const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));53const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));54const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));55const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));56const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));57const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));58const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));59const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6061type View =62 | "lock"63 | "pro-celebration"64 | "whats-new"65 | "entry"66 | "list"67 | "add"68 | "settings"69 | "pair-device"70 | "import"71 | "export"72 | "health"73 | "trash"74 | "panic"75 | "adopting"76 | "initial-sync"77 | "codex-generate"78 | "codex-restore";79type BootState = "loading" | "ready";8081const INITIAL_SYNC_POLL_MS = 500;82const INITIAL_SYNC_SLOW_MS = 15_000;8384let shellRingAnimation: Animation | null = null;8586export function triggerRing() {87 const shell = document.getElementById("hd-shell");88 if (!shell) return;8990 shellRingAnimation?.cancel();91 shell.dataset.glowing = "true";92 const animation = shell.animate(93 [94 { offset: 0, boxShadow: "var(--copy-shadow)" },95 {96 offset: 0.3,97 boxShadow:98 "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)",99 },100 { offset: 1, boxShadow: "var(--copy-shadow)" },101 ],102 {103 duration: 720,104 easing: "cubic-bezier(0.4, 0, 0.2, 1)",105 fill: "none",106 }107 );108 shellRingAnimation = animation;109 const clearGlow = () => {110 if (shellRingAnimation !== animation) return;111 shellRingAnimation = null;112 delete shell.dataset.glowing;113 };114 animation.onfinish = clearGlow;115 animation.oncancel = clearGlow;116}117118export function triggerPipPulse() {119 const pip = document.querySelector(".peach-status-pip, .pip");120 if (pip) {121 pip.classList.remove("pulse");122 void (pip as HTMLElement).offsetWidth;123 pip.classList.add("pulse");124 }125}126127const ENTRY_TRANSITION_SETTLE_MS = 135;128129export default function App() {130 const [bootState, setBootState] = createSignal<BootState>("loading");131 const [view, setView] = createSignal<View>("lock");132 const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");133 const [hasVault, setHasVault] = createSignal(false);134 const [onboarded, setOnboarded] = createSignal(true);135 const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);136 137 138 139 const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);140 141 142 143 const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);144 const [entries, setEntries] = createSignal<VaultEntry[]>([]);145 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);146 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);147148 149 interface SyncAlert {150 type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";151 entryId: string;152 entryTitle: string;153 deviceName: string;154 timestamp: number;155 }156 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);157 158 const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});159 async function loadSyncAlerts() {160 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {161 ok: boolean;162 data?: SyncAlert[];163 };164 if (r.ok && (r.data?.length ?? 0) > 0) {165 setSyncAlerts(r.data ?? []);166 167 168 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });169 }170 }171 function dismissAlertGroup(type: string) {172 setSyncAlerts((arr) => arr.filter((a) => a.type !== type));173 setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });174 }175 function flipAlert(type: string, dir: 1 | -1) {176 const group = syncAlerts().filter((a) => a.type === type);177 if (group.length <= 1) return;178 setAlertFlipIdx((m) => {179 const cur = m[type] ?? 0;180 return { ...m, [type]: (cur + dir + group.length) % group.length };181 });182 }183 184 const alertGroups = createMemo(() => {185 const map = new Map<string, SyncAlert[]>();186 for (const a of syncAlerts()) {187 const g = map.get(a.type);188 if (g) g.push(a); else map.set(a.type, [a]);189 }190 return [...map.entries()];191 });192 function syncAlertText(a: SyncAlert): string {193 if (a.type === "DEVICE_REVOKED") {194 return `${a.deviceName} revoked a paired device — review your devices in Settings`;195 }196 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;197 }198 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");199 const [theme, setTheme] = createSignal<"dark" | "light">("dark");200 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");201 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);202 const [lockOnClose, setLockOnClose] = createSignal(false);203 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);204 const [breachMonitoring, setBreachMonitoring] = createSignal(true);205 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);206 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);207 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);208 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);209 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);210 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");211 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");212 213 214 215 216 const [proStatus, setProStatus] = createSignal(false);217 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);218 219 220 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");221 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);222 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");223 const [listSearchSeed, setListSearchSeed] = createSignal("");224 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);225 const [pendingCelebration, setPendingCelebration] = createSignal(false);226 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);227 228 229 230 const [pendingRememberSite, setPendingRememberSite] = createSignal<{231 entryId: string;232 url: string;233 title: string;234 } | null>(null);235 236 237 238 const [returnToPanic, setReturnToPanic] = createSignal(false);239 240 241 const [editBackView, setEditBackView] = createSignal<View>("entry");242 const [settingsExiting, setSettingsExiting] = createSignal(false);243 const [showNotes, setShowNotes] = createSignal(false);244 245 246 247 const [syncBusy, setSyncBusy] = createSignal(false);248 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);249 250 251 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});252 const [pairedCount, setPairedCount] = createSignal(0);253 const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);254 const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);255 let initialSyncWaitGeneration = 0;256 const syncLight = () =>257 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());258 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());259 async function loadSyncIndicator() {260 const [stateRes, devicesRes] = await Promise.all([261 sendMessage({ type: "GET_SYNC_STATE" }),262 sendMessage({ type: "LIST_PAIRED_DEVICES" }),263 ]);264 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };265 if (s.ok && s.data) setSyncSummary(s.data);266 const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };267 if (d.ok) {268 setPairedCount(d.data?.devices?.length ?? 0);269 } else if (d.error === "trust_data_corrupted") {270 setPairedCount(0);271 setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });272 }273 }274275 async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {276 const response = (await sendMessage({277 type: "LIST_PAIRED_DEVICES",278 })) as {279 ok: boolean;280 error?: string;281 data?: { devices?: InitialSyncPairedDevice[] };282 };283 if (response.ok) return response.data?.devices ?? [];284 if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");285 throw new Error(response.error ?? "list_paired_devices_failed");286 }287 interface InitialSyncPairedDevice extends InitialSyncDevice {288 pairedAt?: number;289 }290291292 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 document.documentElement.style.setProperty("--peach-glow", hex + "2A");409 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");410 }411412 createEffect(() => {413 applyAccent(accentColor());414 });415416 createEffect(() => {417 document.documentElement.classList.toggle("light", theme() === "light");418 document.body.classList.toggle("light", theme() === "light");419 });420421 createEffect(() => {422 document.documentElement.setAttribute("data-text-size", textSize());423 });424425 createEffect(() => {426 if (reduceMotion()) {427 document.documentElement.setAttribute("data-motion", "reduce");428 } else {429 document.documentElement.removeAttribute("data-motion");430 }431 });432433 434 435 436 437 438 const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));439440 const selectedIndex = createMemo(() => {441 const currentId = selectedEntry()?.id;442 if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;443 return nonNoteEntries().findIndex((entry) => entry.id === currentId);444 });445446 async function applyAccentColor(name: AccentColorName) {447 applyAccent(name);448 setAccentColor(name);449 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });450 }451452 async function applyTheme(t: "dark" | "light") {453 setTheme(t);454 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });455 }456457 async function applyTextSize(size: "small" | "medium" | "large") {458 setTextSize(size);459 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });460 }461462 async function applyReduceMotion(enabled: boolean) {463 setReduceMotion(enabled);464 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });465 }466467 468 469 470 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {471 setShortcutBindings(active);472 const overrides: Record<string, Keybinding> = {};473 for (const action of Object.keys(active) as ShortcutAction[]) {474 const a = active[action];475 const d = DEFAULT_BINDINGS[action];476 const same = a.key === d.key477 && Boolean(a.ctrl) === Boolean(d.ctrl)478 && Boolean(a.meta) === Boolean(d.meta)479 && Boolean(a.shift) === Boolean(d.shift)480 && Boolean(a.alt) === Boolean(d.alt);481 if (!same) overrides[action] = a;482 }483 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });484 }485486 async function applyLockOnClose(enabled: boolean) {487 setLockOnClose(enabled);488 await sendMessage({489 type: "UPDATE_SETTINGS",490 payload: { lockOnClose: enabled },491 });492 }493494 async function applyAutoLockMinutes(minutes: number) {495 setAutoLockMinutes(minutes);496 await sendMessage({497 type: "UPDATE_SETTINGS",498 payload: { autoLockMinutes: minutes },499 });500 }501502 async function applyBreachMonitoring(enabled: boolean) {503 setBreachMonitoring(enabled);504 await sendMessage({505 type: "UPDATE_SETTINGS",506 payload: { breachMonitoring: enabled },507 });508 }509510 511 512 513 function markOnboardingExit(flow: "create" | "restore" | "adopt") {514 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);515 void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});516 517 518 void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});519 }520 function clearOnboardingExit() {521 void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});522 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});523 setResumeRestoreSource(null);524 }525526 onMount(async () => {527 528 529 530 531 532 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([533 sendMessage({ type: "IS_UNLOCKED" }),534 sendMessage({ type: "HAS_VAULT" }),535 refreshCurrentTab(),536 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),537 ]);538 const unlocked = Boolean(539 (unlockResponse as { ok: boolean; data?: boolean }).data540 );541 const vaultExists = Boolean(542 (vaultResponse as { ok: boolean; data?: boolean }).data543 );544 const shouldCelebrate = Boolean(545 (celebrationResponse as { ok: boolean; data?: boolean }).data546 );547 setHasVault(vaultExists);548 549 550 551 try {552 const onboarded = await storageGet("peach_onboarded");553 const seen = onboarded === "true";554 if (vaultExists && !seen) {555 await storageSet("peach_onboarded", "true");556 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});557 await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});558 await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});559 setOnboarded(true);560 } else {561 setOnboarded(seen);562 if (!seen) {563 564 565 566 const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);567 if (exit === "create" || exit === "restore" || exit === "adopt") {568 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);569 setOnboarded(true);570 setStartOnboardingRestore(exit === "restore");571 if (exit === "restore") {572 573 574 const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);575 if (source === "symbols" || source === "codex" || source === "csv") {576 setResumeRestoreSource(source);577 }578 }579 if (exit === "adopt") setView("adopting");580 } else {581 582 583 const stored = Number(584 await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),585 );586 if (587 Number.isInteger(stored) &&588 stored > 0 &&589 stored <= ONBOARDING_LAST_CARD590 ) {591 setOnboardingCardAtExit(stored);592 }593 }594 }595 }596 } catch {597 598 setOnboarded(true);599 }600601 602 603 604 605 606 try {607 const restoreRoute = new URLSearchParams(window.location.search).get("restore");608 if (restoreRoute === "peachscript" || restoreRoute === "csv") {609 setOnboarded(true);610 setStartOnboardingRestore(true);611 setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");612 }613 } catch {614 615 }616617 await Promise.all([restoreSettings(), refreshProStatus()]);618619 const pendingInitialSyncMarker =620 unlocked && vaultExists621 ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)622 : null;623 const pendingInitialSyncDeviceId = pendingInitialSyncMarker624 ? await resolveInitialSyncDeviceId()625 : null;626627 628 629 630 if (shouldCelebrate && !pendingInitialSyncDeviceId) {631 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });632 }633634 635 636 if (unlocked && vaultExists) {637 void sendMessage({638 type: "TRIGGER_SYNC",639 payload: { reason: "foreground" },640 });641 void loadSyncIndicator();642 643 void loadSyncAlerts();644 }645646 647 648 649 650 651 let recoveryPending = false;652 try {653 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });654 recoveryPending = out.ok === true && out.data === true;655 } catch { }656657 658 659 let pendingOpenEntryId: string | undefined;660 if (chrome.storage.session) {661 try {662 const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);663 const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };664 if (pending.peach_inline_unlock) {665 openedForInlineUnlock = true;666 }667 if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;668 await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);669 } catch { }670 }671672 if (recoveryPending && unlocked) {673 674 675 setForceRecoveryWords(true);676 setView("lock");677 } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {678 void beginInitialSyncWait(pendingInitialSyncDeviceId);679 } else if (unlocked && vaultExists) {680 await openVault(pendingOpenEntryId, shouldCelebrate);681 if (shouldCelebrate) {682 setView("pro-celebration");683 window.setTimeout(() => setView("entry"), 2500);684 } else if (await shouldShowWhatsNew()) {685 setView("whats-new");686 }687 } else if (shouldCelebrate) {688 setPendingCelebration(true);689 }690691 await refreshPendingPasskey();692693 694 const handleTabActivated = () => { void refreshCurrentTab(); };695 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {696 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();697 };698 try {699 chrome.tabs.onActivated.addListener(handleTabActivated);700 chrome.tabs.onUpdated.addListener(handleTabUpdated);701 onCleanup(() => {702 chrome.tabs.onActivated.removeListener(handleTabActivated);703 chrome.tabs.onUpdated.removeListener(handleTabUpdated);704 });705 } catch {706 707 }708709 setBootState("ready");710711 712 713 714 715 try {716 const route = new URLSearchParams(window.location.search).get("codex");717 if (route === "pdf" || route === "restore") {718 setView("codex-restore");719 }720 } catch {721 722 }723724 725 if (chrome.storage.session) {726 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as727 Record<string, { feature?: string } | undefined>;728 if (session.pendingProGate?.feature) {729 await chrome.storage.session.remove("pendingProGate");730 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);731 setSettingsInitialSection("license");732 setView("settings");733 } else if (session.pendingProNotice?.feature) {734 await chrome.storage.session.remove("pendingProNotice");735 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });736 }737 const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {738 pendingRememberSite?: { entryId?: unknown; url?: unknown };739 };740 const request = remembered.pendingRememberSite;741 if (742 typeof request?.entryId === "string" &&743 typeof request.url === "string"744 ) {745 const entry = await sendMessage({ type: "GET_ENTRIES" });746 const match = entry.ok747 ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(748 (candidate) => candidate.id === request.entryId,749 )750 : undefined;751 if (match) {752 let host = request.url;753 try { host = new URL(request.url).hostname; } catch { }754 setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });755 } else {756 await chrome.storage.session.remove("pendingRememberSite");757 }758 }759 }760761 762 763 764 765 void Promise.all([766 import("./views/Settings").catch(() => {}),767 import("./views/Import").catch(() => {}),768 import("./views/Export").catch(() => {}),769 import("./views/Health").catch(() => {}),770 import("./views/TrashView").catch(() => {}),771 ]);772773 774 775 776 let popupPort: chrome.runtime.Port | null = null;777 try {778 779 780 781 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });782 } catch { }783 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });784785 786 787 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {788 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();789 if (msg?.type === "SYNC_STATE_CHANGED") {790 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);791 }792 if (msg?.type === "PRO_GATE_TRIGGERED") {793 setUpgradeHighlight(msg.payload?.feature ?? null);794 setSettingsInitialSection("license");795 setView("settings");796 }797 };798 chrome.runtime.onMessage.addListener(handleRuntimeMessage);799 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));800801 802 803 804 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {805 copy_password: () => {806 const entry = selectedEntry();807 if (entry?.password) void popupCopy(808 entry.password,809 "Password",810 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,811 ).then((ok) => { if (ok) triggerRing(); });812 },813 copy_username: () => {814 const entry = selectedEntry();815 if (entry?.username) void popupCopy(816 entry.username,817 "Username",818 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,819 ).then((ok) => { if (ok) triggerRing(); });820 },821 copy_totp: () => {822 const entry = selectedEntry();823 if (!entry?.totp) return;824 try {825 const { code } = generateTOTP(826 entry.totp,827 entry.totpAlgorithm,828 entry.totpDigits,829 entry.totpPeriod,830 );831 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });832 } catch { }833 },834 new_entry: () => handleAdd(),835 edit_entry: () => {836 const entry = selectedEntry();837 838 839 if (!entry || isPasskeyOnly(entry)) return;840 setEditEntry(entry);841 setView("add");842 },843 delete_entry: () => {844 if (selectedEntry()) void handleDelete();845 },846 lock_vault: () => { void handleHeaderLock(); },847 search: () => openList(),848 nav_prev: () => navigate(-1),849 nav_next: () => navigate(1),850 jump_vault: () => setView("entry"),851 jump_settings: () => setView("settings"),852 show_help: () => setShowShortcutsHelp(true),853 };854855 856 857 858 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([859 "copy_password", "copy_username", "copy_totp",860 "edit_entry", "delete_entry", "nav_prev", "nav_next",861 ]);862863 const handleGlobalKeydown = (e: KeyboardEvent) => {864 const v = view();865 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"866 || (e.target as HTMLElement)?.tagName === "TEXTAREA"867 || (e.target as HTMLElement)?.isContentEditable;868 const isPrintableSearchKey =869 e.key.length === 1 &&870 !e.ctrlKey &&871 !e.metaKey &&872 !e.altKey;873874 875 876 877 878 879 if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;880881 882 883 if (showShortcutsHelp()) {884 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }885 return;886 }887888 889 if (e.key === "Escape") {890 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }891 if (v === "list") { setView("entry"); return; }892 if (v === "settings" || v === "add") { handleBack(); return; }893 if (v === "import" || v === "health") { setView("settings"); return; }894 if (v === "export") { handleExportBack(); return; }895 if (v === "pair-device") { setView("settings"); return; }896 }897898 899 900 901 const bindings = shortcutBindings();902 for (const action of Object.keys(bindings) as ShortcutAction[]) {903 const binding = bindings[action];904 if (!matchKeybinding(e, binding)) continue;905 906 907 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;908 if (!hasMod && binding.key.length === 1 && inInput) return;909 if (ENTRY_SCOPED.has(action) && v !== "entry") return;910 e.preventDefault();911 SHORTCUT_HANDLERS[action]?.();912 return;913 }914915 916 917 918 919 if (v === "entry" && !inInput && isPrintableSearchKey) {920 e.preventDefault();921 primeListSearch(e.key);922 return;923 }924 };925 window.addEventListener("keydown", handleGlobalKeydown);926 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));927928 const handleStorageChanged = (929 changes: Record<string, chrome.storage.StorageChange>,930 areaName: string931 ) => {932 if (areaName !== "session") return;933 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {934 return;935 }936 void refreshPendingPasskey();937 };938 chrome.storage.onChanged.addListener(handleStorageChanged);939 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));940 });941942 async function refreshCurrentTab() {943 try {944 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });945 const url = tab?.url ?? tab?.pendingUrl ?? "";946 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");947 948 949 const isWebUrl = /^https?:\/\//i.test(url);950 setCurrentTabUrl(isWebUrl ? url : "");951 } catch {952 setCurrentTabDomain("");953 setCurrentTabUrl("");954 }955 }956957 async function refreshPendingPasskey() {958 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });959 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);960 }961962 async function refreshProStatus() {963 const res = await sendMessage({ type: "GET_LICENSE" });964 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;965 setProStatus(Boolean(data?.valid && data?.plan === "pro"));966 }967968 async function restoreSettings() {969 const res = await sendMessage({ type: "GET_SETTINGS" });970 const s = (res as { ok: boolean; data?: any }).data ?? {};971 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {972 setAccentColor(s.accentColor as AccentColorName);973 }974 setTheme(s.theme === "light" ? "light" : "dark");975 if (s.textSize === "small" || s.textSize === "large") {976 setTextSize(s.textSize);977 } else {978 setTextSize("medium");979 }980 setReduceMotion(s.reduceMotion === true);981 setLockOnClose(s.lockOnClose === true);982 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);983 setBreachMonitoring(s.breachMonitoring !== false);984 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);985 setShortcutBindings(getActiveBindings(s.shortcuts));986 }987988 async function applyTrashRetention(days: number) {989 setTrashRetentionDays(days);990 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });991 }992993 function primeListSearch(seed: string) {994 setListSearchSeed(seed);995 setListSearchSeedNonce((value) => value + 1);996 openList();997 }998999 1000 1001 1002 async function loadEntries(): Promise<VaultEntry[] | null> {1003 const response = await sendMessage({ type: "GET_ENTRIES" });1004 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10051006 1007 1008 1009 1010 if (!res.ok) {1011 if (1012 res.error === "vault_locked" ||1013 res.error === "SERVICE_WORKER_UNAVAILABLE" ||1014 (typeof res.error === "string" && res.error.includes("locked"))1015 ) {1016 handleLock();1017 return null; 1018 }1019 return [];1020 }10211022 const loadedEntries = res.data ?? [];1023 setEntries(loadedEntries);10241025 const currentId = selectedEntry()?.id;1026 if (currentId) {1027 setSelectedEntry(1028 loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1029 );1030 }10311032 return loadedEntries;1033 }10341035 async function openVault(preferredEntryId?: string, skipViewSet = false) {1036 1037 const retDays = trashRetentionDays();1038 if (retDays > 0) {1039 void sendMessage({1040 type: "PURGE_EXPIRED_TRASH",1041 payload: { retentionMs: retDays * 86_400_000 },1042 });1043 }10441045 const loadedEntries = await loadEntries();10461047 1048 1049 if (loadedEntries === null) return false;10501051 1052 let nextSelectedEntry: VaultEntry | null = null;1053 if (preferredEntryId) {1054 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1055 }1056 if (!nextSelectedEntry) {1057 const tabDomain = currentTabDomain();1058 if (tabDomain) {1059 nextSelectedEntry = loadedEntries.find((e) => {1060 if (e.type === "login") {1061 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1062 for (const extra of e.additionalUrls ?? []) {1063 if ((extractDomain(extra) ?? "") === tabDomain) return true;1064 }1065 return false;1066 }1067 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1068 return false;1069 }) ?? null;1070 }1071 }1072 nextSelectedEntry ??= pickNavigable(loadedEntries);10731074 setSelectedEntry(nextSelectedEntry);1075 setEditEntry(undefined);1076 setContentPhase("enter");1077 queueContentIdle();1078 if (!skipViewSet) setView("entry");1079 return true;1080 }10811082 async function handleUnlock() {1083 1084 1085 1086 1087 1088 1089 let recoveryPending = false;1090 try {1091 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1092 recoveryPending = out.ok === true && out.data === true;1093 } catch { }1094 if (recoveryPending) {1095 setForceRecoveryWords(true);1096 setView("lock");1097 triggerPipPulse();1098 await restoreSettings();1099 return;1100 }1101 setHasVault(true);1102 triggerPipPulse();1103 await restoreSettings();1104 const celebrating = pendingCelebration();1105 if (celebrating) {1106 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1107 setPendingCelebration(false);1108 }1109 await openVault(undefined, celebrating);1110 if (celebrating) {1111 setView("pro-celebration");1112 window.setTimeout(() => setView("entry"), 2500);1113 } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1114 setView("whats-new");1115 }1116 1117 1118 if (openedForInlineUnlock) {1119 openedForInlineUnlock = false;1120 window.close();1121 }1122 }11231124 function handleSelectEntry(entry: VaultEntry) {1125 if (entry.type === "note") {1126 setOpenNoteId(entry.id);1127 setShowNotes(true);1128 return;1129 }1130 setSelectedEntry(entry);1131 setContentPhase("enter");1132 queueContentIdle();1133 setView("entry");1134 }11351136 function handleAdd() {1137 setEditEntry(undefined);1138 setView("add");1139 }11401141 async function handleDelete() {1142 const entry = selectedEntry();1143 if (!entry) return;1144 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1145 const loadedEntries = await loadEntries() ?? [];1146 setSelectedEntry(pickNavigable(loadedEntries));1147 setView("entry");1148 }11491150 async function handleDeleteMany(ids: string[]) {1151 for (const id of ids) {1152 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1153 }1154 const loadedEntries = await loadEntries() ?? [];1155 1156 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1157 if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1158 }11591160 async function handleSave(savedEntry?: VaultEntry) {1161 await openVault(savedEntry?.id);1162 }11631164 function handleBack() {1165 const dest = editBackView();1166 setEditBackView("entry");1167 setEditEntry(undefined);1168 setView(dest);1169 }11701171 function handleExportBack() {1172 if (returnToPanic()) {1173 setReturnToPanic(false);1174 setView("panic");1175 return;1176 }1177 setView("settings");1178 }11791180 function handleLock() {1181 setEntries([]);1182 setSelectedEntry(null);1183 setEditEntry(undefined);1184 setContentPhase("idle");1185 setView("lock");1186 }11871188 function handleHeaderLock() {1189 1190 1191 1192 handleLock();1193 void sendMessage({ type: "LOCK" });1194 }11951196 1197 1198 1199 async function handleHeaderSync(): Promise<void> {1200 if (syncBusy()) return;1201 setSyncBusy(true);1202 setSyncFlash(null);1203 try {1204 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1205 let state: SyncOutcomeSummary = { status: "syncing" };1206 for (let i = 0; i < 40 && state.status === "syncing"; i++) {1207 await new Promise((r) => setTimeout(r, 250));1208 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1209 ok: boolean;1210 data?: SyncOutcomeSummary;1211 };1212 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1213 }1214 setSyncSummary(state);1215 setSyncFlash(state.status === "error" ? "error" : "ok");1216 await loadSyncAlerts();1217 await loadEntries();1218 await loadSyncIndicator();1219 } catch {1220 setSyncSummary({ status: "error", outcome: "run_failed" });1221 setSyncFlash("error");1222 } finally {1223 setSyncBusy(false);1224 setTimeout(() => setSyncFlash(null), 2_000);1225 }1226 }12271228 function navigate(dir: number) {1229 const navigable = nonNoteEntries();1230 if (navigable.length === 0) return;1231 const currentIndex = selectedIndex();1232 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1233 const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1234 setSelectedEntry(navigable[nextIndex]);1235 setContentPhase("enter");1236 queueContentIdle();1237 }12381239 124012411242124312441245 function pickNavigable(list: VaultEntry[]): VaultEntry | null {1246 return list.find((e) => e.type !== "note") ?? null;1247 }12481249 function openList() {1250 setView("list");1251 }12521253 function openHealth() {1254 setEditEntry(undefined);1255 setView("health");1256 }12571258 function handleOpenFileImport() {1259 setRestoreView("file");1260 }12611262 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12631264 function handleFixEntry(entry: VaultEntry) {1265 setSelectedEntry(entry);1266 1267 1268 if (isPasskeyOnly(entry)) {1269 setView("entry");1270 return;1271 }1272 setEditBackView(view()); 1273 setEditEntry(entry);1274 setView("add");1275 }12761277 const isLockedView = () => view() === "lock" || view() === "adopting";12781279 async function handleAdoptionDone() {1280 await storageSet("peach_onboarded", "true").catch(() => {});1281 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1282 clearOnboardingExit();1283 setOnboarded(true);1284 setHasVault(true);1285 setView("initial-sync");1286 1287 1288 1289 await refreshProStatus();1290 const deviceId = await resolveInitialSyncDeviceId();1291 if (deviceId) {1292 void beginInitialSyncWait(deviceId);1293 } else {1294 1295 1296 1297 setInitialSyncSlow(true);1298 }1299 }13001301 async function handleApprovePendingPasskey() {1302 const pending = pendingPasskey();1303 if (!pending) return;1304 if (pending.browser === "firefox" && pending.type === "create") {1305 1306 1307 1308 1309 const { createFirefoxPasskey } = await import("./firefox-passkey");1310 const result = await createFirefoxPasskey(pending);1311 if (!result.ok) {1312 console.warn("Peach Firefox passkey create failed:", result.error);1313 }1314 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1315 setPendingPasskey(null);1316 void loadEntries();1317 window.close();1318 return;1319 }1320 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1321 window.close();1322 }13231324 async function handleDenyPendingPasskey() {1325 const pending = pendingPasskey();1326 if (!pending) return;1327 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1328 window.close();1329 }13301331 function queueContentIdle(after?: () => void) {1332 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1333 contentPhaseTimer = window.setTimeout(() => {1334 setContentPhase("idle");1335 after?.();1336 }, ENTRY_TRANSITION_SETTLE_MS);1337 }13381339 return (1340 <div class="popup-root peach-theme">1341 <Show when={bootState() === "ready"}>1342 <Show1343 when={!pendingPasskey()}1344 fallback={1345 <PasskeyApprovalView1346 type={pendingPasskey()!.type}1347 rpName={pendingPasskey()!.rpName}1348 rpId={pendingPasskey()!.rpId}1349 userName={pendingPasskey()!.userName}1350 userDisplayName={pendingPasskey()!.userDisplayName}1351 onApprove={handleApprovePendingPasskey}1352 onDeny={handleDenyPendingPasskey}1353 />1354 }1355 >1356 <Show1357 when={!isLockedView()}1358 fallback={1359 <Show1360 when={view() !== "adopting"}1361 fallback={1362 <Suspense fallback={<div class="peach-loading">loading...</div>}>1363 <AdoptVaultView1364 onCancel={() => {1365 clearOnboardingExit();1366 setOnboarded(false);1367 setView("lock");1368 }}1369 onDone={handleAdoptionDone}1370 />1371 </Suspense>1372 }1373 >1374 <Show1375 when={onboarded() || hasVault()}1376 fallback={1377 <Onboarding1378 initialCard={onboardingCardAtExit()}1379 onComplete={() => {1380 markOnboardingExit("create");1381 setStartOnboardingRestore(false);1382 setOnboarded(true);1383 }}1384 onRestore={() => {1385 markOnboardingExit("restore");1386 setStartOnboardingRestore(true);1387 setOnboarded(true);1388 }}1389 onAdopt={() => {1390 markOnboardingExit("adopt");1391 setStartOnboardingRestore(false);1392 setOnboarded(true);1393 setView("adopting");1394 }}1395 />1396 }1397 >1398 <LockView1399 hasVault={hasVault()}1400 ready={bootState() === "ready"}1401 isPro={proStatus()}1402 forceRecoveryWords={forceRecoveryWords()}1403 initialRestore={startOnboardingRestore()}1404 initialRestoreSource={resumeRestoreSource() ?? undefined}1405 onUnlock={() => {1406 1407 1408 1409 if (forceRecoveryWords()) setForceRecoveryWords(false);1410 1411 1412 1413 setStartOnboardingRestore(false);1414 setResumeRestoreSource(null);1415 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1416 return handleUnlock();1417 }}1418 onBackToOnboarding={1419 !hasVault()1420 ? () => {1421 1422 1423 clearOnboardingExit();1424 setStartOnboardingRestore(false);1425 setOnboarded(false);1426 }1427 : undefined1428 }1429 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1430 />1431 </Show>1432 </Show>1433 }1434 >1435 <Show1436 when={1437 view() !== "pro-celebration" &&1438 view() !== "initial-sync" &&1439 view() !== "whats-new"1440 }1441 fallback={1442 <Show1443 when={view() === "initial-sync"}1444 fallback={1445 <Show1446 when={view() === "whats-new"}1447 fallback={<ProCelebration />}1448 >1449 <WhatsNewView onContinue={() => setView("entry")} />1450 </Show>1451 }1452 >1453 <InitialSyncView1454 slow={initialSyncSlow()}1455 onRetry={() => void retryInitialSync()}1456 />1457 </Show>1458 }1459 >1460 <section1461 id="hd-shell"1462 class="peach-shell peach-theme"1463 data-surface="extension"1464 >1465 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1466 <PeachWordmark unlocked />1467 <div class="peach-chrome-actions">1468 <button1469 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1470 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1471 aria-label="Notes"1472 title="Notes"1473 >1474 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1475 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1476 <line x1="4" y1="5" x2="10" y2="5" />1477 <line x1="4" y1="7.5" x2="10" y2="7.5" />1478 <line x1="4" y1="10" x2="7" y2="10" />1479 </svg>1480 </button>1481 <button1482 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1483 classList={{1484 "is-syncing": syncBusy(),1485 "is-ok": syncFlash() === "ok",1486 "is-error": syncFlash() === "error",1487 }}1488 onClick={() => void handleHeaderSync()}1489 disabled={syncBusy()}1490 aria-label={`Sync now. ${syncLabel()}`}1491 title={syncLabel()}1492 >1493 <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">1494 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1495 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1496 <path d="M2.6 1v2.4h2.4" />1497 <path d="M11.4 13v-2.4H9" />1498 </svg>1499 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1500 </button>1501 <button1502 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1503 onClick={() => setView("settings")}1504 aria-label="Settings"1505 >1506 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1507 <path1508 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"1509 stroke="currentColor"1510 stroke-width="0.9"1511 />1512 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1513 </svg>1514 </button>1515 <button1516 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1517 onClick={handleAdd}1518 aria-label="New entry"1519 >1520 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1521 <path d="M5.5 1v9M1 5.5h9" />1522 </svg>1523 </button>1524 <button1525 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1526 onClick={() => void handleHeaderLock()}1527 aria-label="Lock vault"1528 >1529 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1530 <path1531 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"1532 stroke="currentColor"1533 stroke-width="1"1534 stroke-linecap="round"1535 />1536 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1537 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1538 </svg>1539 </button>1540 </div>1541 </header>15421543 <Show when={pendingProNotice() !== null}>1544 <div class="peach-pro-notice-banner">1545 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1546 <button1547 class="peach-pro-notice-link"1548 onClick={() => {1549 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1550 setSettingsInitialSection("license");1551 setView("settings");1552 setPendingProNotice(null);1553 }}1554 >1555 Learn more →1556 </button>1557 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1558 </div>1559 </Show>15601561 <Show when={pendingRememberSite()}>1562 {(request) => {1563 const origin = getSecurityOriginDisplay(request().url);1564 const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1565 return (1566 <div class="peach-pro-notice-banner">1567 <span>Also use “{request().title}” on {originLabel}?</span>1568 <button1569 class="peach-pro-notice-link"1570 onClick={async () => {1571 await sendMessage({1572 type: "ADD_ADDITIONAL_URL",1573 payload: { id: request().entryId, url: request().url },1574 });1575 await chrome.storage.session.remove("pendingRememberSite");1576 setPendingRememberSite(null);1577 }}1578 >1579 Add1580 </button>1581 <button1582 class="peach-pro-notice-close"1583 onClick={async () => {1584 await chrome.storage.session.remove("pendingRememberSite");1585 setPendingRememberSite(null);1586 }}1587 aria-label="Dismiss"1588 >1589 ✕1590 </button>1591 </div>1592 );1593 }}1594 </Show>15951596 <div class="peach-shell-body">1597 <div class="peach-shell-veil"></div>1598 <div class="peach-content-frame" data-content-phase={contentPhase()}>1599 <EntryView1600 entry={selectedEntry()}1601 currentTabUrl={currentTabUrl()}1602 entries={entries()}1603 isPro={proStatus()}1604 onDelete={handleDelete}1605 onFieldSaved={() => void loadEntries()}1606 onEscape={() => { setView("list"); }}1607 onProGate={(feature) => {1608 setUpgradeHighlight(feature);1609 setSettingsInitialSection("license");1610 setView("settings");1611 }}1612 />1613 </div>16141615 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1616 <div class="peach-search-bar">1617 <span class="peach-search-icon">1618 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1619 <circle cx="5.5" cy="5.5" r="3.75" />1620 <path d="M8.75 8.75L11.5 11.5" />1621 </svg>1622 </span>1623 <input1624 class="peach-search-input"1625 placeholder="search vault..."1626 autocomplete="off"1627 aria-label="Search vault entries"1628 onClick={openList}1629 onFocus={openList}1630 readOnly1631 />1632 <div class="peach-search-meta">1633 <span class="peach-search-counter">1634 {nonNoteEntries().length > 01635 ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1636 : "0 entries"}1637 </span>1638 <span class="peach-divider"></span>1639 <button1640 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1641 onClick={() => navigate(-1)}1642 aria-label="Previous entry"1643 disabled={nonNoteEntries().length === 0}1644 >1645 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1646 <path d="M5 1L1 5.5L5 10" />1647 </svg>1648 </button>1649 <button1650 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1651 onClick={() => navigate(1)}1652 aria-label="Next entry"1653 disabled={nonNoteEntries().length === 0}1654 >1655 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1656 <path d="M1 1L5 5.5L1 10" />1657 </svg>1658 </button>1659 </div>1660 </div>1661 </section>1662 </div>16631664 {}1665 <Show when={alertGroups().length > 0}>1666 <div class="peach-sync-alerts">1667 <For each={alertGroups()}>1668 {([type, group]) => {1669 const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1670 return (1671 <div class="peach-sync-alert">1672 <Show when={group.length > 1}>1673 <button1674 class="peach-sync-alert-flip"1675 type="button"1676 aria-label="Previous"1677 onClick={() => flipAlert(type, -1)}1678 >‹</button>1679 </Show>1680 <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1681 <Show when={group.length > 1}>1682 <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1683 <button1684 class="peach-sync-alert-flip"1685 type="button"1686 aria-label="Next"1687 onClick={() => flipAlert(type, 1)}1688 >›</button>1689 </Show>1690 <button1691 class="peach-sync-alert-dismiss"1692 type="button"1693 aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1694 onClick={() => dismissAlertGroup(type)}1695 >×</button>1696 </div>1697 );1698 }}1699 </For>1700 </div>1701 </Show>17021703 <Show when={view() === "list"}>1704 <VaultView1705 entries={entries()}1706 selectedEntryId={selectedEntry()?.id}1707 currentTabDomain={currentTabDomain()}1708 sortOrder={sortOrder()}1709 searchSeed={listSearchSeed()}1710 searchSeedNonce={listSearchSeedNonce()}1711 onSelectEntry={handleSelectEntry}1712 onSortOrderChange={setSortOrder}1713 onAdd={handleAdd}1714 onClose={() => setView("entry")}1715 onOpenTrash={() => setView("trash")}1716 onDeleteMany={handleDeleteMany}1717 />1718 </Show>1719 <Show when={view() === "settings" || settingsExiting()}>1720 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1721 <SettingsView1722 entriesCount={entries().length}1723 breachMonitoring={breachMonitoring()}1724 lockOnClose={lockOnClose()}1725 autoLockMinutes={autoLockMinutes()}1726 trashRetentionDays={trashRetentionDays()}1727 theme={theme()}1728 accentColor={accentColor()}1729 textSize={textSize()}1730 reduceMotion={reduceMotion()}1731 isPro={proStatus()}1732 initialSection={settingsInitialSection()}1733 upgradeHighlight={upgradeHighlight()}1734 isExiting={settingsExiting()}1735 onBreachMonitoringChange={applyBreachMonitoring}1736 onLockOnCloseChange={applyLockOnClose}1737 onAutoLockMinutesChange={applyAutoLockMinutes}1738 onTrashRetentionChange={applyTrashRetention}1739 onThemeChange={applyTheme}1740 onAccentChange={applyAccentColor}1741 onTextSizeChange={applyTextSize}1742 onReduceMotionChange={applyReduceMotion}1743 shortcuts={shortcutBindings()}1744 onShortcutsChange={applyShortcuts}1745 onLicenseChange={async () => {1746 1747 1748 1749 1750 1751 await refreshProStatus();1752 void loadEntries();1753 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1754 if ((should as { ok: boolean; data?: boolean }).data === true) {1755 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1756 setView("pro-celebration");1757 window.setTimeout(() => setView("settings"), 2500);1758 }1759 }}1760 onBack={() => {1761 setSettingsExiting(true);1762 window.setTimeout(() => {1763 setSettingsExiting(false);1764 setSettingsInitialSection(null);1765 setUpgradeHighlight(null);1766 handleBack();1767 }, 200);1768 }}1769 onImport={() => setView("import")}1770 onExport={() => setView("export")}1771 onBreachCheckDone={loadEntries}1772 onOpenHealth={openHealth}1773 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1774 onOpenFileImport={handleOpenFileImport}1775 onOpenPanic={() => setView("panic")}1776 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1777 onOpenCodexRestore={() => setView("codex-restore")}1778 onCardChange={setSettingsActiveCard}1779 onProGate={(feature) => {1780 setUpgradeHighlight(feature);1781 setSettingsInitialSection("license");1782 }}1783 />1784 </Suspense>1785 </Show>1786 <Show when={view() === "codex-generate"}>1787 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1788 <CodexGenerateView1789 isPro={proStatus()}1790 onBack={() => setView("settings")}1791 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1792 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1793 onVaultLocked={() => handleLock()}1794 />1795 </Suspense>1796 </Show>1797 <Show when={view() === "codex-restore"}>1798 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1799 <CodexRestoreView1800 onBack={() => setView("lock")}1801 onRestoreSuccess={async () => {1802 1803 1804 1805 await handleUnlock();1806 }}1807 />1808 </Suspense>1809 </Show>1810 <Show when={view() === "pair-device"}>1811 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1812 <PairDeviceView1813 isPro={proStatus()}1814 onProChanged={() => { void refreshProStatus(); }}1815 onBack={() => setView("settings")}1816 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1817 />1818 </Suspense>1819 </Show>1820 <Show when={view() === "add"}>1821 <AddView1822 entry={editEntry()}1823 entries={entries()}1824 onBack={handleBack}1825 onSave={handleSave}1826 />1827 </Show>1828 <Show when={view() === "import"}>1829 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1830 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1831 </Suspense>1832 </Show>1833 <Show when={view() === "export"}>1834 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1835 <ExportView onBack={handleExportBack} />1836 </Suspense>1837 </Show>1838 <Show when={view() === "panic"}>1839 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1840 <PanicView1841 onCancel={() => setView("settings")}1842 onOpenExport={() => {1843 setReturnToPanic(true);1844 setView("export");1845 }}1846 onDestroyed={async () => {1847 1848 1849 setOnboardingCardAtExit(0);1850 void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1851 clearOnboardingExit();1852 setEntries([]);1853 setSelectedEntry(null);1854 setEditEntry(undefined);1855 setHasVault(false);1856 setOnboarded(false);1857 setProStatus(false);1858 setPendingPasskey(null);1859 setUpgradeHighlight(null);1860 setSettingsInitialSection(null);1861 setPendingProNotice(null);1862 setPendingCelebration(false);1863 setReturnToPanic(false);1864 setContentPhase("idle");1865 setView("lock");1866 }}1867 />1868 </Suspense>1869 </Show>1870 {1871}1872 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1873 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1874 <HealthView1875 entries={entries()}1876 isPro={proStatus()}1877 onFixEntry={handleFixEntry}1878 onBack={() => setView("settings")}1879 onRefresh={loadEntries}1880 hidden={view() !== "health"}1881 />1882 </Suspense>1883 </Show>1884 <Show when={view() === "trash"}>1885 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1886 <TrashView1887 onBack={() => setView("list")}1888 onRestored={() => void loadEntries()}1889 />1890 </Suspense>1891 </Show>1892 <Show when={showNotes()}>1893 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1894 <NotesMode1895 notes={entries().filter((e) => e.type === "note")}1896 initialNoteId={openNoteId()}1897 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1898 onNoteListChanged={() => void loadEntries()}1899 />1900 </div>1901 </Show>1902 <Show when={restoreView() === "file"}>1903 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1904 <ImportView1905 mode="onboarding"1906 replacesExistingVault={true}1907 onBack={() => setRestoreView(null)}1908 onImport={async () => {1909 setRestoreView(null);1910 await handleUnlock();1911 }}1912 />1913 </Suspense>1914 </Show>1915 </section>1916 </Show>1917 </Show>1918 </Show>1919 </Show>1920 <Show when={showShortcutsHelp()}>1921 <ShortcutsHelpOverlay1922 bindings={shortcutBindings()}1923 onClose={() => setShowShortcutsHelp(false)}1924 />1925 </Show>1926 </div>1927 );1928}19291930function ShortcutsHelpOverlay(props: {1931 bindings: Record<ShortcutAction, Keybinding>;1932 onClose: () => void;1933}) {1934 return (1935 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1936 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1937 <div class="peach-shortcuts-help-head">1938 <h3>Keyboard shortcuts</h3>1939 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1940 </div>1941 <div class="peach-shortcuts-help-body">1942 <p class="peach-settings-sub-label">ENTRY VIEW</p>1943 <ul>1944 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1945 <li>1946 <span>{d.label}</span>1947 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1948 </li>1949 ))}1950 </ul>1951 <p class="peach-settings-sub-label">ANYWHERE</p>1952 <ul>1953 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1954 <li>1955 <span>{d.label}</span>1956 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1957 </li>1958 ))}1959 </ul>1960 </div>1961 </div>1962 </div>1963 );1964}