Settings.tsx

69.5 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"];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 initialSectionToCard(value: string | null | undefined): CardId | null {89  if (!value) return null;90  if (value === "security") return "protection";91  if (value === "license") return "pro";92  if (93    value === "protection" ||94    value === "vault" ||95    value === "sync" ||96    value === "appearance" ||97    value === "shortcuts" ||98    value === "pro" ||99    value === "feedback"100  ) {101    return value;102  }103  return null;104}105106export function SettingsView(props: SettingsProps) {107  const manifest = chrome.runtime.getManifest();108  const version = manifest.version;109  const initial = props.initialSection;110  const [activeCard, setActiveCard] = createSignal<CardId>(111    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"112  );113114  createEffect(() => { props.onCardChange?.(activeCard()); });115116  const [checkingBreaches, setCheckingBreaches] = createSignal(false);117  const [breachResult, setBreachResult] = createSignal<string | null>(null);118  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);119  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");120  const [licenseError, setLicenseError] = createSignal<string | null>(null);121  const [, setLicenseSuccess] = createSignal<string | null>(null);122  const [activatingLicense, setActivatingLicense] = createSignal(false);123124  const isPro = () => props.isPro ?? false;125  const cards = createMemo<StackCardSpec[]>(() => [126    {127      id: "protection",128      num: "I · PROTECT",129      title: "Protection",130      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,131      bgVar: "--stack-protect-bg",132      fgVar: "--stack-protect-fg",133      icon: "shield",134    },135    {136      id: "vault",137      num: "II · KEEP",138      title: "Vault",139      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,140      bgVar: "--stack-vault-bg",141      fgVar: "--stack-vault-fg",142      icon: "lock",143    },144    {145      id: "sync",146      num: "III · ECHO",147      title: "Sync",148      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",149      bgVar: "--stack-sync-bg",150      fgVar: "--stack-sync-fg",151      icon: "sync",152    },153    {154      id: "appearance",155      num: "IV · DRESS",156      title: "Appearance",157      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,158      bgVar: "--stack-appear-bg",159      fgVar: "--stack-appear-fg",160      icon: "sun",161    },162    {163      id: "shortcuts",164      num: "V · KEYS",165      title: "Shortcuts",166      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,167      bgVar: "--stack-shortcut-bg",168      fgVar: "--stack-shortcut-fg",169      icon: "keys",170    },171    {172      id: "pro",173      num: "VI · FOREVER",174      title: "Peach Pro",175      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",176      bgVar: "--stack-pro-bg",177      fgVar: "--stack-pro-fg",178      icon: "spark",179    },180    {181      id: "feedback",182      num: "VII · ECHO",183      title: "Feedback",184      lede: `version ${version} · tell us what would make it better`,185      bgVar: "--stack-feedback-bg",186      fgVar: "--stack-feedback-fg",187      icon: "bubble",188    },189  ]);190  const [biometricSupported, setBiometricSupported] = createSignal(false);191  const [biometricStored, setBiometricStored] = createSignal(false);192  const [biometricBusy, setBiometricBusy] = createSignal(false);193  const [biometricError, setBiometricError] = createSignal<string | null>(null);194  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);195  const [passkeySaving, setPasskeySaving] = createSignal(false);196197  const [dedupCount, setDedupCount] = createSignal<number | null>(null);198  const [dedupRunning, setDedupRunning] = createSignal(false);199  const [dedupDone, setDedupDone] = createSignal<number | null>(null);200201  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);202203  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted204  // BEFORE every real-mode vault write so the user can roll back if a sync205  // or import overwrites the wrong state.206  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);207  const [historyLoading, setHistoryLoading] = createSignal(false);208  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);209  const [historyShowAll, setHistoryShowAll] = createSignal(false);210211  async function loadVaultHistory() {212    setHistoryLoading(true);213    try {214      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });215      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];216      setHistorySnapshots(list);217    } finally {218      setHistoryLoading(false);219    }220  }221222  async function handleRestoreVaultSnapshot(id: string) {223    if (historyRestoring()) return;224    setHistoryRestoring(id);225    const res = await sendMessage({226      type: "RESTORE_VAULT_SNAPSHOT",227      payload: { snapshotId: id },228    });229    setHistoryRestoring(null);230    if ((res as { ok: boolean }).ok) {231      // Vault is now locked — let the parent close Settings; App.tsx's232      // VAULT_LOCKED listener will route back to the lock screen.233      props.onBack();234    }235  }236237  function formatSnapshotTime(savedAt: number): string {238    const diff = Date.now() - savedAt;239    if (diff < 60_000) return "Just now";240    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;241    const d = new Date(savedAt);242    const isToday = new Date().toDateString() === d.toDateString();243    const isYest =244      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();245    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });246    if (isToday) return `Today ${t}`;247    if (isYest) return `Yesterday ${t}`;248    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;249  }250251  function reasonLabel(reason: VaultSnapshot["reason"]): string {252    return (253      {254        local_save: "saved",255        sync_received: "synced",256        import: "imported",257        restore: "restored",258      } as const259    )[reason] ?? reason;260  }261262  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);263264  const [showChangePw, setShowChangePw] = createSignal(false);265  const [changePwCurrent, setChangePwCurrent] = createSignal("");266  const [changePwNew, setChangePwNew] = createSignal("");267  const [changePwConfirm, setChangePwConfirm] = createSignal("");268  const [changePwSaving, setChangePwSaving] = createSignal(false);269  const [changePwError, setChangePwError] = createSignal<string | null>(null);270  const [changePwSuccess] = createSignal(false);271  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);272273  const [duressEnabled, setDuressEnabled] = createSignal(false);274  const [showDuressSetup, setShowDuressSetup] = createSignal(false);275  const [duressPassword, setDuressPassword] = createSignal("");276  const [duressConfirm, setDuressConfirm] = createSignal("");277  const [duressMainPassword, setDuressMainPassword] = createSignal("");278  const [duressSaving, setDuressSaving] = createSignal(false);279  const [duressError, setDuressError] = createSignal<string | null>(null);280  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);281282  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);283284  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);285  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);286287  const [fbCategory, setFbCategory] = createSignal("Bug report");288  const [fbMessage, setFbMessage] = createSignal("");289  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';290  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');291292  createEffect(() => {293    if (props.upgradeHighlight) {294      setActiveCard("pro");295    }296  });297298  onMount(async () => {299    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([300      sendMessage({ type: "GET_LICENSE" }),301      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),302      sendMessage({ type: "IS_DURESS_ENABLED" }),303      sendMessage({ type: "GET_SETTINGS" }),304      sendMessage({ type: "GET_LICENSE_KEY" }),305    ]);306307    // Vault history loads in the background — the Vault sub-view picks it up308    // when the user navigates in. Loading it lazily keeps Settings boot fast.309    void loadVaultHistory();310311    if ((licenseRes as { ok: boolean }).ok) {312      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);313    }314315    if ((passkeyRes as { ok: boolean }).ok) {316      setPasskeyState(317        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null318      );319    }320321    if ((duressRes as { ok: boolean }).ok) {322      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);323    }324325    if ((settingsRes as { ok: boolean }).ok) {326      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;327      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);328    }329330    if ((keyRes as { ok: boolean }).ok) {331      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);332    }333334    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });335    if ((entriesRes as { ok: boolean }).ok) {336      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];337      setCanaryEntries(338        entries339          .filter((e) => e.canary?.webhookUrl)340          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))341      );342    }343344345    if (isNativePlatform()) {346      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");347      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");348      try {349        if (biometric) {350          const a = await biometric.isAvailable();351          setBiometricSupported(a.available);352        }353        if (keystore) {354          const k = await keystore.hasMasterKey();355          setBiometricStored(k.exists);356        }357      } catch { /* missing plugins — leave defaults */ }358    }359  });360361  async function handleEnableBiometrics() {362    setBiometricBusy(true);363    setBiometricError(null);364    try {365      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {366        ok: boolean;367        data?: { masterKeyHex: string };368        error?: string;369      };370      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {371        setBiometricError(keyRes.error === "vault_locked"372          ? "unlock the vault first"373          : "couldn't read derived key");374        return;375      }376      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");377      if (!keystore) {378        setBiometricError("biometric plugin unavailable");379        return;380      }381      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });382      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });383      setBiometricStored(true);384    } catch (e) {385      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");386    } finally {387      setBiometricBusy(false);388    }389  }390391  async function handleDisableBiometrics() {392    setBiometricBusy(true);393    setBiometricError(null);394    try {395      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");396      if (keystore) await keystore.clearMasterKey();397      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });398      setBiometricStored(false);399    } catch (e) {400      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");401    } finally {402      setBiometricBusy(false);403    }404  }405406  async function handleChangePassword() {407    setChangePwError(null);408    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }409    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }410    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }411    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }412    setChangePwSaving(true);413    const res = await sendMessage({414      type: "CHANGE_PASSWORD",415      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },416    }) as { ok: boolean; error?: string };417    if (res.ok) {418      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");419      setShowChangePw(false);420      setShowNewRecoveryWords(true);421    } else {422      setChangePwError(res.error ?? "failed to change password");423    }424    setChangePwSaving(false);425  }426427  const [copied, setCopied] = createSignal(false);428429  async function copyLicenseKey(key: string) {430    await navigator.clipboard.writeText(key);431    setCopied(true);432    setTimeout(() => setCopied(false), 2000);433  }434435  async function handleActivateLicense() {436    const key = licenseKeyInput().trim();437    if (!key) return;438    setActivatingLicense(true);439    setLicenseError(null);440    setLicenseSuccess(null);441    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });442    setActivatingLicense(false);443    if ((res as { ok: boolean }).ok) {444      const data = (res as { ok: boolean; data?: LicenseInfo }).data;445      if (data?.valid) {446        setLicenseInfo(data);447        setLicenseKey(key);448        setLicenseSuccess("License activated!");449        setLicenseKeyInput("");450      } else {451        setLicenseError("Invalid license key");452      }453    } else {454      setLicenseError("Activation failed");455    }456  }457458  async function handleTogglePasskeys() {459    const current = passkeyState();460    if (!current || !current.supported) return;461    setPasskeySaving(true);462    const res = await sendMessage({463      type: "SET_PASSKEY_PROVIDER_ENABLED",464      payload: { enabled: !current.enabled },465    });466    setPasskeySaving(false);467    if ((res as { ok: boolean }).ok) {468      setPasskeyState(469        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null470      );471    }472  }473474  async function handleCheckAllBreaches() {475    setCheckingBreaches(true);476    setBreachResult(null);477    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });478    setCheckingBreaches(false);479    if ((res as { ok: boolean }).ok) {480      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};481      const breached = Object.values(data).filter((count) => count > 0).length;482      setBreachResult(483        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"484      );485      props.onBreachCheckDone();486    }487  }488489490  async function handleSetupDuress() {491    const mainPw = duressMainPassword().trim();492    const duressPw = duressPassword().trim();493    const confirmPw = duressConfirm().trim();494495    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }496    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }497    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }498    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }499500    setDuressSaving(true);501    setDuressError(null);502503    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };504    if (!verifyRes.ok || !verifyRes.data) {505      setDuressError("incorrect main password");506      setDuressSaving(false);507      return;508    }509510    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };511    setDuressSaving(false);512    if (res.ok) {513      setDuressEnabled(true);514      setShowDuressSetup(false);515      setDuressPassword("");516      setDuressConfirm("");517      setDuressMainPassword("");518      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");519      setTimeout(() => setDuressSuccess(null), 5000);520    } else {521      setDuressError(522        res.error === "session_expired"523          ? "Vault session expired — lock and unlock Peach, then try again"524          : res.error ?? "failed to set duress password"525      );526    }527  }528529  async function handleClearDeclinedCards() {530    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });531    setDeclinedCardCount(0);532  }533534  async function handleCountDuplicates() {535    setDedupRunning(true);536    setDedupDone(null);537    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };538    setDedupRunning(false);539    if (res.ok) setDedupCount(res.data ?? 0);540  }541542  async function handleDeduplicate() {543    setDedupRunning(true);544    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };545    setDedupRunning(false);546    if (res.ok) {547      setDedupDone(res.data ?? 0);548      setDedupCount(0);549    }550  }551552  async function handleDisableDuress() {553    const mainPw = duressMainPassword().trim();554    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }555556    setDuressSaving(true);557    setDuressError(null);558559    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };560    if (!verifyRes.ok || !verifyRes.data) {561      setDuressError("incorrect main password");562      setDuressSaving(false);563      return;564    }565566    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };567    setDuressSaving(false);568    if (res.ok) {569      setDuressEnabled(false);570      setShowDuressSetup(false);571      setDuressMainPassword("");572      setDuressSuccess("Duress password disabled.");573      setTimeout(() => setDuressSuccess(null), 4000);574    } else {575      setDuressError(res.error ?? "failed to disable duress password");576    }577  }578579  async function handleSubmitFeedback() {580    if (!fbMessage().trim()) return;581582    setFeedbackState('sending');583584    try {585      const res = await fetch('https://api.web3forms.com/submit', {586        method: 'POST',587        headers: { 'Content-Type': 'application/json' },588        body: JSON.stringify({589          access_key: WEB3FORMS_KEY,590          subject: `Peach Feedback — ${fbCategory()}`,591          message: fbMessage(),592          from_name: 'Peach User',593          // Honeypot — leave blank594          botcheck: '',595        }),596      });597598      if (!res.ok) throw new Error(`HTTP ${res.status}`);599      const data = await res.json() as { success: boolean; message?: string };600      if (!data.success) throw new Error(data.message ?? 'unknown error');601602      setFeedbackState('sent');603      setFbMessage('');604      setFbCategory(FEEDBACK_CATEGORIES[0]);605      setTimeout(() => setFeedbackState('idle'), 4000);606    } catch (err) {607      console.error('[feedback]', err);608      setFeedbackState('error');609      setTimeout(() => setFeedbackState('idle'), 4000);610    }611  }612613  function renderSecurity() {614    return (615      <div class="peach-settings-body">616        <div class="peach-settings-group">617          <p class="peach-settings-sub-label">LOCKING</p>618          <div class="peach-settings-row">619            <span>Lock on close</span>620            <button621              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}622              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}623              aria-label="Toggle lock on close"624            >625              <span class="peach-toggle-knob"></span>626            </button>627          </div>628          <div class="peach-settings-row">629            <span>Auto-lock after</span>630            <select631              class="peach-settings-select"632              value={String(props.autoLockMinutes)}633              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}634            >635              <option value="1">1 minute</option>636              <option value="5">5 minutes</option>637              <option value="15">15 minutes</option>638              <option value="30">30 minutes</option>639              <option value="0">Never</option>640            </select>641          </div>642643          <Show when={isNativePlatform() && biometricSupported()}>644            <p class="peach-settings-sub-label">BIOMETRICS</p>645            <div class="peach-settings-row">646              <span>Unlock with biometrics</span>647              <button648                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}649                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}650                disabled={biometricBusy()}651                aria-label="Toggle biometric unlock"652              >653                <span class="peach-toggle-knob"></span>654              </button>655            </div>656            <Show when={biometricError()}>657              <div class="peach-settings-row peach-settings-row--meta">658                <span class="peach-hint peach-hint-error">{biometricError()}</span>659              </div>660            </Show>661          </Show>662663          <p class="peach-settings-sub-label">BREACH PROTECTION</p>664          <div class="peach-settings-row">665            <span>Breach monitoring</span>666            <button667              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}668              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}669              aria-label="Toggle breach monitoring"670            >671              <span class="peach-toggle-knob"></span>672            </button>673          </div>674          <div class="peach-settings-row peach-settings-row--meta">675            <span>Check all passwords</span>676            <button677              class="peach-btn peach-btn--subtle peach-btn--sm"678              onClick={() => void handleCheckAllBreaches()}679              disabled={checkingBreaches()}680            >681              {checkingBreaches() ? "checking..." : "run"}682            </button>683          </div>684          <Show when={breachResult()}>685            <div class="peach-settings-row peach-settings-row--meta">686              <span>Last check</span>687              <span class="peach-settings-value">{breachResult()}</span>688            </div>689          </Show>690691          <p class="peach-settings-sub-label">MASTER PASSWORD</p>692          <div class="peach-settings-row peach-settings-row--meta">693            <span>Master password</span>694            <button695              class="peach-btn peach-btn--subtle peach-btn--sm"696              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}697            >698              {showChangePw() ? "cancel" : "change"}699            </button>700          </div>701          <Show when={showChangePw()}>702            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">703              <input704                class="peach-settings-input"705                type="password"706                placeholder="current password"707                value={changePwCurrent()}708                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}709                autocomplete="current-password"710              />711              <input712                class="peach-settings-input"713                type="password"714                placeholder="new password (min 12 chars)"715                value={changePwNew()}716                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}717                autocomplete="new-password"718              />719              <input720                class="peach-settings-input"721                type="password"722                placeholder="confirm new password"723                value={changePwConfirm()}724                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}725                autocomplete="new-password"726                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}727              />728              <Show when={changePwError()}>729                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>730              </Show>731              <Show when={changePwSuccess()}>732                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>733              </Show>734              <button735                class="peach-btn peach-btn--accent peach-btn--sm"736                style="align-self:flex-start"737                onClick={() => void handleChangePassword()}738                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}739              >740                {changePwSaving() ? "saving…" : "save new password"}741              </button>742            </div>743          </Show>744745          <p class="peach-settings-sub-label">746            DURESS PASSWORD747            <Show when={!isPro()}>748              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>749            </Show>750          </p>751          <div class="peach-settings-row peach-settings-row--top">752            <div class="peach-settings-row-main">753              <span>754                Duress password755                <Show when={!isPro()}>756                  <span class="peach-pro-badge">Pro</span>757                </Show>758              </span>759              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">760                If entered under coercion, shows a decoy empty vault instead of your real one.761              </span>762            </div>763            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>764              {duressEnabled() ? "on" : "off"}765            </div>766          </div>767768          <Show when={duressEnabled() && !showDuressSetup()}>769            <div class="peach-settings-row peach-settings-row--meta">770              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">771                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.772              </span>773            </div>774          </Show>775776          <Show when={duressSuccess()}>777            <div class="peach-settings-row peach-settings-row--meta">778              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>779            </div>780          </Show>781782          <Show when={!showDuressSetup()}>783            <div class="peach-settings-row peach-settings-row--meta">784              <button785                class="peach-btn peach-btn--subtle peach-btn--sm"786                onClick={() => {787                  // Pro gate entry-point: free users are redirected to the788                  // upgrade screen before the setup form opens. The server-789                  // side SETUP_DURESS handler is the single enforcement790                  // point; this is UI redirection so the form never appears791                  // for a user who can't complete it.792                  if (!isPro() && !duressEnabled()) {793                    props.onProGate?.("duress_vault");794                    setActiveCard("pro");795                    return;796                  }797                  setShowDuressSetup(true);798                  setDuressError(null);799                  setDuressSuccess(null);800                  setDuressPassword("");801                  setDuressConfirm("");802                  setDuressMainPassword("");803                }}804              >805                {duressEnabled() ? "Change / disable" : "Set up"}806              </button>807            </div>808          </Show>809810          <Show when={showDuressSetup()}>811            <div class="peach-duress-form">812              <label class="peach-label">main password (to confirm)</label>813              <input814                class="peach-settings-input"815                type="password"816                placeholder="your current master password"817                autocomplete="current-password"818                value={duressMainPassword()}819                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}820              />821              <Show when={!duressEnabled()}>822                <label class="peach-label">new duress password</label>823                <input824                  class="peach-settings-input"825                  type="password"826                  placeholder="duress password (min 8 chars)"827                  autocomplete="new-password"828                  value={duressPassword()}829                  onInput={(e) => setDuressPassword(e.currentTarget.value)}830                />831                <label class="peach-label">confirm duress password</label>832                <input833                  class="peach-settings-input"834                  type="password"835                  placeholder="confirm duress password"836                  autocomplete="new-password"837                  value={duressConfirm()}838                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}839                />840              </Show>841842              <Show when={duressError()}>843                <p class="peach-hint peach-hint-error">{duressError()}</p>844              </Show>845846              <div class="peach-duress-actions">847                <button848                  class="peach-btn peach-btn--subtle peach-btn--sm"849                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}850                >851                  cancel852                </button>853                <Show when={duressEnabled()}>854                  <button855                    class="peach-btn peach-btn--ghost peach-btn--sm"856                    style="color:var(--peach-breach)"857                    onClick={() => void handleDisableDuress()}858                    disabled={duressSaving()}859                  >860                    {duressSaving() ? "disabling..." : "disable duress"}861                  </button>862                </Show>863                <Show when={!duressEnabled()}>864                  <button865                    class="peach-btn peach-btn--accent peach-btn--sm"866                    onClick={() => void handleSetupDuress()}867                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}868                  >869                    {duressSaving() ? "saving..." : "save"}870                  </button>871                </Show>872              </div>873            </div>874          </Show>875876          <p class="peach-settings-sub-label">877            CANARY ENTRIES878            <Show when={!isPro()}>879              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>880            </Show>881          </p>882          <div class="peach-settings-row peach-settings-row--top">883            <div class="peach-settings-row-main">884              <span>885                Canary entries886                <Show when={!isPro()}>887                  <span class="peach-pro-badge">Pro</span>888                </Show>889              </span>890              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">891                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.892              </span>893            </div>894            <span class="peach-settings-value" style="flex-shrink:0">895              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}896            </span>897          </div>898          <Show when={canaryEntries().length > 0}>899            <For each={canaryEntries()}>900              {(entry) => (901                <div class="peach-settings-row peach-settings-row--meta">902                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>903                  <button904                    class="peach-btn peach-btn--subtle peach-btn--sm"905                    onClick={() => void handleTestCanary(entry)}906                    disabled={canaryTestingId() === entry.id}907                    style="flex-shrink:0"908                  >909                    {canaryTestingId() === entry.id910                      ? "sending…"911                      : canaryTestResult()?.id === entry.id912                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")913                        : "test"}914                  </button>915                </div>916              )}917            </For>918          </Show>919920          <p class="peach-settings-sub-label">PASSKEYS</p>921          <div class="peach-settings-row peach-settings-row--top">922            <div class="peach-settings-row-main">923              <span>Intercept passkey requests</span>924              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">925                Peach can handle passkey sign-in and creation on supported Chromium browsers.926              </span>927            </div>928            <button929              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}930              onClick={() => void handleTogglePasskeys()}931              aria-label="Toggle passkey interception"932              disabled={!passkeyState()?.supported || passkeySaving()}933            >934              <span class="peach-toggle-knob"></span>935            </button>936          </div>937          <div class="peach-settings-row peach-settings-row--meta">938            <span>Status</span>939            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>940          </div>941          <div class="peach-settings-row peach-settings-row--meta">942            <span>Browser</span>943            <span class="peach-settings-value">944              {passkeyState()?.browserLabel ?? "Unknown"}945              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}946            </span>947          </div>948          <div class="peach-info-notice" style="margin-top:8px">949            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.950          </div>951          <Show when={passkeyState()?.conflict}>952            <div class="peach-settings-row peach-settings-row--meta">953              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>954            </div>955          </Show>956        </div>957      </div>958    );959  }960961  function renderVault() {962    return (963      <div class="peach-settings-body">964        <div class="peach-settings-group">965          <p class="peach-settings-sub-label">ENTRIES</p>966          <div class="peach-settings-row peach-settings-row--meta">967            <span>Entries</span>968            <span class="peach-settings-value">{props.entriesCount}</span>969          </div>970          <div class="peach-settings-row peach-settings-row--meta">971            <span>Duplicates</span>972            <Show when={dedupDone() !== null} fallback={973              <button974                class="peach-btn peach-btn--subtle peach-btn--sm"975                onClick={() => void handleCountDuplicates()}976                disabled={dedupRunning()}977              >978                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}979              </button>980            }>981              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">982                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}983              </span>984            </Show>985          </div>986          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>987            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">988              <div class="peach-restore-warning">989                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.990              </div>991              <div style="display:flex;gap:6px">992                <button993                  class="peach-btn peach-btn--subtle peach-btn--sm"994                  onClick={() => setDedupCount(null)}995                >996                  cancel997                </button>998                <button999                  class="peach-btn peach-btn--accent peach-btn--sm"1000                  onClick={() => void handleDeduplicate()}1001                  disabled={dedupRunning()}1002                >1003                  {dedupRunning() ? "removing..." : "remove duplicates"}1004                </button>1005              </div>1006            </div>1007          </Show>10081009          <p class="peach-settings-sub-label">MAINTENANCE</p>1010          <div class="peach-settings-row peach-settings-row--meta">1011            <span>Trash auto-delete</span>1012            <select1013              class="peach-settings-select"1014              value={String(props.trashRetentionDays)}1015              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1016            >1017              <option value="7">7 days</option>1018              <option value="30">30 days</option>1019              <option value="90">90 days</option>1020              <option value="0">Never</option>1021            </select>1022          </div>1023          <div class="peach-settings-row peach-settings-row--meta">1024            <span>Vault Health</span>1025            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1026              open1027            </button>1028          </div>1029          <Show when={declinedCardCount() > 0}>1030            <div class="peach-settings-row peach-settings-row--meta">1031              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1032              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1033                Clear1034              </button>1035            </div>1036          </Show>10371038          <p class="peach-settings-sub-label">DATA</p>1039          <div class="peach-settings-row peach-settings-row--meta">1040            <span>Import passwords</span>1041            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1042              open1043            </button>1044          </div>1045          <div class="peach-settings-row peach-settings-row--meta">1046            <span>Export backup</span>1047            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1048              open1049            </button>1050          </div>1051          <div class="peach-settings-row peach-settings-row--meta">1052            <span>1053              Peach Codex1054              <Show when={!isPro()}>1055                <span class="peach-pro-badge">Pro</span>1056              </Show>1057            </span>1058            <button1059              class="peach-btn peach-btn--subtle peach-btn--sm"1060              onClick={() => props.onOpenCodexGenerate?.()}1061            >1062              open1063            </button>1064          </div>1065          {/* Restore from Codex: intentionally never gated — a printed1066              Codex must remain restorable regardless of license state. */}1067          <div class="peach-settings-row peach-settings-row--meta">1068            <span>Restore from Codex</span>1069            <button1070              class="peach-btn peach-btn--subtle peach-btn--sm"1071              onClick={() => props.onOpenCodexRestore?.()}1072            >1073              open1074            </button>1075          </div>10761077          <span class="peach-settings-sub-label">VAULT HISTORY</span>1078          <Show when={historyLoading()}>1079            <p class="peach-settings-hint">Loading…</p>1080          </Show>1081          <Show when={!historyLoading() && historySnapshots().length === 0}>1082            <p class="peach-settings-hint">1083              No snapshots yet. Peach saves automatically before every change —1084              saves, syncs, and imports.1085            </p>1086          </Show>1087          <Show when={!historyLoading() && historySnapshots().length > 0}>1088            <p class="peach-settings-hint">1089              Restoring locks your vault. Unlock with the password that1090              encrypted that snapshot to confirm.1091            </p>1092            <div class="peach-history-list">1093              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1094                {(snap) => (1095                  <div class="peach-history-row">1096                    <div class="peach-history-meta">1097                      <span class="peach-history-time">1098                        {formatSnapshotTime(snap.savedAt)}1099                      </span>1100                      <span class="peach-history-sub">1101                        {[1102                          snap.entryCount !== null1103                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1104                            : null,1105                          reasonLabel(snap.reason),1106                        ]1107                          .filter(Boolean)1108                          .join(" · ")}1109                      </span>1110                    </div>1111                    <button1112                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1113                        historyRestoring() === snap.id ? " is-busy" : ""1114                      }`}1115                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1116                      disabled={historyRestoring() !== null}1117                    >1118                      <Show when={historyRestoring() === snap.id} fallback="Restore">1119                        …1120                      </Show>1121                    </button>1122                  </div>1123                )}1124              </For>1125            </div>1126            <Show when={historySnapshots().length > 3}>1127              <button1128                class="peach-settings-advanced-toggle"1129                onClick={() => setHistoryShowAll((v) => !v)}1130                aria-expanded={historyShowAll()}1131              >1132                <span class="peach-settings-hint" style="margin:0">1133                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1134                </span>1135                <span class="peach-settings-advanced-chevron">1136                  {historyShowAll() ? "▲" : "▼"}1137                </span>1138              </button>1139            </Show>1140            <p class="peach-history-footer">1141              {historySnapshots().length} of 10 snapshots1142            </p>1143          </Show>11441145          <button1146            class="peach-settings-advanced-toggle"1147            onClick={() => setShowVaultAdvanced((v) => !v)}1148            aria-expanded={showVaultAdvanced()}1149          >1150            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1151            <span class="peach-settings-advanced-chevron">1152              {showVaultAdvanced() ? "▲" : "▼"}1153            </span>1154          </button>11551156          <Show when={showVaultAdvanced()}>1157            <div class="peach-danger-card">1158              <div class="peach-danger-card-head">1159                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1160                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1161              </div>1162              <p class="peach-danger-card-copy">1163                Wipes the current vault and restores from a backup file.1164                Recoverable only if your backup is good.1165              </p>1166              <button1167                class="peach-danger-card-button"1168                onClick={() => props.onOpenFileImport?.()}1169              >1170                Choose backup file1171              </button>1172            </div>11731174            <Show when={props.onOpenPanic}>1175              <div class="peach-danger-card peach-danger-card--hazard">1176                <div class="peach-danger-card-head">1177                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1178                  <h3 class="peach-danger-card-title">Destroy vault</h3>1179                </div>1180                <p class="peach-danger-card-copy">1181                  Permanently wipes every entry, setting, and license on this1182                  device. There is no recovery.1183                </p>1184                <button1185                  class="peach-danger-card-button"1186                  onClick={() => props.onOpenPanic?.()}1187                >1188                  Begin destruction1189                </button>1190              </div>1191            </Show>1192          </Show>1193        </div>1194      </div>1195    );1196  }11971198  function renderSync() {1199    return (1200      <div class="peach-settings-body">1201        <div class="peach-settings-group">1202          <p class="peach-settings-sub-label">PAIRING</p>1203          <div class="peach-settings-row peach-settings-row--top">1204            <div class="peach-settings-row-main">1205              <span>1206                Peach Sync1207                <Show when={!isPro()}>1208                  <span class="peach-pro-badge">Pro</span>1209                </Show>1210              </span>1211              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1212                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1213              </span>1214            </div>1215            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1216          </div>1217          <div class="peach-settings-row peach-settings-row--meta">1218            <span>Status</span>1219            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1220          </div>1221          <div class="peach-settings-row peach-settings-row--meta">1222            <span>Sync hub</span>1223            <button1224              class="peach-btn peach-btn--subtle peach-btn--sm"1225              onClick={() => props.onOpenSyncPairing?.()}1226            >1227              open1228            </button>1229          </div>12301231          <p class="peach-settings-sub-label">DEVICE FLOW</p>1232          <div class="peach-settings-row peach-settings-row--top">1233            <div class="peach-settings-row-main">1234              <span>Pair new device</span>1235              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1236                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1237              </span>1238            </div>1239            <button1240              class="peach-btn peach-btn--subtle peach-btn--sm"1241              onClick={() => props.onOpenSyncPairing?.()}1242            >1243              pair1244            </button>1245          </div>1246          <div class="peach-info-notice" style="margin-top:8px">1247            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1248          </div>1249        </div>1250      </div>1251    );1252  }12531254  function renderAppearance() {1255    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1256      { id: "medium", label: "Default" },1257      { id: "large", label: "Large" },1258    ];1259    return (1260      <div class="peach-settings-body">1261        <div class="peach-settings-group">1262          <p class="peach-settings-sub-label">THEME</p>1263          <div class="peach-settings-row peach-settings-row--meta">1264            <span>Theme</span>1265            <button1266              class="peach-btn peach-btn--subtle peach-btn--sm"1267              onClick={() =>1268                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1269              }1270            >1271              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1272            </button>1273          </div>12741275          <p class="peach-settings-sub-label">ACCENT</p>1276          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1277            <span>Accent</span>1278            <div class="peach-accent-picker">1279              <For each={ACCENT_COLORS}>1280                {(color) => (1281                  <button1282                    class="peach-accent-swatch peach-swatch"1283                    classList={{ "is-active": props.accentColor === color.name }}1284                    style={{ background: color.hex }}1285                    onClick={() => void props.onAccentChange(color.name)}1286                    aria-label={color.name}1287                    title={color.name}1288                  />1289                )}1290              </For>1291            </div>1292          </div>12931294          <p class="peach-settings-sub-label">TEXT SIZE</p>1295          <div class="peach-settings-row peach-settings-row--meta">1296            <span>Size</span>1297            <div class="peach-pill-group">1298              <For each={sizeOptions}>1299                {(opt) => (1300                  <button1301                    class="peach-btn peach-btn--ghost peach-btn--sm"1302                    classList={{ "is-active": props.textSize === opt.id }}1303                    onClick={() => void props.onTextSizeChange(opt.id)}1304                  >1305                    {opt.label}1306                  </button>1307                )}1308              </For>1309            </div>1310          </div>13111312          <p class="peach-settings-sub-label">MOTION</p>1313          <div class="peach-settings-row peach-settings-row--meta">1314            <span>Reduce motion</span>1315            <button1316              class="peach-btn peach-btn--subtle peach-btn--sm"1317              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1318            >1319              {props.reduceMotion ? "On" : "Off"}1320            </button>1321          </div>1322        </div>1323      </div>1324    );1325  }13261327  function renderShortcuts() {1328    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1329    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13301331    function startCapture(action: ShortcutAction) {1332      setConflictMsg(null);1333      setCapturingAction(action);1334    }1335    function cancelCapture() {1336      setCapturingAction(null);1337      setConflictMsg(null);1338    }1339    function commitBinding(action: ShortcutAction, binding: Keybinding) {1340      const conflict = findConflict(props.shortcuts, binding, action);1341      if (conflict) {1342        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1343        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1344        return;1345      }1346      const next = { ...props.shortcuts, [action]: binding };1347      setCapturingAction(null);1348      setConflictMsg(null);1349      void props.onShortcutsChange(next);1350    }1351    function resetAction(action: ShortcutAction) {1352      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1353      void props.onShortcutsChange(next);1354    }1355    function resetAll() {1356      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1357    }13581359    // Capture-mode key handler — wired via ref onto the chip element so it1360    // catches the next non-modifier keypress and ignores other bindings.1361    function handleCaptureKey(e: KeyboardEvent) {1362      e.preventDefault();1363      e.stopPropagation();1364      if (e.key === "Escape") { cancelCapture(); return; }1365      const binding = keybindingFromEvent(e);1366      if (!binding) return; // bare modifier — keep waiting1367      const action = capturingAction();1368      if (action) commitBinding(action, binding);1369    }13701371    return (1372      <div class="peach-settings-body">1373        <div class="peach-settings-group peach-shortcuts-group">1374          <p class="peach-settings-sub-label">ENTRY VIEW</p>1375          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1376            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1377          </For>1378        </div>13791380        <div class="peach-settings-group peach-shortcuts-group">1381          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1382          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1383            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1384          </For>1385        </div>13861387        <Show when={conflictMsg()}>1388          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1389        </Show>13901391        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1392          <div class="peach-settings-row peach-settings-row--meta">1393            <span>Inline autofill on page</span>1394            <a1395              class="peach-settings-link"1396              href="chrome://extensions/shortcuts"1397              onClick={(e) => {1398                e.preventDefault();1399                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1400              }}1401            >1402              ⌘⇧P — customize ↗1403            </a>1404          </div>1405          <button1406            class="peach-btn peach-btn--subtle peach-btn--sm"1407            style="align-self:flex-start"1408            onClick={resetAll}1409          >1410            Reset all to defaults1411          </button>1412        </div>1413      </div>1414    );14151416    function renderRow(1417      desc: typeof SHORTCUT_DESCRIPTORS[number],1418      capturing: () => ShortcutAction | null,1419      onStart: (a: ShortcutAction) => void,1420      onCancel: () => void,1421      onReset: (a: ShortcutAction) => void,1422      onKey: (e: KeyboardEvent) => void,1423    ) {1424      const isActive = () => capturing() === desc.action;1425      const binding = () => props.shortcuts[desc.action];1426      const isCustom = () => {1427        const b = binding();1428        const d = DEFAULT_BINDINGS[desc.action];1429        return b.key !== d.key1430          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1431          || Boolean(b.meta)  !== Boolean(d.meta)1432          || Boolean(b.shift) !== Boolean(d.shift)1433          || Boolean(b.alt)   !== Boolean(d.alt);1434      };1435      return (1436        <div class="peach-shortcut-row">1437          <span class="peach-shortcut-row-label">{desc.label}</span>1438          <button1439            class="peach-shortcut-chip"1440            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1441            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1442            onKeyDown={(e) => { if (isActive()) onKey(e); }}1443            ref={(el) => {1444              // Auto-focus when entering capture mode so the keydown listener catches the next press.1445              if (el) {1446                queueMicrotask(() => { if (isActive()) el.focus(); });1447              }1448            }}1449          >1450            {isActive() ? "Press a key…" : formatKeybinding(binding())}1451          </button>1452          <Show when={isCustom() && !isActive()}>1453            <button1454              class="peach-shortcut-reset"1455              title="Reset to default"1456              onClick={() => onReset(desc.action)}1457            >1458              ↺1459            </button>1460          </Show>1461        </div>1462      );1463    }1464  }14651466  const PRO_FEATURES: Array<{1467    feature: ProFeature;1468    name: string;1469    desc: string;1470  }> = [1471    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1472    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1473    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1474    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1475    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1476  ];14771478  const PRO_SECURITY_FEATURES: Array<{1479    feature: ProFeature;1480    name: string;1481    desc: string;1482  }> = [1483    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1484    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1485  ];14861487  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1488  const [showKey, setShowKey] = createSignal(false);14891490  const [deactivating, setDeactivating] = createSignal(false);14911492  async function handleDeactivateLicense() {1493    setDeactivating(true);1494    await sendMessage({ type: "DEACTIVATE_LICENSE" });1495    setLicenseInfo(null);1496    await props.onLicenseChange?.();1497    setDeactivating(false);1498  }14991500  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1501    setCanaryTestingId(entry.id);1502    setCanaryTestResult(null);1503    const res = await sendMessage({1504      type: "FIRE_CANARY_PING",1505      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1506    }) as { ok: boolean };1507    setCanaryTestResult({ id: entry.id, ok: res.ok });1508    setCanaryTestingId(null);1509    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1510  }15111512  function renderLicense() {1513    async function onActivate() {1514      await handleActivateLicense();1515      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1516        await props.onLicenseChange?.();1517      }1518    }15191520    return (1521      <div class="peach-settings-body">1522        <Show1523          when={isPro()}1524          fallback={1525                <div class="peach-pro-upgrade">1526                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15271528                  <h2 class="peach-pro-headline">1529                    Every<em>thing,</em><br />every<em>where.</em>1530                  </h2>15311532                  <p class="peach-pro-subtitle">1533                    One key unlocks every Pro feature — for the lifetime of the app.1534                  </p>15351536                  <div class="peach-pro-grid">1537                    <For each={PRO_FEATURES}>1538                      {(item, i) => (1539                        <div1540                          class="peach-pro-grid-item"1541                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1542                        >1543                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1544                          <span class="peach-pro-grid-name">{item.name}</span>1545                          <span class="peach-pro-grid-desc">{item.desc}</span>1546                        </div>1547                      )}1548                    </For>1549                  </div>15501551                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1552                  <div class="peach-pro-grid">1553                    <For each={PRO_SECURITY_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(PRO_FEATURES.length + 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                  <div class="peach-pro-upgrade-actions">1568                    <p class="peach-pro-pricing">1569                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1570                    </p>1571                    <a1572                      href="https://peachpasswords.com/#pricing"1573                      target="_blank"1574                      rel="noopener noreferrer"1575                      class="peach-pro-upgrade-cta"1576                    >1577                      Get Peach Pro1578                    </a>1579                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1580                    <div class="peach-pro-key-inline">1581                      <input1582                        class="peach-pro-key-inline-input"1583                        type="text"1584                        placeholder="PEACH-XXXX-XXXX-XXXX"1585                        value={licenseKeyInput()}1586                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1587                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1588                        autocomplete="off"1589                        spellcheck={false}1590                      />1591                      <button1592                        class="peach-pro-key-apply"1593                        onClick={() => void onActivate()}1594                        disabled={activatingLicense() || !licenseKeyInput().trim()}1595                      >1596                        {activatingLicense() ? "…" : "APPLY"}1597                      </button>1598                    </div>1599                    <Show when={licenseError()}>1600                      <p class="peach-pro-key-error">{licenseError()}</p>1601                    </Show>1602                  </div>1603                </div>1604          }1605        >1606          <div class="peach-pro-active">1607            <div class="peach-pro-active-hero">1608              <div class="peach-pro-active-mark-wrap">1609                <PeachMark class="peach-pro-active-mark" />1610              </div>1611              <h2 class="peach-pro-active-title">Peach Pro</h2>1612              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1613            </div>16141615            <Show when={licenseKey()}>1616              <div class="peach-settings-license">1617                <span class="peach-settings-label">LICENSE KEY</span>1618                <div class="peach-settings-license-row">1619                  <Show1620                    when={showKey()}1621                    fallback={1622                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1623                        ••••••••••••••••••••••••••••1624                      </span>1625                    }1626                  >1627                    <span class="peach-settings-license-key">{licenseKey()!}</span>1628                  </Show>1629                  <div class="peach-settings-license-actions">1630                    <button1631                      class="peach-settings-license-copy"1632                      title="Copy license key"1633                      onClick={() => copyLicenseKey(licenseKey()!)}1634                    >1635                      {copied() ? "✓" : "copy"}1636                    </button>1637                    <button1638                      class="peach-settings-license-toggle"1639                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1640                      title={showKey() ? "Hide" : "Reveal"}1641                      onClick={() => setShowKey(v => !v)}1642                    >1643                      <Show1644                        when={showKey()}1645                        fallback={1646                          <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">1647                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1648                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1649                          </svg>1650                        }1651                      >1652                        <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">1653                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1654                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1655                          <line x1="2" y1="2" x2="12" y2="12" />1656                        </svg>1657                      </Show>1658                    </button>1659                  </div>1660                </div>1661              </div>1662            </Show>16631664            <div class="peach-pro-active-features">1665              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1666                {(item) => (1667                  <div class="peach-pro-active-feature">1668                    <span class="peach-pro-active-feature-mark">✦</span>1669                    <span class="peach-pro-active-feature-name">{item.name}</span>1670                  </div>1671                )}1672              </For>1673            </div>16741675            <button1676              class="peach-pro-deactivate"1677              onClick={() => void handleDeactivateLicense()}1678              disabled={deactivating()}1679            >1680              {deactivating() ? "Deactivating…" : "Deactivate"}1681            </button>1682          </div>1683        </Show>1684      </div>1685    );1686  }16871688  function renderFeedback() {1689    return (1690      <div class="peach-settings-body">1691        <div class="peach-settings-group peach-feedback-group">1692          <p class="peach-feedback-intro">1693            Help us improve Peach. Every report goes directly to our inbox.1694          </p>16951696          <p class="peach-settings-sub-label">TYPE</p>1697          <div class="peach-feedback-chips">1698            <For each={FEEDBACK_CATEGORIES}>1699              {(cat) => (1700                <button1701                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1702                  onClick={() => setFbCategory(cat)}1703                >1704                  {cat}1705                </button>1706              )}1707            </For>1708          </div>17091710          <p class="peach-settings-sub-label">MESSAGE</p>1711          <textarea1712            class="peach-feedback-textarea"1713            placeholder="Describe the issue or idea..."1714            value={fbMessage()}1715            onInput={(e) => setFbMessage(e.currentTarget.value)}1716          />17171718          <p class="peach-feedback-meta">1719            Version {version} · will be included automatically1720          </p>17211722          <button1723            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1724            disabled={!fbMessage().trim() || feedbackState() === 'sending'}1725            onClick={() => void handleSubmitFeedback()}1726          >1727            <Switch>1728              <Match when={feedbackState() === 'idle'}>Send</Match>1729              <Match when={feedbackState() === 'sending'}>Sending…</Match>1730              <Match when={feedbackState() === 'sent'}>Sent ✓</Match>1731              <Match when={feedbackState() === 'error'}>Failed — try again</Match>1732            </Switch>1733          </button>17341735          <p class="peach-feedback-anon">1736            Sent anonymously · no account required1737          </p>1738        </div>1739      </div>1740    );1741  }17421743  const bodies: StackBodies = {1744    protection: renderSecurity(),1745    vault: renderVault(),1746    sync: renderSync(),1747    appearance: renderAppearance(),1748    shortcuts: renderShortcuts(),1749    pro: renderLicense(),1750    feedback: renderFeedback(),1751  };17521753  return (1754    <Show1755      when={showNewRecoveryWords()}1756      fallback={1757        <div1758          class="peach-overlay-view peach-settings-overlay"1759          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1760          style="display:flex;flex-direction:column"1761        >1762          <StackDeck1763            active={activeCard()}1764            onActiveChange={setActiveCard}1765            onBack={props.onBack}1766            cards={cards()}1767            bodies={bodies}1768            version={version}1769          />1770        </div>1771      }1772    >1773      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1774    </Show>1775  );1776}