1import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2import { Portal } from "solid-js/web";3import { storageGet, storageSet } from "../shared/storage";4import { sendMessage } from "../shared/bridge";5import { ACCENT_COLORS } from "../shared/types";6import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7import { isPasskeyOnly } from "../shared/types";8import { LockView } from "./views/Lock";9import { Onboarding } from "./views/Onboarding";10import { NotesMode } from "./views/NotesMode";11import { ProCelebration } from "./views/ProCelebration";12import { VaultView, type SortOrder } from "./views/Vault";13import { EntryView } from "./views/Entry";14import { AddView } from "./views/Add";15import { PeachWordmark } from "./components/PeachMark";16import { PasskeyApprovalView } from "./views/PasskeyApproval";17import { extractDomain } from "../shared/domain";18import {19 syncIndicator,20 syncIndicatorLabel,21 type SyncOutcomeSummary,22} from "../shared/sync-outcome";23import { popupCopy } from "./copy";24import {25 DEFAULT_BINDINGS,26 formatKeybinding,27 getActiveBindings,28 matchKeybinding,29 SHORTCUT_DESCRIPTORS,30 type Keybinding,31 type ShortcutAction,32} from "../shared/shortcuts";33import { generateTOTP } from "../crypto/totp";34import { isNativePlatform, nativePlugin } from "../shared/native";3536export { sendMessage } from "../shared/bridge";3738const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));39const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));40const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));41const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));42const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));43const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));44const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));45const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));46const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));47const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));4849type View =50 | "lock"51 | "pro-celebration"52 | "entry"53 | "list"54 | "add"55 | "settings"56 | "pair-device"57 | "import"58 | "export"59 | "health"60 | "trash"61 | "panic"62 | "adopting"63 | "codex-generate"64 | "codex-restore";65type BootState = "loading" | "ready";6667const canarySuppressMap = new Map<string, boolean>();68let shellRingAnimation: Animation | null = null;6970export function isCanarySuppressed(entryId: string): boolean {71 return canarySuppressMap.get(entryId) ?? false;72}7374export function setCanarySuppressed(entryId: string, suppressed: boolean): void {75 canarySuppressMap.set(entryId, suppressed);76}7778export function clearCanarySuppressions(): void {79 canarySuppressMap.clear();80}8182export function triggerRing() {83 const shell = document.getElementById("hd-shell");84 if (!shell) return;8586 shellRingAnimation?.cancel();87 shell.dataset.glowing = "true";88 const animation = shell.animate(89 [90 { offset: 0, boxShadow: "var(--copy-shadow)" },91 {92 offset: 0.3,93 boxShadow:94 "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)",95 },96 { offset: 1, boxShadow: "var(--copy-shadow)" },97 ],98 {99 duration: 720,100 easing: "cubic-bezier(0.4, 0, 0.2, 1)",101 fill: "none",102 }103 );104 shellRingAnimation = animation;105 const clearGlow = () => {106 if (shellRingAnimation !== animation) return;107 shellRingAnimation = null;108 delete shell.dataset.glowing;109 };110 animation.onfinish = clearGlow;111 animation.oncancel = clearGlow;112}113114115export function triggerPipPulse() {116 const pip = document.querySelector(".peach-status-pip, .pip");117 if (pip) {118 pip.classList.remove("pulse");119 void (pip as HTMLElement).offsetWidth;120 pip.classList.add("pulse");121 }122}123124function defaultAdoptionDeviceName(): string {125 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";126 if (/Firefox/i.test(ua)) return "Firefox";127 if (/Edg\//i.test(ua)) return "Edge";128 if (/Chrome/i.test(ua)) return "Chrome";129 if (/Safari/i.test(ua)) return "Safari";130 return "Peach";131}132133const ENTRY_TRANSITION_SETTLE_MS = 135;134135export default function App() {136 const [bootState, setBootState] = createSignal<BootState>("loading");137 const [view, setView] = createSignal<View>("lock");138 139 140 const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);141 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(142 "enter"143 );144 const [contentTransitioning, setContentTransitioning] = createSignal(false);145 const [hasVault, setHasVault] = createSignal(false);146 const [onboarded, setOnboarded] = createSignal(true);147 const [entries, setEntries] = createSignal<VaultEntry[]>([]);148 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);149 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);150151 152 153 interface SyncAlert {154 type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";155 entryId: string;156 entryTitle: string;157 deviceName: string;158 timestamp: number;159 }160 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);161 async function loadSyncAlerts() {162 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {163 ok: boolean;164 data?: SyncAlert[];165 };166 if (r.ok && (r.data?.length ?? 0) > 0) {167 setSyncAlerts(r.data ?? []);168 169 170 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });171 }172 }173 function dismissSyncAlert(index: number) {174 setSyncAlerts((arr) => arr.filter((_, i) => i !== index));175 }176 function syncAlertText(a: SyncAlert): string {177 if (a.type === "REMOTE_PASSWORD_CHANGE") {178 return `${a.deviceName} changed the password for ${a.entryTitle}`;179 }180 if (a.type === "REMOTE_DELETE") {181 return `${a.deviceName} deleted ${a.entryTitle}`;182 }183 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;184 }185 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");186 const [theme, setTheme] = createSignal<"dark" | "light">("dark");187 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");188 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);189 const [lockOnClose, setLockOnClose] = createSignal(true);190 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);191 const [breachMonitoring, setBreachMonitoring] = createSignal(true);192 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);193 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);194 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);195 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);196 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);197 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");198 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");199 200 201 202 203 const [proStatus, setProStatus] = createSignal(false);204 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);205 206 207 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");208 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);209 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");210 const [listSearchSeed, setListSearchSeed] = createSignal("");211 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);212 const [pendingCelebration, setPendingCelebration] = createSignal(false);213 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);214 215 216 217 const [returnToPanic, setReturnToPanic] = createSignal(false);218 219 220 const [editBackView, setEditBackView] = createSignal<View>("entry");221 const [settingsExiting, setSettingsExiting] = createSignal(false);222 const [showNotes, setShowNotes] = createSignal(false);223 224 225 226 const [syncBusy, setSyncBusy] = createSignal(false);227 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);228 229 230 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});231 const [pairedCount, setPairedCount] = createSignal(0);232 const syncLight = () =>233 syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());234 const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());235 async function loadSyncIndicator() {236 const [stateRes, devicesRes] = await Promise.all([237 sendMessage({ type: "GET_SYNC_STATE" }),238 sendMessage({ type: "LIST_PAIRED_DEVICES" }),239 ]);240 const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };241 if (s.ok && s.data) setSyncSummary(s.data);242 const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };243 if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);244 }245 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);246 247 248 249 250 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);251252 let contentPhaseTimer: number | undefined;253 254 255 let openedForInlineUnlock = false;256257 function applyAccent(name: AccentColorName) {258 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;259 const hex = color.hex;260 document.documentElement.style.setProperty("--peach-accent", hex);261 262 263 document.documentElement.style.setProperty("--peach-glow", hex + "2A");264 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");265 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");266 }267268 createEffect(() => {269 applyAccent(accentColor());270 });271272 createEffect(() => {273 document.documentElement.classList.toggle("light", theme() === "light");274 document.body.classList.toggle("light", theme() === "light");275 });276277 createEffect(() => {278 document.documentElement.setAttribute("data-text-size", textSize());279 });280281 createEffect(() => {282 if (reduceMotion()) {283 document.documentElement.setAttribute("data-motion", "reduce");284 } else {285 document.documentElement.removeAttribute("data-motion");286 }287 });288289 const selectedIndex = createMemo(() => {290 const currentId = selectedEntry()?.id;291 if (!currentId) return entries().length > 0 ? 0 : -1;292 return entries().findIndex((entry) => entry.id === currentId);293 });294295 async function applyAccentColor(name: AccentColorName) {296 applyAccent(name);297 setAccentColor(name);298 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });299 }300301 async function applyTheme(t: "dark" | "light") {302 setTheme(t);303 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });304 }305306 async function applyTextSize(size: "small" | "medium" | "large") {307 setTextSize(size);308 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });309 }310311 async function applyReduceMotion(enabled: boolean) {312 setReduceMotion(enabled);313 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });314 }315316 317 318 319 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {320 setShortcutBindings(active);321 const overrides: Record<string, Keybinding> = {};322 for (const action of Object.keys(active) as ShortcutAction[]) {323 const a = active[action];324 const d = DEFAULT_BINDINGS[action];325 const same = a.key === d.key326 && Boolean(a.ctrl) === Boolean(d.ctrl)327 && Boolean(a.meta) === Boolean(d.meta)328 && Boolean(a.shift) === Boolean(d.shift)329 && Boolean(a.alt) === Boolean(d.alt);330 if (!same) overrides[action] = a;331 }332 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });333 }334335 async function applyLockOnClose(enabled: boolean) {336 setLockOnClose(enabled);337 await sendMessage({338 type: "UPDATE_SETTINGS",339 payload: { lockOnClose: enabled },340 });341 }342343 async function applyAutoLockMinutes(minutes: number) {344 setAutoLockMinutes(minutes);345 await sendMessage({346 type: "UPDATE_SETTINGS",347 payload: { autoLockMinutes: minutes },348 });349 }350351 async function applyBreachMonitoring(enabled: boolean) {352 setBreachMonitoring(enabled);353 await sendMessage({354 type: "UPDATE_SETTINGS",355 payload: { breachMonitoring: enabled },356 });357 }358359 onMount(async () => {360 361 362 363 364 365 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([366 sendMessage({ type: "IS_UNLOCKED" }),367 sendMessage({ type: "HAS_VAULT" }),368 refreshCurrentTab(),369 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),370 ]);371 const unlocked = Boolean(372 (unlockResponse as { ok: boolean; data?: boolean }).data373 );374 const vaultExists = Boolean(375 (vaultResponse as { ok: boolean; data?: boolean }).data376 );377 const shouldCelebrate = Boolean(378 (celebrationResponse as { ok: boolean; data?: boolean }).data379 );380 setHasVault(vaultExists);381 382 383 384 try {385 const onboarded = await storageGet("peach_onboarded");386 const seen = onboarded === "true";387 if (vaultExists && !seen) {388 await storageSet("peach_onboarded", "true");389 setOnboarded(true);390 } else {391 setOnboarded(seen);392 }393 } catch {394 395 setOnboarded(true);396 }397 await Promise.all([restoreSettings(), refreshProStatus()]);398399 400 if (shouldCelebrate) {401 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });402 }403404 405 406 if (unlocked && vaultExists) {407 void sendMessage({408 type: "TRIGGER_SYNC",409 payload: { reason: "foreground" },410 });411 void loadSyncIndicator();412 413 void loadSyncAlerts();414 }415416 417 418 419 420 421 422 let recoveryPending = false;423 try {424 const out = await chrome.storage.local?.get("peach_recovery_pending");425 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";426 } catch { }427428 429 430 431 if (chrome.storage.session) {432 try {433 const s = await chrome.storage.session.get("peach_inline_unlock");434 if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {435 openedForInlineUnlock = true;436 await chrome.storage.session.remove("peach_inline_unlock");437 }438 } catch { }439 }440441 442 443 444 445 446 if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {447 try {448 await chrome.storage.local?.remove([449 "peach_vault_a",450 "peach_vault_b",451 "peach_recovery_pending",452 "peach_onboarded",453 "peach_vault_history",454 ]);455 } catch { }456 setHasVault(false);457 setOnboarded(false);458 setView("lock");459 setBootState("ready");460 return;461 }462463 if (recoveryPending && unlocked) {464 465 466 setForceRecoveryWords(true);467 setView("lock");468 } else if (unlocked && vaultExists) {469 await openVault(undefined, shouldCelebrate);470 if (shouldCelebrate) {471 setView("pro-celebration");472 window.setTimeout(() => setView("entry"), 2500);473 }474 } else if (shouldCelebrate) {475 setPendingCelebration(true);476 }477478 await refreshPendingPasskey();479480 481 const handleTabActivated = () => { void refreshCurrentTab(); };482 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {483 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();484 };485 try {486 chrome.tabs.onActivated.addListener(handleTabActivated);487 chrome.tabs.onUpdated.addListener(handleTabUpdated);488 onCleanup(() => {489 chrome.tabs.onActivated.removeListener(handleTabActivated);490 chrome.tabs.onUpdated.removeListener(handleTabUpdated);491 });492 } catch {493 494 }495496 setBootState("ready");497498 499 500 501 502 try {503 const route = new URLSearchParams(window.location.search).get("codex");504 if (route === "pdf" || route === "restore") {505 setView("codex-restore");506 }507 } catch {508 509 }510511 512 if (chrome.storage.session) {513 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as514 Record<string, { feature?: string } | undefined>;515 if (session.pendingProGate?.feature) {516 await chrome.storage.session.remove("pendingProGate");517 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);518 setSettingsInitialSection("license");519 setView("settings");520 } else if (session.pendingProNotice?.feature) {521 await chrome.storage.session.remove("pendingProNotice");522 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });523 }524 }525526 527 528 529 530 void Promise.all([531 import("./views/Settings").catch(() => {}),532 import("./views/Import").catch(() => {}),533 import("./views/Export").catch(() => {}),534 import("./views/Health").catch(() => {}),535 import("./views/TrashView").catch(() => {}),536 ]);537538 539 540 541 let popupPort: chrome.runtime.Port | null = null;542 try {543 544 545 546 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });547 } catch { }548 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });549550 551 552 553 if (isNativePlatform()) {554 let backgroundedAt: number | null = null;555556 const CapApp = nativePlugin<{557 addListener: (558 ev: string,559 cb: (s: { isActive: boolean }) => void,560 ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };561 }>("App");562 if (CapApp?.addListener) {563 const sub = CapApp.addListener(564 "appStateChange",565 ({ isActive }: { isActive: boolean }) => {566 if (!isActive) {567 backgroundedAt = Date.now();568 return;569 }570 if (backgroundedAt !== null && autoLockMinutes() > 0) {571 const elapsed = Date.now() - backgroundedAt;572 if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();573 }574 backgroundedAt = null;575 }576 );577 578 onCleanup(() => {579 Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});580 });581 }582583 584 585 586 587 588 let lastUserActivityAt = Date.now();589 const bumpActivity = () => { lastUserActivityAt = Date.now(); };590 const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;591 for (const ev of ACTIVITY_EVENTS) {592 document.addEventListener(ev, bumpActivity, { passive: true, capture: true });593 }594 const idleLockInterval = setInterval(() => {595 const minutes = autoLockMinutes();596 if (!minutes || minutes <= 0) return;597 const elapsed = Date.now() - lastUserActivityAt;598 if (elapsed >= minutes * 60_000) {599 lastUserActivityAt = Date.now();600 handleHeaderLock();601 }602 }, 30_000);603 onCleanup(() => {604 clearInterval(idleLockInterval);605 for (const ev of ACTIVITY_EVENTS) {606 document.removeEventListener(ev, bumpActivity, { capture: true });607 }608 });609610 611 612 613 614 if (isNativePlatform()) {615 616 617 618 void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>619 import("../background/sync-runner").then(({ productionSyncRunner }) => {620 setSyncRunner(productionSyncRunner);621 void hydratePersistedSyncState();622 })623 );624 }625 }626627 628 629 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {630 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();631 if (msg?.type === "SYNC_STATE_CHANGED") {632 setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);633 }634 if (msg?.type === "PRO_GATE_TRIGGERED") {635 setUpgradeHighlight(msg.payload?.feature ?? null);636 setSettingsInitialSection("license");637 setView("settings");638 }639 };640 chrome.runtime.onMessage.addListener(handleRuntimeMessage);641 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));642643 644 645 646 const handlePeachBack = () => {647 648 if (showNotes()) {649 setShowNotes(false);650 setOpenNoteId(undefined);651 return;652 }653 const v = view();654 if (v === "lock" && !hasVault() && onboarded()) {655 setOnboarded(false);656 return;657 }658 if (v === "list") { setView("entry"); return; }659 if (v === "settings") {660 window.dispatchEvent(new Event("peach:settings-back"));661 return;662 }663 if (v === "import" || v === "health") { setView("settings"); return; }664 if (v === "export") { handleExportBack(); return; }665 if (v === "add") { handleBack(); return; }666 if (v === "pair-device") { setView("settings"); return; }667 };668 window.addEventListener("peach:back", handlePeachBack);669 onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));670671 672 673 674 675 676 if (isNativePlatform()) {677 let touchStartY = 0;678 let touchStartX = 0;679 let lastY = 0;680 let lastT = 0;681 let dragging = false;682 let captured = false;683 const HORIZONTAL_TOLERANCE = 16;684 const ACTIVATION_DY = 8;685 const PEEK_PX = 60;686687 const onTouchStart = (e: TouchEvent) => {688 const t = e.touches[0];689 if (!t) return;690 const v = view();691 if (v !== "entry" && v !== "list") return;692 693 694 const inBand = v === "entry"695 ? t.clientY > window.innerHeight * 0.6696 : t.clientY < window.innerHeight * 0.35;697 if (!inBand) return;698 touchStartY = lastY = t.clientY;699 touchStartX = t.clientX;700 lastT = e.timeStamp;701 dragging = true;702 captured = false;703 };704705 const onTouchMove = (e: TouchEvent) => {706 if (!dragging) return;707 const t = e.touches[0];708 if (!t) return;709 const dy = t.clientY - touchStartY;710 const dx = Math.abs(t.clientX - touchStartX);711712 if (!captured) {713 if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {714 715 dragging = false;716 return;717 }718 if (Math.abs(dy) < ACTIVATION_DY) return;719 captured = true;720 }721722 723 724 const v = view();725 const restClosed = window.innerHeight - PEEK_PX;726 const baseTranslate = v === "list" ? 0 : restClosed;727 const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));728 setSheetTranslate(next);729 lastY = t.clientY;730 lastT = e.timeStamp;731 };732733 const onTouchEnd = (e: TouchEvent) => {734 if (!dragging) { return; }735 const t = e.changedTouches[0];736 const elapsed = Math.max(1, e.timeStamp - lastT);737 const velocity = t ? (t.clientY - lastY) / elapsed : 0; 738 const wasCaptured = captured;739 dragging = false;740 captured = false;741 const v = view();742743 if (!wasCaptured) {744 745 if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");746 setSheetTranslate(null);747 return;748 }749750 const restClosed = window.innerHeight - PEEK_PX;751 const current = sheetTranslate() ?? restClosed;752753 754 755 756 757 let nextOpen: boolean;758 if (velocity < -0.05) nextOpen = true;759 else if (velocity > 0.05) nextOpen = false;760 else nextOpen = current < restClosed * 0.5;761762 if (nextOpen && v !== "list") setView("list");763 if (!nextOpen && v !== "entry") setView("entry");764 765 setSheetTranslate(null);766 };767768 document.addEventListener("touchstart", onTouchStart, { passive: true });769 document.addEventListener("touchmove", onTouchMove, { passive: true });770 document.addEventListener("touchend", onTouchEnd, { passive: true });771 document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });772 onCleanup(() => {773 document.removeEventListener("touchstart", onTouchStart);774 document.removeEventListener("touchmove", onTouchMove);775 document.removeEventListener("touchend", onTouchEnd);776 });777 }778779 780 781 782 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {783 copy_password: () => {784 const entry = selectedEntry();785 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });786 },787 copy_username: () => {788 const entry = selectedEntry();789 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });790 },791 copy_totp: () => {792 const entry = selectedEntry();793 if (!entry?.totp) return;794 try {795 const { code } = generateTOTP(entry.totp);796 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });797 } catch { }798 },799 new_entry: () => handleAdd(),800 edit_entry: () => {801 const entry = selectedEntry();802 803 804 if (!entry || isPasskeyOnly(entry)) return;805 setEditEntry(entry);806 setView("add");807 },808 delete_entry: () => {809 if (selectedEntry()) void handleDelete();810 },811 lock_vault: () => { void handleHeaderLock(); },812 search: () => openList(),813 nav_prev: () => navigate(-1),814 nav_next: () => navigate(1),815 jump_vault: () => setView("entry"),816 jump_settings: () => setView("settings"),817 show_help: () => setShowShortcutsHelp(true),818 };819820 821 822 823 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([824 "copy_password", "copy_username", "copy_totp",825 "edit_entry", "delete_entry", "nav_prev", "nav_next",826 ]);827828 const handleGlobalKeydown = (e: KeyboardEvent) => {829 const v = view();830 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"831 || (e.target as HTMLElement)?.tagName === "TEXTAREA"832 || (e.target as HTMLElement)?.isContentEditable;833 const isPrintableSearchKey =834 e.key.length === 1 &&835 !e.ctrlKey &&836 !e.metaKey &&837 !e.altKey;838839 840 if (v === "pro-celebration") return;841842 843 844 if (showShortcutsHelp()) {845 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }846 return;847 }848849 850 if (e.key === "Escape") {851 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }852 if (v === "list") { setView("entry"); return; }853 if (v === "settings" || v === "add") { handleBack(); return; }854 if (v === "import" || v === "health") { setView("settings"); return; }855 if (v === "export") { handleExportBack(); return; }856 if (v === "pair-device") { setView("settings"); return; }857 }858859 860 861 862 const bindings = shortcutBindings();863 for (const action of Object.keys(bindings) as ShortcutAction[]) {864 const binding = bindings[action];865 if (!matchKeybinding(e, binding)) continue;866 867 868 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;869 if (!hasMod && binding.key.length === 1 && inInput) return;870 if (ENTRY_SCOPED.has(action) && v !== "entry") return;871 e.preventDefault();872 SHORTCUT_HANDLERS[action]?.();873 return;874 }875876 877 878 879 880 if (v === "entry" && !inInput && isPrintableSearchKey) {881 e.preventDefault();882 primeListSearch(e.key);883 return;884 }885 };886 window.addEventListener("keydown", handleGlobalKeydown);887 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));888889 const handleStorageChanged = (890 changes: Record<string, chrome.storage.StorageChange>,891 areaName: string892 ) => {893 if (areaName !== "session") return;894 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {895 return;896 }897 void refreshPendingPasskey();898 };899 chrome.storage.onChanged.addListener(handleStorageChanged);900 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));901 });902903 async function refreshCurrentTab() {904 try {905 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });906 const url = tab?.url ?? tab?.pendingUrl ?? "";907 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");908 909 910 const isWebUrl = /^https?:\/\//i.test(url);911 setCurrentTabUrl(isWebUrl ? url : "");912 } catch {913 setCurrentTabDomain("");914 setCurrentTabUrl("");915 }916 }917918 async function refreshPendingPasskey() {919 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });920 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);921 }922923 async function refreshProStatus() {924 const res = await sendMessage({ type: "GET_LICENSE" });925 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;926 setProStatus(Boolean(data?.valid && data?.plan === "pro"));927 }928929 async function restoreSettings() {930 const res = await sendMessage({ type: "GET_SETTINGS" });931 const s = (res as { ok: boolean; data?: any }).data ?? {};932 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {933 setAccentColor(s.accentColor as AccentColorName);934 }935 setTheme(s.theme === "light" ? "light" : "dark");936 if (s.textSize === "small" || s.textSize === "large") {937 setTextSize(s.textSize);938 } else {939 setTextSize("medium");940 }941 setReduceMotion(s.reduceMotion === true);942 setLockOnClose(s.lockOnClose !== false);943 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);944 setBreachMonitoring(s.breachMonitoring !== false);945 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);946 setShortcutBindings(getActiveBindings(s.shortcuts));947 }948949 async function applyTrashRetention(days: number) {950 setTrashRetentionDays(days);951 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });952 }953954 function primeListSearch(seed: string) {955 setListSearchSeed(seed);956 setListSearchSeedNonce((value) => value + 1);957 openList();958 }959960 961 962 963 async function loadEntries(): Promise<VaultEntry[] | null> {964 const response = await sendMessage({ type: "GET_ENTRIES" });965 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };966967 968 969 970 971 if (!res.ok) {972 if (973 res.error === "vault_locked" ||974 res.error === "SERVICE_WORKER_UNAVAILABLE" ||975 (typeof res.error === "string" && res.error.includes("locked"))976 ) {977 handleLock();978 return null; 979 }980 return [];981 }982983 const loadedEntries = res.data ?? [];984 setEntries(loadedEntries);985986 const currentId = selectedEntry()?.id;987 if (currentId) {988 setSelectedEntry(989 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null990 );991 }992993 return loadedEntries;994 }995996 async function openVault(preferredEntryId?: string, skipViewSet = false) {997 998 const retDays = trashRetentionDays();999 if (retDays > 0) {1000 void sendMessage({1001 type: "PURGE_EXPIRED_TRASH",1002 payload: { retentionMs: retDays * 86_400_000 },1003 });1004 }10051006 const loadedEntries = await loadEntries();10071008 1009 1010 if (loadedEntries === null) return;10111012 1013 let nextSelectedEntry: VaultEntry | null = null;1014 if (preferredEntryId) {1015 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;1016 }1017 if (!nextSelectedEntry) {1018 const tabDomain = currentTabDomain();1019 if (tabDomain) {1020 nextSelectedEntry = loadedEntries.find((e) => {1021 if (e.type === "login") {1022 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;1023 for (const extra of e.additionalUrls ?? []) {1024 if ((extractDomain(extra) ?? "") === tabDomain) return true;1025 }1026 return false;1027 }1028 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1029 return false;1030 }) ?? null;1031 }1032 }1033 nextSelectedEntry ??= loadedEntries[0] ?? null;10341035 setSelectedEntry(nextSelectedEntry);1036 setEditEntry(undefined);1037 setContentTransitioning(false);1038 setContentPhase("enter");1039 queueContentIdle();1040 if (!skipViewSet) setView("entry");1041 }10421043 async function handleUnlock() {1044 1045 1046 1047 1048 1049 1050 let recoveryPending = false;1051 try {1052 const out = await chrome.storage.local?.get("peach_recovery_pending");1053 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1054 } catch { }1055 if (recoveryPending) {1056 setForceRecoveryWords(true);1057 setView("lock");1058 triggerPipPulse();1059 await restoreSettings();1060 return;1061 }1062 setHasVault(true);1063 triggerPipPulse();1064 await restoreSettings();1065 const celebrating = pendingCelebration();1066 if (celebrating) {1067 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1068 setPendingCelebration(false);1069 }1070 await openVault(undefined, celebrating);1071 if (celebrating) {1072 setView("pro-celebration");1073 window.setTimeout(() => setView("entry"), 2500);1074 }1075 1076 1077 if (openedForInlineUnlock) {1078 openedForInlineUnlock = false;1079 window.close();1080 }1081 }10821083 function handleSelectEntry(entry: VaultEntry) {1084 if (entry.type === "note") {1085 setOpenNoteId(entry.id);1086 setShowNotes(true);1087 return;1088 }1089 setSelectedEntry(entry);1090 setContentTransitioning(false);1091 setContentPhase("enter");1092 queueContentIdle();1093 setView("entry");1094 }10951096 function handleAdd() {1097 setEditEntry(undefined);1098 setView("add");1099 }11001101 async function handleDelete() {1102 const entry = selectedEntry();1103 if (!entry) return;1104 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1105 const loadedEntries = await loadEntries() ?? [];1106 setSelectedEntry(loadedEntries[0] ?? null);1107 setView("entry");1108 }11091110 async function handleDeleteMany(ids: string[]) {1111 for (const id of ids) {1112 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1113 }1114 const loadedEntries = await loadEntries() ?? [];1115 1116 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1117 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1118 }11191120 async function handleSave(savedEntry?: VaultEntry) {1121 await openVault(savedEntry?.id);1122 }11231124 function handleBack() {1125 const dest = editBackView();1126 setEditBackView("entry");1127 setEditEntry(undefined);1128 setView(dest);1129 }11301131 function handleExportBack() {1132 if (returnToPanic()) {1133 setReturnToPanic(false);1134 setView("panic");1135 return;1136 }1137 setView("settings");1138 }11391140 function handleLock() {1141 clearCanarySuppressions();1142 setEntries([]);1143 setSelectedEntry(null);1144 setEditEntry(undefined);1145 setContentPhase("idle");1146 setView("lock");1147 }11481149 function handleHeaderLock() {1150 1151 1152 1153 handleLock();1154 void sendMessage({ type: "LOCK" });1155 }11561157 1158 1159 1160 async function handleHeaderSync(): Promise<void> {1161 if (syncBusy()) return;1162 setSyncBusy(true);1163 setSyncFlash(null);1164 try {1165 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1166 let state: SyncOutcomeSummary = { status: "syncing" };1167 for (let i = 0; i < 40 && state.status === "syncing"; i++) {1168 await new Promise((r) => setTimeout(r, 250));1169 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1170 ok: boolean;1171 data?: SyncOutcomeSummary;1172 };1173 state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };1174 }1175 setSyncSummary(state);1176 setSyncFlash(state.status === "error" ? "error" : "ok");1177 await loadSyncAlerts();1178 await loadEntries();1179 await loadSyncIndicator();1180 } catch {1181 setSyncSummary({ status: "error", outcome: "run_failed" });1182 setSyncFlash("error");1183 } finally {1184 setSyncBusy(false);1185 setTimeout(() => setSyncFlash(null), 2_000);1186 }1187 }11881189 function navigate(dir: number) {1190 const allEntries = entries();1191 if (allEntries.length === 0) return;1192 const currentIndex = selectedIndex();1193 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1194 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1195 setSelectedEntry(allEntries[nextIndex]);1196 setContentPhase("enter");1197 queueContentIdle();1198 }11991200 function openList() {1201 setView("list");1202 }12031204 function openHealth() {1205 setEditEntry(undefined);1206 setView("health");1207 }12081209 function handleOpenFileImport() {1210 setRestoreView("file");1211 }12121213 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;12141215 function handleFixEntry(entry: VaultEntry) {1216 setSelectedEntry(entry);1217 1218 1219 if (isPasskeyOnly(entry)) {1220 setView("entry");1221 return;1222 }1223 setEditBackView(view()); 1224 setEditEntry(entry);1225 setView("add");1226 }12271228 const isLockedView = () => view() === "lock" || view() === "adopting";12291230 async function handleAdoptionDone() {1231 await storageSet("peach_onboarded", "true").catch(() => {});1232 setOnboarded(true);1233 setHasVault(true);1234 await handleUnlock();1235 void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });1236 }12371238 123912401241124212431244124512461247124812491250 async function startVaultAdoption(args: {1251 password: string;1252 connection: { invitation: string };1253 confirmSAS(sas: string): Promise<boolean>;1254 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1255 }) {1256 const [{ dialRelayPairingChannel, openRelayPairingSocket }, { requestAdoption }] =1257 await Promise.all([1258 import("../shared/pairing-dial"),1259 import("../shared/vault-adopt"),1260 ]);12611262 const dial = await dialRelayPairingChannel(args.connection.invitation, {1263 openSocket: openRelayPairingSocket,1264 });1265 if (!dial.ok) throw new Error(dial.error);1266 const { channel, sas, socket, relayUrl, rendezvousSecretHex } = dial.dialled;12671268 try {1269 const source = await requestAdoption(channel, {1270 deviceName: defaultAdoptionDeviceName(),1271 generateDeviceId: () => crypto.randomUUID(),1272 1273 1274 confirmSas: () => args.confirmSAS(sas),1275 pairState: { rendezvousSecretHex, relayUrl },1276 createVaultFromRootKey: args.createVault,1277 deriveLocalIdentity: async () => {1278 const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });1279 const r = res as { ok: boolean; data?: unknown; error?: string };1280 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1281 return r.data as Awaited<ReturnType<Parameters<typeof requestAdoption>[1]["deriveLocalIdentity"]>>;1282 },1283 upsertPairedDevice: async (paired) => {1284 const res = await sendMessage({ type: "COMPLETE_PAIRING", payload: { paired } });1285 const r = res as { ok: boolean; error?: string };1286 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1287 },1288 applyLicense: async (license) => {1289 const res = (await sendMessage({1290 type: "ACTIVATE_LICENSE",1291 payload: { key: license },1292 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1293 if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1294 },1295 onError(error) {1296 throw new Error(1297 error === "ADOPT_SAS_REJECTED" ? "sas_rejected" : error,1298 );1299 },1300 });1301 if (!source) throw new Error("adoption_rejected");1302 if (relayUrl) {1303 1304 const setting = (await sendMessage({1305 type: "SET_SYNC_SETTINGS",1306 payload: { relayUrl },1307 })) as { ok: boolean; error?: string };1308 if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");1309 }1310 } finally {1311 socket.close();1312 }1313 }13141315 async function handleApprovePendingPasskey() {1316 const pending = pendingPasskey();1317 if (!pending) return;1318 if (pending.browser === "firefox" && pending.type === "create") {1319 1320 1321 1322 1323 const { createFirefoxPasskey } = await import("./firefox-passkey");1324 const result = await createFirefoxPasskey(pending);1325 if (!result.ok) {1326 console.warn("Peach Firefox passkey create failed:", result.error);1327 }1328 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1329 setPendingPasskey(null);1330 void loadEntries();1331 window.close();1332 return;1333 }1334 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1335 window.close();1336 }13371338 async function handleDenyPendingPasskey() {1339 const pending = pendingPasskey();1340 if (!pending) return;1341 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1342 window.close();1343 }13441345 function queueContentIdle(after?: () => void) {1346 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1347 contentPhaseTimer = window.setTimeout(() => {1348 setContentPhase("idle");1349 after?.();1350 }, ENTRY_TRANSITION_SETTLE_MS);1351 }13521353 return (1354 <div class="popup-root peach-theme">1355 <Show when={bootState() === "ready"}>1356 <Show1357 when={!pendingPasskey()}1358 fallback={1359 <PasskeyApprovalView1360 type={pendingPasskey()!.type}1361 rpName={pendingPasskey()!.rpName}1362 rpId={pendingPasskey()!.rpId}1363 userName={pendingPasskey()!.userName}1364 userDisplayName={pendingPasskey()!.userDisplayName}1365 onApprove={handleApprovePendingPasskey}1366 onDeny={handleDenyPendingPasskey}1367 />1368 }1369 >1370 <Show1371 when={!isLockedView()}1372 fallback={1373 <Show1374 when={view() !== "adopting"}1375 fallback={1376 <Suspense fallback={<div class="peach-loading">loading...</div>}>1377 <AdoptVaultView1378 onCancel={() => {1379 setOnboarded(false);1380 setView("lock");1381 }}1382 onDone={handleAdoptionDone}1383 startAdoption={startVaultAdoption}1384 />1385 </Suspense>1386 }1387 >1388 <Show1389 when={onboarded() || hasVault()}1390 fallback={1391 <Onboarding1392 onComplete={() => { setOnboarded(true); }}1393 onRestore={() => { setOnboarded(true); }}1394 onAdopt={() => {1395 setOnboarded(true);1396 setView("adopting");1397 }}1398 />1399 }1400 >1401 <LockView1402 hasVault={hasVault()}1403 ready={bootState() === "ready"}1404 isPro={proStatus()}1405 forceRecoveryWords={forceRecoveryWords()}1406 onUnlock={() => {1407 1408 1409 1410 if (forceRecoveryWords()) setForceRecoveryWords(false);1411 return handleUnlock();1412 }}1413 onBackToOnboarding={1414 !hasVault()1415 ? () => setOnboarded(false)1416 : undefined1417 }1418 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1419 />1420 </Show>1421 </Show>1422 }1423 >1424 <Show1425 when={view() !== "pro-celebration"}1426 fallback={<ProCelebration />}1427 >1428 <section1429 id="hd-shell"1430 class="peach-shell peach-theme"1431 data-surface="extension"1432 >1433 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1434 <PeachWordmark unlocked />1435 <div class="peach-chrome-actions">1436 <button1437 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1438 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1439 aria-label="Notes"1440 title="Notes"1441 >1442 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1443 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1444 <line x1="4" y1="5" x2="10" y2="5" />1445 <line x1="4" y1="7.5" x2="10" y2="7.5" />1446 <line x1="4" y1="10" x2="7" y2="10" />1447 </svg>1448 </button>1449 <button1450 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1451 classList={{1452 "is-syncing": syncBusy(),1453 "is-ok": syncFlash() === "ok",1454 "is-error": syncFlash() === "error",1455 }}1456 onClick={() => void handleHeaderSync()}1457 disabled={syncBusy()}1458 aria-label={`Sync now. ${syncLabel()}`}1459 title={syncLabel()}1460 >1461 <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">1462 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1463 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1464 <path d="M2.6 1v2.4h2.4" />1465 <path d="M11.4 13v-2.4H9" />1466 </svg>1467 <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />1468 </button>1469 <button1470 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1471 onClick={() => setView("settings")}1472 aria-label="Settings"1473 >1474 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1475 <path1476 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"1477 stroke="currentColor"1478 stroke-width="0.9"1479 />1480 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1481 </svg>1482 </button>1483 <button1484 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1485 onClick={handleAdd}1486 aria-label="New entry"1487 >1488 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1489 <path d="M5.5 1v9M1 5.5h9" />1490 </svg>1491 </button>1492 <button1493 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1494 onClick={() => void handleHeaderLock()}1495 aria-label="Lock vault"1496 >1497 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1498 <path1499 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"1500 stroke="currentColor"1501 stroke-width="1"1502 stroke-linecap="round"1503 />1504 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1505 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1506 </svg>1507 </button>1508 </div>1509 </header>15101511 <Show when={pendingProNotice() !== null}>1512 <div class="peach-pro-notice-banner">1513 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1514 <button1515 class="peach-pro-notice-link"1516 onClick={() => {1517 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1518 setSettingsInitialSection("license");1519 setView("settings");1520 setPendingProNotice(null);1521 }}1522 >1523 Learn more →1524 </button>1525 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1526 </div>1527 </Show>15281529 <div class="peach-shell-body">1530 <div class="peach-shell-veil"></div>1531 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1532 <EntryView1533 entry={selectedEntry()}1534 currentTabUrl={currentTabUrl()}1535 entries={entries()}1536 isPro={proStatus()}1537 onDelete={handleDelete}1538 onFieldSaved={() => void loadEntries()}1539 onEscape={() => { setView("list"); }}1540 onProGate={(feature) => {1541 setUpgradeHighlight(feature);1542 setSettingsInitialSection("license");1543 setView("settings");1544 }}1545 />1546 </div>15471548 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1549 <div class="peach-search-bar">1550 <span class="peach-search-icon">1551 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1552 <circle cx="5.5" cy="5.5" r="3.75" />1553 <path d="M8.75 8.75L11.5 11.5" />1554 </svg>1555 </span>1556 <input1557 class="peach-search-input"1558 placeholder="search vault..."1559 autocomplete="off"1560 aria-label="Search vault entries"1561 onClick={openList}1562 onFocus={openList}1563 readOnly1564 />1565 <div class="peach-search-meta">1566 <span class="peach-search-counter">1567 {entries().length > 01568 ? `${selectedIndex() + 1} / ${entries().length}`1569 : "0 entries"}1570 </span>1571 <span class="peach-divider"></span>1572 <button1573 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1574 onClick={() => navigate(-1)}1575 aria-label="Previous entry"1576 disabled={entries().length === 0}1577 >1578 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1579 <path d="M5 1L1 5.5L5 10" />1580 </svg>1581 </button>1582 <button1583 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1584 onClick={() => navigate(1)}1585 aria-label="Next entry"1586 disabled={entries().length === 0}1587 >1588 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1589 <path d="M1 1L5 5.5L1 10" />1590 </svg>1591 </button>1592 </div>1593 </div>1594 </section>1595 </div>15961597 {15981599}1600 {}1601 <Show when={syncAlerts().length > 0}>1602 <div class="peach-sync-alerts">1603 <For each={syncAlerts()}>1604 {(alert, i) => (1605 <div class="peach-sync-alert">1606 <span class="peach-sync-alert-text">⚠ {syncAlertText(alert)}</span>1607 <button1608 class="peach-sync-alert-dismiss"1609 type="button"1610 aria-label="Dismiss"1611 onClick={() => dismissSyncAlert(i())}1612 >×</button>1613 </div>1614 )}1615 </For>1616 </div>1617 </Show>16181619 <Show when={!isNativePlatform() && view() === "list"}>1620 <VaultView1621 entries={entries()}1622 selectedEntryId={selectedEntry()?.id}1623 currentTabDomain={currentTabDomain()}1624 sortOrder={sortOrder()}1625 searchSeed={listSearchSeed()}1626 searchSeedNonce={listSearchSeedNonce()}1627 onSelectEntry={handleSelectEntry}1628 onSortOrderChange={setSortOrder}1629 onAdd={handleAdd}1630 onClose={() => setView("entry")}1631 onOpenTrash={() => setView("trash")}1632 onDeleteMany={handleDeleteMany}1633 />1634 </Show>1635 <Show when={isNativePlatform() && hasVault()}>1636 <Portal>1637 <div1638 class="peach-vault-sheet"1639 classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1640 style={(() => {1641 const live = sheetTranslate();1642 if (live === null) return undefined;1643 return { transform: `translateY(${live}px)` };1644 })()}1645 >1646 <VaultView1647 entries={entries()}1648 selectedEntryId={selectedEntry()?.id}1649 currentTabDomain={currentTabDomain()}1650 sortOrder={sortOrder()}1651 searchSeed={listSearchSeed()}1652 searchSeedNonce={listSearchSeedNonce()}1653 isNative={true}1654 onSelectEntry={handleSelectEntry}1655 onSortOrderChange={setSortOrder}1656 onAdd={handleAdd}1657 onClose={() => setView("entry")}1658 onOpenTrash={() => setView("trash")}1659 onDeleteMany={handleDeleteMany}1660 />1661 </div>1662 </Portal>1663 </Show>1664 <Show when={view() === "settings" || settingsExiting()}>1665 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1666 <SettingsView1667 entriesCount={entries().length}1668 breachMonitoring={breachMonitoring()}1669 lockOnClose={lockOnClose()}1670 autoLockMinutes={autoLockMinutes()}1671 trashRetentionDays={trashRetentionDays()}1672 theme={theme()}1673 accentColor={accentColor()}1674 textSize={textSize()}1675 reduceMotion={reduceMotion()}1676 isPro={proStatus()}1677 initialSection={settingsInitialSection()}1678 upgradeHighlight={upgradeHighlight()}1679 isExiting={settingsExiting()}1680 onBreachMonitoringChange={applyBreachMonitoring}1681 onLockOnCloseChange={applyLockOnClose}1682 onAutoLockMinutesChange={applyAutoLockMinutes}1683 onTrashRetentionChange={applyTrashRetention}1684 onThemeChange={applyTheme}1685 onAccentChange={applyAccentColor}1686 onTextSizeChange={applyTextSize}1687 onReduceMotionChange={applyReduceMotion}1688 shortcuts={shortcutBindings()}1689 onShortcutsChange={applyShortcuts}1690 onLicenseChange={async () => {1691 1692 1693 1694 1695 1696 await refreshProStatus();1697 void loadEntries();1698 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1699 if ((should as { ok: boolean; data?: boolean }).data === true) {1700 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1701 setView("pro-celebration");1702 window.setTimeout(() => setView("settings"), 2500);1703 }1704 }}1705 onBack={() => {1706 setSettingsExiting(true);1707 window.setTimeout(() => {1708 setSettingsExiting(false);1709 setSettingsInitialSection(null);1710 setUpgradeHighlight(null);1711 handleBack();1712 }, 200);1713 }}1714 onImport={() => setView("import")}1715 onExport={() => setView("export")}1716 onBreachCheckDone={loadEntries}1717 onOpenHealth={openHealth}1718 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1719 onOpenFileImport={handleOpenFileImport}1720 onOpenPanic={() => setView("panic")}1721 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1722 onOpenCodexRestore={() => setView("codex-restore")}1723 onCardChange={setSettingsActiveCard}1724 onProGate={(feature) => {1725 setUpgradeHighlight(feature);1726 setSettingsInitialSection("license");1727 }}1728 />1729 </Suspense>1730 </Show>1731 <Show when={view() === "codex-generate"}>1732 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1733 <CodexGenerateView1734 isPro={proStatus()}1735 onBack={() => setView("settings")}1736 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1737 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1738 onVaultLocked={() => handleLock()}1739 />1740 </Suspense>1741 </Show>1742 <Show when={view() === "codex-restore"}>1743 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1744 <CodexRestoreView1745 onBack={() => setView("lock")}1746 onRestoreSuccess={async () => {1747 1748 1749 1750 await handleUnlock();1751 }}1752 />1753 </Suspense>1754 </Show>1755 <Show when={view() === "pair-device"}>1756 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1757 <PairDeviceView1758 isPro={proStatus()}1759 onProChanged={() => { void refreshProStatus(); }}1760 onBack={() => setView("settings")}1761 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1762 />1763 </Suspense>1764 </Show>1765 <Show when={view() === "add"}>1766 <AddView1767 entry={editEntry()}1768 entries={entries()}1769 onBack={handleBack}1770 onSave={handleSave}1771 />1772 </Show>1773 <Show when={view() === "import"}>1774 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1775 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1776 </Suspense>1777 </Show>1778 <Show when={view() === "export"}>1779 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1780 <ExportView onBack={handleExportBack} />1781 </Suspense>1782 </Show>1783 <Show when={view() === "panic"}>1784 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1785 <PanicView1786 onCancel={() => setView("settings")}1787 onOpenExport={() => {1788 setReturnToPanic(true);1789 setView("export");1790 }}1791 onDestroyed={async () => {1792 clearCanarySuppressions();1793 setEntries([]);1794 setSelectedEntry(null);1795 setEditEntry(undefined);1796 setHasVault(false);1797 setOnboarded(false);1798 setProStatus(false);1799 setPendingPasskey(null);1800 setUpgradeHighlight(null);1801 setSettingsInitialSection(null);1802 setPendingProNotice(null);1803 setPendingCelebration(false);1804 setReturnToPanic(false);1805 setContentPhase("idle");1806 setView("lock");1807 }}1808 />1809 </Suspense>1810 </Show>1811 {1812}1813 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1814 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1815 <HealthView1816 entries={entries()}1817 isPro={proStatus()}1818 onFixEntry={handleFixEntry}1819 onBack={() => setView("settings")}1820 onRefresh={loadEntries}1821 hidden={view() !== "health"}1822 />1823 </Suspense>1824 </Show>1825 <Show when={view() === "trash"}>1826 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1827 <TrashView1828 onBack={() => setView("list")}1829 onRestored={() => void loadEntries()}1830 />1831 </Suspense>1832 </Show>1833 <Show when={showNotes()}>1834 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1835 <NotesMode1836 notes={entries().filter((e) => e.type === "note")}1837 initialNoteId={openNoteId()}1838 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1839 onNoteListChanged={() => void loadEntries()}1840 />1841 </div>1842 </Show>1843 <Show when={restoreView() === "file"}>1844 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1845 <ImportView1846 mode="onboarding"1847 replacesExistingVault={true}1848 onBack={() => setRestoreView(null)}1849 onImport={async () => {1850 setRestoreView(null);1851 await handleUnlock();1852 }}1853 />1854 </Suspense>1855 </Show>1856 </section>1857 </Show>1858 </Show>1859 </Show>1860 </Show>1861 <Show when={showShortcutsHelp()}>1862 <ShortcutsHelpOverlay1863 bindings={shortcutBindings()}1864 onClose={() => setShowShortcutsHelp(false)}1865 />1866 </Show>1867 </div>1868 );1869}18701871function ShortcutsHelpOverlay(props: {1872 bindings: Record<ShortcutAction, Keybinding>;1873 onClose: () => void;1874}) {1875 return (1876 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1877 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1878 <div class="peach-shortcuts-help-head">1879 <h3>Keyboard shortcuts</h3>1880 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1881 </div>1882 <div class="peach-shortcuts-help-body">1883 <p class="peach-settings-sub-label">ENTRY VIEW</p>1884 <ul>1885 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1886 <li>1887 <span>{d.label}</span>1888 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1889 </li>1890 ))}1891 </ul>1892 <p class="peach-settings-sub-label">ANYWHERE</p>1893 <ul>1894 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1895 <li>1896 <span>{d.label}</span>1897 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1898 </li>1899 ))}1900 </ul>1901 </div>1902 </div>1903 </div>1904 );1905}