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 { popupCopy } from "./copy";19import {20 DEFAULT_BINDINGS,21 formatKeybinding,22 getActiveBindings,23 matchKeybinding,24 SHORTCUT_DESCRIPTORS,25 type Keybinding,26 type ShortcutAction,27} from "../shared/shortcuts";28import { generateTOTP } from "../crypto/totp";29import { isNativePlatform, nativePlugin } from "../shared/native";3031export { sendMessage } from "../shared/bridge";3233const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));34const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));35const SettingsView = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));36const HealthView = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));37const TrashView = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));38const PanicView = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));39const PairDeviceView = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));40const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));41const CodexRestoreView = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));42const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));4344type View =45 | "lock"46 | "pro-celebration"47 | "entry"48 | "list"49 | "add"50 | "settings"51 | "pair-device"52 | "import"53 | "export"54 | "health"55 | "trash"56 | "panic"57 | "adopting"58 | "codex-generate"59 | "codex-restore";60type BootState = "loading" | "ready";6162const canarySuppressMap = new Map<string, boolean>();63let shellRingAnimation: Animation | null = null;6465export function isCanarySuppressed(entryId: string): boolean {66 return canarySuppressMap.get(entryId) ?? false;67}6869export function setCanarySuppressed(entryId: string, suppressed: boolean): void {70 canarySuppressMap.set(entryId, suppressed);71}7273export function clearCanarySuppressions(): void {74 canarySuppressMap.clear();75}7677export function triggerRing() {78 const shell = document.getElementById("hd-shell");79 if (!shell) return;8081 shellRingAnimation?.cancel();82 shell.dataset.glowing = "true";83 const animation = shell.animate(84 [85 { offset: 0, boxShadow: "var(--copy-shadow)" },86 {87 offset: 0.3,88 boxShadow:89 "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)",90 },91 { offset: 1, boxShadow: "var(--copy-shadow)" },92 ],93 {94 duration: 720,95 easing: "cubic-bezier(0.4, 0, 0.2, 1)",96 fill: "none",97 }98 );99 shellRingAnimation = animation;100 const clearGlow = () => {101 if (shellRingAnimation !== animation) return;102 shellRingAnimation = null;103 delete shell.dataset.glowing;104 };105 animation.onfinish = clearGlow;106 animation.oncancel = clearGlow;107}108109110export function triggerPipPulse() {111 const pip = document.querySelector(".peach-status-pip, .pip");112 if (pip) {113 pip.classList.remove("pulse");114 void (pip as HTMLElement).offsetWidth;115 pip.classList.add("pulse");116 }117}118119function defaultAdoptionDeviceName(): string {120 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";121 if (/Firefox/i.test(ua)) return "Firefox";122 if (/Edg\//i.test(ua)) return "Edge";123 if (/Chrome/i.test(ua)) return "Chrome";124 if (/Safari/i.test(ua)) return "Safari";125 return "Peach";126}127128const ENTRY_TRANSITION_SETTLE_MS = 135;129130export default function App() {131 const [bootState, setBootState] = createSignal<BootState>("loading");132 const [view, setView] = createSignal<View>("lock");133 134 135 const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);136 const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(137 "enter"138 );139 const [contentTransitioning, setContentTransitioning] = createSignal(false);140 const [hasVault, setHasVault] = createSignal(false);141 const [onboarded, setOnboarded] = createSignal(true);142 const [entries, setEntries] = createSignal<VaultEntry[]>([]);143 const [selectedEntry, setSelectedEntry] = createSignal<VaultEntry | null>(null);144 const [editEntry, setEditEntry] = createSignal<VaultEntry | undefined>(undefined);145146 147 148 interface SyncAlert {149 type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";150 entryId: string;151 entryTitle: string;152 deviceName: string;153 timestamp: number;154 }155 const [syncAlerts, setSyncAlerts] = createSignal<SyncAlert[]>([]);156 async function loadSyncAlerts() {157 const r = (await sendMessage({ type: "GET_SYNC_ALERTS" })) as {158 ok: boolean;159 data?: SyncAlert[];160 };161 if (r.ok && (r.data?.length ?? 0) > 0) {162 setSyncAlerts(r.data ?? []);163 164 165 void sendMessage({ type: "GET_SYNC_ALERTS", payload: { consume: true } });166 }167 }168 function dismissSyncAlert(index: number) {169 setSyncAlerts((arr) => arr.filter((_, i) => i !== index));170 }171 function syncAlertText(a: SyncAlert): string {172 if (a.type === "REMOTE_PASSWORD_CHANGE") {173 return `${a.deviceName} changed the password for ${a.entryTitle}`;174 }175 if (a.type === "REMOTE_DELETE") {176 return `${a.deviceName} deleted ${a.entryTitle}`;177 }178 return `${a.deviceName}'s sync key changed — if you didn't reinstall Peach on that device, rotate your sync identity`;179 }180 const [accentColor, setAccentColor] = createSignal<AccentColorName>("peach");181 const [theme, setTheme] = createSignal<"dark" | "light">("dark");182 const [textSize, setTextSize] = createSignal<"small" | "medium" | "large">("medium");183 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);184 const [lockOnClose, setLockOnClose] = createSignal(true);185 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);186 const [breachMonitoring, setBreachMonitoring] = createSignal(true);187 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);188 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);189 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);190 const [restoreView, setRestoreView] = createSignal<"file" | null>(null);191 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);192 const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");193 const [currentTabUrl, setCurrentTabUrl] = createSignal<string>("");194 195 196 197 198 const [proStatus, setProStatus] = createSignal(false);199 const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);200 201 202 const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");203 const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);204 const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");205 const [listSearchSeed, setListSearchSeed] = createSignal("");206 const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);207 const [pendingCelebration, setPendingCelebration] = createSignal(false);208 const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);209 210 211 212 const [returnToPanic, setReturnToPanic] = createSignal(false);213 214 215 const [editBackView, setEditBackView] = createSignal<View>("entry");216 const [settingsExiting, setSettingsExiting] = createSignal(false);217 const [showNotes, setShowNotes] = createSignal(false);218 219 220 221 const [syncBusy, setSyncBusy] = createSignal(false);222 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);223 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);224 225 226 227 228 const [forceRecoveryWords, setForceRecoveryWords] = createSignal(false);229230 let contentPhaseTimer: number | undefined;231 232 233 let openedForInlineUnlock = false;234235 function applyAccent(name: AccentColorName) {236 const color = ACCENT_COLORS.find(c => c.name === name) ?? ACCENT_COLORS[0]!;237 const hex = color.hex;238 document.documentElement.style.setProperty("--peach-accent", hex);239 240 241 document.documentElement.style.setProperty("--peach-glow", hex + "2A");242 document.documentElement.style.setProperty("--peach-surface-tint", hex + "09");243 document.documentElement.style.setProperty("--peach-border-accent", hex + "24");244 }245246 createEffect(() => {247 applyAccent(accentColor());248 });249250 createEffect(() => {251 document.documentElement.classList.toggle("light", theme() === "light");252 document.body.classList.toggle("light", theme() === "light");253 });254255 createEffect(() => {256 document.documentElement.setAttribute("data-text-size", textSize());257 });258259 createEffect(() => {260 if (reduceMotion()) {261 document.documentElement.setAttribute("data-motion", "reduce");262 } else {263 document.documentElement.removeAttribute("data-motion");264 }265 });266267 const selectedIndex = createMemo(() => {268 const currentId = selectedEntry()?.id;269 if (!currentId) return entries().length > 0 ? 0 : -1;270 return entries().findIndex((entry) => entry.id === currentId);271 });272273 async function applyAccentColor(name: AccentColorName) {274 applyAccent(name);275 setAccentColor(name);276 await sendMessage({ type: "UPDATE_SETTINGS", payload: { accentColor: name } });277 }278279 async function applyTheme(t: "dark" | "light") {280 setTheme(t);281 await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });282 }283284 async function applyTextSize(size: "small" | "medium" | "large") {285 setTextSize(size);286 await sendMessage({ type: "UPDATE_SETTINGS", payload: { textSize: size } });287 }288289 async function applyReduceMotion(enabled: boolean) {290 setReduceMotion(enabled);291 await sendMessage({ type: "UPDATE_SETTINGS", payload: { reduceMotion: enabled } });292 }293294 295 296 297 async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {298 setShortcutBindings(active);299 const overrides: Record<string, Keybinding> = {};300 for (const action of Object.keys(active) as ShortcutAction[]) {301 const a = active[action];302 const d = DEFAULT_BINDINGS[action];303 const same = a.key === d.key304 && Boolean(a.ctrl) === Boolean(d.ctrl)305 && Boolean(a.meta) === Boolean(d.meta)306 && Boolean(a.shift) === Boolean(d.shift)307 && Boolean(a.alt) === Boolean(d.alt);308 if (!same) overrides[action] = a;309 }310 await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });311 }312313 async function applyLockOnClose(enabled: boolean) {314 setLockOnClose(enabled);315 await sendMessage({316 type: "UPDATE_SETTINGS",317 payload: { lockOnClose: enabled },318 });319 }320321 async function applyAutoLockMinutes(minutes: number) {322 setAutoLockMinutes(minutes);323 await sendMessage({324 type: "UPDATE_SETTINGS",325 payload: { autoLockMinutes: minutes },326 });327 }328329 async function applyBreachMonitoring(enabled: boolean) {330 setBreachMonitoring(enabled);331 await sendMessage({332 type: "UPDATE_SETTINGS",333 payload: { breachMonitoring: enabled },334 });335 }336337 onMount(async () => {338 339 340 341 342 343 const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([344 sendMessage({ type: "IS_UNLOCKED" }),345 sendMessage({ type: "HAS_VAULT" }),346 refreshCurrentTab(),347 sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),348 ]);349 const unlocked = Boolean(350 (unlockResponse as { ok: boolean; data?: boolean }).data351 );352 const vaultExists = Boolean(353 (vaultResponse as { ok: boolean; data?: boolean }).data354 );355 const shouldCelebrate = Boolean(356 (celebrationResponse as { ok: boolean; data?: boolean }).data357 );358 setHasVault(vaultExists);359 360 361 362 try {363 const onboarded = await storageGet("peach_onboarded");364 const seen = onboarded === "true";365 if (vaultExists && !seen) {366 await storageSet("peach_onboarded", "true");367 setOnboarded(true);368 } else {369 setOnboarded(seen);370 }371 } catch {372 373 setOnboarded(true);374 }375 await Promise.all([restoreSettings(), refreshProStatus()]);376377 378 if (shouldCelebrate) {379 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });380 }381382 383 384 385 if (unlocked && vaultExists) {386 void sendMessage({387 type: "TRIGGER_SYNC",388 payload: { reason: "foreground" },389 });390 391 void loadSyncAlerts();392 }393394 395 396 397 398 399 400 let recoveryPending = false;401 try {402 const out = await chrome.storage.local?.get("peach_recovery_pending");403 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";404 } catch { }405406 407 408 409 if (chrome.storage.session) {410 try {411 const s = await chrome.storage.session.get("peach_inline_unlock");412 if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {413 openedForInlineUnlock = true;414 await chrome.storage.session.remove("peach_inline_unlock");415 }416 } catch { }417 }418419 420 421 422 423 424 if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {425 try {426 await chrome.storage.local?.remove([427 "peach_vault_a",428 "peach_vault_b",429 "peach_recovery_pending",430 "peach_onboarded",431 "peach_vault_history",432 ]);433 } catch { }434 setHasVault(false);435 setOnboarded(false);436 setView("lock");437 setBootState("ready");438 return;439 }440441 if (recoveryPending && unlocked) {442 443 444 setForceRecoveryWords(true);445 setView("lock");446 } else if (unlocked && vaultExists) {447 await openVault(undefined, shouldCelebrate);448 if (shouldCelebrate) {449 setView("pro-celebration");450 window.setTimeout(() => setView("entry"), 2500);451 }452 } else if (shouldCelebrate) {453 setPendingCelebration(true);454 }455456 await refreshPendingPasskey();457458 459 const handleTabActivated = () => { void refreshCurrentTab(); };460 const handleTabUpdated = (_tabId: number, changeInfo: { url?: string; status?: string }) => {461 if (changeInfo.url || changeInfo.status === "complete") void refreshCurrentTab();462 };463 try {464 chrome.tabs.onActivated.addListener(handleTabActivated);465 chrome.tabs.onUpdated.addListener(handleTabUpdated);466 onCleanup(() => {467 chrome.tabs.onActivated.removeListener(handleTabActivated);468 chrome.tabs.onUpdated.removeListener(handleTabUpdated);469 });470 } catch {471 472 }473474 setBootState("ready");475476 477 478 479 480 try {481 const route = new URLSearchParams(window.location.search).get("codex");482 if (route === "pdf" || route === "restore") {483 setView("codex-restore");484 }485 } catch {486 487 }488489 490 if (chrome.storage.session) {491 const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as492 Record<string, { feature?: string } | undefined>;493 if (session.pendingProGate?.feature) {494 await chrome.storage.session.remove("pendingProGate");495 setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);496 setSettingsInitialSection("license");497 setView("settings");498 } else if (session.pendingProNotice?.feature) {499 await chrome.storage.session.remove("pendingProNotice");500 setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });501 }502 }503504 505 506 507 508 void Promise.all([509 import("./views/Settings").catch(() => {}),510 import("./views/Import").catch(() => {}),511 import("./views/Export").catch(() => {}),512 import("./views/Health").catch(() => {}),513 import("./views/TrashView").catch(() => {}),514 ]);515516 517 518 519 let popupPort: chrome.runtime.Port | null = null;520 try {521 522 523 524 popupPort = chrome.runtime.connect({ name: `popup-${crypto.randomUUID()}` });525 } catch { }526 onCleanup(() => { try { popupPort?.disconnect(); } catch {} });527528 529 530 531 if (isNativePlatform()) {532 let backgroundedAt: number | null = null;533534 const CapApp = nativePlugin<{535 addListener: (536 ev: string,537 cb: (s: { isActive: boolean }) => void,538 ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };539 }>("App");540 if (CapApp?.addListener) {541 const sub = CapApp.addListener(542 "appStateChange",543 ({ isActive }: { isActive: boolean }) => {544 if (!isActive) {545 backgroundedAt = Date.now();546 return;547 }548 if (backgroundedAt !== null && autoLockMinutes() > 0) {549 const elapsed = Date.now() - backgroundedAt;550 if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();551 }552 backgroundedAt = null;553 }554 );555 556 onCleanup(() => {557 Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});558 });559 }560561 562 563 564 565 566 let lastUserActivityAt = Date.now();567 const bumpActivity = () => { lastUserActivityAt = Date.now(); };568 const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;569 for (const ev of ACTIVITY_EVENTS) {570 document.addEventListener(ev, bumpActivity, { passive: true, capture: true });571 }572 const idleLockInterval = setInterval(() => {573 const minutes = autoLockMinutes();574 if (!minutes || minutes <= 0) return;575 const elapsed = Date.now() - lastUserActivityAt;576 if (elapsed >= minutes * 60_000) {577 lastUserActivityAt = Date.now();578 handleHeaderLock();579 }580 }, 30_000);581 onCleanup(() => {582 clearInterval(idleLockInterval);583 for (const ev of ACTIVITY_EVENTS) {584 document.removeEventListener(ev, bumpActivity, { capture: true });585 }586 });587588 589 590 591 592 if (isNativePlatform()) {593 594 595 596 void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>597 import("../background/sync-runner").then(({ productionSyncRunner }) => {598 setSyncRunner(productionSyncRunner);599 void hydratePersistedSyncState();600 })601 );602 }603 }604605 606 607 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {608 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();609 if (msg?.type === "PRO_GATE_TRIGGERED") {610 setUpgradeHighlight(msg.payload?.feature ?? null);611 setSettingsInitialSection("license");612 setView("settings");613 }614 };615 chrome.runtime.onMessage.addListener(handleRuntimeMessage);616 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));617618 619 620 621 const handlePeachBack = () => {622 623 if (showNotes()) {624 setShowNotes(false);625 setOpenNoteId(undefined);626 return;627 }628 const v = view();629 if (v === "lock" && !hasVault() && onboarded()) {630 setOnboarded(false);631 return;632 }633 if (v === "list") { setView("entry"); return; }634 if (v === "settings") {635 window.dispatchEvent(new Event("peach:settings-back"));636 return;637 }638 if (v === "import" || v === "health") { setView("settings"); return; }639 if (v === "export") { handleExportBack(); return; }640 if (v === "add") { handleBack(); return; }641 if (v === "pair-device") { setView("settings"); return; }642 };643 window.addEventListener("peach:back", handlePeachBack);644 onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));645646 647 648 649 650 651 if (isNativePlatform()) {652 let touchStartY = 0;653 let touchStartX = 0;654 let lastY = 0;655 let lastT = 0;656 let dragging = false;657 let captured = false;658 const HORIZONTAL_TOLERANCE = 16;659 const ACTIVATION_DY = 8;660 const PEEK_PX = 60;661662 const onTouchStart = (e: TouchEvent) => {663 const t = e.touches[0];664 if (!t) return;665 const v = view();666 if (v !== "entry" && v !== "list") return;667 668 669 const inBand = v === "entry"670 ? t.clientY > window.innerHeight * 0.6671 : t.clientY < window.innerHeight * 0.35;672 if (!inBand) return;673 touchStartY = lastY = t.clientY;674 touchStartX = t.clientX;675 lastT = e.timeStamp;676 dragging = true;677 captured = false;678 };679680 const onTouchMove = (e: TouchEvent) => {681 if (!dragging) return;682 const t = e.touches[0];683 if (!t) return;684 const dy = t.clientY - touchStartY;685 const dx = Math.abs(t.clientX - touchStartX);686687 if (!captured) {688 if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {689 690 dragging = false;691 return;692 }693 if (Math.abs(dy) < ACTIVATION_DY) return;694 captured = true;695 }696697 698 699 const v = view();700 const restClosed = window.innerHeight - PEEK_PX;701 const baseTranslate = v === "list" ? 0 : restClosed;702 const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));703 setSheetTranslate(next);704 lastY = t.clientY;705 lastT = e.timeStamp;706 };707708 const onTouchEnd = (e: TouchEvent) => {709 if (!dragging) { return; }710 const t = e.changedTouches[0];711 const elapsed = Math.max(1, e.timeStamp - lastT);712 const velocity = t ? (t.clientY - lastY) / elapsed : 0; 713 const wasCaptured = captured;714 dragging = false;715 captured = false;716 const v = view();717718 if (!wasCaptured) {719 720 if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");721 setSheetTranslate(null);722 return;723 }724725 const restClosed = window.innerHeight - PEEK_PX;726 const current = sheetTranslate() ?? restClosed;727728 729 730 731 732 let nextOpen: boolean;733 if (velocity < -0.05) nextOpen = true;734 else if (velocity > 0.05) nextOpen = false;735 else nextOpen = current < restClosed * 0.5;736737 if (nextOpen && v !== "list") setView("list");738 if (!nextOpen && v !== "entry") setView("entry");739 740 setSheetTranslate(null);741 };742743 document.addEventListener("touchstart", onTouchStart, { passive: true });744 document.addEventListener("touchmove", onTouchMove, { passive: true });745 document.addEventListener("touchend", onTouchEnd, { passive: true });746 document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });747 onCleanup(() => {748 document.removeEventListener("touchstart", onTouchStart);749 document.removeEventListener("touchmove", onTouchMove);750 document.removeEventListener("touchend", onTouchEnd);751 });752 }753754 755 756 757 const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {758 copy_password: () => {759 const entry = selectedEntry();760 if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });761 },762 copy_username: () => {763 const entry = selectedEntry();764 if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });765 },766 copy_totp: () => {767 const entry = selectedEntry();768 if (!entry?.totp) return;769 try {770 const { code } = generateTOTP(entry.totp);771 void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });772 } catch { }773 },774 new_entry: () => handleAdd(),775 edit_entry: () => {776 const entry = selectedEntry();777 778 779 if (!entry || isPasskeyOnly(entry)) return;780 setEditEntry(entry);781 setView("add");782 },783 delete_entry: () => {784 if (selectedEntry()) void handleDelete();785 },786 lock_vault: () => { void handleHeaderLock(); },787 search: () => openList(),788 nav_prev: () => navigate(-1),789 nav_next: () => navigate(1),790 jump_vault: () => setView("entry"),791 jump_settings: () => setView("settings"),792 show_help: () => setShowShortcutsHelp(true),793 };794795 796 797 798 const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([799 "copy_password", "copy_username", "copy_totp",800 "edit_entry", "delete_entry", "nav_prev", "nav_next",801 ]);802803 const handleGlobalKeydown = (e: KeyboardEvent) => {804 const v = view();805 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"806 || (e.target as HTMLElement)?.tagName === "TEXTAREA"807 || (e.target as HTMLElement)?.isContentEditable;808 const isPrintableSearchKey =809 e.key.length === 1 &&810 !e.ctrlKey &&811 !e.metaKey &&812 !e.altKey;813814 815 if (v === "pro-celebration") return;816817 818 819 if (showShortcutsHelp()) {820 if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }821 return;822 }823824 825 if (e.key === "Escape") {826 if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }827 if (v === "list") { setView("entry"); return; }828 if (v === "settings" || v === "add") { handleBack(); return; }829 if (v === "import" || v === "health") { setView("settings"); return; }830 if (v === "export") { handleExportBack(); return; }831 if (v === "pair-device") { setView("settings"); return; }832 }833834 835 836 837 const bindings = shortcutBindings();838 for (const action of Object.keys(bindings) as ShortcutAction[]) {839 const binding = bindings[action];840 if (!matchKeybinding(e, binding)) continue;841 842 843 const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;844 if (!hasMod && binding.key.length === 1 && inInput) return;845 if (ENTRY_SCOPED.has(action) && v !== "entry") return;846 e.preventDefault();847 SHORTCUT_HANDLERS[action]?.();848 return;849 }850851 852 853 854 855 if (v === "entry" && !inInput && isPrintableSearchKey) {856 e.preventDefault();857 primeListSearch(e.key);858 return;859 }860 };861 window.addEventListener("keydown", handleGlobalKeydown);862 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));863864 const handleStorageChanged = (865 changes: Record<string, chrome.storage.StorageChange>,866 areaName: string867 ) => {868 if (areaName !== "session") return;869 if (!Object.keys(changes).some((key) => key.startsWith("passkey_pending_"))) {870 return;871 }872 void refreshPendingPasskey();873 };874 chrome.storage.onChanged.addListener(handleStorageChanged);875 onCleanup(() => chrome.storage.onChanged.removeListener(handleStorageChanged));876 });877878 async function refreshCurrentTab() {879 try {880 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });881 const url = tab?.url ?? tab?.pendingUrl ?? "";882 setCurrentTabDomain(url ? (extractDomain(url) ?? "") : "");883 884 885 const isWebUrl = /^https?:\/\//i.test(url);886 setCurrentTabUrl(isWebUrl ? url : "");887 } catch {888 setCurrentTabDomain("");889 setCurrentTabUrl("");890 }891 }892893 async function refreshPendingPasskey() {894 const res = await sendMessage({ type: "GET_PENDING_PASSKEY" });895 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);896 }897898 async function refreshProStatus() {899 const res = await sendMessage({ type: "GET_LICENSE" });900 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;901 setProStatus(Boolean(data?.valid && data?.plan === "pro"));902 }903904 async function restoreSettings() {905 const res = await sendMessage({ type: "GET_SETTINGS" });906 const s = (res as { ok: boolean; data?: any }).data ?? {};907 if (s.accentColor && ACCENT_COLORS.some(c => c.name === s.accentColor)) {908 setAccentColor(s.accentColor as AccentColorName);909 }910 setTheme(s.theme === "light" ? "light" : "dark");911 if (s.textSize === "small" || s.textSize === "large") {912 setTextSize(s.textSize);913 } else {914 setTextSize("medium");915 }916 setReduceMotion(s.reduceMotion === true);917 setLockOnClose(s.lockOnClose !== false);918 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);919 setBreachMonitoring(s.breachMonitoring !== false);920 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);921 setShortcutBindings(getActiveBindings(s.shortcuts));922 }923924 async function applyTrashRetention(days: number) {925 setTrashRetentionDays(days);926 await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });927 }928929 function primeListSearch(seed: string) {930 setListSearchSeed(seed);931 setListSearchSeedNonce((value) => value + 1);932 openList();933 }934935 936 937 938 async function loadEntries(): Promise<VaultEntry[] | null> {939 const response = await sendMessage({ type: "GET_ENTRIES" });940 const res = response as { ok: boolean; data?: VaultEntry[]; error?: string };941942 943 944 945 946 if (!res.ok) {947 if (948 res.error === "vault_locked" ||949 res.error === "SERVICE_WORKER_UNAVAILABLE" ||950 (typeof res.error === "string" && res.error.includes("locked"))951 ) {952 handleLock();953 return null; 954 }955 return [];956 }957958 const loadedEntries = res.data ?? [];959 setEntries(loadedEntries);960961 const currentId = selectedEntry()?.id;962 if (currentId) {963 setSelectedEntry(964 loadedEntries.find((entry) => entry.id === currentId) ?? loadedEntries[0] ?? null965 );966 }967968 return loadedEntries;969 }970971 async function openVault(preferredEntryId?: string, skipViewSet = false) {972 973 const retDays = trashRetentionDays();974 if (retDays > 0) {975 void sendMessage({976 type: "PURGE_EXPIRED_TRASH",977 payload: { retentionMs: retDays * 86_400_000 },978 });979 }980981 const loadedEntries = await loadEntries();982983 984 985 if (loadedEntries === null) return;986987 988 let nextSelectedEntry: VaultEntry | null = null;989 if (preferredEntryId) {990 nextSelectedEntry = loadedEntries.find((e) => e.id === preferredEntryId) ?? null;991 }992 if (!nextSelectedEntry) {993 const tabDomain = currentTabDomain();994 if (tabDomain) {995 nextSelectedEntry = loadedEntries.find((e) => {996 if (e.type === "login") {997 if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;998 for (const extra of e.additionalUrls ?? []) {999 if ((extractDomain(extra) ?? "") === tabDomain) return true;1000 }1001 return false;1002 }1003 if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;1004 return false;1005 }) ?? null;1006 }1007 }1008 nextSelectedEntry ??= loadedEntries[0] ?? null;10091010 setSelectedEntry(nextSelectedEntry);1011 setEditEntry(undefined);1012 setContentTransitioning(false);1013 setContentPhase("enter");1014 queueContentIdle();1015 if (!skipViewSet) setView("entry");1016 }10171018 async function handleUnlock() {1019 1020 1021 1022 1023 1024 1025 let recoveryPending = false;1026 try {1027 const out = await chrome.storage.local?.get("peach_recovery_pending");1028 recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";1029 } catch { }1030 if (recoveryPending) {1031 setForceRecoveryWords(true);1032 setView("lock");1033 triggerPipPulse();1034 await restoreSettings();1035 return;1036 }1037 setHasVault(true);1038 triggerPipPulse();1039 await restoreSettings();1040 const celebrating = pendingCelebration();1041 if (celebrating) {1042 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1043 setPendingCelebration(false);1044 }1045 await openVault(undefined, celebrating);1046 if (celebrating) {1047 setView("pro-celebration");1048 window.setTimeout(() => setView("entry"), 2500);1049 }1050 1051 1052 if (openedForInlineUnlock) {1053 openedForInlineUnlock = false;1054 window.close();1055 }1056 }10571058 function handleSelectEntry(entry: VaultEntry) {1059 if (entry.type === "note") {1060 setOpenNoteId(entry.id);1061 setShowNotes(true);1062 return;1063 }1064 setSelectedEntry(entry);1065 setContentTransitioning(false);1066 setContentPhase("enter");1067 queueContentIdle();1068 setView("entry");1069 }10701071 function handleAdd() {1072 setEditEntry(undefined);1073 setView("add");1074 }10751076 async function handleDelete() {1077 const entry = selectedEntry();1078 if (!entry) return;1079 await sendMessage({ type: "DELETE_ENTRY", payload: { id: entry.id } });1080 const loadedEntries = await loadEntries() ?? [];1081 setSelectedEntry(loadedEntries[0] ?? null);1082 setView("entry");1083 }10841085 async function handleDeleteMany(ids: string[]) {1086 for (const id of ids) {1087 await sendMessage({ type: "DELETE_ENTRY", payload: { id } });1088 }1089 const loadedEntries = await loadEntries() ?? [];1090 1091 const stillExists = loadedEntries.find(e => e.id === selectedEntry()?.id);1092 if (!stillExists) setSelectedEntry(loadedEntries[0] ?? null);1093 }10941095 async function handleSave(savedEntry?: VaultEntry) {1096 await openVault(savedEntry?.id);1097 }10981099 function handleBack() {1100 const dest = editBackView();1101 setEditBackView("entry");1102 setEditEntry(undefined);1103 setView(dest);1104 }11051106 function handleExportBack() {1107 if (returnToPanic()) {1108 setReturnToPanic(false);1109 setView("panic");1110 return;1111 }1112 setView("settings");1113 }11141115 function handleLock() {1116 clearCanarySuppressions();1117 setEntries([]);1118 setSelectedEntry(null);1119 setEditEntry(undefined);1120 setContentPhase("idle");1121 setView("lock");1122 }11231124 function handleHeaderLock() {1125 1126 1127 1128 handleLock();1129 void sendMessage({ type: "LOCK" });1130 }11311132 1133 1134 1135 async function handleHeaderSync(): Promise<void> {1136 if (syncBusy()) return;1137 setSyncBusy(true);1138 setSyncFlash(null);1139 try {1140 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1141 let status: string = "syncing";1142 for (let i = 0; i < 40 && status === "syncing"; i++) {1143 await new Promise((r) => setTimeout(r, 250));1144 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1145 ok: boolean;1146 data?: { status?: string };1147 };1148 status = res.ok ? res.data?.status ?? "idle" : "error";1149 }1150 setSyncFlash(status === "error" ? "error" : "ok");1151 await loadSyncAlerts();1152 await loadEntries();1153 } catch {1154 setSyncFlash("error");1155 } finally {1156 setSyncBusy(false);1157 setTimeout(() => setSyncFlash(null), 2_000);1158 }1159 }11601161 function navigate(dir: number) {1162 const allEntries = entries();1163 if (allEntries.length === 0) return;1164 const currentIndex = selectedIndex();1165 const baseIndex = currentIndex >= 0 ? currentIndex : 0;1166 const nextIndex = (baseIndex + dir + allEntries.length) % allEntries.length;1167 setSelectedEntry(allEntries[nextIndex]);1168 setContentPhase("enter");1169 queueContentIdle();1170 }11711172 function openList() {1173 setView("list");1174 }11751176 function openHealth() {1177 setEditEntry(undefined);1178 setView("health");1179 }11801181 function handleOpenFileImport() {1182 setRestoreView("file");1183 }11841185 const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;11861187 function handleFixEntry(entry: VaultEntry) {1188 setSelectedEntry(entry);1189 1190 1191 if (isPasskeyOnly(entry)) {1192 setView("entry");1193 return;1194 }1195 setEditBackView(view()); 1196 setEditEntry(entry);1197 setView("add");1198 }11991200 const isLockedView = () => view() === "lock" || view() === "adopting";12011202 async function handleAdoptionDone() {1203 await storageSet("peach_onboarded", "true").catch(() => {});1204 setOnboarded(true);1205 setHasVault(true);1206 await handleUnlock();1207 void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });1208 }12091210 121112121213121412151216121712181219122012211222 async function startVaultAdoption(args: {1223 password: string;1224 connection: { invitation: string };1225 confirmSAS(sas: string): Promise<boolean>;1226 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1227 }) {1228 const [{ dialRelayPairingChannel, openRelayPairingSocket }, { requestAdoption }] =1229 await Promise.all([1230 import("../shared/pairing-dial"),1231 import("../shared/vault-adopt"),1232 ]);12331234 const dial = await dialRelayPairingChannel(args.connection.invitation, {1235 openSocket: openRelayPairingSocket,1236 });1237 if (!dial.ok) throw new Error(dial.error);1238 const { channel, sas, socket, relayUrl, rendezvousSecretHex } = dial.dialled;12391240 try {1241 const source = await requestAdoption(channel, {1242 deviceName: defaultAdoptionDeviceName(),1243 generateDeviceId: () => crypto.randomUUID(),1244 1245 1246 confirmSas: () => args.confirmSAS(sas),1247 pairState: { rendezvousSecretHex, relayUrl },1248 createVaultFromRootKey: args.createVault,1249 deriveLocalIdentity: async () => {1250 const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });1251 const r = res as { ok: boolean; data?: unknown; error?: string };1252 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1253 return r.data as Awaited<ReturnType<Parameters<typeof requestAdoption>[1]["deriveLocalIdentity"]>>;1254 },1255 upsertPairedDevice: async (paired) => {1256 const res = await sendMessage({ type: "COMPLETE_PAIRING", payload: { paired } });1257 const r = res as { ok: boolean; error?: string };1258 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1259 },1260 applyLicense: async (license) => {1261 const res = (await sendMessage({1262 type: "ACTIVATE_LICENSE",1263 payload: { key: license },1264 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1265 if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1266 },1267 onError(error) {1268 throw new Error(1269 error === "ADOPT_SAS_REJECTED" ? "sas_rejected" : error,1270 );1271 },1272 });1273 if (!source) throw new Error("adoption_rejected");1274 if (relayUrl) {1275 1276 const setting = (await sendMessage({1277 type: "SET_SYNC_SETTINGS",1278 payload: { relayUrl },1279 })) as { ok: boolean; error?: string };1280 if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");1281 }1282 } finally {1283 socket.close();1284 }1285 }12861287 async function handleApprovePendingPasskey() {1288 const pending = pendingPasskey();1289 if (!pending) return;1290 if (pending.browser === "firefox" && pending.type === "create") {1291 1292 1293 1294 1295 const { createFirefoxPasskey } = await import("./firefox-passkey");1296 const result = await createFirefoxPasskey(pending);1297 if (!result.ok) {1298 console.warn("Peach Firefox passkey create failed:", result.error);1299 }1300 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1301 setPendingPasskey(null);1302 void loadEntries();1303 window.close();1304 return;1305 }1306 await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });1307 window.close();1308 }13091310 async function handleDenyPendingPasskey() {1311 const pending = pendingPasskey();1312 if (!pending) return;1313 await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });1314 window.close();1315 }13161317 function queueContentIdle(after?: () => void) {1318 if (contentPhaseTimer) window.clearTimeout(contentPhaseTimer);1319 contentPhaseTimer = window.setTimeout(() => {1320 setContentPhase("idle");1321 after?.();1322 }, ENTRY_TRANSITION_SETTLE_MS);1323 }13241325 return (1326 <div class="popup-root peach-theme">1327 <Show when={bootState() === "ready"}>1328 <Show1329 when={!pendingPasskey()}1330 fallback={1331 <PasskeyApprovalView1332 type={pendingPasskey()!.type}1333 rpName={pendingPasskey()!.rpName}1334 rpId={pendingPasskey()!.rpId}1335 userName={pendingPasskey()!.userName}1336 userDisplayName={pendingPasskey()!.userDisplayName}1337 onApprove={handleApprovePendingPasskey}1338 onDeny={handleDenyPendingPasskey}1339 />1340 }1341 >1342 <Show1343 when={!isLockedView()}1344 fallback={1345 <Show1346 when={view() !== "adopting"}1347 fallback={1348 <Suspense fallback={<div class="peach-loading">loading...</div>}>1349 <AdoptVaultView1350 onCancel={() => {1351 setOnboarded(false);1352 setView("lock");1353 }}1354 onDone={handleAdoptionDone}1355 startAdoption={startVaultAdoption}1356 />1357 </Suspense>1358 }1359 >1360 <Show1361 when={onboarded() || hasVault()}1362 fallback={1363 <Onboarding1364 onComplete={() => { setOnboarded(true); }}1365 onRestore={() => { setOnboarded(true); }}1366 onAdopt={() => {1367 setOnboarded(true);1368 setView("adopting");1369 }}1370 />1371 }1372 >1373 <LockView1374 hasVault={hasVault()}1375 ready={bootState() === "ready"}1376 isPro={proStatus()}1377 forceRecoveryWords={forceRecoveryWords()}1378 onUnlock={() => {1379 1380 1381 1382 if (forceRecoveryWords()) setForceRecoveryWords(false);1383 return handleUnlock();1384 }}1385 onBackToOnboarding={1386 !hasVault()1387 ? () => setOnboarded(false)1388 : undefined1389 }1390 onOpenCodexRestore={!hasVault() ? () => setView("codex-restore") : undefined}1391 />1392 </Show>1393 </Show>1394 }1395 >1396 <Show1397 when={view() !== "pro-celebration"}1398 fallback={<ProCelebration />}1399 >1400 <section1401 id="hd-shell"1402 class="peach-shell peach-theme"1403 data-surface="extension"1404 >1405 <header class="peach-chrome" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1406 <PeachWordmark unlocked />1407 <div class="peach-chrome-actions">1408 <button1409 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1410 onClick={() => { setOpenNoteId(undefined); setShowNotes(true); }}1411 aria-label="Notes"1412 title="Notes"1413 >1414 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">1415 <rect x="1.5" y="1.5" width="11" height="11" rx="2" />1416 <line x1="4" y1="5" x2="10" y2="5" />1417 <line x1="4" y1="7.5" x2="10" y2="7.5" />1418 <line x1="4" y1="10" x2="7" y2="10" />1419 </svg>1420 </button>1421 <button1422 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1423 classList={{1424 "is-syncing": syncBusy(),1425 "is-ok": syncFlash() === "ok",1426 "is-error": syncFlash() === "error",1427 }}1428 onClick={() => void handleHeaderSync()}1429 disabled={syncBusy()}1430 aria-label={syncBusy() ? "Syncing" : "Sync now"}1431 title="Sync now"1432 >1433 <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">1434 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1435 <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1436 <path d="M2.6 1v2.4h2.4" />1437 <path d="M11.4 13v-2.4H9" />1438 </svg>1439 </button>1440 <button1441 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1442 onClick={() => setView("settings")}1443 aria-label="Settings"1444 >1445 <svg width="14" height="14" viewBox="0 0 14 14" fill="none">1446 <path1447 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"1448 stroke="currentColor"1449 stroke-width="0.9"1450 />1451 <circle cx="7" cy="7" r="2.1" stroke="currentColor" stroke-width="0.9" />1452 </svg>1453 </button>1454 <button1455 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1456 onClick={handleAdd}1457 aria-label="New entry"1458 >1459 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">1460 <path d="M5.5 1v9M1 5.5h9" />1461 </svg>1462 </button>1463 <button1464 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn"1465 onClick={() => void handleHeaderLock()}1466 aria-label="Lock vault"1467 >1468 <svg class="peach-lock-icon" width="14" height="16" viewBox="0 0 14 16" fill="none">1469 <path1470 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"1471 stroke="currentColor"1472 stroke-width="1"1473 stroke-linecap="round"1474 />1475 <rect x="1.5" y="6" width="11" height="8.5" rx="2.2" stroke="currentColor" stroke-width="1" />1476 <circle cx="7" cy="10.25" r="1" fill="currentColor" />1477 </svg>1478 </button>1479 </div>1480 </header>14811482 <Show when={pendingProNotice() !== null}>1483 <div class="peach-pro-notice-banner">1484 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>1485 <button1486 class="peach-pro-notice-link"1487 onClick={() => {1488 setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);1489 setSettingsInitialSection("license");1490 setView("settings");1491 setPendingProNotice(null);1492 }}1493 >1494 Learn more →1495 </button>1496 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>1497 </div>1498 </Show>14991500 <div class="peach-shell-body">1501 <div class="peach-shell-veil"></div>1502 <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>1503 <EntryView1504 entry={selectedEntry()}1505 currentTabUrl={currentTabUrl()}1506 entries={entries()}1507 isPro={proStatus()}1508 onDelete={handleDelete}1509 onFieldSaved={() => void loadEntries()}1510 onEscape={() => { setView("list"); }}1511 onProGate={(feature) => {1512 setUpgradeHighlight(feature);1513 setSettingsInitialSection("license");1514 setView("settings");1515 }}1516 />1517 </div>15181519 <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen(), "is-collapsed": view() === "pair-device" }}>1520 <div class="peach-search-bar">1521 <span class="peach-search-icon">1522 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">1523 <circle cx="5.5" cy="5.5" r="3.75" />1524 <path d="M8.75 8.75L11.5 11.5" />1525 </svg>1526 </span>1527 <input1528 class="peach-search-input"1529 placeholder="search vault..."1530 autocomplete="off"1531 aria-label="Search vault entries"1532 onClick={openList}1533 onFocus={openList}1534 readOnly1535 />1536 <div class="peach-search-meta">1537 <span class="peach-search-counter">1538 {entries().length > 01539 ? `${selectedIndex() + 1} / ${entries().length}`1540 : "0 entries"}1541 </span>1542 <span class="peach-divider"></span>1543 <button1544 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1545 onClick={() => navigate(-1)}1546 aria-label="Previous entry"1547 disabled={entries().length === 0}1548 >1549 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1550 <path d="M5 1L1 5.5L5 10" />1551 </svg>1552 </button>1553 <button1554 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1555 onClick={() => navigate(1)}1556 aria-label="Next entry"1557 disabled={entries().length === 0}1558 >1559 <svg width="6" height="11" viewBox="0 0 6 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1560 <path d="M1 1L5 5.5L1 10" />1561 </svg>1562 </button>1563 </div>1564 </div>1565 </section>1566 </div>15671568 {15691570}1571 {}1572 <Show when={syncAlerts().length > 0}>1573 <div class="peach-sync-alerts">1574 <For each={syncAlerts()}>1575 {(alert, i) => (1576 <div class="peach-sync-alert">1577 <span class="peach-sync-alert-text">⚠ {syncAlertText(alert)}</span>1578 <button1579 class="peach-sync-alert-dismiss"1580 type="button"1581 aria-label="Dismiss"1582 onClick={() => dismissSyncAlert(i())}1583 >×</button>1584 </div>1585 )}1586 </For>1587 </div>1588 </Show>15891590 <Show when={!isNativePlatform() && view() === "list"}>1591 <VaultView1592 entries={entries()}1593 selectedEntryId={selectedEntry()?.id}1594 currentTabDomain={currentTabDomain()}1595 sortOrder={sortOrder()}1596 searchSeed={listSearchSeed()}1597 searchSeedNonce={listSearchSeedNonce()}1598 onSelectEntry={handleSelectEntry}1599 onSortOrderChange={setSortOrder}1600 onAdd={handleAdd}1601 onClose={() => setView("entry")}1602 onOpenTrash={() => setView("trash")}1603 onDeleteMany={handleDeleteMany}1604 />1605 </Show>1606 <Show when={isNativePlatform() && hasVault()}>1607 <Portal>1608 <div1609 class="peach-vault-sheet"1610 classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1611 style={(() => {1612 const live = sheetTranslate();1613 if (live === null) return undefined;1614 return { transform: `translateY(${live}px)` };1615 })()}1616 >1617 <VaultView1618 entries={entries()}1619 selectedEntryId={selectedEntry()?.id}1620 currentTabDomain={currentTabDomain()}1621 sortOrder={sortOrder()}1622 searchSeed={listSearchSeed()}1623 searchSeedNonce={listSearchSeedNonce()}1624 isNative={true}1625 onSelectEntry={handleSelectEntry}1626 onSortOrderChange={setSortOrder}1627 onAdd={handleAdd}1628 onClose={() => setView("entry")}1629 onOpenTrash={() => setView("trash")}1630 onDeleteMany={handleDeleteMany}1631 />1632 </div>1633 </Portal>1634 </Show>1635 <Show when={view() === "settings" || settingsExiting()}>1636 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1637 <SettingsView1638 entriesCount={entries().length}1639 breachMonitoring={breachMonitoring()}1640 lockOnClose={lockOnClose()}1641 autoLockMinutes={autoLockMinutes()}1642 trashRetentionDays={trashRetentionDays()}1643 theme={theme()}1644 accentColor={accentColor()}1645 textSize={textSize()}1646 reduceMotion={reduceMotion()}1647 isPro={proStatus()}1648 initialSection={settingsInitialSection()}1649 upgradeHighlight={upgradeHighlight()}1650 isExiting={settingsExiting()}1651 onBreachMonitoringChange={applyBreachMonitoring}1652 onLockOnCloseChange={applyLockOnClose}1653 onAutoLockMinutesChange={applyAutoLockMinutes}1654 onTrashRetentionChange={applyTrashRetention}1655 onThemeChange={applyTheme}1656 onAccentChange={applyAccentColor}1657 onTextSizeChange={applyTextSize}1658 onReduceMotionChange={applyReduceMotion}1659 shortcuts={shortcutBindings()}1660 onShortcutsChange={applyShortcuts}1661 onLicenseChange={async () => {1662 1663 1664 1665 1666 1667 await refreshProStatus();1668 void loadEntries();1669 const should = await sendMessage({ type: "SHOULD_PLAY_CELEBRATION" });1670 if ((should as { ok: boolean; data?: boolean }).data === true) {1671 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });1672 setView("pro-celebration");1673 window.setTimeout(() => setView("settings"), 2500);1674 }1675 }}1676 onBack={() => {1677 setSettingsExiting(true);1678 window.setTimeout(() => {1679 setSettingsExiting(false);1680 setSettingsInitialSection(null);1681 setUpgradeHighlight(null);1682 handleBack();1683 }, 200);1684 }}1685 onImport={() => setView("import")}1686 onExport={() => setView("export")}1687 onBreachCheckDone={loadEntries}1688 onOpenHealth={openHealth}1689 onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}1690 onOpenFileImport={handleOpenFileImport}1691 onOpenPanic={() => setView("panic")}1692 onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}1693 onOpenCodexRestore={() => setView("codex-restore")}1694 onCardChange={setSettingsActiveCard}1695 onProGate={(feature) => {1696 setUpgradeHighlight(feature);1697 setSettingsInitialSection("license");1698 }}1699 />1700 </Suspense>1701 </Show>1702 <Show when={view() === "codex-generate"}>1703 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1704 <CodexGenerateView1705 isPro={proStatus()}1706 onBack={() => setView("settings")}1707 onUpgradePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1708 onOpenSecurity={() => { setSettingsInitialSection("security"); setView("settings"); }}1709 onVaultLocked={() => handleLock()}1710 />1711 </Suspense>1712 </Show>1713 <Show when={view() === "codex-restore"}>1714 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1715 <CodexRestoreView1716 onBack={() => setView("lock")}1717 onRestoreSuccess={async () => {1718 1719 1720 1721 await handleUnlock();1722 }}1723 />1724 </Suspense>1725 </Show>1726 <Show when={view() === "pair-device"}>1727 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1728 <PairDeviceView1729 isPro={proStatus()}1730 onProChanged={() => { void refreshProStatus(); }}1731 onBack={() => setView("settings")}1732 onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1733 />1734 </Suspense>1735 </Show>1736 <Show when={view() === "add"}>1737 <AddView1738 entry={editEntry()}1739 entries={entries()}1740 onBack={handleBack}1741 onSave={handleSave}1742 />1743 </Show>1744 <Show when={view() === "import"}>1745 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1746 <ImportView onBack={() => setView("settings")} onImport={handleSave} />1747 </Suspense>1748 </Show>1749 <Show when={view() === "export"}>1750 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1751 <ExportView onBack={handleExportBack} />1752 </Suspense>1753 </Show>1754 <Show when={view() === "panic"}>1755 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1756 <PanicView1757 onCancel={() => setView("settings")}1758 onOpenExport={() => {1759 setReturnToPanic(true);1760 setView("export");1761 }}1762 onDestroyed={async () => {1763 clearCanarySuppressions();1764 setEntries([]);1765 setSelectedEntry(null);1766 setEditEntry(undefined);1767 setHasVault(false);1768 setOnboarded(false);1769 setProStatus(false);1770 setPendingPasskey(null);1771 setUpgradeHighlight(null);1772 setSettingsInitialSection(null);1773 setPendingProNotice(null);1774 setPendingCelebration(false);1775 setReturnToPanic(false);1776 setContentPhase("idle");1777 setView("lock");1778 }}1779 />1780 </Suspense>1781 </Show>1782 {1783}1784 <Show when={view() === "health" || (view() === "add" && editBackView() === "health")}>1785 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1786 <HealthView1787 entries={entries()}1788 isPro={proStatus()}1789 onFixEntry={handleFixEntry}1790 onBack={() => setView("settings")}1791 onRefresh={loadEntries}1792 hidden={view() !== "health"}1793 />1794 </Suspense>1795 </Show>1796 <Show when={view() === "trash"}>1797 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1798 <TrashView1799 onBack={() => setView("list")}1800 onRestored={() => void loadEntries()}1801 />1802 </Suspense>1803 </Show>1804 <Show when={showNotes()}>1805 <div class={`peach-notes-overlay${showNotes() ? " is-visible" : ""}`}>1806 <NotesMode1807 notes={entries().filter((e) => e.type === "note")}1808 initialNoteId={openNoteId()}1809 onBack={() => { setShowNotes(false); setOpenNoteId(undefined); }}1810 onNoteListChanged={() => void loadEntries()}1811 />1812 </div>1813 </Show>1814 <Show when={restoreView() === "file"}>1815 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1816 <ImportView1817 mode="onboarding"1818 replacesExistingVault={true}1819 onBack={() => setRestoreView(null)}1820 onImport={async () => {1821 setRestoreView(null);1822 await handleUnlock();1823 }}1824 />1825 </Suspense>1826 </Show>1827 </section>1828 </Show>1829 </Show>1830 </Show>1831 </Show>1832 <Show when={showShortcutsHelp()}>1833 <ShortcutsHelpOverlay1834 bindings={shortcutBindings()}1835 onClose={() => setShowShortcutsHelp(false)}1836 />1837 </Show>1838 </div>1839 );1840}18411842function ShortcutsHelpOverlay(props: {1843 bindings: Record<ShortcutAction, Keybinding>;1844 onClose: () => void;1845}) {1846 return (1847 <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>1848 <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>1849 <div class="peach-shortcuts-help-head">1850 <h3>Keyboard shortcuts</h3>1851 <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>1852 </div>1853 <div class="peach-shortcuts-help-body">1854 <p class="peach-settings-sub-label">ENTRY VIEW</p>1855 <ul>1856 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (1857 <li>1858 <span>{d.label}</span>1859 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1860 </li>1861 ))}1862 </ul>1863 <p class="peach-settings-sub-label">ANYWHERE</p>1864 <ul>1865 {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1866 <li>1867 <span>{d.label}</span>1868 <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1869 </li>1870 ))}1871 </ul>1872 </div>1873 </div>1874 </div>1875 );1876}