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 { ProNudgeView } from "./views/ProNudge";16import { claimProNudge } from "../shared/pro-nudge";17import {18 ONBOARDING_CARD_STORAGE_KEY,19 ONBOARDING_LAST_CARD,20 Onboarding,21} from "./views/Onboarding";2223const ONBOARDING_EXIT_STORAGE_KEY = "peach_onboarding_exit";24import { NotesMode } from "./views/NotesMode";25import { ProCelebration } from "./views/ProCelebration";26import { InitialSyncView } from "./views/InitialSyncView";27import { VaultView, type SortOrder } from "./views/Vault";28import { EntryView } from "./views/Entry";29import { AddView } from "./views/Add";30import { PeachWordmark } from "./components/PeachMark";31import { PasskeyApprovalView } from "./views/PasskeyApproval";32import { detectHomograph, extractDomain, getSecurityOriginDisplay } from "../shared/domain";33import {34 isManualSyncPending,35 syncIndicator,36 syncIndicatorLabel,37 type SyncOutcomeSummary,38} from "../shared/sync-outcome";39import { popupCopy } from "./copy";40import {41 DEFAULT_BINDINGS,42 formatKeybinding,43 getActiveBindings,44 matchKeybinding,45 SHORTCUT_DESCRIPTORS,46 type Keybinding,47 type ShortcutAction,48} from "../shared/shortcuts";49import { generateTOTP } from "../crypto/totp";5051export { sendMessage } from "../shared/bridge";5253const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));54const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));55const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));56const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));57const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));58const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));59const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));60const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));61const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));62const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));6364type View =65 | "lock"66 | "pro-celebration"67 | "whats-new"68 | "pro-nudge"69 | "entry"70 | "list"71 | "add"72 | "settings"73 | "pair-device"74 | "import"75 | "export"76 | "health"77 | "trash"78 | "panic"79 | "adopting"80 | "initial-sync"81 | "codex-generate"82 | "codex-restore";83type BootState = "loading" | "ready";8485const INITIAL_SYNC_POLL_MS = 500;86const INITIAL_SYNC_SLOW_MS = 15_000;8788let shellRingAnimation: Animation | null = null;8990export function triggerRing() {91 const shell = document.getElementById("hd-shell");92 if (!shell) return;9394 shellRingAnimation?.cancel();95 shell.dataset.glowing = "true";96 const animation = shell.animate(97 [98 { offset: 0, boxShadow: "var(--copy-shadow)" },99 {100 offset: 0.3,101 boxShadow:102 "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)",103 },104 { offset: 1, boxShadow: "var(--copy-shadow)" },105 ],106 {107 duration: 720,108 easing: "cubic-bezier(0.4, 0, 0.2, 1)",109 fill: "none",110 }111 );112 shellRingAnimation = animation;113 const clearGlow = () => {114 if (shellRingAnimation !== animation) return;115 shellRingAnimation = null;116 delete shell.dataset.glowing;117 };118 animation.onfinish = clearGlow;119 animation.oncancel = clearGlow;120}121122export function triggerPipPulse() {123 const pip = document.querySelector(".peach-status-pip, .pip");124 if (pip) {125 pip.classList.remove("pulse");126 void (pip as HTMLElement).offsetWidth;127 pip.classList.add("pulse");128 }129}130131const ENTRY_TRANSITION_SETTLE_MS = 135;132133export default function App() {134 const [bootState, setBootState] = createSignal<BootState>("loading");135 const [view, setView] = createSignal<View>("lock");136 const [contentPhase, setContentPhase] = createSignal<"enter" | "idle">("enter");137 const [hasVault, setHasVault] = createSignal(false);138 const [onboarded, setOnboarded] = createSignal(true);139 const [startOnboardingRestore, setStartOnboardingRestore] = createSignal(false);140 141 142 143 const [resumeRestoreSource, setResumeRestoreSource] = createSignal<RestoreSource | null>(null);144 145 146 147 const [onboardingCardAtExit, setOnboardingCardAtExit] = createSignal(0);148 const [entries, setEntries] = createSignal<VaultEntry[]>([]);149 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);150 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);151152 153 interface SyncAlert {154 type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";155 entryId: string;156 entryTitle: string;157 deviceName: string;158 timestamp: number;159 }160 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);161 162 const [alertFlipIdx, setAlertFlipIdx] = createSignal<Record<string, number>>({});163 async function loadSyncAlerts() {164 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {165 ok: boolean;166 data?: SyncAlert[];167 };168 if (r.ok) setSyncAlerts(r.data ?? []);169 }170 async function dismissAlertGroup(type: SyncAlert["type"]) {171 const result = (await sendMessage({172 type: "DISMISS_SYNC_ALERTS",173 payload: { type },174 })) as { ok: boolean };175 if (!result.ok) return;176 setSyncAlerts((arr) => arr.filter((a) => a.type !== type));177 setAlertFlipIdx((m) => { const n = { ...m }; delete n[type]; return n; });178 }179 function flipAlert(type: string, dir: 1 | -1) {180 const group = syncAlerts().filter((a) => a.type === type);181 if (group.length <= 1) return;182 setAlertFlipIdx((m) => {183 const cur = m[type] ?? 0;184 return { ...m, [type]: (cur + dir + group.length) % group.length };185 });186 }187 188 const alertGroups = createMemo(() => {189 const map = new Map<string, SyncAlert[]>();190 for (const a of syncAlerts()) {191 const g = map.get(a.type);192 if (g) g.push(a); else map.set(a.type, [a]);193 }194 return [...map.entries()];195 });196 function syncAlertText(a: SyncAlert): string {197 if (a.type === "DEVICE_REVOKED") {198 return `${a.deviceName} revoked a paired device — review your devices in Settings`;199 }200 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;201 }202 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");203 const [theme, setTheme] = createSignal<"dark" | "light">("dark");204 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");205 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);206 const [lockOnClose, setLockOnClose] = createSignal(false);207 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);208 const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);209 const [breachMonitoring, setBreachMonitoring] = createSignal(true);210 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);211 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);212 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);213 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);214 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);215 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");216 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");217 218 219 220 221 const [proStatus, setProStatus] = createSignal(false);222 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);223 224 225 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");226 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);227 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");228 const [listSearchSeed, setListSearchSeed] = createSignal("");229 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);230 const [pendingCelebration, setPendingCelebration] = createSignal(false);231 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);232 233 234 235 const [pendingRememberSite, setPendingRememberSite] = createSignal<{236 entryId: string;237 url: string;238 title: string;239 } | null>(null);240 241 242 243 const [returnToPanic, setReturnToPanic] = createSignal(false);244 245 246 const [editBackView, setEditBackView] = createSignal<View>("entry");247 const [settingsExiting, setSettingsExiting] = createSignal(false);248 const [showNotes, setShowNotes] = createSignal(false);249 250 251 252 const [syncBusy, setSyncBusy] = createSignal(false);253 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);254 255 256 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});257 const [pairedCount, setPairedCount] = createSignal(0);258 const [initialSyncDeviceId, setInitialSyncDeviceId] = createSignal<string | null>(null);259 const [initialSyncSlow, setInitialSyncSlow] = createSignal(false);260 let initialSyncWaitGeneration = 0;261 const syncLight = () =>262 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());263 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());264 async function loadSyncIndicator() {265 const [stateRes, devicesRes] = await Promise.all([266 sendMessage({ type: "GET_SYNC_STATE" }),267 sendMessage({ type: "LIST_PAIRED_DEVICES" }),268 ]);269 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };270 if (s.ok && s.data) setSyncSummary(s.data);271 const d = devicesRes as { ok: boolean; error?: string; data?: { devices?: unknown[] } };272 if (d.ok) {273 setPairedCount(d.data?.devices?.length ?? 0);274 } else if (d.error === "trust_data_corrupted") {275 setPairedCount(0);276 setSyncSummary({ status: "error", outcome: "trust_data_corrupted" });277 }278 }279280 async function listInitialSyncDevices(): Promise<InitialSyncPairedDevice[]> {281 const response = (await sendMessage({282 type: "LIST_PAIRED_DEVICES",283 })) as {284 ok: boolean;285 error?: string;286 data?: { devices?: InitialSyncPairedDevice[] };287 };288 if (response.ok) return response.data?.devices ?? [];289 if (response.error === "trust_data_corrupted") throw new Error("trust_data_corrupted");290 throw new Error(response.error ?? "list_paired_devices_failed");291 }292 interface InitialSyncPairedDevice extends InitialSyncDevice {293 pairedAt?: number;294 }295296297 async function resolveInitialSyncDeviceId(): Promise<string | null> {298 const stored = await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(299 () => null,300 );301 const devices = await listInitialSyncDevices().catch(() => []);302 if (stored && devices.some((device) => device.deviceId === stored)) {303 return stored;304 }305306 307 308 const newest = [...devices].sort(309 (a, b) => (b.pairedAt ?? 0) - (a.pairedAt ?? 0),310 )[0];311 if (!newest) return stored;312 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, newest.deviceId).catch(313 () => undefined,314 );315 return newest.deviceId;316 }317318 async function finishInitialSync(generation: number): Promise<void> {319 if (generation !== initialSyncWaitGeneration) return;320 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);321 setInitialSyncDeviceId(null);322 setInitialSyncSlow(false);323 await refreshProStatus();324325 326 327 328 const celebration = (await sendMessage({329 type: "SHOULD_PLAY_CELEBRATION",330 })) as { ok: boolean; data?: boolean };331 const shouldCelebrate = celebration.ok && celebration.data === true;332 if (shouldCelebrate) {333 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });334 }335336 const opened = await openVault(undefined, shouldCelebrate);337 if (!opened || generation !== initialSyncWaitGeneration) return;338 if (shouldCelebrate) {339 setView("pro-celebration");340 window.setTimeout(() => {341 if (generation === initialSyncWaitGeneration) setView("entry");342 }, 2500);343 }344 }345346 async function beginInitialSyncWait(deviceId: string): Promise<void> {347 const generation = ++initialSyncWaitGeneration;348 setInitialSyncDeviceId(deviceId);349 setInitialSyncSlow(false);350 setView("initial-sync");351 const startedAt = Date.now();352353 354 355 void sendMessage({356 type: "TRIGGER_SYNC",357 payload: { reason: "foreground" },358 }).catch(() => undefined);359360 while (361 generation === initialSyncWaitGeneration &&362 view() === "initial-sync"363 ) {364 try {365 const devices = await listInitialSyncDevices();366 if (hasCompletedInitialSync(devices, deviceId)) {367 await finishInitialSync(generation);368 return;369 }370 } catch {371 372 }373 if (Date.now() - startedAt >= INITIAL_SYNC_SLOW_MS) {374 setInitialSyncSlow(true);375 }376 await new Promise((resolve) =>377 window.setTimeout(resolve, INITIAL_SYNC_POLL_MS),378 );379 }380 }381382 async function retryInitialSync(): Promise<void> {383 const deviceId =384 initialSyncDeviceId() ?? (await resolveInitialSyncDeviceId());385 if (deviceId) {386 await beginInitialSyncWait(deviceId);387 } else {388 setInitialSyncSlow(true);389 }390 }391392 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);393 394 395 396 397 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);398399 let contentPhaseTimer: number | undefined;400 401 402 let openedForInlineUnlock = false;403404 onCleanup(() => {405 initialSyncWaitGeneration += 1;406 });407408 function applyAccent(name: AccentColorName) {409 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;410 const hex = color.hex;411 document.documentElement.style.setProperty("--peach-accent", hex);412 413 document.documentElement.style.setProperty("--peach-glow", hex + "2A");414 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");415 }416417 createEffect(() => {418 applyAccent(accentColor());419 });420421 createEffect(() => {422 document.documentElement.classList.toggle("light", theme() === "light");423 document.body.classList.toggle("light", theme() === "light");424 });425426 createEffect(() => {427 document.documentElement.setAttribute("data-text-size", textSize());428 });429430 createEffect(() => {431 if (reduceMotion()) {432 document.documentElement.setAttribute("data-motion", "reduce");433 } else {434 document.documentElement.removeAttribute("data-motion");435 }436 });437438 439 440 441 442 443 const nonNoteEntries = createMemo(() => entries().filter((e) => e.type !== "note"));444445 const selectedIndex = createMemo(() => {446 const currentId = selectedEntry()?.id;447 if (!currentId) return nonNoteEntries().length > 0 ? 0 : -1;448 return nonNoteEntries().findIndex((entry) => entry.id === currentId);449 });450451 async function applyAccentColor(name: AccentColorName) {452 applyAccent(name);453 setAccentColor(name);454 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });455 }456457 async function applyTheme(t: "dark" | "light") {458 setTheme(t);459 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });460 }461462 async function applyTextSize(size: "small" | "medium" | "large") {463 setTextSize(size);464 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });465 }466467 async function applyReduceMotion(enabled: boolean) {468 setReduceMotion(enabled);469 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });470 }471472 473 474 475 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {476 setShortcutBindings(active);477 const overrides: Record<string, Keybinding> = {};478 for (const action of Object.keys(active) as ShortcutAction[]) {479 const a = active[action];480 const d = DEFAULT_BINDINGS[action];481 const same = a.key === d.key482 && Boolean(a.ctrl) === Boolean(d.ctrl)483 && Boolean(a.meta) === Boolean(d.meta)484 && Boolean(a.shift) === Boolean(d.shift)485 && Boolean(a.alt) === Boolean(d.alt);486 if (!same) overrides[action] = a;487 }488 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });489 }490491 async function applyLockOnClose(enabled: boolean) {492 setLockOnClose(enabled);493 await sendMessage({494 type: "UPDATE_SETTINGS",495 payload: { lockOnClose: enabled },496 });497 }498499 async function applyAutoLockMinutes(minutes: number) {500 setAutoLockMinutes(minutes);501 await sendMessage({502 type: "UPDATE_SETTINGS",503 payload: { autoLockMinutes: minutes },504 });505 }506 async function applyLockedSyncRequiresUnlock(enabled: boolean) {507 setLockedSyncRequiresUnlock(enabled);508 await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });509 }510511 async function applyBreachMonitoring(enabled: boolean) {512 setBreachMonitoring(enabled);513 await sendMessage({514 type: "UPDATE_SETTINGS",515 payload: { breachMonitoring: enabled },516 });517 }518519 520 521 522 function markOnboardingExit(flow: "create" | "restore" | "adopt") {523 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);524 void storageSet(ONBOARDING_EXIT_STORAGE_KEY, flow).catch(() => {});525 526 527 void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(ONBOARDING_LAST_CARD)).catch(() => {});528 }529 function clearOnboardingExit() {530 void storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});531 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});532 setResumeRestoreSource(null);533 }534535 onMount(async () => {536 537 538 539 540 541 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([542 sendMessage({ type: "IS_UNLOCKED" }),543 sendMessage({ type: "HAS_VAULT" }),544 refreshCurrentTab(),545 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),546 ]);547 const unlocked = Boolean(548 (unlockResponse as { ok: boolean; data?: boolean }).data549 );550 const vaultExists = Boolean(551 (vaultResponse as { ok: boolean; data?: boolean }).data552 );553 const shouldCelebrate = Boolean(554 (celebrationResponse as { ok: boolean; data?: boolean }).data555 );556 setHasVault(vaultExists);557 558 559 560 try {561 const onboarded = await storageGet("peach_onboarded");562 const seen = onboarded === "true";563 if (vaultExists && !seen) {564 await storageSet("peach_onboarded", "true");565 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});566 await storageRemove(ONBOARDING_EXIT_STORAGE_KEY).catch(() => {});567 await storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});568 setOnboarded(true);569 } else {570 setOnboarded(seen);571 if (!seen) {572 573 574 575 const exit = await storageGet(ONBOARDING_EXIT_STORAGE_KEY).catch(() => null);576 if (exit === "create" || exit === "restore" || exit === "adopt") {577 setOnboardingCardAtExit(ONBOARDING_LAST_CARD);578 setOnboarded(true);579 setStartOnboardingRestore(exit === "restore");580 if (exit === "restore") {581 582 583 const source = await storageGet(RESTORE_SOURCE_STORAGE_KEY).catch(() => null);584 if (source === "symbols" || source === "codex" || source === "csv") {585 setResumeRestoreSource(source);586 }587 }588 if (exit === "adopt") setView("adopting");589 } else {590 591 592 const stored = Number(593 await storageGet(ONBOARDING_CARD_STORAGE_KEY).catch(() => null),594 );595 if (596 Number.isInteger(stored) &&597 stored > 0 &&598 stored <= ONBOARDING_LAST_CARD599 ) {600 setOnboardingCardAtExit(stored);601 }602 }603 }604 }605 } catch {606 607 setOnboarded(true);608 }609610 611 612 613 614 615 try {616 const restoreRoute = new URLSearchParams(window.location.search).get("restore");617 if (restoreRoute === "peachscript" || restoreRoute === "csv") {618 setOnboarded(true);619 setStartOnboardingRestore(true);620 setResumeRestoreSource(restoreRoute === "peachscript" ? "symbols" : "csv");621 }622 } catch {623 624 }625626 await Promise.all([restoreSettings(), refreshProStatus()]);627628 const pendingInitialSyncMarker =629 unlocked && vaultExists630 ? await storageGet(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => null)631 : null;632 const pendingInitialSyncDeviceId = pendingInitialSyncMarker633 ? await resolveInitialSyncDeviceId()634 : null;635636 637 638 639 if (shouldCelebrate && !pendingInitialSyncDeviceId) {640 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });641 }642643 644 645 if (unlocked && vaultExists) {646 void sendMessage({647 type: "TRIGGER_SYNC",648 payload: { reason: "foreground" },649 });650 void loadSyncIndicator();651 652 void loadSyncAlerts();653 }654655 656 657 658 659 660 let recoveryPending = false;661 try {662 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });663 recoveryPending = out.ok === true && out.data === true;664 } catch { }665666 667 668 let pendingOpenEntryId: string | undefined;669 if (chrome.storage.session) {670 try {671 const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);672 const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };673 if (pending.peach_inline_unlock) {674 openedForInlineUnlock = true;675 }676 if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;677 await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);678 } catch { }679 }680681 if (recoveryPending && unlocked) {682 683 684 setForceRecoveryWords(true);685 setView("lock");686 } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {687 void beginInitialSyncWait(pendingInitialSyncDeviceId);688 } else if (unlocked && vaultExists) {689 await openVault(pendingOpenEntryId, shouldCelebrate);690 if (shouldCelebrate) {691 setView("pro-celebration");692 window.setTimeout(() => setView("entry"), 2500);693 } else if (await shouldShowWhatsNew()) {694 setView("whats-new");695 } else if (!proStatus() && await claimProNudge()) {696 setView("pro-nudge");697 }698 } else if (shouldCelebrate) {699 setPendingCelebration(true);700 }701702 await refreshPendingPasskey();703704 705 const handleTabActivated = () => { void refreshCurrentTab(); };706 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {707 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();708 };709 try {710 chrome.tabs.onActivated.addListener(handleTabActivated);711 chrome.tabs.onUpdated.addListener(handleTabUpdated);712 onCleanup(() => {713 chrome.tabs.onActivated.removeListener(handleTabActivated);714 chrome.tabs.onUpdated.removeListener(handleTabUpdated);715 });716 } catch {717 718 }719720 setBootState("ready");721722 723 724 725 726 try {727 const route = new URLSearchParams(window.location.search).get("codex");728 if (route === "pdf" || route === "restore") {729 setView("codex-restore");730 }731 } catch {732 733 }734735 736 if (chrome.storage.session) {737 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as738 Record<string, { feature?: string } | undefined>;739 if (session.pendingProGate?.feature) {740 await chrome.storage.session.remove("pendingProGate");741 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);742 setSettingsInitialSection("license");743 setView("settings");744 } else if (session.pendingProNotice?.feature) {745 await chrome.storage.session.remove("pendingProNotice");746 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });747 }748 const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {749 pendingRememberSite?: { entryId?: unknown; url?: unknown };750 };751 const request = remembered.pendingRememberSite;752 if (753 typeof request?.entryId === "string" &&754 typeof request.url === "string"755 ) {756 const entry = await sendMessage({ type: "GET_ENTRIES" });757 const match = entry.ok758 ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(759 (candidate) => candidate.id === request.entryId,760 )761 : undefined;762 if (match) {763 let host = request.url;764 try { host = new URL(request.url).hostname; } catch { }765 setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });766 } else {767 await chrome.storage.session.remove("pendingRememberSite");768 }769 }770 }771772 773 774 775 776 void Promise.all([777 import("./views/Settings").catch(() => {}),778 import("./views/Import").catch(() => {}),779 import("./views/Export").catch(() => {}),780 import("./views/Health").catch(() => {}),781 import("./views/TrashView").catch(() => {}),782 ]);783784 785 786 787 let popupPort: chrome.runtime.Port | null = null;788 try {789 790 791 792 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });793 } catch { }794 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });795796 797 798 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {799 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();800 if (msg?.type === "SYNC_STATE_CHANGED") {801 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);802 }803 804 805 806 if (msg?.type === "VAULT_ENTRIES_CHANGED" && view() !== "lock") {807 void loadEntries();808 }809 if (msg?.type === "PRO_GATE_TRIGGERED") {810 setUpgradeHighlight(msg.payload?.feature ?? null);811 setSettingsInitialSection("license");812 setView("settings");813 }814 };815 chrome.runtime.onMessage.addListener(handleRuntimeMessage);816 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));817818 819 820 821 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {822 copy_password: () => {823 const entry = selectedEntry();824 if (entry?.password) void popupCopy(825 entry.password,826 "Password",827 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,828 ).then((ok) => { if (ok) triggerRing(); });829 },830 copy_username: () => {831 const entry = selectedEntry();832 if (entry?.username) void popupCopy(833 entry.username,834 "Username",835 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,836 ).then((ok) => { if (ok) triggerRing(); });837 },838 copy_totp: () => {839 const entry = selectedEntry();840 if (!entry?.totp) return;841 try {842 const { code } = generateTOTP(843 entry.totp,844 entry.totpAlgorithm,845 entry.totpDigits,846 entry.totpPeriod,847 );848 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });849 } catch { }850 },851 new_entry: () => handleAdd(),852 edit_entry: () => {853 const entry = selectedEntry();854 855 856 if (!entry || isPasskeyOnly(entry)) return;857 setEditEntry(entry);858 setView("add");859 },860 delete_entry: () => {861 if (selectedEntry()) void handleDelete();862 },863 lock_vault: () => { void handleHeaderLock(); },864 search: () => openList(),865 nav_prev: () => navigate(-1),866 nav_next: () => navigate(1),867 jump_vault: () => setView("entry"),868 jump_settings: () => setView("settings"),869 show_help: () => setShowShortcutsHelp(true),870 };871872 873 874 875 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([876 "copy_password", "copy_username", "copy_totp",877 "edit_entry", "delete_entry", "nav_prev", "nav_next",878 ]);879880 const handleGlobalKeydown = (e: KeyboardEvent) => {881 const v = view();882 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"883 || (e.target as HTMLElement)?.tagName === "TEXTAREA"884 || (e.target as HTMLElement)?.isContentEditable;885 const isPrintableSearchKey =886 e.key.length === 1 &&887 !e.ctrlKey &&888 !e.metaKey &&889 !e.altKey;890891 892 893 894 895 896 if (v === "lock" || v === "adopting" || v === "pro-celebration" || v === "initial-sync") return;897898 899 900 if (showShortcutsHelp()) {901 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }902 return;903 }904905 906 if (e.key === "Escape") {907 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }908 if (v === "list") { setView("entry"); return; }909 if (v === "settings" || v === "add") { handleBack(); return; }910 if (v === "import" || v === "health") { setView("settings"); return; }911 if (v === "export") { handleExportBack(); return; }912 if (v === "pair-device") { setView("settings"); return; }913 }914915 916 917 918 const bindings = shortcutBindings();919 for (const action of Object.keys(bindings) as ShortcutAction[]) {920 const binding = bindings[action];921 if (!matchKeybinding(e, binding)) continue;922 923 924 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;925 if (!hasMod && binding.key.length === 1 && inInput) return;926 if (ENTRY_SCOPED.has(action) && v !== "entry") return;927 e.preventDefault();928 SHORTCUT_HANDLERS[action]?.();929 return;930 }931932 933 934 935 936 if (v === "entry" && !inInput && isPrintableSearchKey) {937 e.preventDefault();938 primeListSearch(e.key);939 return;940 }941 };942 window.addEventListener("keydown", handleGlobalKeydown);943 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));944945 const handleStorageChanged = (946 changes: Record<string, chrome.storage.StorageChange>,947 areaName: string948 ) => {949 if (areaName !== "session") return;950 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {951 return;952 }953 void refreshPendingPasskey();954 };955 chrome.storage.onChanged.addListener(handleStorageChanged);956 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));957 });958959 async function refreshCurrentTab() {960 try {961 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });962 const url = tab?.url ?? tab?.pendingUrl ?? "";963 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");964 965 966 const isWebUrl = /^https?:\/\//i.test(url);967 setCurrentTabUrl(isWebUrl ? url : "");968 } catch {969 setCurrentTabDomain("");970 setCurrentTabUrl("");971 }972 }973974 async function refreshPendingPasskey() {975 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });976 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);977 }978979 async function refreshProStatus() {980 const res = await sendMessage({ type: "GET_LICENSE" });981 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;982 setProStatus(Boolean(data?.valid && data?.plan === "pro"));983 }984985 async function restoreSettings() {986 const res = await sendMessage({ type: "GET_SETTINGS" });987 const s = (res as { ok: boolean; data?: any }).data ?? {};988 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {989 setAccentColor(s.accentColor as AccentColorName);990 }991 setTheme(s.theme === "light" ? "light" : "dark");992 if (s.textSize === "small" || s.textSize === "large") {993 setTextSize(s.textSize);994 } else {995 setTextSize("medium");996 }997 setReduceMotion(s.reduceMotion === true);998 setLockOnClose(s.lockOnClose === true);999 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);1000 setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);1001 setBreachMonitoring(s.breachMonitoring !== false);1002 setShortcutBindings(getActiveBindings(s.shortcuts));1003 }10041005 async function applyTrashRetention(days: number) {1006 setTrashRetentionDays(days);1007 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });1008 }10091010 function primeListSearch(seed: string) {1011 setListSearchSeed(seed);1012 setListSearchSeedNonce((value) => value + 1);1013 openList();1014 }10151016 1017 1018 1019 async function loadEntries(): Promise<VaultEntry[] | null> {1020 const response = await sendMessage({ type: "GET_ENTRIES" });1021 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };10221023 1024 1025 1026 1027 if (!res.ok) {1028 if (1029 res.error === "vault_locked" ||1030 res.error === "SERVICE_WORKER_UNAVAILABLE" ||1031 (typeof res.error === "string" && res.error.includes("locked"))1032 ) {1033 handleLock();1034 return null; 1035 }1036 return [];1037 }10381039 const loadedEntries = res.data ?? [];1040 setEntries(loadedEntries);10411042 const currentId = selectedEntry()?.id;1043 if (currentId) {1044 setSelectedEntry(1045 loadedEntries.find((entry) => entry.id === currentId) ?? pickNavigable(loadedEntries)1046 );1047 }10481049 return loadedEntries;1050 }10511052 async function openVault(preferredEntryId?: string, skipViewSet = false) {1053 1054 const retDays = trashRetentionDays();1055 if (retDays > 0) {1056 void sendMessage({1057 type: "PURGE_EXPIRED_TRASH",1058 payload: { retentionMs: retDays * 86_400_000 },1059 });1060 }10611062 const loadedEntries = await loadEntries();10631064 1065 1066 if (loadedEntries === null) return false;10671068 1069 let nextSelectedEntry: VaultEntry | null = null;1070 if (preferredEntryId) {1071 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1072 }1073 if (!nextSelectedEntry) {1074 const tabDomain = currentTabDomain();1075 if (tabDomain) {1076 nextSelectedEntry = loadedEntries.find((e) => {1077 if (e.type === "login") {1078 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1079 for (const extra of e.additionalUrls ?? []) {1080 if ((extractDomain(extra) ?? "") === tabDomain) return true;1081 }1082 return false;1083 }1084 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1085 return false;1086 }) ?? null;1087 }1088 }1089 nextSelectedEntry ??= pickNavigable(loadedEntries);10901091 setSelectedEntry(nextSelectedEntry);1092 setEditEntry(undefined);1093 setContentPhase("enter");1094 queueContentIdle();1095 if (!skipViewSet) setView("entry");1096 return true;1097 }10981099 async function handleUnlock() {1100 1101 1102 1103 1104 1105 1106 let recoveryPending = false;1107 try {1108 const out = await sendMessage({ type: "GET_RECOVERY_CONFIRM_PENDING" });1109 recoveryPending = out.ok === true && out.data === true;1110 } catch { }1111 if (recoveryPending) {1112 setForceRecoveryWords(true);1113 setView("lock");1114 triggerPipPulse();1115 await restoreSettings();1116 return;1117 }1118 setHasVault(true);1119 triggerPipPulse();1120 await restoreSettings();1121 const celebrating = pendingCelebration();1122 if (celebrating) {1123 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1124 setPendingCelebration(false);1125 }1126 await openVault(undefined, celebrating);1127 if (celebrating) {1128 setView("pro-celebration");1129 window.setTimeout(() => setView("entry"), 2500);1130 } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1131 setView("whats-new");1132 } else if (!openedForInlineUnlock && !proStatus() && await claimProNudge()) {1133 setView("pro-nudge");1134 }1135 1136 1137 if (openedForInlineUnlock) {1138 openedForInlineUnlock = false;1139 window.close();1140 }1141 }11421143 function handleSelectEntry(entry: VaultEntry) {1144 if (entry.type === "note") {1145 setOpenNoteId(entry.id);1146 setShowNotes(true);1147 return;1148 }1149 setSelectedEntry(entry);1150 setContentPhase("enter");1151 queueContentIdle();1152 setView("entry");1153 }11541155 function handleAdd() {1156 setEditEntry(undefined);1157 setView("add");1158 }11591160 async function handleDelete() {1161 const entry = selectedEntry();1162 if (!entry) return;1163 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1164 const loadedEntries = await loadEntries() ?? [];1165 setSelectedEntry(pickNavigable(loadedEntries));1166 setView("entry");1167 }11681169 async function handleDeleteMany(ids: string[]) {1170 for (const id of ids) {1171 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1172 }1173 const loadedEntries = await loadEntries() ?? [];1174 1175 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1176 if (!stillExists) setSelectedEntry(pickNavigable(loadedEntries));1177 }11781179 async function handleSave(savedEntry?: VaultEntry) {1180 await openVault(savedEntry?.id);1181 }11821183 function handleBack() {1184 const dest = editBackView();1185 setEditBackView("entry");1186 setEditEntry(undefined);1187 setView(dest);1188 }11891190 function handleExportBack() {1191 if (returnToPanic()) {1192 setReturnToPanic(false);1193 setView("panic");1194 return;1195 }1196 setView("settings");1197 }11981199 function handleLock() {1200 setEntries([]);1201 setSelectedEntry(null);1202 setEditEntry(undefined);1203 setContentPhase("idle");1204 setView("lock");1205 }12061207 function handleHeaderLock() {1208 1209 1210 1211 handleLock();1212 void sendMessage({ type: "LOCK" });1213 }12141215 1216 1217 1218 async function handleHeaderSync(): Promise<void> {1219 if (syncBusy()) return;1220 setSyncBusy(true);1221 setSyncFlash(null);1222 try {1223 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1224 let state: SyncOutcomeSummary = { status: "syncing" };1225 for (let i = 0; i < 360 && isManualSyncPending(state); i++) {1226 await new Promise((r) => setTimeout(r, 250));1227 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1228 ok: boolean;1229 data?: SyncOutcomeSummary;1230 };1231 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1232 }1233 setSyncSummary(state);1234 setSyncFlash(state.status === "error" ? "error" : "ok");1235 await loadSyncAlerts();1236 await loadEntries();1237 await loadSyncIndicator();1238 } catch {1239 setSyncSummary({ status: "error", outcome: "run_failed" });1240 setSyncFlash("error");1241 } finally {1242 setSyncBusy(false);1243 setTimeout(() => setSyncFlash(null), 2_000);1244 }1245 }12461247 function navigate(dir: number) {1248 const navigable = nonNoteEntries();1249 if (navigable.length === 0) return;1250 const currentIndex = selectedIndex();1251 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1252 const nextIndex = (baseIndex + dir + navigable.length) % navigable.length;1253 setSelectedEntry(navigable[nextIndex]);1254 setContentPhase("enter");1255 queueContentIdle();1256 }12571258 125912601261126212631264 function pickNavigable(list: VaultEntry[]): VaultEntry | null {1265 return list.find((e) => e.type !== "note") ?? null;1266 }12671268 function openList() {1269 setView("list");1270 }12711272 function openHealth() {1273 setEditEntry(undefined);1274 setView("health");1275 }12761277 function handleOpenFileImport() {1278 setRestoreView("file");1279 }12801281 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12821283 function handleFixEntry(entry: VaultEntry) {1284 setSelectedEntry(entry);1285 1286 1287 if (isPasskeyOnly(entry)) {1288 setView("entry");1289 return;1290 }1291 setEditBackView(view()); 1292 setEditEntry(entry);1293 setView("add");1294 }12951296 const isLockedView = () => view() === "lock" || view() === "adopting";12971298 async function handleAdoptionDone() {1299 await storageSet("peach_onboarded", "true").catch(() => {});1300 await storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1301 clearOnboardingExit();1302 setOnboarded(true);1303 setHasVault(true);1304 setView("initial-sync");1305 1306 1307 1308 await refreshProStatus();1309 const deviceId = await resolveInitialSyncDeviceId();1310 if (deviceId) {1311 void beginInitialSyncWait(deviceId);1312 } else {1313 1314 1315 1316 setInitialSyncSlow(true);1317 }1318 }13191320 async function handleApprovePendingPasskey() {1321 const pending = pendingPasskey();1322 if (!pending) return;1323 if (pending.browser === "firefox" && pending.type === "create") {1324 1325 1326 1327 1328 const { createFirefoxPasskey } = await import("./firefox-passkey");1329 const result = await createFirefoxPasskey(pending);1330 if (!result.ok) {1331 console.warn("Peach Firefox passkey create failed:", result.error);1332 }1333 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1334 setPendingPasskey(null);1335 void loadEntries();1336 window.close();1337 return;1338 }1339 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1340 window.close();1341 }13421343 async function handleDenyPendingPasskey() {1344 const pending = pendingPasskey();1345 if (!pending) return;1346 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1347 window.close();1348 }13491350 function queueContentIdle(after?: () => void) {1351 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1352 contentPhaseTimer = window.setTimeout(() => {1353 setContentPhase("idle");1354 after?.();1355 }, ENTRY_TRANSITION_SETTLE_MS);1356 }13571358 return (1359 <div class="popup-root peach-theme">1360 <Show when={bootState() === "ready"}>1361 <Show1362 when={!pendingPasskey()}1363 fallback={1364 <PasskeyApprovalView1365 type={pendingPasskey()!.type}1366 rpName={pendingPasskey()!.rpName}1367 rpId={pendingPasskey()!.rpId}1368 userName={pendingPasskey()!.userName}1369 userDisplayName={pendingPasskey()!.userDisplayName}1370 onApprove={handleApprovePendingPasskey}1371 onDeny={handleDenyPendingPasskey}1372 />1373 }1374 >1375 <Show1376 when={!isLockedView()}1377 fallback={1378 <Show1379 when={view() !== "adopting"}1380 fallback={1381 <Suspense fallback={<div class="peach-loading">loading...</div>}>1382 <AdoptVaultView1383 onCancel={() => {1384 clearOnboardingExit();1385 setOnboarded(false);1386 setView("lock");1387 }}1388 onDone={handleAdoptionDone}1389 />1390 </Suspense>1391 }1392 >1393 <Show1394 when={onboarded() || hasVault()}1395 fallback={1396 <Onboarding1397 initialCard={onboardingCardAtExit()}1398 onComplete={() => {1399 markOnboardingExit("create");1400 setStartOnboardingRestore(false);1401 setOnboarded(true);1402 }}1403 onRestore={() => {1404 markOnboardingExit("restore");1405 setStartOnboardingRestore(true);1406 setOnboarded(true);1407 }}1408 onAdopt={() => {1409 markOnboardingExit("adopt");1410 setStartOnboardingRestore(false);1411 setOnboarded(true);1412 setView("adopting");1413 }}1414 />1415 }1416 >1417 <LockView1418 hasVault={hasVault()}1419 ready={bootState() === "ready"}1420 isPro={proStatus()}1421 forceRecoveryWords={forceRecoveryWords()}1422 initialRestore={startOnboardingRestore()}1423 initialRestoreSource={resumeRestoreSource() ?? undefined}1424 onUnlock={() => {1425 1426 1427 1428 if (forceRecoveryWords()) setForceRecoveryWords(false);1429 1430 1431 1432 setStartOnboardingRestore(false);1433 setResumeRestoreSource(null);1434 void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});1435 return handleUnlock();1436 }}1437 onBackToOnboarding={1438 !hasVault()1439 ? () => {1440 1441 1442 clearOnboardingExit();1443 setStartOnboardingRestore(false);1444 setOnboarded(false);1445 }1446 : undefined1447 }1448 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1449 />1450 </Show>1451 </Show>1452 }1453 >1454 <Show1455 when={1456 view() !== "pro-celebration" &&1457 view() !== "initial-sync" &&1458 view() !== "whats-new" &&1459 view() !== "pro-nudge"1460 }1461 fallback={1462 <Show1463 when={view() === "initial-sync"}1464 fallback={1465 <Show1466 when={view() === "whats-new"}1467 fallback={1468 <Show when={view() === "pro-nudge"} fallback={<ProCelebration />}>1469 <ProNudgeView1470 onSeePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1471 onDismiss={() => setView("entry")}1472 />1473 </Show>1474 }1475 >1476 <WhatsNewView onContinue={() => setView("entry")} />1477 </Show>1478 }1479 >1480 <InitialSyncView1481 slow={initialSyncSlow()}1482 onRetry={() => void retryInitialSync()}1483 />1484 </Show>1485 }1486 >1487 <section1488 id="hd-shell"1489 class="peach-shell peach-theme"1490 data-surface="extension"1491 >1492 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1493 <PeachWordmark unlocked />1494 <div class="peach-chrome-actions">1495 <button1496 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1497 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1498 aria-label="Notes"1499 title="Notes"1500 >1501 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1502 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1503 <line x1="4" y1="5" x2="10" y2="5" />1504 <line x1="4" y1="7.5" x2="10" y2="7.5" />1505 <line x1="4" y1="10" x2="7" y2="10" />1506 </svg>1507 </button>1508 <button1509 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1510 classList={{1511 "is-syncing": syncBusy(),1512 "is-ok": syncFlash() === "ok",1513 "is-error": syncFlash() === "error",1514 }}1515 onClick={() => void handleHeaderSync()}1516 disabled={syncBusy()}1517 aria-label={`Sync now. ${syncLabel()}`}1518 title={syncLabel()}1519 >1520 <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">1521 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1522 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1523 <path d="M2.6 1v2.4h2.4" />1524 <path d="M11.4 13v-2.4H9" />1525 </svg>1526 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1527 </button>1528 <button1529 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1530 onClick={() => setView("settings")}1531 aria-label="Settings"1532 >1533 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1534 <path1535 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"1536 stroke="currentColor"1537 stroke-width="0.9"1538 />1539 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1540 </svg>1541 </button>1542 <button1543 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1544 onClick={handleAdd}1545 aria-label="New entry"1546 >1547 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1548 <path d="M5.5 1v9M1 5.5h9" />1549 </svg>1550 </button>1551 <button1552 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1553 onClick={() => void handleHeaderLock()}1554 aria-label="Lock vault"1555 >1556 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1557 <path1558 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"1559 stroke="currentColor"1560 stroke-width="1"1561 stroke-linecap="round"1562 />1563 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1564 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1565 </svg>1566 </button>1567 </div>1568 </header>15691570 <Show when={pendingProNotice() !== null}>1571 <div class="peach-pro-notice-banner">1572 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1573 <button1574 class="peach-pro-notice-link"1575 onClick={() => {1576 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1577 setSettingsInitialSection("license");1578 setView("settings");1579 setPendingProNotice(null);1580 }}1581 >1582 Learn more →1583 </button>1584 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1585 </div>1586 </Show>15871588 <Show when={pendingRememberSite()}>1589 {(request) => {1590 const origin = getSecurityOriginDisplay(request().url);1591 const originLabel = detectHomograph(origin) ? `⚠ ${origin}` : origin;1592 return (1593 <div class="peach-pro-notice-banner">1594 <span>Also use “{request().title}” on {originLabel}?</span>1595 <button1596 class="peach-pro-notice-link"1597 onClick={async () => {1598 await sendMessage({1599 type: "ADD_ADDITIONAL_URL",1600 payload: { id: request().entryId, url: request().url },1601 });1602 await chrome.storage.session.remove("pendingRememberSite");1603 setPendingRememberSite(null);1604 }}1605 >1606 Add1607 </button>1608 <button1609 class="peach-pro-notice-close"1610 onClick={async () => {1611 await chrome.storage.session.remove("pendingRememberSite");1612 setPendingRememberSite(null);1613 }}1614 aria-label="Dismiss"1615 >1616 ✕1617 </button>1618 </div>1619 );1620 }}1621 </Show>16221623 <div class="peach-shell-body">1624 <div class="peach-shell-veil"></div>1625 <div class="peach-content-frame" data-content-phase={contentPhase()}>1626 <EntryView1627 keyboardEnabled={view() === "entry"}1628 entry={selectedEntry()}1629 currentTabUrl={currentTabUrl()}1630 entries={entries()}1631 isPro={proStatus()}1632 onDelete={handleDelete}1633 onFieldSaved={() => void loadEntries()}1634 onEscape={() => { setView("list"); }}1635 onProGate={(feature) => {1636 setUpgradeHighlight(feature);1637 setSettingsInitialSection("license");1638 setView("settings");1639 }}1640 />1641 </div>16421643 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1644 <div class="peach-search-bar">1645 <span class="peach-search-icon">1646 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1647 <circle cx="5.5" cy="5.5" r="3.75" />1648 <path d="M8.75 8.75L11.5 11.5" />1649 </svg>1650 </span>1651 <input1652 class="peach-search-input"1653 placeholder="search vault..."1654 autocomplete="off"1655 aria-label="Search vault entries"1656 onClick={openList}1657 onFocus={openList}1658 readOnly1659 />1660 <div class="peach-search-meta">1661 <span class="peach-search-counter">1662 {nonNoteEntries().length > 01663 ? `${selectedIndex() + 1} / ${nonNoteEntries().length}`1664 : "0 entries"}1665 </span>1666 <span class="peach-divider"></span>1667 <button1668 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1669 onClick={() => navigate(-1)}1670 aria-label="Previous entry"1671 disabled={nonNoteEntries().length === 0}1672 >1673 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1674 <path d="M5 1L1 5.5L5 10" />1675 </svg>1676 </button>1677 <button1678 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1679 onClick={() => navigate(1)}1680 aria-label="Next entry"1681 disabled={nonNoteEntries().length === 0}1682 >1683 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1684 <path d="M1 1L5 5.5L1 10" />1685 </svg>1686 </button>1687 </div>1688 </div>1689 </section>1690 </div>16911692 {}1693 <Show when={alertGroups().length > 0}>1694 <div class="peach-sync-alerts">1695 <For each={alertGroups()}>1696 {([type, group]) => {1697 const idx = () => Math.min(alertFlipIdx()[type] ?? 0, group.length - 1);1698 return (1699 <div class="peach-sync-alert">1700 <Show when={group.length > 1}>1701 <button1702 class="peach-sync-alert-flip"1703 type="button"1704 aria-label="Previous"1705 onClick={() => flipAlert(type, -1)}1706 >‹</button>1707 </Show>1708 <span class="peach-sync-alert-text">⚠ {syncAlertText(group[idx()])}</span>1709 <Show when={group.length > 1}>1710 <span class="peach-sync-alert-count">{idx() + 1}/{group.length}</span>1711 <button1712 class="peach-sync-alert-flip"1713 type="button"1714 aria-label="Next"1715 onClick={() => flipAlert(type, 1)}1716 >›</button>1717 </Show>1718 <button1719 class="peach-sync-alert-dismiss"1720 type="button"1721 aria-label={group.length > 1 ? "Dismiss all" : "Dismiss"}1722 onClick={() => void dismissAlertGroup(type as SyncAlert["type"])}1723 >×</button>1724 </div>1725 );1726 }}1727 </For>1728 </div>1729 </Show>17301731 <Show when={view() === "list"}>1732 <VaultView1733 entries={entries()}1734 selectedEntryId={selectedEntry()?.id}1735 currentTabDomain={currentTabDomain()}1736 sortOrder={sortOrder()}1737 searchSeed={listSearchSeed()}1738 searchSeedNonce={listSearchSeedNonce()}1739 onSelectEntry={handleSelectEntry}1740 onSortOrderChange={setSortOrder}1741 onAdd={handleAdd}1742 onClose={() => setView("entry")}1743 onOpenTrash={() => setView("trash")}1744 onDeleteMany={handleDeleteMany}1745 />1746 </Show>1747 <Show when={view() === "settings" || settingsExiting()}>1748 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1749 <SettingsView1750 entriesCount={entries().length}1751 breachMonitoring={breachMonitoring()}1752 lockOnClose={lockOnClose()}1753 autoLockMinutes={autoLockMinutes()}1754 trashRetentionDays={trashRetentionDays()}1755 lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}1756 theme={theme()}1757 accentColor={accentColor()}1758 textSize={textSize()}1759 reduceMotion={reduceMotion()}1760 isPro={proStatus()}1761 initialSection={settingsInitialSection()}1762 upgradeHighlight={upgradeHighlight()}1763 isExiting={settingsExiting()}1764 onBreachMonitoringChange={applyBreachMonitoring}1765 onLockOnCloseChange={applyLockOnClose}1766 onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}1767 onAutoLockMinutesChange={applyAutoLockMinutes}1768 onTrashRetentionChange={applyTrashRetention}1769 onThemeChange={applyTheme}1770 onAccentChange={applyAccentColor}1771 onTextSizeChange={applyTextSize}1772 onReduceMotionChange={applyReduceMotion}1773 shortcuts={shortcutBindings()}1774 onShortcutsChange={applyShortcuts}1775 onLicenseChange={async () => {1776 1777 1778 1779 1780 1781 await refreshProStatus();1782 void loadEntries();1783 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1784 if ((should as { ok: boolean; data?: boolean }).data === true) {1785 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1786 setView("pro-celebration");1787 window.setTimeout(() => setView("settings"), 2500);1788 }1789 }}1790 onBack={() => {1791 setSettingsExiting(true);1792 window.setTimeout(() => {1793 setSettingsExiting(false);1794 setSettingsInitialSection(null);1795 setUpgradeHighlight(null);1796 handleBack();1797 }, 200);1798 }}1799 onImport={() => setView("import")}1800 onExport={() => setView("export")}1801 onBreachCheckDone={loadEntries}1802 onOpenHealth={openHealth}1803 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1804 onOpenFileImport={handleOpenFileImport}1805 onOpenPanic={() => setView("panic")}1806 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1807 onOpenCodexRestore={() => setView("codex-restore")}1808 onCardChange={setSettingsActiveCard}1809 onProGate={(feature) => {1810 setUpgradeHighlight(feature);1811 setSettingsInitialSection("license");1812 }}1813 />1814 </Suspense>1815 </Show>1816 <Show when={view() === "codex-generate"}>1817 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1818 <CodexGenerateView1819 isPro={proStatus()}1820 onBack={() => setView("settings")}1821 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1822 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1823 onVaultLocked={() => handleLock()}1824 />1825 </Suspense>1826 </Show>1827 <Show when={view() === "codex-restore"}>1828 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1829 <CodexRestoreView1830 onBack={() => setView("lock")}1831 onRestoreSuccess={async () => {1832 1833 1834 1835 await handleUnlock();1836 }}1837 />1838 </Suspense>1839 </Show>1840 <Show when={view() === "pair-device"}>1841 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1842 <PairDeviceView1843 isPro={proStatus()}1844 onProChanged={() => { void refreshProStatus(); }}1845 onBack={() => setView("settings")}1846 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1847 />1848 </Suspense>1849 </Show>1850 <Show when={view() === "add"}>1851 <AddView1852 entry={editEntry()}1853 entries={entries()}1854 onBack={handleBack}1855 onSave={handleSave}1856 />1857 </Show>1858 <Show when={view() === "import"}>1859 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1860 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1861 </Suspense>1862 </Show>1863 <Show when={view() === "export"}>1864 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1865 <ExportView onBack={handleExportBack} />1866 </Suspense>1867 </Show>1868 <Show when={view() === "panic"}>1869 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1870 <PanicView1871 onCancel={() => setView("settings")}1872 onOpenExport={() => {1873 setReturnToPanic(true);1874 setView("export");1875 }}1876 onDestroyed={async () => {1877 1878 1879 setOnboardingCardAtExit(0);1880 void storageRemove(ONBOARDING_CARD_STORAGE_KEY).catch(() => {});1881 clearOnboardingExit();1882 setEntries([]);1883 setSelectedEntry(null);1884 setEditEntry(undefined);1885 setHasVault(false);1886 setOnboarded(false);1887 setProStatus(false);1888 setPendingPasskey(null);1889 setUpgradeHighlight(null);1890 setSettingsInitialSection(null);1891 setPendingProNotice(null);1892 setPendingCelebration(false);1893 setReturnToPanic(false);1894 setContentPhase("idle");1895 setView("lock");1896 }}1897 />1898 </Suspense>1899 </Show>1900 {1901}1902 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1903 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1904 <HealthView1905 entries={entries()}1906 isPro={proStatus()}1907 onFixEntry={handleFixEntry}1908 onBack={() => setView("settings")}1909 onRefresh={loadEntries}1910 hidden={view() !== "health"}1911 />1912 </Suspense>1913 </Show>1914 <Show when={view() === "trash"}>1915 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1916 <TrashView1917 onBack={() => setView("list")}1918 onRestored={() => void loadEntries()}1919 />1920 </Suspense>1921 </Show>1922 <Show when={showNotes()}>1923 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1924 <NotesMode1925 notes={entries().filter((e) => e.type === "note")}1926 initialNoteId={openNoteId()}1927 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1928 onNoteListChanged={() => void loadEntries()}1929 />1930 </div>1931 </Show>1932 <Show when={restoreView() === "file"}>1933 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1934 <ImportView1935 mode="onboarding"1936 replacesExistingVault={true}1937 onBack={() => setRestoreView(null)}1938 onImport={async () => {1939 setRestoreView(null);1940 await handleUnlock();1941 }}1942 />1943 </Suspense>1944 </Show>1945 </section>1946 </Show>1947 </Show>1948 </Show>1949 </Show>1950 <Show when={showShortcutsHelp()}>1951 <ShortcutsHelpOverlay1952 bindings={shortcutBindings()}1953 onClose={() => setShowShortcutsHelp(false)}1954 />1955 </Show>1956 </div>1957 );1958}19591960function ShortcutsHelpOverlay(props: {1961 bindings: Record<ShortcutAction, Keybinding>;1962 onClose: () => void;1963}) {1964 return (1965 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1966 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1967 <div class="peach-shortcuts-help-head">1968 <h3>Keyboard shortcuts</h3>1969 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1970 </div>1971 <div class="peach-shortcuts-help-body">1972 <p class="peach-settings-sub-label">ENTRY VIEW</p>1973 <ul>1974 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1975 <li>1976 <span>{d.label}</span>1977 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1978 </li>1979 ))}1980 </ul>1981 <p class="peach-settings-sub-label">ANYWHERE</p>1982 <ul>1983 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1984 <li>1985 <span>{d.label}</span>1986 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1987 </li>1988 ))}1989 </ul>1990 </div>1991 </div>1992 </div>1993 );1994}