Settings.tsx

65.8 KB · raw

1import { createEffect, 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  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";40  textSize: "small" | "medium" | "large";41  reduceMotion: boolean;42  isPro?: boolean;43  initialSection?: string | null;44  upgradeHighlight?: ProFeature | null;45  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48  onTrashRetentionChange: (days: number) => void | Promise<void>;49  onThemeChange: (theme: "dark" | "light") => void;50  onAccentChange: (name: AccentColorName) => void | Promise<void>;51  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;52  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54  shortcuts: Record<ShortcutAction, Keybinding>;55  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56  onLicenseChange?: () => void | Promise<void>;57  onBack: () => void;58  onImport: () => void;59  onExport: () => void;60  onBreachCheckDone: () => void;61  onOpenHealth?: () => void;62  onOpenSyncPairing?: () => void;63  onOpenFileImport?: () => void;64  onOpenPanic?: () => 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 version = chrome.runtime.getManifest().version;101  const initial = props.initialSection;102  const [activeSection, setActiveSection] = createSignal<SectionName | null>(103    initial && isSectionName(initial) ? (initial as SectionName) : null104  );105106  const [checkingBreaches, setCheckingBreaches] = createSignal(false);107  const [breachResult, setBreachResult] = createSignal<string | null>(null);108  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);109  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");110  const [licenseError, setLicenseError] = createSignal<string | null>(null);111  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);112  const [activatingLicense, setActivatingLicense] = createSignal(false);113114  const isPro = () => props.isPro ?? false;115  const [biometricSupported, setBiometricSupported] = createSignal(false);116  const [biometricStored, setBiometricStored] = createSignal(false);117  const [biometricBusy, setBiometricBusy] = createSignal(false);118  const [biometricError, setBiometricError] = createSignal<string | null>(null);119  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);120  const [passkeySaving, setPasskeySaving] = createSignal(false);121122  const [dedupCount, setDedupCount] = createSignal<number | null>(null);123  const [dedupRunning, setDedupRunning] = createSignal(false);124  const [dedupDone, setDedupDone] = createSignal<number | null>(null);125126  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);127128  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted129  // BEFORE every real-mode vault write so the user can roll back if a sync130  // or import overwrites the wrong state.131  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);132  const [historyLoading, setHistoryLoading] = createSignal(false);133  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);134135  async function loadVaultHistory() {136    setHistoryLoading(true);137    try {138      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });139      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];140      setHistorySnapshots(list);141    } finally {142      setHistoryLoading(false);143    }144  }145146  async function handleRestoreVaultSnapshot(id: string) {147    if (historyRestoring()) return;148    setHistoryRestoring(id);149    const res = await sendMessage({150      type: "RESTORE_VAULT_SNAPSHOT",151      payload: { snapshotId: id },152    });153    setHistoryRestoring(null);154    if ((res as { ok: boolean }).ok) {155      // Vault is now locked — let the parent close Settings; App.tsx's156      // VAULT_LOCKED listener will route back to the lock screen.157      props.onBack();158    }159  }160161  function formatSnapshotTime(savedAt: number): string {162    const diff = Date.now() - savedAt;163    if (diff < 60_000) return "Just now";164    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;165    const d = new Date(savedAt);166    const isToday = new Date().toDateString() === d.toDateString();167    const isYest =168      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();169    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });170    if (isToday) return `Today ${t}`;171    if (isYest) return `Yesterday ${t}`;172    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;173  }174175  function reasonLabel(reason: VaultSnapshot["reason"]): string {176    return (177      {178        local_save: "saved",179        sync_received: "synced",180        import: "imported",181        restore: "restored",182      } as const183    )[reason] ?? reason;184  }185186  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);187188  const [showChangePw, setShowChangePw] = createSignal(false);189  const [changePwCurrent, setChangePwCurrent] = createSignal("");190  const [changePwNew, setChangePwNew] = createSignal("");191  const [changePwConfirm, setChangePwConfirm] = createSignal("");192  const [changePwSaving, setChangePwSaving] = createSignal(false);193  const [changePwError, setChangePwError] = createSignal<string | null>(null);194  const [changePwSuccess, setChangePwSuccess] = createSignal(false);195  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);196197  const [duressEnabled, setDuressEnabled] = createSignal(false);198  const [showDuressSetup, setShowDuressSetup] = createSignal(false);199  const [duressPassword, setDuressPassword] = createSignal("");200  const [duressConfirm, setDuressConfirm] = createSignal("");201  const [duressMainPassword, setDuressMainPassword] = createSignal("");202  const [duressSaving, setDuressSaving] = createSignal(false);203  const [duressError, setDuressError] = createSignal<string | null>(null);204  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);205206  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);207208  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);209  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);210211  const [fbCategory, setFbCategory] = createSignal("Bug report");212  const [fbMessage, setFbMessage] = createSignal("");213  const [fbSubmitting, setFbSubmitting] = createSignal(false);214  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);215216  onMount(async () => {217    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([218      sendMessage({ type: "GET_LICENSE" }),219      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),220      sendMessage({ type: "IS_DURESS_ENABLED" }),221      sendMessage({ type: "GET_SETTINGS" }),222      sendMessage({ type: "GET_LICENSE_KEY" }),223    ]);224225    // Vault history loads in the background — the Vault sub-view picks it up226    // when the user navigates in. Loading it lazily keeps Settings boot fast.227    void loadVaultHistory();228229    if ((licenseRes as { ok: boolean }).ok) {230      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);231    }232233    if ((passkeyRes as { ok: boolean }).ok) {234      setPasskeyState(235        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null236      );237    }238239    if ((duressRes as { ok: boolean }).ok) {240      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);241    }242243    if ((settingsRes as { ok: boolean }).ok) {244      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;245      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);246    }247248    if ((keyRes as { ok: boolean }).ok) {249      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);250    }251252    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });253    if ((entriesRes as { ok: boolean }).ok) {254      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];255      setCanaryEntries(256        entries257          .filter((e) => e.canary?.webhookUrl)258          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))259      );260    }261262263    if (isNativePlatform()) {264      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");265      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");266      try {267        if (biometric) {268          const a = await biometric.isAvailable();269          setBiometricSupported(a.available);270        }271        if (keystore) {272          const k = await keystore.hasMasterKey();273          setBiometricStored(k.exists);274        }275      } catch { /* missing plugins — leave defaults */ }276    }277  });278279  async function handleEnableBiometrics() {280    setBiometricBusy(true);281    setBiometricError(null);282    try {283      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {284        ok: boolean;285        data?: { masterKeyHex: string };286        error?: string;287      };288      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {289        setBiometricError(keyRes.error === "vault_locked"290          ? "unlock the vault first"291          : "couldn't read derived key");292        return;293      }294      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");295      if (!keystore) {296        setBiometricError("biometric plugin unavailable");297        return;298      }299      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });300      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });301      setBiometricStored(true);302    } catch (e) {303      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");304    } finally {305      setBiometricBusy(false);306    }307  }308309  async function handleDisableBiometrics() {310    setBiometricBusy(true);311    setBiometricError(null);312    try {313      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");314      if (keystore) await keystore.clearMasterKey();315      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });316      setBiometricStored(false);317    } catch (e) {318      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");319    } finally {320      setBiometricBusy(false);321    }322  }323324  async function handleChangePassword() {325    setChangePwError(null);326    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }327    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }328    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }329    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }330    setChangePwSaving(true);331    const res = await sendMessage({332      type: "CHANGE_PASSWORD",333      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },334    }) as { ok: boolean; error?: string };335    if (res.ok) {336      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");337      setShowChangePw(false);338      setShowNewRecoveryWords(true);339    } else {340      setChangePwError(res.error ?? "failed to change password");341    }342    setChangePwSaving(false);343  }344345  const [copied, setCopied] = createSignal(false);346347  async function copyLicenseKey(key: string) {348    await navigator.clipboard.writeText(key);349    setCopied(true);350    setTimeout(() => setCopied(false), 2000);351  }352353  async function handleActivateLicense() {354    const key = licenseKeyInput().trim();355    if (!key) return;356    setActivatingLicense(true);357    setLicenseError(null);358    setLicenseSuccess(null);359    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });360    setActivatingLicense(false);361    if ((res as { ok: boolean }).ok) {362      const data = (res as { ok: boolean; data?: LicenseInfo }).data;363      if (data?.valid) {364        setLicenseInfo(data);365        setLicenseKey(key);366        setLicenseSuccess("License activated!");367        setLicenseKeyInput("");368      } else {369        setLicenseError("Invalid license key");370      }371    } else {372      setLicenseError("Activation failed");373    }374  }375376  async function handleTogglePasskeys() {377    const current = passkeyState();378    if (!current || !current.supported) return;379    setPasskeySaving(true);380    const res = await sendMessage({381      type: "SET_PASSKEY_PROVIDER_ENABLED",382      payload: { enabled: !current.enabled },383    });384    setPasskeySaving(false);385    if ((res as { ok: boolean }).ok) {386      setPasskeyState(387        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null388      );389    }390  }391392  async function handleCheckAllBreaches() {393    setCheckingBreaches(true);394    setBreachResult(null);395    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });396    setCheckingBreaches(false);397    if ((res as { ok: boolean }).ok) {398      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};399      const breached = Object.values(data).filter((count) => count > 0).length;400      setBreachResult(401        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"402      );403      props.onBreachCheckDone();404    }405  }406407408  async function handleSetupDuress() {409    const mainPw = duressMainPassword().trim();410    const duressPw = duressPassword().trim();411    const confirmPw = duressConfirm().trim();412413    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }414    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }415    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }416    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }417418    setDuressSaving(true);419    setDuressError(null);420421    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };422    if (!verifyRes.ok || !verifyRes.data) {423      setDuressError("incorrect main password");424      setDuressSaving(false);425      return;426    }427428    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };429    setDuressSaving(false);430    if (res.ok) {431      setDuressEnabled(true);432      setShowDuressSetup(false);433      setDuressPassword("");434      setDuressConfirm("");435      setDuressMainPassword("");436      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");437      setTimeout(() => setDuressSuccess(null), 5000);438    } else {439      setDuressError(res.error ?? "failed to set duress password");440    }441  }442443  async function handleClearDeclinedCards() {444    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });445    setDeclinedCardCount(0);446  }447448  async function handleCountDuplicates() {449    setDedupRunning(true);450    setDedupDone(null);451    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };452    setDedupRunning(false);453    if (res.ok) setDedupCount(res.data ?? 0);454  }455456  async function handleDeduplicate() {457    setDedupRunning(true);458    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };459    setDedupRunning(false);460    if (res.ok) {461      setDedupDone(res.data ?? 0);462      setDedupCount(0);463    }464  }465466  async function handleDisableDuress() {467    const mainPw = duressMainPassword().trim();468    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }469470    setDuressSaving(true);471    setDuressError(null);472473    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };474    if (!verifyRes.ok || !verifyRes.data) {475      setDuressError("incorrect main password");476      setDuressSaving(false);477      return;478    }479480    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };481    setDuressSaving(false);482    if (res.ok) {483      setDuressEnabled(false);484      setShowDuressSetup(false);485      setDuressMainPassword("");486      setDuressSuccess("Duress password disabled.");487      setTimeout(() => setDuressSuccess(null), 4000);488    } else {489      setDuressError(res.error ?? "failed to disable duress password");490    }491  }492493  async function handleSubmitFeedback() {494    if (!fbMessage().trim() || fbSubmitting()) return;495    setFbSubmitting(true);496    setFbResult(null);497    const res = await sendMessage({498      type: "SUBMIT_FEEDBACK",499      payload: {500        category: fbCategory(),501        message: fbMessage().trim(),502        version,503        browser: navigator.userAgent,504      },505    }) as { ok: boolean; error?: string };506    setFbSubmitting(false);507    if (res.ok) {508      setFbResult("success");509      setFbMessage("");510      setTimeout(() => setFbResult(null), 4000);511    } else {512      setFbResult("error");513    }514  }515516  function handleHeaderBack() {517    if (activeSection() !== null) {518      setActiveSection(null);519    } else {520      props.onBack();521    }522  }523524  function currentTitle(): string {525    const s = activeSection();526    if (s === null) return "settings";527    if (s === "license") return "account";528    return s;529  }530531  function renderCatRow(opts: {532    section: SectionName;533    name: string;534    desc: string;535    icon: () => any;536    pro?: boolean;537  }) {538    return (539      <button540        class="peach-settings-cat-row"541        onClick={() => setActiveSection(opts.section)}542      >543        <div class="peach-settings-cat-icon">{opts.icon()}</div>544        <div class="peach-settings-cat-content">545          <span class="peach-settings-cat-name">546            {opts.name}547            <Show when={opts.pro && !isPro()}>548              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>549            </Show>550          </span>551          <span class="peach-settings-cat-desc">{opts.desc}</span>552        </div>553        <svg554          class="peach-settings-cat-chevron"555          width="6" height="10" viewBox="0 0 6 10" fill="none"556          stroke="currentColor" stroke-width="1.1"557          stroke-linecap="round" stroke-linejoin="round"558        >559          <path d="M1 1l4 4-4 4" />560        </svg>561      </button>562    );563  }564565  const iconShield = () => (566    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">567      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>568    </svg>569  );570  const iconKey = () => (571    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">572      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>573    </svg>574  );575  const iconLock = () => (576    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">577      <rect x="3" y="7" width="10" height="7" rx="1.5"/>578      <path d="M6 7V5a3 3 0 016 0v2"/>579    </svg>580  );581  const iconSync = () => (582    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">583      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>584      <path d="M14 6l-2 2-2-2"/>585      <path d="M2 10l2-2 2 2"/>586    </svg>587  );588  const iconSun = () => (589    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">590      <circle cx="8" cy="8" r="3"/>591      <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"/>592    </svg>593  );594  const iconKeys = () => (595    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">596      <rect x="2" y="5" width="5" height="4" rx="1"/>597      <rect x="9" y="7" width="5" height="4" rx="1"/>598      <path d="M7 7h2m-2 2h2"/>599    </svg>600  );601  const iconBubble = () => (602    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">603      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>604    </svg>605  );606607  function renderHome() {608    return (609      <div class="peach-settings-body">610        <p class="peach-settings-nav-label">PROTECTION</p>611        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}612        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}613614        <p class="peach-settings-nav-label">VAULT</p>615        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}616        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>617          <div class="peach-settings-cat-icon">{iconSync()}</div>618          <div class="peach-settings-cat-content">619            <span class="peach-settings-cat-name">Sync</span>620            <span class="peach-settings-cat-desc">Pair and sync across devices</span>621          </div>622          <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">623            <path d="M1 1l4 4-4 4" />624          </svg>625        </button>626627        <p class="peach-settings-nav-label">PREFERENCES</p>628        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}629        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}630631        <p class="peach-settings-nav-label">SUPPORT</p>632        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}633634        <div class="peach-settings-footer">635          <span>Peach {version}</span>636          <span class="peach-divider"></span>637          <span>Browser extension</span>638        </div>639      </div>640    );641  }642643  function renderSecurity() {644    return (645      <div class="peach-settings-body">646        <div class="peach-settings-group">647          <p class="peach-settings-sub-label">LOCKING</p>648          <div class="peach-settings-row">649            <span>Lock on close</span>650            <button651              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}652              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}653              aria-label="Toggle lock on close"654            >655              <span class="peach-toggle-knob"></span>656            </button>657          </div>658          <div class="peach-settings-row">659            <span>Auto-lock after</span>660            <select661              class="peach-settings-select"662              value={String(props.autoLockMinutes)}663              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}664            >665              <option value="1">1 minute</option>666              <option value="5">5 minutes</option>667              <option value="15">15 minutes</option>668              <option value="30">30 minutes</option>669              <option value="0">Never</option>670            </select>671          </div>672673          <Show when={isNativePlatform() && biometricSupported()}>674            <p class="peach-settings-sub-label">BIOMETRICS</p>675            <div class="peach-settings-row">676              <span>Unlock with biometrics</span>677              <button678                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}679                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}680                disabled={biometricBusy()}681                aria-label="Toggle biometric unlock"682              >683                <span class="peach-toggle-knob"></span>684              </button>685            </div>686            <Show when={biometricError()}>687              <div class="peach-settings-row peach-settings-row--meta">688                <span class="peach-hint peach-hint-error">{biometricError()}</span>689              </div>690            </Show>691          </Show>692693          <p class="peach-settings-sub-label">BREACH PROTECTION</p>694          <div class="peach-settings-row">695            <span>Breach monitoring</span>696            <button697              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}698              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}699              aria-label="Toggle breach monitoring"700            >701              <span class="peach-toggle-knob"></span>702            </button>703          </div>704          <div class="peach-settings-row peach-settings-row--meta">705            <span>Check all passwords</span>706            <button707              class="peach-btn peach-btn--subtle peach-btn--sm"708              onClick={() => void handleCheckAllBreaches()}709              disabled={checkingBreaches()}710            >711              {checkingBreaches() ? "checking..." : "run"}712            </button>713          </div>714          <Show when={breachResult()}>715            <div class="peach-settings-row peach-settings-row--meta">716              <span>Last check</span>717              <span class="peach-settings-value">{breachResult()}</span>718            </div>719          </Show>720721          <p class="peach-settings-sub-label">MASTER PASSWORD</p>722          <div class="peach-settings-row peach-settings-row--meta">723            <span>Master password</span>724            <button725              class="peach-btn peach-btn--subtle peach-btn--sm"726              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}727            >728              {showChangePw() ? "cancel" : "change"}729            </button>730          </div>731          <Show when={showChangePw()}>732            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">733              <input734                class="peach-settings-input"735                type="password"736                placeholder="current password"737                value={changePwCurrent()}738                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}739                autocomplete="current-password"740              />741              <input742                class="peach-settings-input"743                type="password"744                placeholder="new password (min 12 chars)"745                value={changePwNew()}746                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}747                autocomplete="new-password"748              />749              <input750                class="peach-settings-input"751                type="password"752                placeholder="confirm new password"753                value={changePwConfirm()}754                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}755                autocomplete="new-password"756                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}757              />758              <Show when={changePwError()}>759                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>760              </Show>761              <Show when={changePwSuccess()}>762                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>763              </Show>764              <button765                class="peach-btn peach-btn--accent peach-btn--sm"766                style="align-self:flex-start"767                onClick={() => void handleChangePassword()}768                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}769              >770                {changePwSaving() ? "saving…" : "save new password"}771              </button>772            </div>773          </Show>774775          <p class="peach-settings-sub-label">DURESS PASSWORD</p>776          <div class="peach-settings-row peach-settings-row--top">777            <div class="peach-settings-row-main">778              <span>Duress password</span>779              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">780                If entered under coercion, shows a decoy empty vault instead of your real one.781              </span>782            </div>783            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>784              {duressEnabled() ? "on" : "off"}785            </div>786          </div>787788          <Show when={duressEnabled() && !showDuressSetup()}>789            <div class="peach-settings-row peach-settings-row--meta">790              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">791                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.792              </span>793            </div>794          </Show>795796          <Show when={duressSuccess()}>797            <div class="peach-settings-row peach-settings-row--meta">798              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>799            </div>800          </Show>801802          <Show when={!showDuressSetup()}>803            <div class="peach-settings-row peach-settings-row--meta">804              <button805                class="peach-btn peach-btn--subtle peach-btn--sm"806                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}807              >808                {duressEnabled() ? "Change / disable" : "Set up"}809              </button>810            </div>811          </Show>812813          <Show when={showDuressSetup()}>814            <div class="peach-duress-form">815              <label class="peach-label">main password (to confirm)</label>816              <input817                class="peach-settings-input"818                type="password"819                placeholder="your current master password"820                autocomplete="current-password"821                value={duressMainPassword()}822                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}823              />824              <Show when={!duressEnabled()}>825                <label class="peach-label">new duress password</label>826                <input827                  class="peach-settings-input"828                  type="password"829                  placeholder="duress password (min 8 chars)"830                  autocomplete="new-password"831                  value={duressPassword()}832                  onInput={(e) => setDuressPassword(e.currentTarget.value)}833                />834                <label class="peach-label">confirm duress password</label>835                <input836                  class="peach-settings-input"837                  type="password"838                  placeholder="confirm duress password"839                  autocomplete="new-password"840                  value={duressConfirm()}841                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}842                />843              </Show>844845              <Show when={duressError()}>846                <p class="peach-hint peach-hint-error">{duressError()}</p>847              </Show>848849              <div class="peach-duress-actions">850                <button851                  class="peach-btn peach-btn--subtle peach-btn--sm"852                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}853                >854                  cancel855                </button>856                <Show when={duressEnabled()}>857                  <button858                    class="peach-btn peach-btn--ghost peach-btn--sm"859                    style="color:var(--peach-breach)"860                    onClick={() => void handleDisableDuress()}861                    disabled={duressSaving()}862                  >863                    {duressSaving() ? "disabling..." : "disable duress"}864                  </button>865                </Show>866                <Show when={!duressEnabled()}>867                  <button868                    class="peach-btn peach-btn--accent peach-btn--sm"869                    onClick={() => void handleSetupDuress()}870                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}871                  >872                    {duressSaving() ? "saving..." : "save"}873                  </button>874                </Show>875              </div>876            </div>877          </Show>878879          <p class="peach-settings-sub-label">CANARY ENTRIES</p>880          <div class="peach-settings-row peach-settings-row--top">881            <div class="peach-settings-row-main">882              <span>Canary entries</span>883              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">884                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.885              </span>886            </div>887            <span class="peach-settings-value" style="flex-shrink:0">888              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}889            </span>890          </div>891          <Show when={canaryEntries().length > 0}>892            <For each={canaryEntries()}>893              {(entry) => (894                <div class="peach-settings-row peach-settings-row--meta">895                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>896                  <button897                    class="peach-btn peach-btn--subtle peach-btn--sm"898                    onClick={() => void handleTestCanary(entry)}899                    disabled={canaryTestingId() === entry.id}900                    style="flex-shrink:0"901                  >902                    {canaryTestingId() === entry.id903                      ? "sending…"904                      : canaryTestResult()?.id === entry.id905                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")906                        : "test"}907                  </button>908                </div>909              )}910            </For>911          </Show>912913          <p class="peach-settings-sub-label">PASSKEYS</p>914          <div class="peach-settings-row peach-settings-row--top">915            <div class="peach-settings-row-main">916              <span>Intercept passkey requests</span>917              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">918                Peach can handle passkey sign-in and creation on supported Chromium browsers.919              </span>920            </div>921            <button922              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}923              onClick={() => void handleTogglePasskeys()}924              aria-label="Toggle passkey interception"925              disabled={!passkeyState()?.supported || passkeySaving()}926            >927              <span class="peach-toggle-knob"></span>928            </button>929          </div>930          <div class="peach-settings-row peach-settings-row--meta">931            <span>Status</span>932            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>933          </div>934          <div class="peach-settings-row peach-settings-row--meta">935            <span>Browser</span>936            <span class="peach-settings-value">937              {passkeyState()?.browserLabel ?? "Unknown"}938              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}939            </span>940          </div>941          <div class="peach-info-notice" style="margin-top:8px">942            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.943          </div>944          <Show when={passkeyState()?.conflict}>945            <div class="peach-settings-row peach-settings-row--meta">946              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>947            </div>948          </Show>949        </div>950      </div>951    );952  }953954  function renderVault() {955    return (956      <div class="peach-settings-body">957        <div class="peach-settings-group">958          <p class="peach-settings-sub-label">ENTRIES</p>959          <div class="peach-settings-row peach-settings-row--meta">960            <span>Entries</span>961            <span class="peach-settings-value">{props.entriesCount}</span>962          </div>963          <div class="peach-settings-row peach-settings-row--meta">964            <span>Duplicates</span>965            <Show when={dedupDone() !== null} fallback={966              <button967                class="peach-btn peach-btn--subtle peach-btn--sm"968                onClick={() => void handleCountDuplicates()}969                disabled={dedupRunning()}970              >971                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}972              </button>973            }>974              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">975                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}976              </span>977            </Show>978          </div>979          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>980            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">981              <div class="peach-restore-warning">982                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.983              </div>984              <div style="display:flex;gap:6px">985                <button986                  class="peach-btn peach-btn--subtle peach-btn--sm"987                  onClick={() => setDedupCount(null)}988                >989                  cancel990                </button>991                <button992                  class="peach-btn peach-btn--accent peach-btn--sm"993                  onClick={() => void handleDeduplicate()}994                  disabled={dedupRunning()}995                >996                  {dedupRunning() ? "removing..." : "remove duplicates"}997                </button>998              </div>999            </div>1000          </Show>10011002          <p class="peach-settings-sub-label">MAINTENANCE</p>1003          <div class="peach-settings-row peach-settings-row--meta">1004            <span>Trash auto-delete</span>1005            <select1006              class="peach-settings-select"1007              value={String(props.trashRetentionDays)}1008              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1009            >1010              <option value="7">7 days</option>1011              <option value="30">30 days</option>1012              <option value="90">90 days</option>1013              <option value="0">Never</option>1014            </select>1015          </div>1016          <div class="peach-settings-row peach-settings-row--meta">1017            <span>Vault Health</span>1018            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1019              open1020            </button>1021          </div>1022          <Show when={declinedCardCount() > 0}>1023            <div class="peach-settings-row peach-settings-row--meta">1024              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1025              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1026                Clear1027              </button>1028            </div>1029          </Show>10301031          <p class="peach-settings-sub-label">DATA</p>1032          <div class="peach-settings-row peach-settings-row--meta">1033            <span>Import passwords</span>1034            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1035              open1036            </button>1037          </div>1038          <div class="peach-settings-row peach-settings-row--meta">1039            <span>Export backup</span>1040            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1041              open1042            </button>1043          </div>10441045          <p class="peach-settings-sub-label">VAULT HISTORY</p>1046          <Show when={historyLoading()}>1047            <p class="peach-settings-hint">Loading…</p>1048          </Show>1049          <Show when={!historyLoading() && historySnapshots().length === 0}>1050            <p class="peach-settings-hint">1051              No snapshots yet. Peach saves automatically before every change —1052              saves, syncs, and imports.1053            </p>1054          </Show>1055          <Show when={!historyLoading() && historySnapshots().length > 0}>1056            <p class="peach-settings-hint">1057              Restoring locks your vault. Unlock with the password that1058              encrypted that snapshot to confirm.1059            </p>1060            <div class="peach-history-list">1061              <For each={historySnapshots()}>1062                {(snap) => (1063                  <div class="peach-history-row">1064                    <div class="peach-history-meta">1065                      <span class="peach-history-time">1066                        {formatSnapshotTime(snap.savedAt)}1067                      </span>1068                      <span class="peach-history-sub">1069                        {[1070                          snap.entryCount !== null1071                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1072                            : null,1073                          reasonLabel(snap.reason),1074                        ]1075                          .filter(Boolean)1076                          .join(" · ")}1077                      </span>1078                    </div>1079                    <button1080                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1081                        historyRestoring() === snap.id ? " is-busy" : ""1082                      }`}1083                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1084                      disabled={historyRestoring() !== null}1085                    >1086                      <Show when={historyRestoring() === snap.id} fallback="Restore">1087                        …1088                      </Show>1089                    </button>1090                  </div>1091                )}1092              </For>1093            </div>1094            <p class="peach-history-footer">1095              {historySnapshots().length} of 10 snapshots1096            </p>1097          </Show>10981099          <button1100            class="peach-settings-advanced-toggle"1101            onClick={() => setShowVaultAdvanced((v) => !v)}1102            aria-expanded={showVaultAdvanced()}1103          >1104            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1105            <span class="peach-settings-advanced-chevron">1106              {showVaultAdvanced() ? "▲" : "▼"}1107            </span>1108          </button>11091110          <Show when={showVaultAdvanced()}>1111            <div class="peach-danger-card">1112              <div class="peach-danger-card-head">1113                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1114                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1115              </div>1116              <p class="peach-danger-card-copy">1117                Wipes the current vault and restores from a backup file.1118                Recoverable only if your backup is good.1119              </p>1120              <button1121                class="peach-danger-card-button"1122                onClick={() => props.onOpenFileImport?.()}1123              >1124                Choose backup file1125              </button>1126            </div>11271128            <Show when={props.onOpenPanic}>1129              <div class="peach-danger-card peach-danger-card--hazard">1130                <div class="peach-danger-card-head">1131                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1132                  <h3 class="peach-danger-card-title">Destroy vault</h3>1133                </div>1134                <p class="peach-danger-card-copy">1135                  Permanently wipes every entry, setting, and license on this1136                  device. There is no recovery.1137                </p>1138                <button1139                  class="peach-danger-card-button"1140                  onClick={() => props.onOpenPanic?.()}1141                >1142                  Begin destruction1143                </button>1144              </div>1145            </Show>1146          </Show>1147        </div>1148      </div>1149    );1150  }11511152  function renderAppearance() {1153    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1154      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1155      { id: "cormorant", label: "Cormorant", preview: "Aa" },1156      { id: "inter", label: "Inter", preview: "Aa" },1157      { id: "system", label: "System", preview: "Aa" },1158    ];1159    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1160      { id: "small", label: "Small" },1161      { id: "medium", label: "Default" },1162      { id: "large", label: "Large" },1163    ];1164    return (1165      <div class="peach-settings-body">1166        <div class="peach-settings-group">1167          <p class="peach-settings-sub-label">THEME</p>1168          <div class="peach-settings-row peach-settings-row--meta">1169            <span>Theme</span>1170            <button1171              class="peach-btn peach-btn--subtle peach-btn--sm"1172              onClick={() =>1173                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1174              }1175            >1176              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1177            </button>1178          </div>11791180          <p class="peach-settings-sub-label">ACCENT</p>1181          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1182            <span>Accent</span>1183            <div class="peach-accent-picker">1184              <For each={ACCENT_COLORS}>1185                {(color) => (1186                  <button1187                    class="peach-accent-swatch"1188                    classList={{ "is-active": props.accentColor === color.name }}1189                    style={{ background: color.hex }}1190                    onClick={() => void props.onAccentChange(color.name)}1191                    aria-label={color.name}1192                    title={color.name}1193                  />1194                )}1195              </For>1196            </div>1197          </div>11981199          <p class="peach-settings-sub-label">DISPLAY FONT</p>1200          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1201            <span>Font</span>1202            <div class="peach-font-picker">1203              <For each={fontOptions}>1204                {(opt) => (1205                  <button1206                    class="peach-font-swatch"1207                    classList={{ "is-active": props.displayFont === opt.id }}1208                    data-font-preview={opt.id}1209                    onClick={() => void props.onDisplayFontChange(opt.id)}1210                    aria-label={opt.label}1211                    title={opt.label}1212                  >1213                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1214                    <span class="peach-font-swatch-name">{opt.label}</span>1215                  </button>1216                )}1217              </For>1218            </div>1219          </div>12201221          <p class="peach-settings-sub-label">TEXT SIZE</p>1222          <div class="peach-settings-row peach-settings-row--meta">1223            <span>Size</span>1224            <div class="peach-pill-group">1225              <For each={sizeOptions}>1226                {(opt) => (1227                  <button1228                    class="peach-btn peach-btn--ghost peach-btn--sm"1229                    classList={{ "is-active": props.textSize === opt.id }}1230                    onClick={() => void props.onTextSizeChange(opt.id)}1231                  >1232                    {opt.label}1233                  </button>1234                )}1235              </For>1236            </div>1237          </div>12381239          <p class="peach-settings-sub-label">MOTION</p>1240          <div class="peach-settings-row peach-settings-row--meta">1241            <span>Reduce motion</span>1242            <button1243              class="peach-btn peach-btn--subtle peach-btn--sm"1244              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1245            >1246              {props.reduceMotion ? "On" : "Off"}1247            </button>1248          </div>1249        </div>1250      </div>1251    );1252  }12531254  function renderShortcuts() {1255    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1256    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12571258    function startCapture(action: ShortcutAction) {1259      setConflictMsg(null);1260      setCapturingAction(action);1261    }1262    function cancelCapture() {1263      setCapturingAction(null);1264      setConflictMsg(null);1265    }1266    function commitBinding(action: ShortcutAction, binding: Keybinding) {1267      const conflict = findConflict(props.shortcuts, binding, action);1268      if (conflict) {1269        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1270        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1271        return;1272      }1273      const next = { ...props.shortcuts, [action]: binding };1274      setCapturingAction(null);1275      setConflictMsg(null);1276      void props.onShortcutsChange(next);1277    }1278    function resetAction(action: ShortcutAction) {1279      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1280      void props.onShortcutsChange(next);1281    }1282    function resetAll() {1283      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1284    }12851286    // Capture-mode key handler — wired via ref onto the chip element so it1287    // catches the next non-modifier keypress and ignores other bindings.1288    function handleCaptureKey(e: KeyboardEvent) {1289      e.preventDefault();1290      e.stopPropagation();1291      if (e.key === "Escape") { cancelCapture(); return; }1292      const binding = keybindingFromEvent(e);1293      if (!binding) return; // bare modifier — keep waiting1294      const action = capturingAction();1295      if (action) commitBinding(action, binding);1296    }12971298    return (1299      <div class="peach-settings-body">1300        <div class="peach-settings-group peach-shortcuts-group">1301          <p class="peach-settings-sub-label">ENTRY VIEW</p>1302          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1303            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1304          </For>1305        </div>13061307        <div class="peach-settings-group peach-shortcuts-group">1308          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1309          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1310            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1311          </For>1312        </div>13131314        <Show when={conflictMsg()}>1315          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1316        </Show>13171318        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1319          <div class="peach-settings-row peach-settings-row--meta">1320            <span>Inline autofill on page</span>1321            <a1322              class="peach-settings-link"1323              href="chrome://extensions/shortcuts"1324              onClick={(e) => {1325                e.preventDefault();1326                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1327              }}1328            >1329              ⌘⇧P — customize ↗1330            </a>1331          </div>1332          <button1333            class="peach-btn peach-btn--subtle peach-btn--sm"1334            style="align-self:flex-start"1335            onClick={resetAll}1336          >1337            Reset all to defaults1338          </button>1339        </div>1340      </div>1341    );13421343    function renderRow(1344      desc: typeof SHORTCUT_DESCRIPTORS[number],1345      capturing: () => ShortcutAction | null,1346      onStart: (a: ShortcutAction) => void,1347      onCancel: () => void,1348      onReset: (a: ShortcutAction) => void,1349      onKey: (e: KeyboardEvent) => void,1350    ) {1351      const isActive = () => capturing() === desc.action;1352      const binding = () => props.shortcuts[desc.action];1353      const isCustom = () => {1354        const b = binding();1355        const d = DEFAULT_BINDINGS[desc.action];1356        return b.key !== d.key1357          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1358          || Boolean(b.meta)  !== Boolean(d.meta)1359          || Boolean(b.shift) !== Boolean(d.shift)1360          || Boolean(b.alt)   !== Boolean(d.alt);1361      };1362      return (1363        <div class="peach-shortcut-row">1364          <span class="peach-shortcut-row-label">{desc.label}</span>1365          <button1366            class="peach-shortcut-chip"1367            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1368            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1369            onKeyDown={(e) => { if (isActive()) onKey(e); }}1370            ref={(el) => {1371              // Auto-focus when entering capture mode so the keydown listener catches the next press.1372              if (el) {1373                queueMicrotask(() => { if (isActive()) el.focus(); });1374              }1375            }}1376          >1377            {isActive() ? "Press a key…" : formatKeybinding(binding())}1378          </button>1379          <Show when={isCustom() && !isActive()}>1380            <button1381              class="peach-shortcut-reset"1382              title="Reset to default"1383              onClick={() => onReset(desc.action)}1384            >1385              ↺1386            </button>1387          </Show>1388        </div>1389      );1390    }1391  }13921393  const PRO_FEATURES: Array<{1394    feature: ProFeature;1395    name: string;1396    desc: string;1397  }> = [1398    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1399    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1400    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1401    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1402    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1403  ];14041405  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14061407  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1408  const [deactivating, setDeactivating] = createSignal(false);14091410  async function handleDeactivateLicense() {1411    setDeactivating(true);1412    await sendMessage({ type: "DEACTIVATE_LICENSE" });1413    setLicenseInfo(null);1414    await props.onLicenseChange?.();1415    setDeactivating(false);1416  }14171418  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1419    setCanaryTestingId(entry.id);1420    setCanaryTestResult(null);1421    const res = await sendMessage({1422      type: "FIRE_CANARY_PING",1423      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1424    }) as { ok: boolean };1425    setCanaryTestResult({ id: entry.id, ok: res.ok });1426    setCanaryTestingId(null);1427    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1428  }14291430  function renderLicense() {1431    async function onActivate() {1432      await handleActivateLicense();1433      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1434        await props.onLicenseChange?.();1435      }1436    }14371438    return (1439      <div class="peach-settings-body">1440        <Show1441          when={isPro()}1442          fallback={1443            <Show1444              when={showKeyEntry()}1445              fallback={1446                <div class="peach-pro-upgrade">1447                  <div class="peach-pro-upgrade-hero">1448                    <div class="peach-pro-upgrade-mark-wrap">1449                      <PeachMark class="peach-pro-upgrade-mark" />1450                    </div>1451                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1452                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1453                  </div>14541455                  <div class="peach-pro-features">1456                    <For each={PRO_FEATURES}>1457                      {(item) => (1458                        <div1459                          class="peach-pro-feature"1460                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1461                        >1462                          <span class="peach-pro-feature-mark">✦</span>1463                          <div class="peach-pro-feature-text">1464                            <span class="peach-pro-feature-name">{item.name}</span>1465                            <span class="peach-pro-feature-desc">{item.desc}</span>1466                          </div>1467                        </div>1468                      )}1469                    </For>1470                  </div>14711472                  <div class="peach-pro-upgrade-actions">1473                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1474                    <a1475                      href="https://peachpasswords.com/#pricing"1476                      target="_blank"1477                      rel="noopener noreferrer"1478                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1479                    >1480                      Get Peach Pro1481                    </a>1482                    <div class="peach-pro-key-divider"><span>or</span></div>1483                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1484                      Activate a license key1485                    </button>1486                  </div>1487                </div>1488              }1489            >1490              <div class="peach-pro-key-ceremony">1491                <LockRuneCanvas />1492                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1493                <input1494                  class="peach-underline-input peach-pro-key-input"1495                  type="text"1496                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1497                  value={licenseKeyInput()}1498                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1499                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1500                  autocomplete="off"1501                  spellcheck={false}1502                />1503                <Show when={licenseError()}>1504                  <p class="peach-pro-key-error">That key doesn't look right.</p>1505                </Show>1506                <button1507                  class="peach-btn peach-btn--accent peach-pro-key-activate"1508                  onClick={() => void onActivate()}1509                  disabled={activatingLicense() || !licenseKeyInput().trim()}1510                >1511                  {activatingLicense() ? "activating…" : "Activate"}1512                </button>1513                <button1514                  class="peach-pro-key-cancel"1515                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1516                >1517                  cancel1518                </button>1519              </div>1520            </Show>1521          }1522        >1523          <div class="peach-pro-active">1524            <div class="peach-pro-active-hero">1525              <div class="peach-pro-active-mark-wrap">1526                <PeachMark class="peach-pro-active-mark" />1527              </div>1528              <h2 class="peach-pro-active-title">Peach Pro</h2>1529              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1530            </div>15311532            <Show when={licenseKey()}>1533              <div class="peach-settings-license">1534                <span class="peach-settings-label">LICENSE KEY</span>1535                <div class="peach-settings-license-row">1536                  <span class="peach-settings-license-key">{licenseKey()!}</span>1537                  <button1538                    class="peach-settings-license-copy"1539                    onClick={() => copyLicenseKey(licenseKey()!)}1540                  >1541                    {copied() ? "✓" : "copy"}1542                  </button>1543                </div>1544              </div>1545            </Show>15461547            <div class="peach-pro-active-features">1548              <For each={PRO_FEATURES}>1549                {(item) => (1550                  <div class="peach-pro-active-feature">1551                    <span class="peach-pro-active-feature-mark">✦</span>1552                    <span class="peach-pro-active-feature-name">{item.name}</span>1553                  </div>1554                )}1555              </For>1556            </div>15571558            <button1559              class="peach-pro-deactivate"1560              onClick={() => void handleDeactivateLicense()}1561              disabled={deactivating()}1562            >1563              {deactivating() ? "Deactivating…" : "Deactivate"}1564            </button>1565          </div>1566        </Show>1567      </div>1568    );1569  }15701571  function renderFeedback() {1572    return (1573      <div class="peach-settings-body">1574        <div class="peach-settings-group peach-feedback-group">1575          <p class="peach-feedback-intro">1576            Help us improve Peach. Every report goes directly to our inbox.1577          </p>15781579          <p class="peach-settings-sub-label">TYPE</p>1580          <div class="peach-feedback-chips">1581            <For each={FEEDBACK_CATEGORIES}>1582              {(cat) => (1583                <button1584                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1585                  onClick={() => setFbCategory(cat)}1586                >1587                  {cat}1588                </button>1589              )}1590            </For>1591          </div>15921593          <p class="peach-settings-sub-label">MESSAGE</p>1594          <textarea1595            class="peach-feedback-textarea"1596            placeholder="Describe the issue or idea..."1597            value={fbMessage()}1598            onInput={(e) => setFbMessage(e.currentTarget.value)}1599          />16001601          <p class="peach-feedback-meta">1602            Version {version} · will be included automatically1603          </p>16041605          <Show when={fbResult() === "success"}>1606            <p class="peach-feedback-result peach-feedback-result--ok">1607              Sent — thank you1608            </p>1609          </Show>1610          <Show when={fbResult() === "error"}>1611            <p class="peach-feedback-result peach-feedback-result--err">1612              Failed to send — try again1613            </p>1614          </Show>16151616          <button1617            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1618            onClick={() => void handleSubmitFeedback()}1619            disabled={fbSubmitting() || !fbMessage().trim()}1620          >1621            {fbSubmitting() ? "Sending..." : "Send feedback"}1622          </button>16231624          <p class="peach-feedback-anon">1625            Sent anonymously · no account required1626          </p>1627        </div>1628      </div>1629    );1630  }16311632  return (1633    <Show1634      when={showNewRecoveryWords()}1635      fallback={1636        <div class="peach-overlay-view ov-enter-right" style="display:flex">1637          <div class="peach-overlay-header">1638            <button1639              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1640              onClick={handleHeaderBack}1641              aria-label="Back"1642            >1643              <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1644                <path d="M6 1L1 6L6 11" />1645              </svg>1646            </button>1647            <span class="peach-overlay-title">{currentTitle()}</span>1648            <div class="peach-overlay-spacer"></div>1649          </div>16501651          <Switch fallback={renderHome()}>1652            <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1653            <Match when={activeSection() === "vault"}>{renderVault()}</Match>1654            <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1655            <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1656            <Match when={activeSection() === "license"}>{renderLicense()}</Match>1657            <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1658          </Switch>1659        </div>1660      }1661    >1662      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1663    </Show>1664  );1665}