Settings.tsx

70.9 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import {16  DEFAULT_BINDINGS,17  findConflict,18  formatKeybinding,19  keybindingFromEvent,20  SHORTCUT_DESCRIPTORS,21  type Keybinding,22  type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28  valid: boolean;29  plan: "free" | "pro";30  issued?: string;31}3233interface SettingsProps {34  entriesCount: number;35  breachMonitoring: boolean;36  lockOnClose: boolean;37  autoLockMinutes: number;38  trashRetentionDays: number;39  theme: "dark" | "light";40  accentColor: AccentColorName;41  textSize: "small" | "medium" | "large";42  reduceMotion: boolean;43  isPro?: boolean;44  initialSection?: string | null;45  upgradeHighlight?: ProFeature | null;46  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49  onTrashRetentionChange: (days: number) => void | Promise<void>;50  onThemeChange: (theme: "dark" | "light") => void;51  onAccentChange: (name: AccentColorName) => 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  isExiting?: boolean;58  onBack: () => void;59  onImport: () => void;60  onExport: () => void;61  onBreachCheckDone: () => void;62  onOpenHealth?: () => void;63  onOpenSyncPairing?: () => void;64  onOpenFileImport?: () => void;65  onOpenPanic?: () => void;66  onOpenCodexGenerate?: () => void;67  onOpenCodexRestore?: () => void;68  /** Invoked from within Settings when a Pro-gated control is tapped on69   *  free tier. App owns the upgrade navigation + highlight state, so this70   *  callback is the single way for Settings to request that navigation. */71  onProGate?: (feature: ProFeature) => void;72  onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;7778function sanitizeFeedbackMessage(value: string): string {79  return value80    .trim()81    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)82    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")83    .replace(/&/g, "&amp;")84    .replace(/</g, "&lt;")85    .replace(/>/g, "&gt;")86    .replace(/"/g, "&quot;")87    .replace(/'/g, "&#39;");88}8990function formatActivatedSince(issued: string | undefined): string {91  if (!issued) return "Active";92  try {93    const d = new Date(issued);94    if (Number.isNaN(d.getTime())) return "Active";95    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;96  } catch {97    return "Active";98  }99}100101function initialSectionToCard(value: string | null | undefined): CardId | null {102  if (!value) return null;103  if (value === "security") return "protection";104  if (value === "license") return "pro";105  if (106    value === "protection" ||107    value === "vault" ||108    value === "sync" ||109    value === "appearance" ||110    value === "shortcuts" ||111    value === "pro" ||112    value === "feedback"113  ) {114    return value;115  }116  return null;117}118119export function SettingsView(props: SettingsProps) {120  const manifest = chrome.runtime.getManifest();121  const version = manifest.version;122  const initial = props.initialSection;123  const [activeCard, setActiveCard] = createSignal<CardId>(124    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"125  );126127  createEffect(() => { props.onCardChange?.(activeCard()); });128129  const [checkingBreaches, setCheckingBreaches] = createSignal(false);130  const [breachResult, setBreachResult] = createSignal<string | null>(null);131  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);132  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");133  const [licenseError, setLicenseError] = createSignal<string | null>(null);134  const [, setLicenseSuccess] = createSignal<string | null>(null);135  const [activatingLicense, setActivatingLicense] = createSignal(false);136137  const isPro = () => props.isPro ?? false;138  const cards = createMemo<StackCardSpec[]>(() => [139    {140      id: "protection",141      num: "I · PROTECT",142      title: "Protection",143      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,144      bgVar: "--stack-protect-bg",145      fgVar: "--stack-protect-fg",146      icon: "shield",147    },148    {149      id: "vault",150      num: "II · KEEP",151      title: "Vault",152      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,153      bgVar: "--stack-vault-bg",154      fgVar: "--stack-vault-fg",155      icon: "lock",156    },157    {158      id: "sync",159      num: "III · ECHO",160      title: "Sync",161      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",162      bgVar: "--stack-sync-bg",163      fgVar: "--stack-sync-fg",164      icon: "sync",165    },166    {167      id: "appearance",168      num: "IV · DRESS",169      title: "Appearance",170      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,171      bgVar: "--stack-appear-bg",172      fgVar: "--stack-appear-fg",173      icon: "sun",174    },175    {176      id: "shortcuts",177      num: "V · KEYS",178      title: "Shortcuts",179      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,180      bgVar: "--stack-shortcut-bg",181      fgVar: "--stack-shortcut-fg",182      icon: "keys",183    },184    {185      id: "pro",186      num: "VI · FOREVER",187      title: "Peach Pro",188      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",189      bgVar: "--stack-pro-bg",190      fgVar: "--stack-pro-fg",191      icon: "spark",192    },193    {194      id: "feedback",195      num: "VII · ECHO",196      title: "Feedback",197      lede: `version ${version} · tell us what would make it better`,198      bgVar: "--stack-feedback-bg",199      fgVar: "--stack-feedback-fg",200      icon: "bubble",201    },202  ]);203  const [biometricSupported, setBiometricSupported] = createSignal(false);204  const [biometricStored, setBiometricStored] = createSignal(false);205  const [biometricBusy, setBiometricBusy] = createSignal(false);206  const [biometricError, setBiometricError] = createSignal<string | null>(null);207  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);208  const [passkeySaving, setPasskeySaving] = createSignal(false);209210  const [dedupCount, setDedupCount] = createSignal<number | null>(null);211  const [dedupRunning, setDedupRunning] = createSignal(false);212  const [dedupDone, setDedupDone] = createSignal<number | null>(null);213214  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);215216  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted217  // BEFORE every real-mode vault write so the user can roll back if a sync218  // or import overwrites the wrong state.219  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);220  const [historyLoading, setHistoryLoading] = createSignal(false);221  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);222  const [historyShowAll, setHistoryShowAll] = createSignal(false);223224  async function loadVaultHistory() {225    setHistoryLoading(true);226    try {227      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });228      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];229      setHistorySnapshots(list);230    } finally {231      setHistoryLoading(false);232    }233  }234235  async function handleRestoreVaultSnapshot(id: string) {236    if (historyRestoring()) return;237    setHistoryRestoring(id);238    const res = await sendMessage({239      type: "RESTORE_VAULT_SNAPSHOT",240      payload: { snapshotId: id },241    });242    setHistoryRestoring(null);243    if ((res as { ok: boolean }).ok) {244      // Vault is now locked — let the parent close Settings; App.tsx's245      // VAULT_LOCKED listener will route back to the lock screen.246      props.onBack();247    }248  }249250  function formatSnapshotTime(savedAt: number): string {251    const diff = Date.now() - savedAt;252    if (diff < 60_000) return "Just now";253    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;254    const d = new Date(savedAt);255    const isToday = new Date().toDateString() === d.toDateString();256    const isYest =257      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();258    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });259    if (isToday) return `Today ${t}`;260    if (isYest) return `Yesterday ${t}`;261    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;262  }263264  function reasonLabel(reason: VaultSnapshot["reason"]): string {265    return (266      {267        local_save: "saved",268        sync_received: "synced",269        import: "imported",270        restore: "restored",271      } as const272    )[reason] ?? reason;273  }274275  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);276277  const [showChangePw, setShowChangePw] = createSignal(false);278  const [changePwCurrent, setChangePwCurrent] = createSignal("");279  const [changePwNew, setChangePwNew] = createSignal("");280  const [changePwConfirm, setChangePwConfirm] = createSignal("");281  const [changePwSaving, setChangePwSaving] = createSignal(false);282  const [changePwError, setChangePwError] = createSignal<string | null>(null);283  const [changePwSuccess] = createSignal(false);284  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);285286  const [duressEnabled, setDuressEnabled] = createSignal(false);287  const [showDuressSetup, setShowDuressSetup] = createSignal(false);288  const [duressPassword, setDuressPassword] = createSignal("");289  const [duressConfirm, setDuressConfirm] = createSignal("");290  const [duressMainPassword, setDuressMainPassword] = createSignal("");291  const [duressSaving, setDuressSaving] = createSignal(false);292  const [duressError, setDuressError] = createSignal<string | null>(null);293  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);294295  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);296297  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);298  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);299300  const [fbCategory, setFbCategory] = createSignal("Bug report");301  const [fbMessage, setFbMessage] = createSignal("");302  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';303  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');304305  createEffect(() => {306    if (props.upgradeHighlight) {307      setActiveCard("pro");308    }309  });310311  onMount(async () => {312    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([313      sendMessage({ type: "GET_LICENSE" }),314      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),315      sendMessage({ type: "IS_DURESS_ENABLED" }),316      sendMessage({ type: "GET_SETTINGS" }),317      sendMessage({ type: "GET_LICENSE_KEY" }),318    ]);319320    // Vault history loads in the background — the Vault sub-view picks it up321    // when the user navigates in. Loading it lazily keeps Settings boot fast.322    void loadVaultHistory();323324    if ((licenseRes as { ok: boolean }).ok) {325      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);326    }327328    if ((passkeyRes as { ok: boolean }).ok) {329      setPasskeyState(330        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null331      );332    }333334    if ((duressRes as { ok: boolean }).ok) {335      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);336    }337338    if ((settingsRes as { ok: boolean }).ok) {339      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;340      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);341    }342343    if ((keyRes as { ok: boolean }).ok) {344      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);345    }346347    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });348    if ((entriesRes as { ok: boolean }).ok) {349      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];350      setCanaryEntries(351        entries352          .filter((e) => e.canary?.webhookUrl)353          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))354      );355    }356357358    if (isNativePlatform()) {359      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");360      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");361      try {362        if (biometric) {363          const a = await biometric.isAvailable();364          setBiometricSupported(a.available);365        }366        if (keystore) {367          const k = await keystore.hasMasterKey();368          setBiometricStored(k.exists);369        }370      } catch { /* missing plugins — leave defaults */ }371    }372  });373374  async function handleEnableBiometrics() {375    setBiometricBusy(true);376    setBiometricError(null);377    try {378      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {379        ok: boolean;380        data?: { masterKeyHex: string };381        error?: string;382      };383      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {384        setBiometricError(keyRes.error === "vault_locked"385          ? "unlock the vault first"386          : "couldn't read derived key");387        return;388      }389      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");390      if (!keystore) {391        setBiometricError("biometric plugin unavailable");392        return;393      }394      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });395      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });396      setBiometricStored(true);397    } catch (e) {398      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");399    } finally {400      setBiometricBusy(false);401    }402  }403404  async function handleDisableBiometrics() {405    setBiometricBusy(true);406    setBiometricError(null);407    try {408      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");409      if (keystore) await keystore.clearMasterKey();410      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });411      setBiometricStored(false);412    } catch (e) {413      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");414    } finally {415      setBiometricBusy(false);416    }417  }418419  async function handleChangePassword() {420    setChangePwError(null);421    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }422    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }423    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }424    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }425    setChangePwSaving(true);426    const res = await sendMessage({427      type: "CHANGE_PASSWORD",428      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },429    }) as { ok: boolean; error?: string };430    if (res.ok) {431      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");432      setShowChangePw(false);433      setShowNewRecoveryWords(true);434    } else {435      setChangePwError(res.error ?? "failed to change password");436    }437    setChangePwSaving(false);438  }439440  const [copied, setCopied] = createSignal(false);441442  async function copyLicenseKey(key: string) {443    await navigator.clipboard.writeText(key);444    setCopied(true);445    setTimeout(() => setCopied(false), 2000);446  }447448  async function handleActivateLicense() {449    const key = licenseKeyInput().trim();450    if (!key) return;451    setActivatingLicense(true);452    setLicenseError(null);453    setLicenseSuccess(null);454    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });455    setActivatingLicense(false);456    if ((res as { ok: boolean }).ok) {457      const data = (res as { ok: boolean; data?: LicenseInfo }).data;458      if (data?.valid) {459        setLicenseInfo(data);460        setLicenseKey(key);461        setLicenseSuccess("License activated!");462        setLicenseKeyInput("");463      } else {464        setLicenseError("Invalid license key");465      }466    } else {467      setLicenseError("Activation failed");468    }469  }470471  async function handleTogglePasskeys() {472    const current = passkeyState();473    if (!current || !current.supported) return;474    setPasskeySaving(true);475    const res = await sendMessage({476      type: "SET_PASSKEY_PROVIDER_ENABLED",477      payload: { enabled: !current.enabled },478    });479    setPasskeySaving(false);480    if ((res as { ok: boolean }).ok) {481      setPasskeyState(482        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null483      );484    }485  }486487  async function handleCheckAllBreaches() {488    setCheckingBreaches(true);489    setBreachResult(null);490    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });491    setCheckingBreaches(false);492    if ((res as { ok: boolean }).ok) {493      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};494      const breached = Object.values(data).filter((count) => count > 0).length;495      setBreachResult(496        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"497      );498      props.onBreachCheckDone();499    }500  }501502503  async function handleSetupDuress() {504    const mainPw = duressMainPassword().trim();505    const duressPw = duressPassword().trim();506    const confirmPw = duressConfirm().trim();507508    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }509    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }510    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }511    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }512513    setDuressSaving(true);514    setDuressError(null);515516    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };517    if (!verifyRes.ok || !verifyRes.data) {518      setDuressError("incorrect main password");519      setDuressSaving(false);520      return;521    }522523    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };524    setDuressSaving(false);525    if (res.ok) {526      setDuressEnabled(true);527      setShowDuressSetup(false);528      setDuressPassword("");529      setDuressConfirm("");530      setDuressMainPassword("");531      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");532      setTimeout(() => setDuressSuccess(null), 5000);533    } else {534      setDuressError(535        res.error === "session_expired"536          ? "Vault session expired — lock and unlock Peach, then try again"537          : res.error ?? "failed to set duress password"538      );539    }540  }541542  async function handleClearDeclinedCards() {543    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });544    setDeclinedCardCount(0);545  }546547  async function handleCountDuplicates() {548    setDedupRunning(true);549    setDedupDone(null);550    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };551    setDedupRunning(false);552    if (res.ok) setDedupCount(res.data ?? 0);553  }554555  async function handleDeduplicate() {556    setDedupRunning(true);557    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };558    setDedupRunning(false);559    if (res.ok) {560      setDedupDone(res.data ?? 0);561      setDedupCount(0);562    }563  }564565  async function handleDisableDuress() {566    const mainPw = duressMainPassword().trim();567    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }568569    setDuressSaving(true);570    setDuressError(null);571572    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };573    if (!verifyRes.ok || !verifyRes.data) {574      setDuressError("incorrect main password");575      setDuressSaving(false);576      return;577    }578579    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };580    setDuressSaving(false);581    if (res.ok) {582      setDuressEnabled(false);583      setShowDuressSetup(false);584      setDuressMainPassword("");585      setDuressSuccess("Duress password disabled.");586      setTimeout(() => setDuressSuccess(null), 4000);587    } else {588      setDuressError(res.error ?? "failed to disable duress password");589    }590  }591592  async function handleSubmitFeedback() {593    const message = sanitizeFeedbackMessage(fbMessage());594    if (!message) return;595596    setFeedbackState('sending');597598    try {599      const category = FEEDBACK_CATEGORIES.includes(fbCategory())600        ? fbCategory()601        : FEEDBACK_CATEGORIES[0];602      const res = await fetch('https://api.web3forms.com/submit', {603        method: 'POST',604        headers: { 'Content-Type': 'application/json' },605        body: JSON.stringify({606          access_key: WEB3FORMS_KEY,607          subject: `Peach Feedback - ${category}`,608          message,609          from_name: 'Peach User',610          // Honeypot — leave blank611          botcheck: '',612        }),613      });614615      if (!res.ok) throw new Error(`HTTP ${res.status}`);616      const data = await res.json() as { success: boolean; message?: string };617      if (!data.success) throw new Error(data.message ?? 'unknown error');618619      setFeedbackState('sent');620      setFbMessage('');621      setFbCategory(FEEDBACK_CATEGORIES[0]);622    } catch (err) {623      console.error('[feedback]', err);624      setFeedbackState('error');625      setTimeout(() => setFeedbackState('idle'), 4000);626    }627  }628629  function renderSecurity() {630    return (631      <div class="peach-settings-body">632        <div class="peach-settings-group">633          <p class="peach-settings-sub-label">LOCKING</p>634          <div class="peach-settings-row">635            <span>Lock on close</span>636            <button637              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}638              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}639              aria-label="Toggle lock on close"640            >641              <span class="peach-toggle-knob"></span>642            </button>643          </div>644          <div class="peach-settings-row">645            <span>Auto-lock after</span>646            <select647              class="peach-settings-select"648              value={String(props.autoLockMinutes)}649              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}650            >651              <option value="1">1 minute</option>652              <option value="5">5 minutes</option>653              <option value="15">15 minutes</option>654              <option value="30">30 minutes</option>655              <option value="0">Never</option>656            </select>657          </div>658659          <Show when={isNativePlatform() && biometricSupported()}>660            <p class="peach-settings-sub-label">BIOMETRICS</p>661            <div class="peach-settings-row">662              <span>Unlock with biometrics</span>663              <button664                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}665                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}666                disabled={biometricBusy()}667                aria-label="Toggle biometric unlock"668              >669                <span class="peach-toggle-knob"></span>670              </button>671            </div>672            <Show when={biometricError()}>673              <div class="peach-settings-row peach-settings-row--meta">674                <span class="peach-hint peach-hint-error">{biometricError()}</span>675              </div>676            </Show>677          </Show>678679          <p class="peach-settings-sub-label">BREACH PROTECTION</p>680          <div class="peach-settings-row">681            <span>Breach monitoring</span>682            <button683              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}684              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}685              aria-label="Toggle breach monitoring"686            >687              <span class="peach-toggle-knob"></span>688            </button>689          </div>690          <div class="peach-settings-row peach-settings-row--meta">691            <span>Check all passwords</span>692            <button693              class="peach-btn peach-btn--subtle peach-btn--sm"694              onClick={() => void handleCheckAllBreaches()}695              disabled={checkingBreaches()}696            >697              {checkingBreaches() ? "checking..." : "run"}698            </button>699          </div>700          <Show when={breachResult()}>701            <div class="peach-settings-row peach-settings-row--meta">702              <span>Last check</span>703              <span class="peach-settings-value">{breachResult()}</span>704            </div>705          </Show>706707          <p class="peach-settings-sub-label">MASTER PASSWORD</p>708          <div class="peach-settings-row peach-settings-row--meta">709            <span>Master password</span>710            <button711              class="peach-btn peach-btn--subtle peach-btn--sm"712              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}713            >714              {showChangePw() ? "cancel" : "change"}715            </button>716          </div>717          <Show when={showChangePw()}>718            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">719              <input720                class="peach-settings-input"721                type="password"722                placeholder="current password"723                value={changePwCurrent()}724                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}725                autocomplete="current-password"726              />727              <input728                class="peach-settings-input"729                type="password"730                placeholder="new password (min 12 chars)"731                value={changePwNew()}732                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}733                autocomplete="new-password"734              />735              <input736                class="peach-settings-input"737                type="password"738                placeholder="confirm new password"739                value={changePwConfirm()}740                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}741                autocomplete="new-password"742                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}743              />744              <Show when={changePwError()}>745                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>746              </Show>747              <Show when={changePwSuccess()}>748                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>749              </Show>750              <button751                class="peach-btn peach-btn--accent peach-btn--sm"752                style="align-self:flex-start"753                onClick={() => void handleChangePassword()}754                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}755              >756                {changePwSaving() ? "saving…" : "save new password"}757              </button>758            </div>759          </Show>760761          <p class="peach-settings-sub-label">762            DURESS PASSWORD763            <Show when={!isPro()}>764              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>765            </Show>766          </p>767          <div class="peach-settings-row peach-settings-row--top">768            <div class="peach-settings-row-main">769              <span>770                Duress password771                <Show when={!isPro()}>772                  <span class="peach-pro-badge">Pro</span>773                </Show>774              </span>775              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">776                If entered under coercion, shows a decoy empty vault instead of your real one.777              </span>778            </div>779            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>780              {duressEnabled() ? "on" : "off"}781            </div>782          </div>783784          <Show when={duressEnabled() && !showDuressSetup()}>785            <div class="peach-settings-row peach-settings-row--meta">786              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">787                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.788              </span>789            </div>790          </Show>791792          <Show when={duressSuccess()}>793            <div class="peach-settings-row peach-settings-row--meta">794              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>795            </div>796          </Show>797798          <Show when={!showDuressSetup()}>799            <div class="peach-settings-row peach-settings-row--meta">800              <button801                class="peach-btn peach-btn--subtle peach-btn--sm"802                onClick={() => {803                  // Pro gate entry-point: free users are redirected to the804                  // upgrade screen before the setup form opens. The server-805                  // side SETUP_DURESS handler is the single enforcement806                  // point; this is UI redirection so the form never appears807                  // for a user who can't complete it.808                  if (!isPro() && !duressEnabled()) {809                    props.onProGate?.("duress_vault");810                    setActiveCard("pro");811                    return;812                  }813                  setShowDuressSetup(true);814                  setDuressError(null);815                  setDuressSuccess(null);816                  setDuressPassword("");817                  setDuressConfirm("");818                  setDuressMainPassword("");819                }}820              >821                {duressEnabled() ? "Change / disable" : "Set up"}822              </button>823            </div>824          </Show>825826          <Show when={showDuressSetup()}>827            <div class="peach-duress-form">828              <label class="peach-label">main password (to confirm)</label>829              <input830                class="peach-settings-input"831                type="password"832                placeholder="your current master password"833                autocomplete="current-password"834                value={duressMainPassword()}835                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}836              />837              <Show when={!duressEnabled()}>838                <label class="peach-label">new duress password</label>839                <input840                  class="peach-settings-input"841                  type="password"842                  placeholder="duress password (min 8 chars)"843                  autocomplete="new-password"844                  value={duressPassword()}845                  onInput={(e) => setDuressPassword(e.currentTarget.value)}846                />847                <label class="peach-label">confirm duress password</label>848                <input849                  class="peach-settings-input"850                  type="password"851                  placeholder="confirm duress password"852                  autocomplete="new-password"853                  value={duressConfirm()}854                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}855                />856              </Show>857858              <Show when={duressError()}>859                <p class="peach-hint peach-hint-error">{duressError()}</p>860              </Show>861862              <div class="peach-duress-actions">863                <button864                  class="peach-btn peach-btn--subtle peach-btn--sm"865                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}866                >867                  cancel868                </button>869                <Show when={duressEnabled()}>870                  <button871                    class="peach-btn peach-btn--ghost peach-btn--sm"872                    style="color:var(--peach-breach)"873                    onClick={() => void handleDisableDuress()}874                    disabled={duressSaving()}875                  >876                    {duressSaving() ? "disabling..." : "disable duress"}877                  </button>878                </Show>879                <Show when={!duressEnabled()}>880                  <button881                    class="peach-btn peach-btn--accent peach-btn--sm"882                    onClick={() => void handleSetupDuress()}883                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}884                  >885                    {duressSaving() ? "saving..." : "save"}886                  </button>887                </Show>888              </div>889            </div>890          </Show>891892          <p class="peach-settings-sub-label">893            CANARY ENTRIES894            <Show when={!isPro()}>895              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>896            </Show>897          </p>898          <div class="peach-settings-row peach-settings-row--top">899            <div class="peach-settings-row-main">900              <span>901                Canary entries902                <Show when={!isPro()}>903                  <span class="peach-pro-badge">Pro</span>904                </Show>905              </span>906              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">907                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.908              </span>909            </div>910            <span class="peach-settings-value" style="flex-shrink:0">911              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}912            </span>913          </div>914          <Show when={canaryEntries().length > 0}>915            <For each={canaryEntries()}>916              {(entry) => (917                <div class="peach-settings-row peach-settings-row--meta">918                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>919                  <button920                    class="peach-btn peach-btn--subtle peach-btn--sm"921                    onClick={() => void handleTestCanary(entry)}922                    disabled={canaryTestingId() === entry.id}923                    style="flex-shrink:0"924                  >925                    {canaryTestingId() === entry.id926                      ? "sending…"927                      : canaryTestResult()?.id === entry.id928                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")929                        : "test"}930                  </button>931                </div>932              )}933            </For>934          </Show>935936          <p class="peach-settings-sub-label">PASSKEYS</p>937          <div class="peach-settings-row peach-settings-row--top">938            <div class="peach-settings-row-main">939              <span>Intercept passkey requests</span>940              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">941                Peach can handle passkey sign-in and creation on supported Chromium browsers.942              </span>943            </div>944            <button945              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}946              onClick={() => void handleTogglePasskeys()}947              aria-label="Toggle passkey interception"948              disabled={!passkeyState()?.supported || passkeySaving()}949            >950              <span class="peach-toggle-knob"></span>951            </button>952          </div>953          <div class="peach-settings-row peach-settings-row--meta">954            <span>Status</span>955            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>956          </div>957          <div class="peach-settings-row peach-settings-row--meta">958            <span>Browser</span>959            <span class="peach-settings-value">960              {passkeyState()?.browserLabel ?? "Unknown"}961              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}962            </span>963          </div>964          <div class="peach-info-notice" style="margin-top:8px">965            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.966          </div>967          <Show when={passkeyState()?.conflict}>968            <div class="peach-settings-row peach-settings-row--meta">969              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>970            </div>971          </Show>972        </div>973      </div>974    );975  }976977  function renderVault() {978    return (979      <div class="peach-settings-body">980        <div class="peach-settings-group">981          <p class="peach-settings-sub-label">ENTRIES</p>982          <div class="peach-settings-row peach-settings-row--meta">983            <span>Entries</span>984            <span class="peach-settings-value">{props.entriesCount}</span>985          </div>986          <div class="peach-settings-row peach-settings-row--meta">987            <span>Duplicates</span>988            <Show when={dedupDone() !== null} fallback={989              <button990                class="peach-btn peach-btn--subtle peach-btn--sm"991                onClick={() => void handleCountDuplicates()}992                disabled={dedupRunning()}993              >994                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}995              </button>996            }>997              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">998                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}999              </span>1000            </Show>1001          </div>1002          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1003            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1004              <div class="peach-restore-warning">1005                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1006              </div>1007              <div style="display:flex;gap:6px">1008                <button1009                  class="peach-btn peach-btn--subtle peach-btn--sm"1010                  onClick={() => setDedupCount(null)}1011                >1012                  cancel1013                </button>1014                <button1015                  class="peach-btn peach-btn--accent peach-btn--sm"1016                  onClick={() => void handleDeduplicate()}1017                  disabled={dedupRunning()}1018                >1019                  {dedupRunning() ? "removing..." : "remove duplicates"}1020                </button>1021              </div>1022            </div>1023          </Show>10241025          <p class="peach-settings-sub-label">MAINTENANCE</p>1026          <div class="peach-settings-row peach-settings-row--meta">1027            <span>Trash auto-delete</span>1028            <select1029              class="peach-settings-select"1030              value={String(props.trashRetentionDays)}1031              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1032            >1033              <option value="7">7 days</option>1034              <option value="30">30 days</option>1035              <option value="90">90 days</option>1036              <option value="0">Never</option>1037            </select>1038          </div>1039          <div class="peach-settings-row peach-settings-row--meta">1040            <span>Vault Health</span>1041            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1042              open1043            </button>1044          </div>1045          <Show when={declinedCardCount() > 0}>1046            <div class="peach-settings-row peach-settings-row--meta">1047              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1048              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1049                Clear1050              </button>1051            </div>1052          </Show>10531054          <p class="peach-settings-sub-label">DATA</p>1055          <div class="peach-settings-row peach-settings-row--meta">1056            <span>Import passwords</span>1057            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1058              open1059            </button>1060          </div>1061          <div class="peach-settings-row peach-settings-row--meta">1062            <span>Export backup</span>1063            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1064              open1065            </button>1066          </div>1067          <div class="peach-settings-row peach-settings-row--meta">1068            <span>1069              Peach Codex1070              <Show when={!isPro()}>1071                <span class="peach-pro-badge">Pro</span>1072              </Show>1073            </span>1074            <button1075              class="peach-btn peach-btn--subtle peach-btn--sm"1076              onClick={() => props.onOpenCodexGenerate?.()}1077            >1078              open1079            </button>1080          </div>1081          {/* Restore from Codex: intentionally never gated — a printed1082              Codex must remain restorable regardless of license state. */}1083          <div class="peach-settings-row peach-settings-row--meta">1084            <span>Restore from Codex</span>1085            <button1086              class="peach-btn peach-btn--subtle peach-btn--sm"1087              onClick={() => props.onOpenCodexRestore?.()}1088            >1089              open1090            </button>1091          </div>10921093          <span class="peach-settings-sub-label">VAULT HISTORY</span>1094          <Show when={historyLoading()}>1095            <p class="peach-settings-hint">Loading…</p>1096          </Show>1097          <Show when={!historyLoading() && historySnapshots().length === 0}>1098            <p class="peach-settings-hint">1099              No snapshots yet. Peach saves automatically before every change —1100              saves, syncs, and imports.1101            </p>1102          </Show>1103          <Show when={!historyLoading() && historySnapshots().length > 0}>1104            <p class="peach-settings-hint">1105              Restoring locks your vault. Unlock with the password that1106              encrypted that snapshot to confirm.1107            </p>1108            <div class="peach-history-list">1109              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1110                {(snap) => (1111                  <div class="peach-history-row">1112                    <div class="peach-history-meta">1113                      <span class="peach-history-time">1114                        {formatSnapshotTime(snap.savedAt)}1115                      </span>1116                      <span class="peach-history-sub">1117                        {[1118                          snap.entryCount !== null1119                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1120                            : null,1121                          reasonLabel(snap.reason),1122                        ]1123                          .filter(Boolean)1124                          .join(" · ")}1125                      </span>1126                    </div>1127                    <button1128                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1129                        historyRestoring() === snap.id ? " is-busy" : ""1130                      }`}1131                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1132                      disabled={historyRestoring() !== null}1133                    >1134                      <Show when={historyRestoring() === snap.id} fallback="Restore">1135                        …1136                      </Show>1137                    </button>1138                  </div>1139                )}1140              </For>1141            </div>1142            <Show when={historySnapshots().length > 3}>1143              <button1144                class="peach-settings-advanced-toggle"1145                onClick={() => setHistoryShowAll((v) => !v)}1146                aria-expanded={historyShowAll()}1147              >1148                <span class="peach-settings-hint" style="margin:0">1149                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1150                </span>1151                <span class="peach-settings-advanced-chevron">1152                  {historyShowAll() ? "▲" : "▼"}1153                </span>1154              </button>1155            </Show>1156            <p class="peach-history-footer">1157              {historySnapshots().length} of 10 snapshots1158            </p>1159          </Show>11601161          <button1162            class="peach-settings-advanced-toggle"1163            onClick={() => setShowVaultAdvanced((v) => !v)}1164            aria-expanded={showVaultAdvanced()}1165          >1166            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1167            <span class="peach-settings-advanced-chevron">1168              {showVaultAdvanced() ? "▲" : "▼"}1169            </span>1170          </button>11711172          <Show when={showVaultAdvanced()}>1173            <div class="peach-danger-card">1174              <div class="peach-danger-card-head">1175                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1176                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1177              </div>1178              <p class="peach-danger-card-copy">1179                Wipes the current vault and restores from a backup file.1180                Recoverable only if your backup is good.1181              </p>1182              <button1183                class="peach-danger-card-button"1184                onClick={() => props.onOpenFileImport?.()}1185              >1186                Choose backup file1187              </button>1188            </div>11891190            <Show when={props.onOpenPanic}>1191              <div class="peach-danger-card peach-danger-card--hazard">1192                <div class="peach-danger-card-head">1193                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1194                  <h3 class="peach-danger-card-title">Destroy vault</h3>1195                </div>1196                <p class="peach-danger-card-copy">1197                  Permanently wipes every entry, setting, and license on this1198                  device. There is no recovery.1199                </p>1200                <button1201                  class="peach-danger-card-button"1202                  onClick={() => props.onOpenPanic?.()}1203                >1204                  Begin destruction1205                </button>1206              </div>1207            </Show>1208          </Show>1209        </div>1210      </div>1211    );1212  }12131214  function renderSync() {1215    return (1216      <div class="peach-settings-body">1217        <div class="peach-settings-group">1218          <p class="peach-settings-sub-label">PAIRING</p>1219          <div class="peach-settings-row peach-settings-row--top">1220            <div class="peach-settings-row-main">1221              <span>1222                Peach Sync1223                <Show when={!isPro()}>1224                  <span class="peach-pro-badge">Pro</span>1225                </Show>1226              </span>1227              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1228                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1229              </span>1230            </div>1231            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1232          </div>1233          <div class="peach-settings-row peach-settings-row--meta">1234            <span>Status</span>1235            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1236          </div>1237          <div class="peach-settings-row peach-settings-row--meta">1238            <span>Sync hub</span>1239            <button1240              class="peach-btn peach-btn--subtle peach-btn--sm"1241              onClick={() => props.onOpenSyncPairing?.()}1242            >1243              open1244            </button>1245          </div>12461247          <p class="peach-settings-sub-label">DEVICE FLOW</p>1248          <div class="peach-settings-row peach-settings-row--top">1249            <div class="peach-settings-row-main">1250              <span>Pair new device</span>1251              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1252                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1253              </span>1254            </div>1255            <button1256              class="peach-btn peach-btn--subtle peach-btn--sm"1257              onClick={() => props.onOpenSyncPairing?.()}1258            >1259              pair1260            </button>1261          </div>1262          <div class="peach-info-notice" style="margin-top:8px">1263            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1264          </div>1265        </div>1266      </div>1267    );1268  }12691270  function renderAppearance() {1271    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1272      { id: "medium", label: "Default" },1273      { id: "large", label: "Large" },1274    ];1275    return (1276      <div class="peach-settings-body">1277        <div class="peach-settings-group">1278          <p class="peach-settings-sub-label">THEME</p>1279          <div class="peach-settings-row peach-settings-row--meta">1280            <span>Theme</span>1281            <button1282              class="peach-btn peach-btn--subtle peach-btn--sm"1283              onClick={() =>1284                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1285              }1286            >1287              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1288            </button>1289          </div>12901291          <p class="peach-settings-sub-label">ACCENT</p>1292          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1293            <span>Accent</span>1294            <div class="peach-accent-picker">1295              <For each={ACCENT_COLORS}>1296                {(color) => (1297                  <button1298                    class="peach-accent-swatch peach-swatch"1299                    classList={{ "is-active": props.accentColor === color.name }}1300                    style={{ background: color.hex }}1301                    onClick={() => void props.onAccentChange(color.name)}1302                    aria-label={color.name}1303                    title={color.name}1304                  />1305                )}1306              </For>1307            </div>1308          </div>13091310          <p class="peach-settings-sub-label">TEXT SIZE</p>1311          <div class="peach-settings-row peach-settings-row--meta">1312            <span>Size</span>1313            <div class="peach-pill-group">1314              <For each={sizeOptions}>1315                {(opt) => (1316                  <button1317                    class="peach-btn peach-btn--ghost peach-btn--sm"1318                    classList={{ "is-active": props.textSize === opt.id }}1319                    onClick={() => void props.onTextSizeChange(opt.id)}1320                  >1321                    {opt.label}1322                  </button>1323                )}1324              </For>1325            </div>1326          </div>13271328          <p class="peach-settings-sub-label">MOTION</p>1329          <div class="peach-settings-row peach-settings-row--meta">1330            <span>Reduce motion</span>1331            <button1332              class="peach-btn peach-btn--subtle peach-btn--sm"1333              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1334            >1335              {props.reduceMotion ? "On" : "Off"}1336            </button>1337          </div>1338        </div>1339      </div>1340    );1341  }13421343  function renderShortcuts() {1344    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1345    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13461347    function startCapture(action: ShortcutAction) {1348      setConflictMsg(null);1349      setCapturingAction(action);1350    }1351    function cancelCapture() {1352      setCapturingAction(null);1353      setConflictMsg(null);1354    }1355    function commitBinding(action: ShortcutAction, binding: Keybinding) {1356      const conflict = findConflict(props.shortcuts, binding, action);1357      if (conflict) {1358        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1359        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1360        return;1361      }1362      const next = { ...props.shortcuts, [action]: binding };1363      setCapturingAction(null);1364      setConflictMsg(null);1365      void props.onShortcutsChange(next);1366    }1367    function resetAction(action: ShortcutAction) {1368      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1369      void props.onShortcutsChange(next);1370    }1371    function resetAll() {1372      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1373    }13741375    // Capture-mode key handler — wired via ref onto the chip element so it1376    // catches the next non-modifier keypress and ignores other bindings.1377    function handleCaptureKey(e: KeyboardEvent) {1378      e.preventDefault();1379      e.stopPropagation();1380      if (e.key === "Escape") { cancelCapture(); return; }1381      const binding = keybindingFromEvent(e);1382      if (!binding) return; // bare modifier — keep waiting1383      const action = capturingAction();1384      if (action) commitBinding(action, binding);1385    }13861387    return (1388      <div class="peach-settings-body">1389        <div class="peach-settings-group peach-shortcuts-group">1390          <p class="peach-settings-sub-label">ENTRY VIEW</p>1391          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1392            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1393          </For>1394        </div>13951396        <div class="peach-settings-group peach-shortcuts-group">1397          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1398          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1399            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1400          </For>1401        </div>14021403        <Show when={conflictMsg()}>1404          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1405        </Show>14061407        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1408          <div class="peach-settings-row peach-settings-row--meta">1409            <span>Inline autofill on page</span>1410            <a1411              class="peach-settings-link"1412              href="chrome://extensions/shortcuts"1413              onClick={(e) => {1414                e.preventDefault();1415                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1416              }}1417            >1418              ⌘⇧P — customize ↗1419            </a>1420          </div>1421          <button1422            class="peach-btn peach-btn--subtle peach-btn--sm"1423            style="align-self:flex-start"1424            onClick={resetAll}1425          >1426            Reset all to defaults1427          </button>1428        </div>1429      </div>1430    );14311432    function renderRow(1433      desc: typeof SHORTCUT_DESCRIPTORS[number],1434      capturing: () => ShortcutAction | null,1435      onStart: (a: ShortcutAction) => void,1436      onCancel: () => void,1437      onReset: (a: ShortcutAction) => void,1438      onKey: (e: KeyboardEvent) => void,1439    ) {1440      const isActive = () => capturing() === desc.action;1441      const binding = () => props.shortcuts[desc.action];1442      const isCustom = () => {1443        const b = binding();1444        const d = DEFAULT_BINDINGS[desc.action];1445        return b.key !== d.key1446          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1447          || Boolean(b.meta)  !== Boolean(d.meta)1448          || Boolean(b.shift) !== Boolean(d.shift)1449          || Boolean(b.alt)   !== Boolean(d.alt);1450      };1451      return (1452        <div class="peach-shortcut-row">1453          <span class="peach-shortcut-row-label">{desc.label}</span>1454          <button1455            class="peach-shortcut-chip"1456            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1457            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1458            onKeyDown={(e) => { if (isActive()) onKey(e); }}1459            ref={(el) => {1460              // Auto-focus when entering capture mode so the keydown listener catches the next press.1461              if (el) {1462                queueMicrotask(() => { if (isActive()) el.focus(); });1463              }1464            }}1465          >1466            {isActive() ? "Press a key…" : formatKeybinding(binding())}1467          </button>1468          <Show when={isCustom() && !isActive()}>1469            <button1470              class="peach-shortcut-reset"1471              title="Reset to default"1472              onClick={() => onReset(desc.action)}1473            >1474              ↺1475            </button>1476          </Show>1477        </div>1478      );1479    }1480  }14811482  const PRO_FEATURES: Array<{1483    feature: ProFeature;1484    name: string;1485    desc: string;1486  }> = [1487    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1488    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1489    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1490    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1491    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1492  ];14931494  const PRO_SECURITY_FEATURES: Array<{1495    feature: ProFeature;1496    name: string;1497    desc: string;1498  }> = [1499    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1500    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1501  ];15021503  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1504  const [showKey, setShowKey] = createSignal(false);15051506  const [deactivating, setDeactivating] = createSignal(false);15071508  async function handleDeactivateLicense() {1509    setDeactivating(true);1510    await sendMessage({ type: "DEACTIVATE_LICENSE" });1511    setLicenseInfo(null);1512    await props.onLicenseChange?.();1513    setDeactivating(false);1514  }15151516  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1517    setCanaryTestingId(entry.id);1518    setCanaryTestResult(null);1519    const res = await sendMessage({1520      type: "FIRE_CANARY_PING",1521      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1522    }) as { ok: boolean };1523    setCanaryTestResult({ id: entry.id, ok: res.ok });1524    setCanaryTestingId(null);1525    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1526  }15271528  function renderLicense() {1529    async function onActivate() {1530      await handleActivateLicense();1531      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1532        await props.onLicenseChange?.();1533      }1534    }15351536    return (1537      <div class="peach-settings-body">1538        <Show1539          when={isPro()}1540          fallback={1541                <div class="peach-pro-upgrade">1542                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15431544                  <h2 class="peach-pro-headline">1545                    Every<em>thing,</em><br />every<em>where.</em>1546                  </h2>15471548                  <p class="peach-pro-subtitle">1549                    One key unlocks every Pro feature — for the lifetime of the app.1550                  </p>15511552                  <div class="peach-pro-grid">1553                    <For each={PRO_FEATURES}>1554                      {(item, i) => (1555                        <div1556                          class="peach-pro-grid-item"1557                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1558                        >1559                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1560                          <span class="peach-pro-grid-name">{item.name}</span>1561                          <span class="peach-pro-grid-desc">{item.desc}</span>1562                        </div>1563                      )}1564                    </For>1565                  </div>15661567                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1568                  <div class="peach-pro-grid">1569                    <For each={PRO_SECURITY_FEATURES}>1570                      {(item, i) => (1571                        <div1572                          class="peach-pro-grid-item"1573                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1574                        >1575                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1576                          <span class="peach-pro-grid-name">{item.name}</span>1577                          <span class="peach-pro-grid-desc">{item.desc}</span>1578                        </div>1579                      )}1580                    </For>1581                  </div>15821583                  <div class="peach-pro-upgrade-actions">1584                    <p class="peach-pro-pricing">1585                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1586                    </p>1587                    <a1588                      href="https://peachpasswords.com/#pricing"1589                      target="_blank"1590                      rel="noopener noreferrer"1591                      class="peach-pro-upgrade-cta"1592                    >1593                      Get Peach Pro1594                    </a>1595                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1596                    <div class="peach-pro-key-inline">1597                      <input1598                        class="peach-pro-key-inline-input"1599                        type="text"1600                        placeholder="PEACH-XXXX-XXXX-XXXX"1601                        value={licenseKeyInput()}1602                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1603                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1604                        autocomplete="off"1605                        spellcheck={false}1606                      />1607                      <button1608                        class="peach-pro-key-apply"1609                        onClick={() => void onActivate()}1610                        disabled={activatingLicense() || !licenseKeyInput().trim()}1611                      >1612                        {activatingLicense() ? "…" : "APPLY"}1613                      </button>1614                    </div>1615                    <Show when={licenseError()}>1616                      <p class="peach-pro-key-error">{licenseError()}</p>1617                    </Show>1618                  </div>1619                </div>1620          }1621        >1622          <div class="peach-pro-active">1623            <div class="peach-pro-active-hero">1624              <div class="peach-pro-active-mark-wrap">1625                <PeachMark class="peach-pro-active-mark" />1626              </div>1627              <h2 class="peach-pro-active-title">Peach Pro</h2>1628              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1629            </div>16301631            <Show when={licenseKey()}>1632              <div class="peach-settings-license">1633                <span class="peach-settings-label">LICENSE KEY</span>1634                <div class="peach-settings-license-row">1635                  <Show1636                    when={showKey()}1637                    fallback={1638                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1639                        ••••••••••••••••••••••••••••1640                      </span>1641                    }1642                  >1643                    <span class="peach-settings-license-key">{licenseKey()!}</span>1644                  </Show>1645                  <div class="peach-settings-license-actions">1646                    <button1647                      class="peach-settings-license-copy"1648                      title="Copy license key"1649                      onClick={() => copyLicenseKey(licenseKey()!)}1650                    >1651                      {copied() ? "✓" : "copy"}1652                    </button>1653                    <button1654                      class="peach-settings-license-toggle"1655                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1656                      title={showKey() ? "Hide" : "Reveal"}1657                      onClick={() => setShowKey(v => !v)}1658                    >1659                      <Show1660                        when={showKey()}1661                        fallback={1662                          <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1663                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1664                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1665                          </svg>1666                        }1667                      >1668                        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1669                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1670                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1671                          <line x1="2" y1="2" x2="12" y2="12" />1672                        </svg>1673                      </Show>1674                    </button>1675                  </div>1676                </div>1677              </div>1678            </Show>16791680            <div class="peach-pro-active-features">1681              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1682                {(item) => (1683                  <div class="peach-pro-active-feature">1684                    <span class="peach-pro-active-feature-mark">✦</span>1685                    <span class="peach-pro-active-feature-name">{item.name}</span>1686                  </div>1687                )}1688              </For>1689            </div>16901691            <button1692              class="peach-pro-deactivate"1693              onClick={() => void handleDeactivateLicense()}1694              disabled={deactivating()}1695            >1696              {deactivating() ? "Deactivating…" : "Deactivate"}1697            </button>1698          </div>1699        </Show>1700      </div>1701    );1702  }17031704  function renderFeedback() {1705    return (1706      <div class="peach-settings-body">1707        <div class="peach-settings-group peach-feedback-group">1708          <Show1709            when={feedbackState() === 'sent'}1710            fallback={1711              <>1712                <p class="peach-feedback-intro">1713                  Help us improve Peach. Every report goes directly to our inbox.1714                </p>17151716                <p class="peach-settings-sub-label">TYPE</p>1717                <div class="peach-feedback-chips">1718                  <For each={FEEDBACK_CATEGORIES}>1719                    {(cat) => {1720                      const selected = () => fbCategory() === cat;1721                      return (1722                        <button1723                          type="button"1724                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1725                          aria-pressed={selected()}1726                          onClick={() => setFbCategory(cat)}1727                        >1728                          {cat}1729                        </button>1730                      );1731                    }}1732                  </For>1733                </div>17341735                <p class="peach-settings-sub-label">MESSAGE</p>1736                <textarea1737                  class="peach-feedback-textarea"1738                  placeholder="Describe the issue or idea..."1739                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1740                  value={fbMessage()}1741                  onInput={(e) => setFbMessage(e.currentTarget.value)}1742                />17431744                <p class="peach-feedback-meta">1745                  Version {version} · will be included automatically1746                </p>17471748                <button1749                  type="button"1750                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1751                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1752                  onClick={() => void handleSubmitFeedback()}1753                >1754                  <Switch>1755                    <Match when={feedbackState() === 'idle'}>Send</Match>1756                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1757                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1758                  </Switch>1759                </button>17601761                <p class="peach-feedback-anon">1762                  Sent anonymously · no account required1763                </p>1764              </>1765            }1766          >1767            <div class="peach-feedback-thanks" role="status" aria-live="polite">1768              <p class="peach-feedback-thanks-title">Thank you</p>1769              <p>1770                Your note is in the queue. Someone will process it soon.1771              </p>1772            </div>1773          </Show>1774        </div>1775      </div>1776    );1777  }17781779  const bodies: StackBodies = {1780    protection: renderSecurity(),1781    vault: renderVault(),1782    sync: renderSync(),1783    appearance: renderAppearance(),1784    shortcuts: renderShortcuts(),1785    pro: renderLicense(),1786    feedback: renderFeedback(),1787  };17881789  return (1790    <Show1791      when={showNewRecoveryWords()}1792      fallback={1793        <div1794          class="peach-overlay-view peach-settings-overlay"1795          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1796          style="display:flex;flex-direction:column"1797        >1798          <StackDeck1799            active={activeCard()}1800            onActiveChange={setActiveCard}1801            onBack={props.onBack}1802            cards={cards()}1803            bodies={bodies}1804            version={version}1805          />1806        </div>1807      }1808    >1809      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1810    </Show>1811  );1812}