Settings.tsx

69.0 KB · raw

1import { createEffect, createMemo, createSignal, For, onMount, Show } 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";2526interface LicenseInfo {27  valid: boolean;28  plan: "free" | "pro";29  issued?: string;30}3132interface SettingsProps {33  entriesCount: number;34  breachMonitoring: boolean;35  lockOnClose: boolean;36  autoLockMinutes: number;37  trashRetentionDays: number;38  theme: "dark" | "light";39  accentColor: AccentColorName;40  textSize: "small" | "medium" | "large";41  reduceMotion: boolean;42  isPro?: boolean;43  initialSection?: string | null;44  upgradeHighlight?: ProFeature | null;45  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48  onTrashRetentionChange: (days: number) => void | Promise<void>;49  onThemeChange: (theme: "dark" | "light") => void;50  onAccentChange: (name: AccentColorName) => void | Promise<void>;51  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53  shortcuts: Record<ShortcutAction, Keybinding>;54  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55  onLicenseChange?: () => void | Promise<void>;56  isExiting?: boolean;57  onBack: () => void;58  onImport: () => void;59  onExport: () => void;60  onBreachCheckDone: () => void;61  onOpenHealth?: () => void;62  onOpenSyncPairing?: () => void;63  onOpenFileImport?: () => void;64  onOpenPanic?: () => void;65  onOpenCodexGenerate?: () => void;66  onOpenCodexRestore?: () => void;67  /** Invoked from within Settings when a Pro-gated control is tapped on68   *  free tier. App owns the upgrade navigation + highlight state, so this69   *  callback is the single way for Settings to request that navigation. */70  onProGate?: (feature: ProFeature) => void;71  onCardChange?: (id: CardId) => void;72}7374const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7576function formatActivatedSince(issued: string | undefined): string {77  if (!issued) return "Active";78  try {79    const d = new Date(issued);80    if (Number.isNaN(d.getTime())) return "Active";81    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;82  } catch {83    return "Active";84  }85}8687function initialSectionToCard(value: string | null | undefined): CardId | null {88  if (!value) return null;89  if (value === "security") return "protection";90  if (value === "license") return "pro";91  if (92    value === "protection" ||93    value === "vault" ||94    value === "sync" ||95    value === "appearance" ||96    value === "shortcuts" ||97    value === "pro" ||98    value === "feedback"99  ) {100    return value;101  }102  return null;103}104105export function SettingsView(props: SettingsProps) {106  const manifest = chrome.runtime.getManifest();107  const version = manifest.version;108  const initial = props.initialSection;109  const [activeCard, setActiveCard] = createSignal<CardId>(110    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"111  );112113  createEffect(() => { props.onCardChange?.(activeCard()); });114115  const [checkingBreaches, setCheckingBreaches] = createSignal(false);116  const [breachResult, setBreachResult] = createSignal<string | null>(null);117  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);118  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");119  const [licenseError, setLicenseError] = createSignal<string | null>(null);120  const [, setLicenseSuccess] = createSignal<string | null>(null);121  const [activatingLicense, setActivatingLicense] = createSignal(false);122123  const isPro = () => props.isPro ?? false;124  const cards = createMemo<StackCardSpec[]>(() => [125    {126      id: "protection",127      num: "I · PROTECT",128      title: "Protection",129      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,130      bgVar: "--stack-protect-bg",131      fgVar: "--stack-protect-fg",132      icon: "shield",133    },134    {135      id: "vault",136      num: "II · KEEP",137      title: "Vault",138      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,139      bgVar: "--stack-vault-bg",140      fgVar: "--stack-vault-fg",141      icon: "lock",142    },143    {144      id: "sync",145      num: "III · ECHO",146      title: "Sync",147      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",148      bgVar: "--stack-sync-bg",149      fgVar: "--stack-sync-fg",150      icon: "sync",151    },152    {153      id: "appearance",154      num: "IV · DRESS",155      title: "Appearance",156      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,157      bgVar: "--stack-appear-bg",158      fgVar: "--stack-appear-fg",159      icon: "sun",160    },161    {162      id: "shortcuts",163      num: "V · KEYS",164      title: "Shortcuts",165      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,166      bgVar: "--stack-shortcut-bg",167      fgVar: "--stack-shortcut-fg",168      icon: "keys",169    },170    {171      id: "pro",172      num: "VI · FOREVER",173      title: "Peach Pro",174      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",175      bgVar: "--stack-pro-bg",176      fgVar: "--stack-pro-fg",177      icon: "spark",178    },179    {180      id: "feedback",181      num: "VII · ECHO",182      title: "Feedback",183      lede: `version ${version} · tell us what would make it better`,184      bgVar: "--stack-feedback-bg",185      fgVar: "--stack-feedback-fg",186      icon: "bubble",187    },188  ]);189  const [biometricSupported, setBiometricSupported] = createSignal(false);190  const [biometricStored, setBiometricStored] = createSignal(false);191  const [biometricBusy, setBiometricBusy] = createSignal(false);192  const [biometricError, setBiometricError] = createSignal<string | null>(null);193  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);194  const [passkeySaving, setPasskeySaving] = createSignal(false);195196  const [dedupCount, setDedupCount] = createSignal<number | null>(null);197  const [dedupRunning, setDedupRunning] = createSignal(false);198  const [dedupDone, setDedupDone] = createSignal<number | null>(null);199200  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);201202  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted203  // BEFORE every real-mode vault write so the user can roll back if a sync204  // or import overwrites the wrong state.205  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);206  const [historyLoading, setHistoryLoading] = createSignal(false);207  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);208  const [historyOpen, setHistoryOpen] = createSignal(false);209210  async function loadVaultHistory() {211    setHistoryLoading(true);212    try {213      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });214      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];215      setHistorySnapshots(list);216    } finally {217      setHistoryLoading(false);218    }219  }220221  async function handleRestoreVaultSnapshot(id: string) {222    if (historyRestoring()) return;223    setHistoryRestoring(id);224    const res = await sendMessage({225      type: "RESTORE_VAULT_SNAPSHOT",226      payload: { snapshotId: id },227    });228    setHistoryRestoring(null);229    if ((res as { ok: boolean }).ok) {230      // Vault is now locked — let the parent close Settings; App.tsx's231      // VAULT_LOCKED listener will route back to the lock screen.232      props.onBack();233    }234  }235236  function formatSnapshotTime(savedAt: number): string {237    const diff = Date.now() - savedAt;238    if (diff < 60_000) return "Just now";239    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;240    const d = new Date(savedAt);241    const isToday = new Date().toDateString() === d.toDateString();242    const isYest =243      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();244    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });245    if (isToday) return `Today ${t}`;246    if (isYest) return `Yesterday ${t}`;247    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;248  }249250  function reasonLabel(reason: VaultSnapshot["reason"]): string {251    return (252      {253        local_save: "saved",254        sync_received: "synced",255        import: "imported",256        restore: "restored",257      } as const258    )[reason] ?? reason;259  }260261  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);262263  const [showChangePw, setShowChangePw] = createSignal(false);264  const [changePwCurrent, setChangePwCurrent] = createSignal("");265  const [changePwNew, setChangePwNew] = createSignal("");266  const [changePwConfirm, setChangePwConfirm] = createSignal("");267  const [changePwSaving, setChangePwSaving] = createSignal(false);268  const [changePwError, setChangePwError] = createSignal<string | null>(null);269  const [changePwSuccess] = createSignal(false);270  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);271272  const [duressEnabled, setDuressEnabled] = createSignal(false);273  const [showDuressSetup, setShowDuressSetup] = createSignal(false);274  const [duressPassword, setDuressPassword] = createSignal("");275  const [duressConfirm, setDuressConfirm] = createSignal("");276  const [duressMainPassword, setDuressMainPassword] = createSignal("");277  const [duressSaving, setDuressSaving] = createSignal(false);278  const [duressError, setDuressError] = createSignal<string | null>(null);279  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);280281  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);282283  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);284  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);285286  const [fbCategory, setFbCategory] = createSignal("Bug report");287  const [fbMessage, setFbMessage] = createSignal("");288  const [fbSubmitting, setFbSubmitting] = createSignal(false);289  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);290291  createEffect(() => {292    if (props.upgradeHighlight) {293      setActiveCard("pro");294    }295  });296297  onMount(async () => {298    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([299      sendMessage({ type: "GET_LICENSE" }),300      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),301      sendMessage({ type: "IS_DURESS_ENABLED" }),302      sendMessage({ type: "GET_SETTINGS" }),303      sendMessage({ type: "GET_LICENSE_KEY" }),304    ]);305306    // Vault history loads in the background — the Vault sub-view picks it up307    // when the user navigates in. Loading it lazily keeps Settings boot fast.308    void loadVaultHistory();309310    if ((licenseRes as { ok: boolean }).ok) {311      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);312    }313314    if ((passkeyRes as { ok: boolean }).ok) {315      setPasskeyState(316        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null317      );318    }319320    if ((duressRes as { ok: boolean }).ok) {321      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);322    }323324    if ((settingsRes as { ok: boolean }).ok) {325      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;326      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);327    }328329    if ((keyRes as { ok: boolean }).ok) {330      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);331    }332333    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });334    if ((entriesRes as { ok: boolean }).ok) {335      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];336      setCanaryEntries(337        entries338          .filter((e) => e.canary?.webhookUrl)339          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))340      );341    }342343344    if (isNativePlatform()) {345      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");346      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");347      try {348        if (biometric) {349          const a = await biometric.isAvailable();350          setBiometricSupported(a.available);351        }352        if (keystore) {353          const k = await keystore.hasMasterKey();354          setBiometricStored(k.exists);355        }356      } catch { /* missing plugins — leave defaults */ }357    }358  });359360  async function handleEnableBiometrics() {361    setBiometricBusy(true);362    setBiometricError(null);363    try {364      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {365        ok: boolean;366        data?: { masterKeyHex: string };367        error?: string;368      };369      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {370        setBiometricError(keyRes.error === "vault_locked"371          ? "unlock the vault first"372          : "couldn't read derived key");373        return;374      }375      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");376      if (!keystore) {377        setBiometricError("biometric plugin unavailable");378        return;379      }380      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });381      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });382      setBiometricStored(true);383    } catch (e) {384      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");385    } finally {386      setBiometricBusy(false);387    }388  }389390  async function handleDisableBiometrics() {391    setBiometricBusy(true);392    setBiometricError(null);393    try {394      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");395      if (keystore) await keystore.clearMasterKey();396      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });397      setBiometricStored(false);398    } catch (e) {399      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");400    } finally {401      setBiometricBusy(false);402    }403  }404405  async function handleChangePassword() {406    setChangePwError(null);407    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }408    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }409    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }410    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }411    setChangePwSaving(true);412    const res = await sendMessage({413      type: "CHANGE_PASSWORD",414      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },415    }) as { ok: boolean; error?: string };416    if (res.ok) {417      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");418      setShowChangePw(false);419      setShowNewRecoveryWords(true);420    } else {421      setChangePwError(res.error ?? "failed to change password");422    }423    setChangePwSaving(false);424  }425426  const [copied, setCopied] = createSignal(false);427428  async function copyLicenseKey(key: string) {429    await navigator.clipboard.writeText(key);430    setCopied(true);431    setTimeout(() => setCopied(false), 2000);432  }433434  async function handleActivateLicense() {435    const key = licenseKeyInput().trim();436    if (!key) return;437    setActivatingLicense(true);438    setLicenseError(null);439    setLicenseSuccess(null);440    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });441    setActivatingLicense(false);442    if ((res as { ok: boolean }).ok) {443      const data = (res as { ok: boolean; data?: LicenseInfo }).data;444      if (data?.valid) {445        setLicenseInfo(data);446        setLicenseKey(key);447        setLicenseSuccess("License activated!");448        setLicenseKeyInput("");449      } else {450        setLicenseError("Invalid license key");451      }452    } else {453      setLicenseError("Activation failed");454    }455  }456457  async function handleTogglePasskeys() {458    const current = passkeyState();459    if (!current || !current.supported) return;460    setPasskeySaving(true);461    const res = await sendMessage({462      type: "SET_PASSKEY_PROVIDER_ENABLED",463      payload: { enabled: !current.enabled },464    });465    setPasskeySaving(false);466    if ((res as { ok: boolean }).ok) {467      setPasskeyState(468        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null469      );470    }471  }472473  async function handleCheckAllBreaches() {474    setCheckingBreaches(true);475    setBreachResult(null);476    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });477    setCheckingBreaches(false);478    if ((res as { ok: boolean }).ok) {479      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};480      const breached = Object.values(data).filter((count) => count > 0).length;481      setBreachResult(482        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"483      );484      props.onBreachCheckDone();485    }486  }487488489  async function handleSetupDuress() {490    const mainPw = duressMainPassword().trim();491    const duressPw = duressPassword().trim();492    const confirmPw = duressConfirm().trim();493494    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }495    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }496    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }497    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }498499    setDuressSaving(true);500    setDuressError(null);501502    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };503    if (!verifyRes.ok || !verifyRes.data) {504      setDuressError("incorrect main password");505      setDuressSaving(false);506      return;507    }508509    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };510    setDuressSaving(false);511    if (res.ok) {512      setDuressEnabled(true);513      setShowDuressSetup(false);514      setDuressPassword("");515      setDuressConfirm("");516      setDuressMainPassword("");517      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");518      setTimeout(() => setDuressSuccess(null), 5000);519    } else {520      setDuressError(521        res.error === "session_expired"522          ? "Vault session expired — lock and unlock Peach, then try again"523          : res.error ?? "failed to set duress password"524      );525    }526  }527528  async function handleClearDeclinedCards() {529    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });530    setDeclinedCardCount(0);531  }532533  async function handleCountDuplicates() {534    setDedupRunning(true);535    setDedupDone(null);536    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };537    setDedupRunning(false);538    if (res.ok) setDedupCount(res.data ?? 0);539  }540541  async function handleDeduplicate() {542    setDedupRunning(true);543    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };544    setDedupRunning(false);545    if (res.ok) {546      setDedupDone(res.data ?? 0);547      setDedupCount(0);548    }549  }550551  async function handleDisableDuress() {552    const mainPw = duressMainPassword().trim();553    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }554555    setDuressSaving(true);556    setDuressError(null);557558    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };559    if (!verifyRes.ok || !verifyRes.data) {560      setDuressError("incorrect main password");561      setDuressSaving(false);562      return;563    }564565    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };566    setDuressSaving(false);567    if (res.ok) {568      setDuressEnabled(false);569      setShowDuressSetup(false);570      setDuressMainPassword("");571      setDuressSuccess("Duress password disabled.");572      setTimeout(() => setDuressSuccess(null), 4000);573    } else {574      setDuressError(res.error ?? "failed to disable duress password");575    }576  }577578  async function handleSubmitFeedback() {579    if (!fbMessage().trim() || fbSubmitting()) return;580    setFbSubmitting(true);581    setFbResult(null);582    const res = await sendMessage({583      type: "SUBMIT_FEEDBACK",584      payload: {585        category: fbCategory(),586        message: fbMessage().trim(),587        version,588        browser: navigator.userAgent,589      },590    }) as { ok: boolean; error?: string };591    setFbSubmitting(false);592    if (res.ok) {593      setFbResult("success");594      setFbMessage("");595      setTimeout(() => setFbResult(null), 4000);596    } else {597      setFbResult("error");598    }599  }600601  function renderSecurity() {602    return (603      <div class="peach-settings-body">604        <div class="peach-settings-group">605          <p class="peach-settings-sub-label">LOCKING</p>606          <div class="peach-settings-row">607            <span>Lock on close</span>608            <button609              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}610              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}611              aria-label="Toggle lock on close"612            >613              <span class="peach-toggle-knob"></span>614            </button>615          </div>616          <div class="peach-settings-row">617            <span>Auto-lock after</span>618            <select619              class="peach-settings-select"620              value={String(props.autoLockMinutes)}621              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}622            >623              <option value="1">1 minute</option>624              <option value="5">5 minutes</option>625              <option value="15">15 minutes</option>626              <option value="30">30 minutes</option>627              <option value="0">Never</option>628            </select>629          </div>630631          <Show when={isNativePlatform() && biometricSupported()}>632            <p class="peach-settings-sub-label">BIOMETRICS</p>633            <div class="peach-settings-row">634              <span>Unlock with biometrics</span>635              <button636                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}637                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}638                disabled={biometricBusy()}639                aria-label="Toggle biometric unlock"640              >641                <span class="peach-toggle-knob"></span>642              </button>643            </div>644            <Show when={biometricError()}>645              <div class="peach-settings-row peach-settings-row--meta">646                <span class="peach-hint peach-hint-error">{biometricError()}</span>647              </div>648            </Show>649          </Show>650651          <p class="peach-settings-sub-label">BREACH PROTECTION</p>652          <div class="peach-settings-row">653            <span>Breach monitoring</span>654            <button655              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}656              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}657              aria-label="Toggle breach monitoring"658            >659              <span class="peach-toggle-knob"></span>660            </button>661          </div>662          <div class="peach-settings-row peach-settings-row--meta">663            <span>Check all passwords</span>664            <button665              class="peach-btn peach-btn--subtle peach-btn--sm"666              onClick={() => void handleCheckAllBreaches()}667              disabled={checkingBreaches()}668            >669              {checkingBreaches() ? "checking..." : "run"}670            </button>671          </div>672          <Show when={breachResult()}>673            <div class="peach-settings-row peach-settings-row--meta">674              <span>Last check</span>675              <span class="peach-settings-value">{breachResult()}</span>676            </div>677          </Show>678679          <p class="peach-settings-sub-label">MASTER PASSWORD</p>680          <div class="peach-settings-row peach-settings-row--meta">681            <span>Master password</span>682            <button683              class="peach-btn peach-btn--subtle peach-btn--sm"684              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}685            >686              {showChangePw() ? "cancel" : "change"}687            </button>688          </div>689          <Show when={showChangePw()}>690            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">691              <input692                class="peach-settings-input"693                type="password"694                placeholder="current password"695                value={changePwCurrent()}696                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}697                autocomplete="current-password"698              />699              <input700                class="peach-settings-input"701                type="password"702                placeholder="new password (min 12 chars)"703                value={changePwNew()}704                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}705                autocomplete="new-password"706              />707              <input708                class="peach-settings-input"709                type="password"710                placeholder="confirm new password"711                value={changePwConfirm()}712                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}713                autocomplete="new-password"714                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}715              />716              <Show when={changePwError()}>717                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>718              </Show>719              <Show when={changePwSuccess()}>720                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>721              </Show>722              <button723                class="peach-btn peach-btn--accent peach-btn--sm"724                style="align-self:flex-start"725                onClick={() => void handleChangePassword()}726                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}727              >728                {changePwSaving() ? "saving…" : "save new password"}729              </button>730            </div>731          </Show>732733          <p class="peach-settings-sub-label">734            DURESS PASSWORD735            <Show when={!isPro()}>736              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>737            </Show>738          </p>739          <div class="peach-settings-row peach-settings-row--top">740            <div class="peach-settings-row-main">741              <span>742                Duress password743                <Show when={!isPro()}>744                  <span class="peach-pro-badge">Pro</span>745                </Show>746              </span>747              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">748                If entered under coercion, shows a decoy empty vault instead of your real one.749              </span>750            </div>751            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>752              {duressEnabled() ? "on" : "off"}753            </div>754          </div>755756          <Show when={duressEnabled() && !showDuressSetup()}>757            <div class="peach-settings-row peach-settings-row--meta">758              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">759                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.760              </span>761            </div>762          </Show>763764          <Show when={duressSuccess()}>765            <div class="peach-settings-row peach-settings-row--meta">766              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>767            </div>768          </Show>769770          <Show when={!showDuressSetup()}>771            <div class="peach-settings-row peach-settings-row--meta">772              <button773                class="peach-btn peach-btn--subtle peach-btn--sm"774                onClick={() => {775                  // Pro gate entry-point: free users are redirected to the776                  // upgrade screen before the setup form opens. The server-777                  // side SETUP_DURESS handler is the single enforcement778                  // point; this is UI redirection so the form never appears779                  // for a user who can't complete it.780                  if (!isPro() && !duressEnabled()) {781                    props.onProGate?.("duress_vault");782                    setActiveCard("pro");783                    return;784                  }785                  setShowDuressSetup(true);786                  setDuressError(null);787                  setDuressSuccess(null);788                  setDuressPassword("");789                  setDuressConfirm("");790                  setDuressMainPassword("");791                }}792              >793                {duressEnabled() ? "Change / disable" : "Set up"}794              </button>795            </div>796          </Show>797798          <Show when={showDuressSetup()}>799            <div class="peach-duress-form">800              <label class="peach-label">main password (to confirm)</label>801              <input802                class="peach-settings-input"803                type="password"804                placeholder="your current master password"805                autocomplete="current-password"806                value={duressMainPassword()}807                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}808              />809              <Show when={!duressEnabled()}>810                <label class="peach-label">new duress password</label>811                <input812                  class="peach-settings-input"813                  type="password"814                  placeholder="duress password (min 8 chars)"815                  autocomplete="new-password"816                  value={duressPassword()}817                  onInput={(e) => setDuressPassword(e.currentTarget.value)}818                />819                <label class="peach-label">confirm duress password</label>820                <input821                  class="peach-settings-input"822                  type="password"823                  placeholder="confirm duress password"824                  autocomplete="new-password"825                  value={duressConfirm()}826                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}827                />828              </Show>829830              <Show when={duressError()}>831                <p class="peach-hint peach-hint-error">{duressError()}</p>832              </Show>833834              <div class="peach-duress-actions">835                <button836                  class="peach-btn peach-btn--subtle peach-btn--sm"837                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}838                >839                  cancel840                </button>841                <Show when={duressEnabled()}>842                  <button843                    class="peach-btn peach-btn--ghost peach-btn--sm"844                    style="color:var(--peach-breach)"845                    onClick={() => void handleDisableDuress()}846                    disabled={duressSaving()}847                  >848                    {duressSaving() ? "disabling..." : "disable duress"}849                  </button>850                </Show>851                <Show when={!duressEnabled()}>852                  <button853                    class="peach-btn peach-btn--accent peach-btn--sm"854                    onClick={() => void handleSetupDuress()}855                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}856                  >857                    {duressSaving() ? "saving..." : "save"}858                  </button>859                </Show>860              </div>861            </div>862          </Show>863864          <p class="peach-settings-sub-label">865            CANARY ENTRIES866            <Show when={!isPro()}>867              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>868            </Show>869          </p>870          <div class="peach-settings-row peach-settings-row--top">871            <div class="peach-settings-row-main">872              <span>873                Canary entries874                <Show when={!isPro()}>875                  <span class="peach-pro-badge">Pro</span>876                </Show>877              </span>878              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">879                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.880              </span>881            </div>882            <span class="peach-settings-value" style="flex-shrink:0">883              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}884            </span>885          </div>886          <Show when={canaryEntries().length > 0}>887            <For each={canaryEntries()}>888              {(entry) => (889                <div class="peach-settings-row peach-settings-row--meta">890                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>891                  <button892                    class="peach-btn peach-btn--subtle peach-btn--sm"893                    onClick={() => void handleTestCanary(entry)}894                    disabled={canaryTestingId() === entry.id}895                    style="flex-shrink:0"896                  >897                    {canaryTestingId() === entry.id898                      ? "sending…"899                      : canaryTestResult()?.id === entry.id900                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")901                        : "test"}902                  </button>903                </div>904              )}905            </For>906          </Show>907908          <p class="peach-settings-sub-label">PASSKEYS</p>909          <div class="peach-settings-row peach-settings-row--top">910            <div class="peach-settings-row-main">911              <span>Intercept passkey requests</span>912              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">913                Peach can handle passkey sign-in and creation on supported Chromium browsers.914              </span>915            </div>916            <button917              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}918              onClick={() => void handleTogglePasskeys()}919              aria-label="Toggle passkey interception"920              disabled={!passkeyState()?.supported || passkeySaving()}921            >922              <span class="peach-toggle-knob"></span>923            </button>924          </div>925          <div class="peach-settings-row peach-settings-row--meta">926            <span>Status</span>927            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>928          </div>929          <div class="peach-settings-row peach-settings-row--meta">930            <span>Browser</span>931            <span class="peach-settings-value">932              {passkeyState()?.browserLabel ?? "Unknown"}933              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}934            </span>935          </div>936          <div class="peach-info-notice" style="margin-top:8px">937            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.938          </div>939          <Show when={passkeyState()?.conflict}>940            <div class="peach-settings-row peach-settings-row--meta">941              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>942            </div>943          </Show>944        </div>945      </div>946    );947  }948949  function renderVault() {950    return (951      <div class="peach-settings-body">952        <div class="peach-settings-group">953          <p class="peach-settings-sub-label">ENTRIES</p>954          <div class="peach-settings-row peach-settings-row--meta">955            <span>Entries</span>956            <span class="peach-settings-value">{props.entriesCount}</span>957          </div>958          <div class="peach-settings-row peach-settings-row--meta">959            <span>Duplicates</span>960            <Show when={dedupDone() !== null} fallback={961              <button962                class="peach-btn peach-btn--subtle peach-btn--sm"963                onClick={() => void handleCountDuplicates()}964                disabled={dedupRunning()}965              >966                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}967              </button>968            }>969              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">970                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}971              </span>972            </Show>973          </div>974          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>975            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">976              <div class="peach-restore-warning">977                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.978              </div>979              <div style="display:flex;gap:6px">980                <button981                  class="peach-btn peach-btn--subtle peach-btn--sm"982                  onClick={() => setDedupCount(null)}983                >984                  cancel985                </button>986                <button987                  class="peach-btn peach-btn--accent peach-btn--sm"988                  onClick={() => void handleDeduplicate()}989                  disabled={dedupRunning()}990                >991                  {dedupRunning() ? "removing..." : "remove duplicates"}992                </button>993              </div>994            </div>995          </Show>996997          <p class="peach-settings-sub-label">MAINTENANCE</p>998          <div class="peach-settings-row peach-settings-row--meta">999            <span>Trash auto-delete</span>1000            <select1001              class="peach-settings-select"1002              value={String(props.trashRetentionDays)}1003              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1004            >1005              <option value="7">7 days</option>1006              <option value="30">30 days</option>1007              <option value="90">90 days</option>1008              <option value="0">Never</option>1009            </select>1010          </div>1011          <div class="peach-settings-row peach-settings-row--meta">1012            <span>Vault Health</span>1013            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1014              open1015            </button>1016          </div>1017          <Show when={declinedCardCount() > 0}>1018            <div class="peach-settings-row peach-settings-row--meta">1019              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1020              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1021                Clear1022              </button>1023            </div>1024          </Show>10251026          <p class="peach-settings-sub-label">DATA</p>1027          <div class="peach-settings-row peach-settings-row--meta">1028            <span>Import passwords</span>1029            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1030              open1031            </button>1032          </div>1033          <div class="peach-settings-row peach-settings-row--meta">1034            <span>Export backup</span>1035            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1036              open1037            </button>1038          </div>1039          <div class="peach-settings-row peach-settings-row--meta">1040            <span>1041              Peach Codex1042              <Show when={!isPro()}>1043                <span class="peach-pro-badge">Pro</span>1044              </Show>1045            </span>1046            <button1047              class="peach-btn peach-btn--subtle peach-btn--sm"1048              onClick={() => props.onOpenCodexGenerate?.()}1049            >1050              open1051            </button>1052          </div>1053          {/* Restore from Codex: intentionally never gated — a printed1054              Codex must remain restorable regardless of license state. */}1055          <div class="peach-settings-row peach-settings-row--meta">1056            <span>Restore from Codex</span>1057            <button1058              class="peach-btn peach-btn--subtle peach-btn--sm"1059              onClick={() => props.onOpenCodexRestore?.()}1060            >1061              open1062            </button>1063          </div>10641065          <button1066            class="peach-settings-advanced-toggle"1067            onClick={() => setHistoryOpen((v) => !v)}1068            aria-expanded={historyOpen()}1069          >1070            <span class="peach-settings-sub-label" style="margin:0">VAULT HISTORY</span>1071            <span class="peach-settings-advanced-chevron">1072              {historyOpen() ? "▲" : "▼"}1073            </span>1074          </button>1075          <Show when={historyOpen()}>1076            <Show when={historyLoading()}>1077              <p class="peach-settings-hint">Loading…</p>1078            </Show>1079            <Show when={!historyLoading() && historySnapshots().length === 0}>1080              <p class="peach-settings-hint">1081                No snapshots yet. Peach saves automatically before every change —1082                saves, syncs, and imports.1083              </p>1084            </Show>1085            <Show when={!historyLoading() && historySnapshots().length > 0}>1086              <p class="peach-settings-hint">1087                Restoring locks your vault. Unlock with the password that1088                encrypted that snapshot to confirm.1089              </p>1090              <div class="peach-history-list">1091                <For each={historySnapshots()}>1092                  {(snap) => (1093                    <div class="peach-history-row">1094                      <div class="peach-history-meta">1095                        <span class="peach-history-time">1096                          {formatSnapshotTime(snap.savedAt)}1097                        </span>1098                        <span class="peach-history-sub">1099                          {[1100                            snap.entryCount !== null1101                              ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1102                              : null,1103                            reasonLabel(snap.reason),1104                          ]1105                            .filter(Boolean)1106                            .join(" · ")}1107                        </span>1108                      </div>1109                      <button1110                        class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1111                          historyRestoring() === snap.id ? " is-busy" : ""1112                        }`}1113                        onClick={() => void handleRestoreVaultSnapshot(snap.id)}1114                        disabled={historyRestoring() !== null}1115                      >1116                        <Show when={historyRestoring() === snap.id} fallback="Restore">1117                          …1118                        </Show>1119                      </button>1120                    </div>1121                  )}1122                </For>1123              </div>1124              <p class="peach-history-footer">1125                {historySnapshots().length} of 10 snapshots1126              </p>1127            </Show>1128          </Show>11291130          <button1131            class="peach-settings-advanced-toggle"1132            onClick={() => setShowVaultAdvanced((v) => !v)}1133            aria-expanded={showVaultAdvanced()}1134          >1135            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1136            <span class="peach-settings-advanced-chevron">1137              {showVaultAdvanced() ? "▲" : "▼"}1138            </span>1139          </button>11401141          <Show when={showVaultAdvanced()}>1142            <div class="peach-danger-card">1143              <div class="peach-danger-card-head">1144                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1145                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1146              </div>1147              <p class="peach-danger-card-copy">1148                Wipes the current vault and restores from a backup file.1149                Recoverable only if your backup is good.1150              </p>1151              <button1152                class="peach-danger-card-button"1153                onClick={() => props.onOpenFileImport?.()}1154              >1155                Choose backup file1156              </button>1157            </div>11581159            <Show when={props.onOpenPanic}>1160              <div class="peach-danger-card peach-danger-card--hazard">1161                <div class="peach-danger-card-head">1162                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1163                  <h3 class="peach-danger-card-title">Destroy vault</h3>1164                </div>1165                <p class="peach-danger-card-copy">1166                  Permanently wipes every entry, setting, and license on this1167                  device. There is no recovery.1168                </p>1169                <button1170                  class="peach-danger-card-button"1171                  onClick={() => props.onOpenPanic?.()}1172                >1173                  Begin destruction1174                </button>1175              </div>1176            </Show>1177          </Show>1178        </div>1179      </div>1180    );1181  }11821183  function renderSync() {1184    return (1185      <div class="peach-settings-body">1186        <div class="peach-settings-group">1187          <p class="peach-settings-sub-label">PAIRING</p>1188          <div class="peach-settings-row peach-settings-row--top">1189            <div class="peach-settings-row-main">1190              <span>1191                Peach Sync1192                <Show when={!isPro()}>1193                  <span class="peach-pro-badge">Pro</span>1194                </Show>1195              </span>1196              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1197                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1198              </span>1199            </div>1200            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1201          </div>1202          <div class="peach-settings-row peach-settings-row--meta">1203            <span>Status</span>1204            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1205          </div>1206          <div class="peach-settings-row peach-settings-row--meta">1207            <span>Sync hub</span>1208            <button1209              class="peach-btn peach-btn--subtle peach-btn--sm"1210              onClick={() => props.onOpenSyncPairing?.()}1211            >1212              open1213            </button>1214          </div>12151216          <p class="peach-settings-sub-label">DEVICE FLOW</p>1217          <div class="peach-settings-row peach-settings-row--top">1218            <div class="peach-settings-row-main">1219              <span>Pair new device</span>1220              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1221                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1222              </span>1223            </div>1224            <button1225              class="peach-btn peach-btn--subtle peach-btn--sm"1226              onClick={() => props.onOpenSyncPairing?.()}1227            >1228              pair1229            </button>1230          </div>1231          <div class="peach-info-notice" style="margin-top:8px">1232            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1233          </div>1234        </div>1235      </div>1236    );1237  }12381239  function renderAppearance() {1240    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1241      { id: "small", label: "Small" },1242      { id: "medium", label: "Default" },1243      { id: "large", label: "Large" },1244    ];1245    return (1246      <div class="peach-settings-body">1247        <div class="peach-settings-group">1248          <p class="peach-settings-sub-label">THEME</p>1249          <div class="peach-settings-row peach-settings-row--meta">1250            <span>Theme</span>1251            <button1252              class="peach-btn peach-btn--subtle peach-btn--sm"1253              onClick={() =>1254                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1255              }1256            >1257              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1258            </button>1259          </div>12601261          <p class="peach-settings-sub-label">ACCENT</p>1262          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1263            <span>Accent</span>1264            <div class="peach-accent-picker">1265              <For each={ACCENT_COLORS}>1266                {(color) => (1267                  <button1268                    class="peach-accent-swatch peach-swatch"1269                    classList={{ "is-active": props.accentColor === color.name }}1270                    style={{ background: color.hex }}1271                    onClick={() => void props.onAccentChange(color.name)}1272                    aria-label={color.name}1273                    title={color.name}1274                  />1275                )}1276              </For>1277            </div>1278          </div>12791280          <p class="peach-settings-sub-label">TEXT SIZE</p>1281          <div class="peach-settings-row peach-settings-row--meta">1282            <span>Size</span>1283            <div class="peach-pill-group">1284              <For each={sizeOptions}>1285                {(opt) => (1286                  <button1287                    class="peach-btn peach-btn--ghost peach-btn--sm"1288                    classList={{ "is-active": props.textSize === opt.id }}1289                    onClick={() => void props.onTextSizeChange(opt.id)}1290                  >1291                    {opt.label}1292                  </button>1293                )}1294              </For>1295            </div>1296          </div>12971298          <p class="peach-settings-sub-label">MOTION</p>1299          <div class="peach-settings-row peach-settings-row--meta">1300            <span>Reduce motion</span>1301            <button1302              class="peach-btn peach-btn--subtle peach-btn--sm"1303              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1304            >1305              {props.reduceMotion ? "On" : "Off"}1306            </button>1307          </div>1308        </div>1309      </div>1310    );1311  }13121313  function renderShortcuts() {1314    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1315    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13161317    function startCapture(action: ShortcutAction) {1318      setConflictMsg(null);1319      setCapturingAction(action);1320    }1321    function cancelCapture() {1322      setCapturingAction(null);1323      setConflictMsg(null);1324    }1325    function commitBinding(action: ShortcutAction, binding: Keybinding) {1326      const conflict = findConflict(props.shortcuts, binding, action);1327      if (conflict) {1328        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1329        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1330        return;1331      }1332      const next = { ...props.shortcuts, [action]: binding };1333      setCapturingAction(null);1334      setConflictMsg(null);1335      void props.onShortcutsChange(next);1336    }1337    function resetAction(action: ShortcutAction) {1338      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1339      void props.onShortcutsChange(next);1340    }1341    function resetAll() {1342      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1343    }13441345    // Capture-mode key handler — wired via ref onto the chip element so it1346    // catches the next non-modifier keypress and ignores other bindings.1347    function handleCaptureKey(e: KeyboardEvent) {1348      e.preventDefault();1349      e.stopPropagation();1350      if (e.key === "Escape") { cancelCapture(); return; }1351      const binding = keybindingFromEvent(e);1352      if (!binding) return; // bare modifier — keep waiting1353      const action = capturingAction();1354      if (action) commitBinding(action, binding);1355    }13561357    return (1358      <div class="peach-settings-body">1359        <div class="peach-settings-group peach-shortcuts-group">1360          <p class="peach-settings-sub-label">ENTRY VIEW</p>1361          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1362            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1363          </For>1364        </div>13651366        <div class="peach-settings-group peach-shortcuts-group">1367          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1368          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1369            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1370          </For>1371        </div>13721373        <Show when={conflictMsg()}>1374          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1375        </Show>13761377        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1378          <div class="peach-settings-row peach-settings-row--meta">1379            <span>Inline autofill on page</span>1380            <a1381              class="peach-settings-link"1382              href="chrome://extensions/shortcuts"1383              onClick={(e) => {1384                e.preventDefault();1385                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1386              }}1387            >1388              ⌘⇧P — customize ↗1389            </a>1390          </div>1391          <button1392            class="peach-btn peach-btn--subtle peach-btn--sm"1393            style="align-self:flex-start"1394            onClick={resetAll}1395          >1396            Reset all to defaults1397          </button>1398        </div>1399      </div>1400    );14011402    function renderRow(1403      desc: typeof SHORTCUT_DESCRIPTORS[number],1404      capturing: () => ShortcutAction | null,1405      onStart: (a: ShortcutAction) => void,1406      onCancel: () => void,1407      onReset: (a: ShortcutAction) => void,1408      onKey: (e: KeyboardEvent) => void,1409    ) {1410      const isActive = () => capturing() === desc.action;1411      const binding = () => props.shortcuts[desc.action];1412      const isCustom = () => {1413        const b = binding();1414        const d = DEFAULT_BINDINGS[desc.action];1415        return b.key !== d.key1416          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1417          || Boolean(b.meta)  !== Boolean(d.meta)1418          || Boolean(b.shift) !== Boolean(d.shift)1419          || Boolean(b.alt)   !== Boolean(d.alt);1420      };1421      return (1422        <div class="peach-shortcut-row">1423          <span class="peach-shortcut-row-label">{desc.label}</span>1424          <button1425            class="peach-shortcut-chip"1426            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1427            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1428            onKeyDown={(e) => { if (isActive()) onKey(e); }}1429            ref={(el) => {1430              // Auto-focus when entering capture mode so the keydown listener catches the next press.1431              if (el) {1432                queueMicrotask(() => { if (isActive()) el.focus(); });1433              }1434            }}1435          >1436            {isActive() ? "Press a key…" : formatKeybinding(binding())}1437          </button>1438          <Show when={isCustom() && !isActive()}>1439            <button1440              class="peach-shortcut-reset"1441              title="Reset to default"1442              onClick={() => onReset(desc.action)}1443            >1444              ↺1445            </button>1446          </Show>1447        </div>1448      );1449    }1450  }14511452  const PRO_FEATURES: Array<{1453    feature: ProFeature;1454    name: string;1455    desc: string;1456  }> = [1457    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1458    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1459    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1460    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1461    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1462  ];14631464  const PRO_SECURITY_FEATURES: Array<{1465    feature: ProFeature;1466    name: string;1467    desc: string;1468  }> = [1469    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1470    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1471  ];14721473  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1474  const [showKey, setShowKey] = createSignal(false);14751476  const [deactivating, setDeactivating] = createSignal(false);14771478  async function handleDeactivateLicense() {1479    setDeactivating(true);1480    await sendMessage({ type: "DEACTIVATE_LICENSE" });1481    setLicenseInfo(null);1482    await props.onLicenseChange?.();1483    setDeactivating(false);1484  }14851486  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1487    setCanaryTestingId(entry.id);1488    setCanaryTestResult(null);1489    const res = await sendMessage({1490      type: "FIRE_CANARY_PING",1491      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1492    }) as { ok: boolean };1493    setCanaryTestResult({ id: entry.id, ok: res.ok });1494    setCanaryTestingId(null);1495    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1496  }14971498  function renderLicense() {1499    async function onActivate() {1500      await handleActivateLicense();1501      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1502        await props.onLicenseChange?.();1503      }1504    }15051506    return (1507      <div class="peach-settings-body">1508        <Show1509          when={isPro()}1510          fallback={1511                <div class="peach-pro-upgrade">1512                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15131514                  <h2 class="peach-pro-headline">1515                    Every<em>thing,</em><br />every<em>where.</em>1516                  </h2>15171518                  <p class="peach-pro-subtitle">1519                    One key unlocks every Pro feature — for the lifetime of the app.1520                  </p>15211522                  <div class="peach-pro-grid">1523                    <For each={PRO_FEATURES}>1524                      {(item, i) => (1525                        <div1526                          class="peach-pro-grid-item"1527                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1528                        >1529                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1530                          <span class="peach-pro-grid-name">{item.name}</span>1531                          <span class="peach-pro-grid-desc">{item.desc}</span>1532                        </div>1533                      )}1534                    </For>1535                  </div>15361537                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1538                  <div class="peach-pro-grid">1539                    <For each={PRO_SECURITY_FEATURES}>1540                      {(item, i) => (1541                        <div1542                          class="peach-pro-grid-item"1543                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1544                        >1545                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1546                          <span class="peach-pro-grid-name">{item.name}</span>1547                          <span class="peach-pro-grid-desc">{item.desc}</span>1548                        </div>1549                      )}1550                    </For>1551                  </div>15521553                  <div class="peach-pro-upgrade-actions">1554                    <p class="peach-pro-pricing">1555                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1556                    </p>1557                    <a1558                      href="https://peachpasswords.com/#pricing"1559                      target="_blank"1560                      rel="noopener noreferrer"1561                      class="peach-pro-upgrade-cta"1562                    >1563                      Get Peach Pro1564                    </a>1565                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1566                    <div class="peach-pro-key-inline">1567                      <input1568                        class="peach-pro-key-inline-input"1569                        type="text"1570                        placeholder="PEACH-XXXX-XXXX-XXXX"1571                        value={licenseKeyInput()}1572                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1573                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1574                        autocomplete="off"1575                        spellcheck={false}1576                      />1577                      <button1578                        class="peach-pro-key-apply"1579                        onClick={() => void onActivate()}1580                        disabled={activatingLicense() || !licenseKeyInput().trim()}1581                      >1582                        {activatingLicense() ? "…" : "APPLY"}1583                      </button>1584                    </div>1585                    <Show when={licenseError()}>1586                      <p class="peach-pro-key-error">{licenseError()}</p>1587                    </Show>1588                  </div>1589                </div>1590          }1591        >1592          <div class="peach-pro-active">1593            <div class="peach-pro-active-hero">1594              <div class="peach-pro-active-mark-wrap">1595                <PeachMark class="peach-pro-active-mark" />1596              </div>1597              <h2 class="peach-pro-active-title">Peach Pro</h2>1598              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1599            </div>16001601            <Show when={licenseKey()}>1602              <div class="peach-settings-license">1603                <span class="peach-settings-label">LICENSE KEY</span>1604                <div class="peach-settings-license-row">1605                  <Show1606                    when={showKey()}1607                    fallback={1608                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1609                        ••••••••••••••••••••••••••••1610                      </span>1611                    }1612                  >1613                    <span class="peach-settings-license-key">{licenseKey()!}</span>1614                  </Show>1615                  <div class="peach-settings-license-actions">1616                    <button1617                      class="peach-settings-license-copy"1618                      title="Copy license key"1619                      onClick={() => copyLicenseKey(licenseKey()!)}1620                    >1621                      {copied() ? "✓" : "copy"}1622                    </button>1623                    <button1624                      class="peach-settings-license-toggle"1625                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1626                      title={showKey() ? "Hide" : "Reveal"}1627                      onClick={() => setShowKey(v => !v)}1628                    >1629                      <Show1630                        when={showKey()}1631                        fallback={1632                          <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">1633                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1634                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1635                          </svg>1636                        }1637                      >1638                        <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">1639                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1640                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1641                          <line x1="2" y1="2" x2="12" y2="12" />1642                        </svg>1643                      </Show>1644                    </button>1645                  </div>1646                </div>1647              </div>1648            </Show>16491650            <div class="peach-pro-active-features">1651              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1652                {(item) => (1653                  <div class="peach-pro-active-feature">1654                    <span class="peach-pro-active-feature-mark">✦</span>1655                    <span class="peach-pro-active-feature-name">{item.name}</span>1656                  </div>1657                )}1658              </For>1659            </div>16601661            <button1662              class="peach-pro-deactivate"1663              onClick={() => void handleDeactivateLicense()}1664              disabled={deactivating()}1665            >1666              {deactivating() ? "Deactivating…" : "Deactivate"}1667            </button>1668          </div>1669        </Show>1670      </div>1671    );1672  }16731674  function renderFeedback() {1675    return (1676      <div class="peach-settings-body">1677        <div class="peach-settings-group peach-feedback-group">1678          <p class="peach-feedback-intro">1679            Help us improve Peach. Every report goes directly to our inbox.1680          </p>16811682          <p class="peach-settings-sub-label">TYPE</p>1683          <div class="peach-feedback-chips">1684            <For each={FEEDBACK_CATEGORIES}>1685              {(cat) => (1686                <button1687                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1688                  onClick={() => setFbCategory(cat)}1689                >1690                  {cat}1691                </button>1692              )}1693            </For>1694          </div>16951696          <p class="peach-settings-sub-label">MESSAGE</p>1697          <textarea1698            class="peach-feedback-textarea"1699            placeholder="Describe the issue or idea..."1700            value={fbMessage()}1701            onInput={(e) => setFbMessage(e.currentTarget.value)}1702          />17031704          <p class="peach-feedback-meta">1705            Version {version} · will be included automatically1706          </p>17071708          <Show when={fbResult() === "success"}>1709            <p class="peach-feedback-result peach-feedback-result--ok">1710              Sent — thank you1711            </p>1712          </Show>1713          <Show when={fbResult() === "error"}>1714            <p class="peach-feedback-result peach-feedback-result--err">1715              Failed to send — try again1716            </p>1717          </Show>17181719          <button1720            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1721            onClick={() => void handleSubmitFeedback()}1722            disabled={fbSubmitting() || !fbMessage().trim()}1723          >1724            {fbSubmitting() ? "Sending..." : "Send feedback"}1725          </button>17261727          <p class="peach-feedback-anon">1728            Sent anonymously · no account required1729          </p>1730        </div>1731      </div>1732    );1733  }17341735  const bodies: StackBodies = {1736    protection: renderSecurity(),1737    vault: renderVault(),1738    sync: renderSync(),1739    appearance: renderAppearance(),1740    shortcuts: renderShortcuts(),1741    pro: renderLicense(),1742    feedback: renderFeedback(),1743  };17441745  return (1746    <Show1747      when={showNewRecoveryWords()}1748      fallback={1749        <div1750          class="peach-overlay-view"1751          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1752          style="display:flex;flex-direction:column"1753        >1754          <StackDeck1755            active={activeCard()}1756            onActiveChange={setActiveCard}1757            onBack={props.onBack}1758            cards={cards()}1759            bodies={bodies}1760            version={version}1761          />1762        </div>1763      }1764    >1765      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1766    </Show>1767  );1768}