Settings.tsx

65.7 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import type { Canary } from "../../shared/types";7import { ACCENT_COLORS } from "../../shared/types";8import type {9  AccentColorName,10  PasskeyProviderState,11  ProFeature,12  VaultSnapshot,13} from "../../shared/types";14import {15  DEFAULT_BINDINGS,16  findConflict,17  formatKeybinding,18  keybindingFromEvent,19  SHORTCUT_DESCRIPTORS,20  type Keybinding,21  type ShortcutAction,22} from "../../shared/shortcuts";23import { isNativePlatform, nativePlugin } from "../../shared/native";2425interface LicenseInfo {26  valid: boolean;27  plan: "free" | "pro";28  issued?: string;29}3031interface SettingsProps {32  entriesCount: number;33  breachMonitoring: boolean;34  lockOnClose: boolean;35  autoLockMinutes: number;36  trashRetentionDays: number;37  theme: "dark" | "light";38  accentColor: AccentColorName;39  textSize: "small" | "medium" | "large";40  reduceMotion: boolean;41  isPro?: boolean;42  initialSection?: string | null;43  upgradeHighlight?: ProFeature | null;44  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47  onTrashRetentionChange: (days: number) => void | Promise<void>;48  onThemeChange: (theme: "dark" | "light") => void;49  onAccentChange: (name: AccentColorName) => void | Promise<void>;50  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;51  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;52  shortcuts: Record<ShortcutAction, Keybinding>;53  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;54  onLicenseChange?: () => void | Promise<void>;55  onBack: () => void;56  onImport: () => void;57  onExport: () => void;58  onBreachCheckDone: () => void;59  onOpenHealth?: () => void;60  onOpenSyncPairing?: () => void;61  onOpenFileImport?: () => void;62  onOpenPanic?: () => void;63  onOpenCodexGenerate?: () => void;64  onOpenCodexRestore?: () => void;65}6667type SectionName =68  | "security"69  | "vault"70  | "appearance"71  | "shortcuts"72  | "license"73  | "feedback";7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7677function formatActivatedSince(issued: string | undefined): string {78  if (!issued) return "Active";79  try {80    const d = new Date(issued);81    if (Number.isNaN(d.getTime())) return "Active";82    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;83  } catch {84    return "Active";85  }86}8788function isSectionName(value: string): value is SectionName {89  return (90    value === "security" ||91    value === "vault" ||92    value === "appearance" ||93    value === "shortcuts" ||94    value === "license" ||95    value === "feedback"96  );97}9899export function SettingsView(props: SettingsProps) {100  const manifest = chrome.runtime.getManifest();101  const version = manifest.version;102  const isAndroid = (manifest as any).version_name === 'mobile';103  const initial = props.initialSection;104  const [activeSection, setActiveSection] = createSignal<SectionName | null>(105    initial && isSectionName(initial) ? (initial as SectionName) : null106  );107108  const [checkingBreaches, setCheckingBreaches] = createSignal(false);109  const [breachResult, setBreachResult] = createSignal<string | null>(null);110  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);111  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");112  const [licenseError, setLicenseError] = createSignal<string | null>(null);113  const [, setLicenseSuccess] = createSignal<string | null>(null);114  const [activatingLicense, setActivatingLicense] = createSignal(false);115116  const isPro = () => props.isPro ?? false;117  const [biometricSupported, setBiometricSupported] = createSignal(false);118  const [biometricStored, setBiometricStored] = createSignal(false);119  const [biometricBusy, setBiometricBusy] = createSignal(false);120  const [biometricError, setBiometricError] = createSignal<string | null>(null);121  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);122  const [passkeySaving, setPasskeySaving] = createSignal(false);123124  const [dedupCount, setDedupCount] = createSignal<number | null>(null);125  const [dedupRunning, setDedupRunning] = createSignal(false);126  const [dedupDone, setDedupDone] = createSignal<number | null>(null);127128  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);129130  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted131  // BEFORE every real-mode vault write so the user can roll back if a sync132  // or import overwrites the wrong state.133  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);134  const [historyLoading, setHistoryLoading] = createSignal(false);135  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);136137  async function loadVaultHistory() {138    setHistoryLoading(true);139    try {140      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });141      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];142      setHistorySnapshots(list);143    } finally {144      setHistoryLoading(false);145    }146  }147148  async function handleRestoreVaultSnapshot(id: string) {149    if (historyRestoring()) return;150    setHistoryRestoring(id);151    const res = await sendMessage({152      type: "RESTORE_VAULT_SNAPSHOT",153      payload: { snapshotId: id },154    });155    setHistoryRestoring(null);156    if ((res as { ok: boolean }).ok) {157      // Vault is now locked — let the parent close Settings; App.tsx's158      // VAULT_LOCKED listener will route back to the lock screen.159      props.onBack();160    }161  }162163  function formatSnapshotTime(savedAt: number): string {164    const diff = Date.now() - savedAt;165    if (diff < 60_000) return "Just now";166    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;167    const d = new Date(savedAt);168    const isToday = new Date().toDateString() === d.toDateString();169    const isYest =170      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();171    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });172    if (isToday) return `Today ${t}`;173    if (isYest) return `Yesterday ${t}`;174    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;175  }176177  function reasonLabel(reason: VaultSnapshot["reason"]): string {178    return (179      {180        local_save: "saved",181        sync_received: "synced",182        import: "imported",183        restore: "restored",184      } as const185    )[reason] ?? reason;186  }187188  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);189190  const [showChangePw, setShowChangePw] = createSignal(false);191  const [changePwCurrent, setChangePwCurrent] = createSignal("");192  const [changePwNew, setChangePwNew] = createSignal("");193  const [changePwConfirm, setChangePwConfirm] = createSignal("");194  const [changePwSaving, setChangePwSaving] = createSignal(false);195  const [changePwError, setChangePwError] = createSignal<string | null>(null);196  const [changePwSuccess] = createSignal(false);197  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);198199  const [duressEnabled, setDuressEnabled] = createSignal(false);200  const [showDuressSetup, setShowDuressSetup] = createSignal(false);201  const [duressPassword, setDuressPassword] = createSignal("");202  const [duressConfirm, setDuressConfirm] = createSignal("");203  const [duressMainPassword, setDuressMainPassword] = createSignal("");204  const [duressSaving, setDuressSaving] = createSignal(false);205  const [duressError, setDuressError] = createSignal<string | null>(null);206  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);207208  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);209210  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);211  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);212213  const [fbCategory, setFbCategory] = createSignal("Bug report");214  const [fbMessage, setFbMessage] = createSignal("");215  const [fbSubmitting, setFbSubmitting] = createSignal(false);216  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);217218  onMount(async () => {219    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([220      sendMessage({ type: "GET_LICENSE" }),221      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),222      sendMessage({ type: "IS_DURESS_ENABLED" }),223      sendMessage({ type: "GET_SETTINGS" }),224      sendMessage({ type: "GET_LICENSE_KEY" }),225    ]);226227    // Vault history loads in the background — the Vault sub-view picks it up228    // when the user navigates in. Loading it lazily keeps Settings boot fast.229    void loadVaultHistory();230231    if ((licenseRes as { ok: boolean }).ok) {232      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);233    }234235    if ((passkeyRes as { ok: boolean }).ok) {236      setPasskeyState(237        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null238      );239    }240241    if ((duressRes as { ok: boolean }).ok) {242      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);243    }244245    if ((settingsRes as { ok: boolean }).ok) {246      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;247      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);248    }249250    if ((keyRes as { ok: boolean }).ok) {251      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);252    }253254    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });255    if ((entriesRes as { ok: boolean }).ok) {256      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];257      setCanaryEntries(258        entries259          .filter((e) => e.canary?.webhookUrl)260          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))261      );262    }263264265    if (isNativePlatform()) {266      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");267      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");268      try {269        if (biometric) {270          const a = await biometric.isAvailable();271          setBiometricSupported(a.available);272        }273        if (keystore) {274          const k = await keystore.hasMasterKey();275          setBiometricStored(k.exists);276        }277      } catch { /* missing plugins — leave defaults */ }278    }279  });280281  async function handleEnableBiometrics() {282    setBiometricBusy(true);283    setBiometricError(null);284    try {285      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {286        ok: boolean;287        data?: { masterKeyHex: string };288        error?: string;289      };290      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {291        setBiometricError(keyRes.error === "vault_locked"292          ? "unlock the vault first"293          : "couldn't read derived key");294        return;295      }296      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");297      if (!keystore) {298        setBiometricError("biometric plugin unavailable");299        return;300      }301      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });302      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });303      setBiometricStored(true);304    } catch (e) {305      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");306    } finally {307      setBiometricBusy(false);308    }309  }310311  async function handleDisableBiometrics() {312    setBiometricBusy(true);313    setBiometricError(null);314    try {315      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");316      if (keystore) await keystore.clearMasterKey();317      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });318      setBiometricStored(false);319    } catch (e) {320      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");321    } finally {322      setBiometricBusy(false);323    }324  }325326  async function handleChangePassword() {327    setChangePwError(null);328    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }329    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }330    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }331    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }332    setChangePwSaving(true);333    const res = await sendMessage({334      type: "CHANGE_PASSWORD",335      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },336    }) as { ok: boolean; error?: string };337    if (res.ok) {338      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");339      setShowChangePw(false);340      setShowNewRecoveryWords(true);341    } else {342      setChangePwError(res.error ?? "failed to change password");343    }344    setChangePwSaving(false);345  }346347  const [copied, setCopied] = createSignal(false);348349  async function copyLicenseKey(key: string) {350    await navigator.clipboard.writeText(key);351    setCopied(true);352    setTimeout(() => setCopied(false), 2000);353  }354355  async function handleActivateLicense() {356    const key = licenseKeyInput().trim();357    if (!key) return;358    setActivatingLicense(true);359    setLicenseError(null);360    setLicenseSuccess(null);361    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });362    setActivatingLicense(false);363    if ((res as { ok: boolean }).ok) {364      const data = (res as { ok: boolean; data?: LicenseInfo }).data;365      if (data?.valid) {366        setLicenseInfo(data);367        setLicenseKey(key);368        setLicenseSuccess("License activated!");369        setLicenseKeyInput("");370      } else {371        setLicenseError("Invalid license key");372      }373    } else {374      setLicenseError("Activation failed");375    }376  }377378  async function handleTogglePasskeys() {379    const current = passkeyState();380    if (!current || !current.supported) return;381    setPasskeySaving(true);382    const res = await sendMessage({383      type: "SET_PASSKEY_PROVIDER_ENABLED",384      payload: { enabled: !current.enabled },385    });386    setPasskeySaving(false);387    if ((res as { ok: boolean }).ok) {388      setPasskeyState(389        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null390      );391    }392  }393394  async function handleCheckAllBreaches() {395    setCheckingBreaches(true);396    setBreachResult(null);397    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });398    setCheckingBreaches(false);399    if ((res as { ok: boolean }).ok) {400      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};401      const breached = Object.values(data).filter((count) => count > 0).length;402      setBreachResult(403        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"404      );405      props.onBreachCheckDone();406    }407  }408409410  async function handleSetupDuress() {411    const mainPw = duressMainPassword().trim();412    const duressPw = duressPassword().trim();413    const confirmPw = duressConfirm().trim();414415    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }416    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }417    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }418    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }419420    setDuressSaving(true);421    setDuressError(null);422423    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };424    if (!verifyRes.ok || !verifyRes.data) {425      setDuressError("incorrect main password");426      setDuressSaving(false);427      return;428    }429430    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };431    setDuressSaving(false);432    if (res.ok) {433      setDuressEnabled(true);434      setShowDuressSetup(false);435      setDuressPassword("");436      setDuressConfirm("");437      setDuressMainPassword("");438      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");439      setTimeout(() => setDuressSuccess(null), 5000);440    } else {441      setDuressError(res.error ?? "failed to set duress password");442    }443  }444445  async function handleClearDeclinedCards() {446    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });447    setDeclinedCardCount(0);448  }449450  async function handleCountDuplicates() {451    setDedupRunning(true);452    setDedupDone(null);453    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };454    setDedupRunning(false);455    if (res.ok) setDedupCount(res.data ?? 0);456  }457458  async function handleDeduplicate() {459    setDedupRunning(true);460    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };461    setDedupRunning(false);462    if (res.ok) {463      setDedupDone(res.data ?? 0);464      setDedupCount(0);465    }466  }467468  async function handleDisableDuress() {469    const mainPw = duressMainPassword().trim();470    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }471472    setDuressSaving(true);473    setDuressError(null);474475    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };476    if (!verifyRes.ok || !verifyRes.data) {477      setDuressError("incorrect main password");478      setDuressSaving(false);479      return;480    }481482    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };483    setDuressSaving(false);484    if (res.ok) {485      setDuressEnabled(false);486      setShowDuressSetup(false);487      setDuressMainPassword("");488      setDuressSuccess("Duress password disabled.");489      setTimeout(() => setDuressSuccess(null), 4000);490    } else {491      setDuressError(res.error ?? "failed to disable duress password");492    }493  }494495  async function handleSubmitFeedback() {496    if (!fbMessage().trim() || fbSubmitting()) return;497    setFbSubmitting(true);498    setFbResult(null);499    const res = await sendMessage({500      type: "SUBMIT_FEEDBACK",501      payload: {502        category: fbCategory(),503        message: fbMessage().trim(),504        version,505        browser: navigator.userAgent,506      },507    }) as { ok: boolean; error?: string };508    setFbSubmitting(false);509    if (res.ok) {510      setFbResult("success");511      setFbMessage("");512      setTimeout(() => setFbResult(null), 4000);513    } else {514      setFbResult("error");515    }516  }517518  function handleHeaderBack() {519    if (activeSection() !== null) {520      setActiveSection(null);521    } else {522      props.onBack();523    }524  }525526  function currentTitle(): string {527    const s = activeSection();528    if (s === null) return "settings";529    if (s === "license") return "account";530    return s;531  }532533  function renderCatRow(opts: {534    section: SectionName;535    name: string;536    desc: string;537    icon: () => any;538    pro?: boolean;539  }) {540    return (541      <button542        class="peach-settings-cat-row"543        onClick={() => setActiveSection(opts.section)}544      >545        <div class="peach-settings-cat-icon">{opts.icon()}</div>546        <div class="peach-settings-cat-content">547          <span class="peach-settings-cat-name">548            {opts.name}549            <Show when={opts.pro && !isPro()}>550              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>551            </Show>552          </span>553          <span class="peach-settings-cat-desc">{opts.desc}</span>554        </div>555        <svg556          class="peach-settings-cat-chevron"557          width="6" height="10" viewBox="0 0 6 10" fill="none"558          stroke="currentColor" stroke-width="1.1"559          stroke-linecap="round" stroke-linejoin="round"560        >561          <path d="M1 1l4 4-4 4" />562        </svg>563      </button>564    );565  }566567  const iconShield = () => (568    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">569      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>570    </svg>571  );572  const iconKey = () => (573    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">574      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>575    </svg>576  );577  const iconLock = () => (578    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">579      <rect x="3" y="7" width="10" height="7" rx="1.5"/>580      <path d="M6 7V5a3 3 0 016 0v2"/>581    </svg>582  );583  const iconSync = () => (584    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">585      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>586      <path d="M14 6l-2 2-2-2"/>587      <path d="M2 10l2-2 2 2"/>588    </svg>589  );590  const iconSun = () => (591    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">592      <circle cx="8" cy="8" r="3"/>593      <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>594    </svg>595  );596  const iconKeys = () => (597    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">598      <rect x="2" y="5" width="5" height="4" rx="1"/>599      <rect x="9" y="7" width="5" height="4" rx="1"/>600      <path d="M7 7h2m-2 2h2"/>601    </svg>602  );603  const iconBubble = () => (604    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">605      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>606    </svg>607  );608609  function renderHome() {610    return (611      <div class="peach-settings-body">612        <p class="peach-settings-nav-label">PROTECTION</p>613        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}614        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}615616        <p class="peach-settings-nav-label">VAULT</p>617        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}618        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>619          <div class="peach-settings-cat-icon">{iconSync()}</div>620          <div class="peach-settings-cat-content">621            <span class="peach-settings-cat-name">Sync</span>622            <span class="peach-settings-cat-desc">Pair and sync across devices</span>623          </div>624          <svg class="peach-settings-cat-chevron" width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">625            <path d="M1 1l4 4-4 4" />626          </svg>627        </button>628629        <p class="peach-settings-nav-label">PREFERENCES</p>630        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}631        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}632633        <p class="peach-settings-nav-label">SUPPORT</p>634        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}635636        <div class="peach-settings-footer">637          <span>Peach {version}</span>638          <span class="peach-divider"></span>639          <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>640        </div>641      </div>642    );643  }644645  function renderSecurity() {646    return (647      <div class="peach-settings-body">648        <div class="peach-settings-group">649          <p class="peach-settings-sub-label">LOCKING</p>650          <div class="peach-settings-row">651            <span>Lock on close</span>652            <button653              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}654              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}655              aria-label="Toggle lock on close"656            >657              <span class="peach-toggle-knob"></span>658            </button>659          </div>660          <div class="peach-settings-row">661            <span>Auto-lock after</span>662            <select663              class="peach-settings-select"664              value={String(props.autoLockMinutes)}665              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}666            >667              <option value="1">1 minute</option>668              <option value="5">5 minutes</option>669              <option value="15">15 minutes</option>670              <option value="30">30 minutes</option>671              <option value="0">Never</option>672            </select>673          </div>674675          <Show when={isNativePlatform() && biometricSupported()}>676            <p class="peach-settings-sub-label">BIOMETRICS</p>677            <div class="peach-settings-row">678              <span>Unlock with biometrics</span>679              <button680                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}681                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}682                disabled={biometricBusy()}683                aria-label="Toggle biometric unlock"684              >685                <span class="peach-toggle-knob"></span>686              </button>687            </div>688            <Show when={biometricError()}>689              <div class="peach-settings-row peach-settings-row--meta">690                <span class="peach-hint peach-hint-error">{biometricError()}</span>691              </div>692            </Show>693          </Show>694695          <p class="peach-settings-sub-label">BREACH PROTECTION</p>696          <div class="peach-settings-row">697            <span>Breach monitoring</span>698            <button699              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}700              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}701              aria-label="Toggle breach monitoring"702            >703              <span class="peach-toggle-knob"></span>704            </button>705          </div>706          <div class="peach-settings-row peach-settings-row--meta">707            <span>Check all passwords</span>708            <button709              class="peach-btn peach-btn--subtle peach-btn--sm"710              onClick={() => void handleCheckAllBreaches()}711              disabled={checkingBreaches()}712            >713              {checkingBreaches() ? "checking..." : "run"}714            </button>715          </div>716          <Show when={breachResult()}>717            <div class="peach-settings-row peach-settings-row--meta">718              <span>Last check</span>719              <span class="peach-settings-value">{breachResult()}</span>720            </div>721          </Show>722723          <p class="peach-settings-sub-label">MASTER PASSWORD</p>724          <div class="peach-settings-row peach-settings-row--meta">725            <span>Master password</span>726            <button727              class="peach-btn peach-btn--subtle peach-btn--sm"728              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}729            >730              {showChangePw() ? "cancel" : "change"}731            </button>732          </div>733          <Show when={showChangePw()}>734            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">735              <input736                class="peach-settings-input"737                type="password"738                placeholder="current password"739                value={changePwCurrent()}740                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}741                autocomplete="current-password"742              />743              <input744                class="peach-settings-input"745                type="password"746                placeholder="new password (min 12 chars)"747                value={changePwNew()}748                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}749                autocomplete="new-password"750              />751              <input752                class="peach-settings-input"753                type="password"754                placeholder="confirm new password"755                value={changePwConfirm()}756                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}757                autocomplete="new-password"758                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}759              />760              <Show when={changePwError()}>761                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>762              </Show>763              <Show when={changePwSuccess()}>764                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>765              </Show>766              <button767                class="peach-btn peach-btn--accent peach-btn--sm"768                style="align-self:flex-start"769                onClick={() => void handleChangePassword()}770                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}771              >772                {changePwSaving() ? "saving…" : "save new password"}773              </button>774            </div>775          </Show>776777          <p class="peach-settings-sub-label">DURESS PASSWORD</p>778          <div class="peach-settings-row peach-settings-row--top">779            <div class="peach-settings-row-main">780              <span>Duress password</span>781              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">782                If entered under coercion, shows a decoy empty vault instead of your real one.783              </span>784            </div>785            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>786              {duressEnabled() ? "on" : "off"}787            </div>788          </div>789790          <Show when={duressEnabled() && !showDuressSetup()}>791            <div class="peach-settings-row peach-settings-row--meta">792              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">793                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.794              </span>795            </div>796          </Show>797798          <Show when={duressSuccess()}>799            <div class="peach-settings-row peach-settings-row--meta">800              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>801            </div>802          </Show>803804          <Show when={!showDuressSetup()}>805            <div class="peach-settings-row peach-settings-row--meta">806              <button807                class="peach-btn peach-btn--subtle peach-btn--sm"808                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}809              >810                {duressEnabled() ? "Change / disable" : "Set up"}811              </button>812            </div>813          </Show>814815          <Show when={showDuressSetup()}>816            <div class="peach-duress-form">817              <label class="peach-label">main password (to confirm)</label>818              <input819                class="peach-settings-input"820                type="password"821                placeholder="your current master password"822                autocomplete="current-password"823                value={duressMainPassword()}824                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}825              />826              <Show when={!duressEnabled()}>827                <label class="peach-label">new duress password</label>828                <input829                  class="peach-settings-input"830                  type="password"831                  placeholder="duress password (min 8 chars)"832                  autocomplete="new-password"833                  value={duressPassword()}834                  onInput={(e) => setDuressPassword(e.currentTarget.value)}835                />836                <label class="peach-label">confirm duress password</label>837                <input838                  class="peach-settings-input"839                  type="password"840                  placeholder="confirm duress password"841                  autocomplete="new-password"842                  value={duressConfirm()}843                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}844                />845              </Show>846847              <Show when={duressError()}>848                <p class="peach-hint peach-hint-error">{duressError()}</p>849              </Show>850851              <div class="peach-duress-actions">852                <button853                  class="peach-btn peach-btn--subtle peach-btn--sm"854                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}855                >856                  cancel857                </button>858                <Show when={duressEnabled()}>859                  <button860                    class="peach-btn peach-btn--ghost peach-btn--sm"861                    style="color:var(--peach-breach)"862                    onClick={() => void handleDisableDuress()}863                    disabled={duressSaving()}864                  >865                    {duressSaving() ? "disabling..." : "disable duress"}866                  </button>867                </Show>868                <Show when={!duressEnabled()}>869                  <button870                    class="peach-btn peach-btn--accent peach-btn--sm"871                    onClick={() => void handleSetupDuress()}872                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}873                  >874                    {duressSaving() ? "saving..." : "save"}875                  </button>876                </Show>877              </div>878            </div>879          </Show>880881          <p class="peach-settings-sub-label">CANARY ENTRIES</p>882          <div class="peach-settings-row peach-settings-row--top">883            <div class="peach-settings-row-main">884              <span>Canary entries</span>885              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">886                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.887              </span>888            </div>889            <span class="peach-settings-value" style="flex-shrink:0">890              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}891            </span>892          </div>893          <Show when={canaryEntries().length > 0}>894            <For each={canaryEntries()}>895              {(entry) => (896                <div class="peach-settings-row peach-settings-row--meta">897                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>898                  <button899                    class="peach-btn peach-btn--subtle peach-btn--sm"900                    onClick={() => void handleTestCanary(entry)}901                    disabled={canaryTestingId() === entry.id}902                    style="flex-shrink:0"903                  >904                    {canaryTestingId() === entry.id905                      ? "sending…"906                      : canaryTestResult()?.id === entry.id907                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")908                        : "test"}909                  </button>910                </div>911              )}912            </For>913          </Show>914915          <p class="peach-settings-sub-label">PASSKEYS</p>916          <div class="peach-settings-row peach-settings-row--top">917            <div class="peach-settings-row-main">918              <span>Intercept passkey requests</span>919              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">920                Peach can handle passkey sign-in and creation on supported Chromium browsers.921              </span>922            </div>923            <button924              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}925              onClick={() => void handleTogglePasskeys()}926              aria-label="Toggle passkey interception"927              disabled={!passkeyState()?.supported || passkeySaving()}928            >929              <span class="peach-toggle-knob"></span>930            </button>931          </div>932          <div class="peach-settings-row peach-settings-row--meta">933            <span>Status</span>934            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>935          </div>936          <div class="peach-settings-row peach-settings-row--meta">937            <span>Browser</span>938            <span class="peach-settings-value">939              {passkeyState()?.browserLabel ?? "Unknown"}940              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}941            </span>942          </div>943          <div class="peach-info-notice" style="margin-top:8px">944            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.945          </div>946          <Show when={passkeyState()?.conflict}>947            <div class="peach-settings-row peach-settings-row--meta">948              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>949            </div>950          </Show>951        </div>952      </div>953    );954  }955956  function renderVault() {957    return (958      <div class="peach-settings-body">959        <div class="peach-settings-group">960          <p class="peach-settings-sub-label">ENTRIES</p>961          <div class="peach-settings-row peach-settings-row--meta">962            <span>Entries</span>963            <span class="peach-settings-value">{props.entriesCount}</span>964          </div>965          <div class="peach-settings-row peach-settings-row--meta">966            <span>Duplicates</span>967            <Show when={dedupDone() !== null} fallback={968              <button969                class="peach-btn peach-btn--subtle peach-btn--sm"970                onClick={() => void handleCountDuplicates()}971                disabled={dedupRunning()}972              >973                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}974              </button>975            }>976              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">977                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}978              </span>979            </Show>980          </div>981          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>982            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">983              <div class="peach-restore-warning">984                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.985              </div>986              <div style="display:flex;gap:6px">987                <button988                  class="peach-btn peach-btn--subtle peach-btn--sm"989                  onClick={() => setDedupCount(null)}990                >991                  cancel992                </button>993                <button994                  class="peach-btn peach-btn--accent peach-btn--sm"995                  onClick={() => void handleDeduplicate()}996                  disabled={dedupRunning()}997                >998                  {dedupRunning() ? "removing..." : "remove duplicates"}999                </button>1000              </div>1001            </div>1002          </Show>10031004          <p class="peach-settings-sub-label">MAINTENANCE</p>1005          <div class="peach-settings-row peach-settings-row--meta">1006            <span>Trash auto-delete</span>1007            <select1008              class="peach-settings-select"1009              value={String(props.trashRetentionDays)}1010              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1011            >1012              <option value="7">7 days</option>1013              <option value="30">30 days</option>1014              <option value="90">90 days</option>1015              <option value="0">Never</option>1016            </select>1017          </div>1018          <div class="peach-settings-row peach-settings-row--meta">1019            <span>Vault Health</span>1020            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1021              open1022            </button>1023          </div>1024          <Show when={declinedCardCount() > 0}>1025            <div class="peach-settings-row peach-settings-row--meta">1026              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1027              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1028                Clear1029              </button>1030            </div>1031          </Show>10321033          <p class="peach-settings-sub-label">DATA</p>1034          <div class="peach-settings-row peach-settings-row--meta">1035            <span>Import passwords</span>1036            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1037              open1038            </button>1039          </div>1040          <div class="peach-settings-row peach-settings-row--meta">1041            <span>Export backup</span>1042            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1043              open1044            </button>1045          </div>1046          <div class="peach-settings-row peach-settings-row--meta">1047            <span>1048              Peach Codex1049              <Show when={!isPro()}>1050                <span class="peach-pro-badge">Pro</span>1051              </Show>1052            </span>1053            <button1054              class="peach-btn peach-btn--subtle peach-btn--sm"1055              onClick={() => props.onOpenCodexGenerate?.()}1056            >1057              open1058            </button>1059          </div>1060          {/* Restore from Codex: intentionally never gated — a printed1061              Codex must remain restorable regardless of license state. */}1062          <div class="peach-settings-row peach-settings-row--meta">1063            <span>Restore from Codex</span>1064            <button1065              class="peach-btn peach-btn--subtle peach-btn--sm"1066              onClick={() => props.onOpenCodexRestore?.()}1067            >1068              open1069            </button>1070          </div>10711072          <p class="peach-settings-sub-label">VAULT HISTORY</p>1073          <Show when={historyLoading()}>1074            <p class="peach-settings-hint">Loading…</p>1075          </Show>1076          <Show when={!historyLoading() && historySnapshots().length === 0}>1077            <p class="peach-settings-hint">1078              No snapshots yet. Peach saves automatically before every change —1079              saves, syncs, and imports.1080            </p>1081          </Show>1082          <Show when={!historyLoading() && historySnapshots().length > 0}>1083            <p class="peach-settings-hint">1084              Restoring locks your vault. Unlock with the password that1085              encrypted that snapshot to confirm.1086            </p>1087            <div class="peach-history-list">1088              <For each={historySnapshots()}>1089                {(snap) => (1090                  <div class="peach-history-row">1091                    <div class="peach-history-meta">1092                      <span class="peach-history-time">1093                        {formatSnapshotTime(snap.savedAt)}1094                      </span>1095                      <span class="peach-history-sub">1096                        {[1097                          snap.entryCount !== null1098                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1099                            : null,1100                          reasonLabel(snap.reason),1101                        ]1102                          .filter(Boolean)1103                          .join(" · ")}1104                      </span>1105                    </div>1106                    <button1107                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1108                        historyRestoring() === snap.id ? " is-busy" : ""1109                      }`}1110                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1111                      disabled={historyRestoring() !== null}1112                    >1113                      <Show when={historyRestoring() === snap.id} fallback="Restore">1114                        …1115                      </Show>1116                    </button>1117                  </div>1118                )}1119              </For>1120            </div>1121            <p class="peach-history-footer">1122              {historySnapshots().length} of 10 snapshots1123            </p>1124          </Show>11251126          <button1127            class="peach-settings-advanced-toggle"1128            onClick={() => setShowVaultAdvanced((v) => !v)}1129            aria-expanded={showVaultAdvanced()}1130          >1131            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1132            <span class="peach-settings-advanced-chevron">1133              {showVaultAdvanced() ? "▲" : "▼"}1134            </span>1135          </button>11361137          <Show when={showVaultAdvanced()}>1138            <div class="peach-danger-card">1139              <div class="peach-danger-card-head">1140                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1141                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1142              </div>1143              <p class="peach-danger-card-copy">1144                Wipes the current vault and restores from a backup file.1145                Recoverable only if your backup is good.1146              </p>1147              <button1148                class="peach-danger-card-button"1149                onClick={() => props.onOpenFileImport?.()}1150              >1151                Choose backup file1152              </button>1153            </div>11541155            <Show when={props.onOpenPanic}>1156              <div class="peach-danger-card peach-danger-card--hazard">1157                <div class="peach-danger-card-head">1158                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1159                  <h3 class="peach-danger-card-title">Destroy vault</h3>1160                </div>1161                <p class="peach-danger-card-copy">1162                  Permanently wipes every entry, setting, and license on this1163                  device. There is no recovery.1164                </p>1165                <button1166                  class="peach-danger-card-button"1167                  onClick={() => props.onOpenPanic?.()}1168                >1169                  Begin destruction1170                </button>1171              </div>1172            </Show>1173          </Show>1174        </div>1175      </div>1176    );1177  }11781179  function renderAppearance() {1180    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1181      { id: "small", label: "Small" },1182      { id: "medium", label: "Default" },1183      { id: "large", label: "Large" },1184    ];1185    return (1186      <div class="peach-settings-body">1187        <div class="peach-settings-group">1188          <p class="peach-settings-sub-label">THEME</p>1189          <div class="peach-settings-row peach-settings-row--meta">1190            <span>Theme</span>1191            <button1192              class="peach-btn peach-btn--subtle peach-btn--sm"1193              onClick={() =>1194                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1195              }1196            >1197              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1198            </button>1199          </div>12001201          <p class="peach-settings-sub-label">ACCENT</p>1202          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1203            <span>Accent</span>1204            <div class="peach-accent-picker">1205              <For each={ACCENT_COLORS}>1206                {(color) => (1207                  <button1208                    class="peach-accent-swatch"1209                    classList={{ "is-active": props.accentColor === color.name }}1210                    style={{ background: color.hex }}1211                    onClick={() => void props.onAccentChange(color.name)}1212                    aria-label={color.name}1213                    title={color.name}1214                  />1215                )}1216              </For>1217            </div>1218          </div>12191220          <p class="peach-settings-sub-label">TEXT SIZE</p>1221          <div class="peach-settings-row peach-settings-row--meta">1222            <span>Size</span>1223            <div class="peach-pill-group">1224              <For each={sizeOptions}>1225                {(opt) => (1226                  <button1227                    class="peach-btn peach-btn--ghost peach-btn--sm"1228                    classList={{ "is-active": props.textSize === opt.id }}1229                    onClick={() => void props.onTextSizeChange(opt.id)}1230                  >1231                    {opt.label}1232                  </button>1233                )}1234              </For>1235            </div>1236          </div>12371238          <p class="peach-settings-sub-label">MOTION</p>1239          <div class="peach-settings-row peach-settings-row--meta">1240            <span>Reduce motion</span>1241            <button1242              class="peach-btn peach-btn--subtle peach-btn--sm"1243              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1244            >1245              {props.reduceMotion ? "On" : "Off"}1246            </button>1247          </div>1248        </div>1249      </div>1250    );1251  }12521253  function renderShortcuts() {1254    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1255    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12561257    function startCapture(action: ShortcutAction) {1258      setConflictMsg(null);1259      setCapturingAction(action);1260    }1261    function cancelCapture() {1262      setCapturingAction(null);1263      setConflictMsg(null);1264    }1265    function commitBinding(action: ShortcutAction, binding: Keybinding) {1266      const conflict = findConflict(props.shortcuts, binding, action);1267      if (conflict) {1268        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1269        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1270        return;1271      }1272      const next = { ...props.shortcuts, [action]: binding };1273      setCapturingAction(null);1274      setConflictMsg(null);1275      void props.onShortcutsChange(next);1276    }1277    function resetAction(action: ShortcutAction) {1278      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1279      void props.onShortcutsChange(next);1280    }1281    function resetAll() {1282      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1283    }12841285    // Capture-mode key handler — wired via ref onto the chip element so it1286    // catches the next non-modifier keypress and ignores other bindings.1287    function handleCaptureKey(e: KeyboardEvent) {1288      e.preventDefault();1289      e.stopPropagation();1290      if (e.key === "Escape") { cancelCapture(); return; }1291      const binding = keybindingFromEvent(e);1292      if (!binding) return; // bare modifier — keep waiting1293      const action = capturingAction();1294      if (action) commitBinding(action, binding);1295    }12961297    return (1298      <div class="peach-settings-body">1299        <div class="peach-settings-group peach-shortcuts-group">1300          <p class="peach-settings-sub-label">ENTRY VIEW</p>1301          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1302            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1303          </For>1304        </div>13051306        <div class="peach-settings-group peach-shortcuts-group">1307          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1308          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1309            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1310          </For>1311        </div>13121313        <Show when={conflictMsg()}>1314          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1315        </Show>13161317        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1318          <div class="peach-settings-row peach-settings-row--meta">1319            <span>Inline autofill on page</span>1320            <a1321              class="peach-settings-link"1322              href="chrome://extensions/shortcuts"1323              onClick={(e) => {1324                e.preventDefault();1325                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1326              }}1327            >1328              ⌘⇧P — customize ↗1329            </a>1330          </div>1331          <button1332            class="peach-btn peach-btn--subtle peach-btn--sm"1333            style="align-self:flex-start"1334            onClick={resetAll}1335          >1336            Reset all to defaults1337          </button>1338        </div>1339      </div>1340    );13411342    function renderRow(1343      desc: typeof SHORTCUT_DESCRIPTORS[number],1344      capturing: () => ShortcutAction | null,1345      onStart: (a: ShortcutAction) => void,1346      onCancel: () => void,1347      onReset: (a: ShortcutAction) => void,1348      onKey: (e: KeyboardEvent) => void,1349    ) {1350      const isActive = () => capturing() === desc.action;1351      const binding = () => props.shortcuts[desc.action];1352      const isCustom = () => {1353        const b = binding();1354        const d = DEFAULT_BINDINGS[desc.action];1355        return b.key !== d.key1356          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1357          || Boolean(b.meta)  !== Boolean(d.meta)1358          || Boolean(b.shift) !== Boolean(d.shift)1359          || Boolean(b.alt)   !== Boolean(d.alt);1360      };1361      return (1362        <div class="peach-shortcut-row">1363          <span class="peach-shortcut-row-label">{desc.label}</span>1364          <button1365            class="peach-shortcut-chip"1366            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1367            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1368            onKeyDown={(e) => { if (isActive()) onKey(e); }}1369            ref={(el) => {1370              // Auto-focus when entering capture mode so the keydown listener catches the next press.1371              if (el) {1372                queueMicrotask(() => { if (isActive()) el.focus(); });1373              }1374            }}1375          >1376            {isActive() ? "Press a key…" : formatKeybinding(binding())}1377          </button>1378          <Show when={isCustom() && !isActive()}>1379            <button1380              class="peach-shortcut-reset"1381              title="Reset to default"1382              onClick={() => onReset(desc.action)}1383            >1384              ↺1385            </button>1386          </Show>1387        </div>1388      );1389    }1390  }13911392  const PRO_FEATURES: Array<{1393    feature: ProFeature;1394    name: string;1395    desc: string;1396  }> = [1397    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1398    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1399    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1400    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1401    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1402  ];14031404  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14051406  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1407  const [deactivating, setDeactivating] = createSignal(false);14081409  async function handleDeactivateLicense() {1410    setDeactivating(true);1411    await sendMessage({ type: "DEACTIVATE_LICENSE" });1412    setLicenseInfo(null);1413    await props.onLicenseChange?.();1414    setDeactivating(false);1415  }14161417  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1418    setCanaryTestingId(entry.id);1419    setCanaryTestResult(null);1420    const res = await sendMessage({1421      type: "FIRE_CANARY_PING",1422      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1423    }) as { ok: boolean };1424    setCanaryTestResult({ id: entry.id, ok: res.ok });1425    setCanaryTestingId(null);1426    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1427  }14281429  function renderLicense() {1430    async function onActivate() {1431      await handleActivateLicense();1432      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1433        await props.onLicenseChange?.();1434      }1435    }14361437    return (1438      <div class="peach-settings-body">1439        <Show1440          when={isPro()}1441          fallback={1442            <Show1443              when={showKeyEntry()}1444              fallback={1445                <div class="peach-pro-upgrade">1446                  <div class="peach-pro-upgrade-hero">1447                    <div class="peach-pro-upgrade-mark-wrap">1448                      <PeachMark class="peach-pro-upgrade-mark" />1449                    </div>1450                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1451                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1452                  </div>14531454                  <div class="peach-pro-features">1455                    <For each={PRO_FEATURES}>1456                      {(item) => (1457                        <div1458                          class="peach-pro-feature"1459                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1460                        >1461                          <span class="peach-pro-feature-mark">✦</span>1462                          <div class="peach-pro-feature-text">1463                            <span class="peach-pro-feature-name">{item.name}</span>1464                            <span class="peach-pro-feature-desc">{item.desc}</span>1465                          </div>1466                        </div>1467                      )}1468                    </For>1469                  </div>14701471                  <div class="peach-pro-upgrade-actions">1472                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1473                    <a1474                      href="https://peachpasswords.com/#pricing"1475                      target="_blank"1476                      rel="noopener noreferrer"1477                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1478                    >1479                      Get Peach Pro1480                    </a>1481                    <div class="peach-pro-key-divider"><span>or</span></div>1482                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1483                      Activate a license key1484                    </button>1485                  </div>1486                </div>1487              }1488            >1489              <div class="peach-pro-key-ceremony">1490                <LockRuneCanvas />1491                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1492                <input1493                  class="peach-underline-input peach-pro-key-input"1494                  type="text"1495                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1496                  value={licenseKeyInput()}1497                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1498                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1499                  autocomplete="off"1500                  spellcheck={false}1501                />1502                <Show when={licenseError()}>1503                  <p class="peach-pro-key-error">That key doesn't look right.</p>1504                </Show>1505                <button1506                  class="peach-btn peach-btn--accent peach-pro-key-activate"1507                  onClick={() => void onActivate()}1508                  disabled={activatingLicense() || !licenseKeyInput().trim()}1509                >1510                  {activatingLicense() ? "activating…" : "Activate"}1511                </button>1512                <button1513                  class="peach-pro-key-cancel"1514                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1515                >1516                  cancel1517                </button>1518              </div>1519            </Show>1520          }1521        >1522          <div class="peach-pro-active">1523            <div class="peach-pro-active-hero">1524              <div class="peach-pro-active-mark-wrap">1525                <PeachMark class="peach-pro-active-mark" />1526              </div>1527              <h2 class="peach-pro-active-title">Peach Pro</h2>1528              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1529            </div>15301531            <Show when={licenseKey()}>1532              <div class="peach-settings-license">1533                <span class="peach-settings-label">LICENSE KEY</span>1534                <div class="peach-settings-license-row">1535                  <span class="peach-settings-license-key">{licenseKey()!}</span>1536                  <button1537                    class="peach-settings-license-copy"1538                    onClick={() => copyLicenseKey(licenseKey()!)}1539                  >1540                    {copied() ? "✓" : "copy"}1541                  </button>1542                </div>1543              </div>1544            </Show>15451546            <div class="peach-pro-active-features">1547              <For each={PRO_FEATURES}>1548                {(item) => (1549                  <div class="peach-pro-active-feature">1550                    <span class="peach-pro-active-feature-mark">✦</span>1551                    <span class="peach-pro-active-feature-name">{item.name}</span>1552                  </div>1553                )}1554              </For>1555            </div>15561557            <button1558              class="peach-pro-deactivate"1559              onClick={() => void handleDeactivateLicense()}1560              disabled={deactivating()}1561            >1562              {deactivating() ? "Deactivating…" : "Deactivate"}1563            </button>1564          </div>1565        </Show>1566      </div>1567    );1568  }15691570  function renderFeedback() {1571    return (1572      <div class="peach-settings-body">1573        <div class="peach-settings-group peach-feedback-group">1574          <p class="peach-feedback-intro">1575            Help us improve Peach. Every report goes directly to our inbox.1576          </p>15771578          <p class="peach-settings-sub-label">TYPE</p>1579          <div class="peach-feedback-chips">1580            <For each={FEEDBACK_CATEGORIES}>1581              {(cat) => (1582                <button1583                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1584                  onClick={() => setFbCategory(cat)}1585                >1586                  {cat}1587                </button>1588              )}1589            </For>1590          </div>15911592          <p class="peach-settings-sub-label">MESSAGE</p>1593          <textarea1594            class="peach-feedback-textarea"1595            placeholder="Describe the issue or idea..."1596            value={fbMessage()}1597            onInput={(e) => setFbMessage(e.currentTarget.value)}1598          />15991600          <p class="peach-feedback-meta">1601            Version {version} · will be included automatically1602          </p>16031604          <Show when={fbResult() === "success"}>1605            <p class="peach-feedback-result peach-feedback-result--ok">1606              Sent — thank you1607            </p>1608          </Show>1609          <Show when={fbResult() === "error"}>1610            <p class="peach-feedback-result peach-feedback-result--err">1611              Failed to send — try again1612            </p>1613          </Show>16141615          <button1616            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1617            onClick={() => void handleSubmitFeedback()}1618            disabled={fbSubmitting() || !fbMessage().trim()}1619          >1620            {fbSubmitting() ? "Sending..." : "Send feedback"}1621          </button>16221623          <p class="peach-feedback-anon">1624            Sent anonymously · no account required1625          </p>1626        </div>1627      </div>1628    );1629  }16301631  return (1632    <Show1633      when={showNewRecoveryWords()}1634      fallback={1635        <div class="peach-overlay-view ov-enter-right" style="display:flex">1636          <div class="peach-overlay-header">1637            <button1638              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1639              onClick={handleHeaderBack}1640              aria-label="Back"1641            >1642              <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1643                <path d="M6 1L1 6L6 11" />1644              </svg>1645            </button>1646            <span class="peach-overlay-title">{currentTitle()}</span>1647            <div class="peach-overlay-spacer"></div>1648          </div>16491650          <Switch fallback={renderHome()}>1651            <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1652            <Match when={activeSection() === "vault"}>{renderVault()}</Match>1653            <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1654            <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1655            <Match when={activeSection() === "license"}>{renderLicense()}</Match>1656            <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1657          </Switch>1658        </div>1659      }1660    >1661      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1662    </Show>1663  );1664}