Settings.tsx

68.2 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}7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76  if (!issued) return "Active";77  try {78    const d = new Date(issued);79    if (Number.isNaN(d.getTime())) return "Active";80    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81  } catch {82    return "Active";83  }84}8586function initialSectionToCard(value: string | null | undefined): CardId | null {87  if (!value) return null;88  if (value === "security") return "protection";89  if (value === "license") return "pro";90  if (91    value === "protection" ||92    value === "vault" ||93    value === "sync" ||94    value === "appearance" ||95    value === "shortcuts" ||96    value === "pro" ||97    value === "feedback"98  ) {99    return value;100  }101  return null;102}103104export function SettingsView(props: SettingsProps) {105  const manifest = chrome.runtime.getManifest();106  const version = manifest.version;107  const initial = props.initialSection;108  const [activeCard, setActiveCard] = createSignal<CardId>(109    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"110  );111112  const [checkingBreaches, setCheckingBreaches] = createSignal(false);113  const [breachResult, setBreachResult] = createSignal<string | null>(null);114  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);115  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");116  const [licenseError, setLicenseError] = createSignal<string | null>(null);117  const [, setLicenseSuccess] = createSignal<string | null>(null);118  const [activatingLicense, setActivatingLicense] = createSignal(false);119120  const isPro = () => props.isPro ?? false;121  const cards = createMemo<StackCardSpec[]>(() => [122    {123      id: "protection",124      num: "I · PROTECT",125      title: "Protection",126      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,127      bgVar: "--stack-protect-bg",128      fgVar: "--stack-protect-fg",129      icon: "shield",130    },131    {132      id: "vault",133      num: "II · KEEP",134      title: "Vault",135      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,136      bgVar: "--stack-vault-bg",137      fgVar: "--stack-vault-fg",138      icon: "lock",139    },140    {141      id: "sync",142      num: "III · ECHO",143      title: "Sync",144      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",145      bgVar: "--stack-sync-bg",146      fgVar: "--stack-sync-fg",147      icon: "sync",148    },149    {150      id: "appearance",151      num: "IV · DRESS",152      title: "Appearance",153      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,154      bgVar: "--stack-appear-bg",155      fgVar: "--stack-appear-fg",156      icon: "sun",157    },158    {159      id: "shortcuts",160      num: "V · KEYS",161      title: "Shortcuts",162      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,163      bgVar: "--stack-shortcut-bg",164      fgVar: "--stack-shortcut-fg",165      icon: "keys",166    },167    {168      id: "pro",169      num: "VI · FOREVER",170      title: "Peach Pro",171      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",172      bgVar: "--stack-pro-bg",173      fgVar: "--stack-pro-fg",174      icon: "spark",175    },176    {177      id: "feedback",178      num: "VII · ECHO",179      title: "Feedback",180      lede: `version ${version} · tell us what would make it better`,181      bgVar: "--stack-feedback-bg",182      fgVar: "--stack-feedback-fg",183      icon: "bubble",184    },185  ]);186  const [biometricSupported, setBiometricSupported] = createSignal(false);187  const [biometricStored, setBiometricStored] = createSignal(false);188  const [biometricBusy, setBiometricBusy] = createSignal(false);189  const [biometricError, setBiometricError] = createSignal<string | null>(null);190  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);191  const [passkeySaving, setPasskeySaving] = createSignal(false);192193  const [dedupCount, setDedupCount] = createSignal<number | null>(null);194  const [dedupRunning, setDedupRunning] = createSignal(false);195  const [dedupDone, setDedupDone] = createSignal<number | null>(null);196197  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);198199  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted200  // BEFORE every real-mode vault write so the user can roll back if a sync201  // or import overwrites the wrong state.202  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);203  const [historyLoading, setHistoryLoading] = createSignal(false);204  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);205206  async function loadVaultHistory() {207    setHistoryLoading(true);208    try {209      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });210      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];211      setHistorySnapshots(list);212    } finally {213      setHistoryLoading(false);214    }215  }216217  async function handleRestoreVaultSnapshot(id: string) {218    if (historyRestoring()) return;219    setHistoryRestoring(id);220    const res = await sendMessage({221      type: "RESTORE_VAULT_SNAPSHOT",222      payload: { snapshotId: id },223    });224    setHistoryRestoring(null);225    if ((res as { ok: boolean }).ok) {226      // Vault is now locked — let the parent close Settings; App.tsx's227      // VAULT_LOCKED listener will route back to the lock screen.228      props.onBack();229    }230  }231232  function formatSnapshotTime(savedAt: number): string {233    const diff = Date.now() - savedAt;234    if (diff < 60_000) return "Just now";235    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;236    const d = new Date(savedAt);237    const isToday = new Date().toDateString() === d.toDateString();238    const isYest =239      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();240    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });241    if (isToday) return `Today ${t}`;242    if (isYest) return `Yesterday ${t}`;243    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;244  }245246  function reasonLabel(reason: VaultSnapshot["reason"]): string {247    return (248      {249        local_save: "saved",250        sync_received: "synced",251        import: "imported",252        restore: "restored",253      } as const254    )[reason] ?? reason;255  }256257  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);258259  const [showChangePw, setShowChangePw] = createSignal(false);260  const [changePwCurrent, setChangePwCurrent] = createSignal("");261  const [changePwNew, setChangePwNew] = createSignal("");262  const [changePwConfirm, setChangePwConfirm] = createSignal("");263  const [changePwSaving, setChangePwSaving] = createSignal(false);264  const [changePwError, setChangePwError] = createSignal<string | null>(null);265  const [changePwSuccess] = createSignal(false);266  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);267268  const [duressEnabled, setDuressEnabled] = createSignal(false);269  const [showDuressSetup, setShowDuressSetup] = createSignal(false);270  const [duressPassword, setDuressPassword] = createSignal("");271  const [duressConfirm, setDuressConfirm] = createSignal("");272  const [duressMainPassword, setDuressMainPassword] = createSignal("");273  const [duressSaving, setDuressSaving] = createSignal(false);274  const [duressError, setDuressError] = createSignal<string | null>(null);275  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);276277  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);278279  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);280  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);281282  const [fbCategory, setFbCategory] = createSignal("Bug report");283  const [fbMessage, setFbMessage] = createSignal("");284  const [fbSubmitting, setFbSubmitting] = createSignal(false);285  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);286287  createEffect(() => {288    if (props.upgradeHighlight) {289      setActiveCard("pro");290    }291  });292293  onMount(async () => {294    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([295      sendMessage({ type: "GET_LICENSE" }),296      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),297      sendMessage({ type: "IS_DURESS_ENABLED" }),298      sendMessage({ type: "GET_SETTINGS" }),299      sendMessage({ type: "GET_LICENSE_KEY" }),300    ]);301302    // Vault history loads in the background — the Vault sub-view picks it up303    // when the user navigates in. Loading it lazily keeps Settings boot fast.304    void loadVaultHistory();305306    if ((licenseRes as { ok: boolean }).ok) {307      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);308    }309310    if ((passkeyRes as { ok: boolean }).ok) {311      setPasskeyState(312        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null313      );314    }315316    if ((duressRes as { ok: boolean }).ok) {317      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);318    }319320    if ((settingsRes as { ok: boolean }).ok) {321      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;322      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);323    }324325    if ((keyRes as { ok: boolean }).ok) {326      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);327    }328329    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });330    if ((entriesRes as { ok: boolean }).ok) {331      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];332      setCanaryEntries(333        entries334          .filter((e) => e.canary?.webhookUrl)335          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))336      );337    }338339340    if (isNativePlatform()) {341      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");342      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");343      try {344        if (biometric) {345          const a = await biometric.isAvailable();346          setBiometricSupported(a.available);347        }348        if (keystore) {349          const k = await keystore.hasMasterKey();350          setBiometricStored(k.exists);351        }352      } catch { /* missing plugins — leave defaults */ }353    }354  });355356  async function handleEnableBiometrics() {357    setBiometricBusy(true);358    setBiometricError(null);359    try {360      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {361        ok: boolean;362        data?: { masterKeyHex: string };363        error?: string;364      };365      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {366        setBiometricError(keyRes.error === "vault_locked"367          ? "unlock the vault first"368          : "couldn't read derived key");369        return;370      }371      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");372      if (!keystore) {373        setBiometricError("biometric plugin unavailable");374        return;375      }376      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });377      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });378      setBiometricStored(true);379    } catch (e) {380      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");381    } finally {382      setBiometricBusy(false);383    }384  }385386  async function handleDisableBiometrics() {387    setBiometricBusy(true);388    setBiometricError(null);389    try {390      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");391      if (keystore) await keystore.clearMasterKey();392      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });393      setBiometricStored(false);394    } catch (e) {395      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");396    } finally {397      setBiometricBusy(false);398    }399  }400401  async function handleChangePassword() {402    setChangePwError(null);403    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }404    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }405    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }406    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }407    setChangePwSaving(true);408    const res = await sendMessage({409      type: "CHANGE_PASSWORD",410      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },411    }) as { ok: boolean; error?: string };412    if (res.ok) {413      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");414      setShowChangePw(false);415      setShowNewRecoveryWords(true);416    } else {417      setChangePwError(res.error ?? "failed to change password");418    }419    setChangePwSaving(false);420  }421422  const [copied, setCopied] = createSignal(false);423424  async function copyLicenseKey(key: string) {425    await navigator.clipboard.writeText(key);426    setCopied(true);427    setTimeout(() => setCopied(false), 2000);428  }429430  async function handleActivateLicense() {431    const key = licenseKeyInput().trim();432    if (!key) return;433    setActivatingLicense(true);434    setLicenseError(null);435    setLicenseSuccess(null);436    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });437    setActivatingLicense(false);438    if ((res as { ok: boolean }).ok) {439      const data = (res as { ok: boolean; data?: LicenseInfo }).data;440      if (data?.valid) {441        setLicenseInfo(data);442        setLicenseKey(key);443        setLicenseSuccess("License activated!");444        setLicenseKeyInput("");445      } else {446        setLicenseError("Invalid license key");447      }448    } else {449      setLicenseError("Activation failed");450    }451  }452453  async function handleTogglePasskeys() {454    const current = passkeyState();455    if (!current || !current.supported) return;456    setPasskeySaving(true);457    const res = await sendMessage({458      type: "SET_PASSKEY_PROVIDER_ENABLED",459      payload: { enabled: !current.enabled },460    });461    setPasskeySaving(false);462    if ((res as { ok: boolean }).ok) {463      setPasskeyState(464        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null465      );466    }467  }468469  async function handleCheckAllBreaches() {470    setCheckingBreaches(true);471    setBreachResult(null);472    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });473    setCheckingBreaches(false);474    if ((res as { ok: boolean }).ok) {475      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};476      const breached = Object.values(data).filter((count) => count > 0).length;477      setBreachResult(478        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"479      );480      props.onBreachCheckDone();481    }482  }483484485  async function handleSetupDuress() {486    const mainPw = duressMainPassword().trim();487    const duressPw = duressPassword().trim();488    const confirmPw = duressConfirm().trim();489490    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }491    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }492    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }493    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }494495    setDuressSaving(true);496    setDuressError(null);497498    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };499    if (!verifyRes.ok || !verifyRes.data) {500      setDuressError("incorrect main password");501      setDuressSaving(false);502      return;503    }504505    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };506    setDuressSaving(false);507    if (res.ok) {508      setDuressEnabled(true);509      setShowDuressSetup(false);510      setDuressPassword("");511      setDuressConfirm("");512      setDuressMainPassword("");513      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");514      setTimeout(() => setDuressSuccess(null), 5000);515    } else {516      setDuressError(res.error ?? "failed to set duress password");517    }518  }519520  async function handleClearDeclinedCards() {521    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });522    setDeclinedCardCount(0);523  }524525  async function handleCountDuplicates() {526    setDedupRunning(true);527    setDedupDone(null);528    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };529    setDedupRunning(false);530    if (res.ok) setDedupCount(res.data ?? 0);531  }532533  async function handleDeduplicate() {534    setDedupRunning(true);535    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };536    setDedupRunning(false);537    if (res.ok) {538      setDedupDone(res.data ?? 0);539      setDedupCount(0);540    }541  }542543  async function handleDisableDuress() {544    const mainPw = duressMainPassword().trim();545    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }546547    setDuressSaving(true);548    setDuressError(null);549550    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };551    if (!verifyRes.ok || !verifyRes.data) {552      setDuressError("incorrect main password");553      setDuressSaving(false);554      return;555    }556557    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };558    setDuressSaving(false);559    if (res.ok) {560      setDuressEnabled(false);561      setShowDuressSetup(false);562      setDuressMainPassword("");563      setDuressSuccess("Duress password disabled.");564      setTimeout(() => setDuressSuccess(null), 4000);565    } else {566      setDuressError(res.error ?? "failed to disable duress password");567    }568  }569570  async function handleSubmitFeedback() {571    if (!fbMessage().trim() || fbSubmitting()) return;572    setFbSubmitting(true);573    setFbResult(null);574    const res = await sendMessage({575      type: "SUBMIT_FEEDBACK",576      payload: {577        category: fbCategory(),578        message: fbMessage().trim(),579        version,580        browser: navigator.userAgent,581      },582    }) as { ok: boolean; error?: string };583    setFbSubmitting(false);584    if (res.ok) {585      setFbResult("success");586      setFbMessage("");587      setTimeout(() => setFbResult(null), 4000);588    } else {589      setFbResult("error");590    }591  }592593  function renderSecurity() {594    return (595      <div class="peach-settings-body">596        <div class="peach-settings-group">597          <p class="peach-settings-sub-label">LOCKING</p>598          <div class="peach-settings-row">599            <span>Lock on close</span>600            <button601              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}602              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}603              aria-label="Toggle lock on close"604            >605              <span class="peach-toggle-knob"></span>606            </button>607          </div>608          <div class="peach-settings-row">609            <span>Auto-lock after</span>610            <select611              class="peach-settings-select"612              value={String(props.autoLockMinutes)}613              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}614            >615              <option value="1">1 minute</option>616              <option value="5">5 minutes</option>617              <option value="15">15 minutes</option>618              <option value="30">30 minutes</option>619              <option value="0">Never</option>620            </select>621          </div>622623          <Show when={isNativePlatform() && biometricSupported()}>624            <p class="peach-settings-sub-label">BIOMETRICS</p>625            <div class="peach-settings-row">626              <span>Unlock with biometrics</span>627              <button628                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}629                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}630                disabled={biometricBusy()}631                aria-label="Toggle biometric unlock"632              >633                <span class="peach-toggle-knob"></span>634              </button>635            </div>636            <Show when={biometricError()}>637              <div class="peach-settings-row peach-settings-row--meta">638                <span class="peach-hint peach-hint-error">{biometricError()}</span>639              </div>640            </Show>641          </Show>642643          <p class="peach-settings-sub-label">BREACH PROTECTION</p>644          <div class="peach-settings-row">645            <span>Breach monitoring</span>646            <button647              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}648              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}649              aria-label="Toggle breach monitoring"650            >651              <span class="peach-toggle-knob"></span>652            </button>653          </div>654          <div class="peach-settings-row peach-settings-row--meta">655            <span>Check all passwords</span>656            <button657              class="peach-btn peach-btn--subtle peach-btn--sm"658              onClick={() => void handleCheckAllBreaches()}659              disabled={checkingBreaches()}660            >661              {checkingBreaches() ? "checking..." : "run"}662            </button>663          </div>664          <Show when={breachResult()}>665            <div class="peach-settings-row peach-settings-row--meta">666              <span>Last check</span>667              <span class="peach-settings-value">{breachResult()}</span>668            </div>669          </Show>670671          <p class="peach-settings-sub-label">MASTER PASSWORD</p>672          <div class="peach-settings-row peach-settings-row--meta">673            <span>Master password</span>674            <button675              class="peach-btn peach-btn--subtle peach-btn--sm"676              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}677            >678              {showChangePw() ? "cancel" : "change"}679            </button>680          </div>681          <Show when={showChangePw()}>682            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">683              <input684                class="peach-settings-input"685                type="password"686                placeholder="current password"687                value={changePwCurrent()}688                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}689                autocomplete="current-password"690              />691              <input692                class="peach-settings-input"693                type="password"694                placeholder="new password (min 12 chars)"695                value={changePwNew()}696                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}697                autocomplete="new-password"698              />699              <input700                class="peach-settings-input"701                type="password"702                placeholder="confirm new password"703                value={changePwConfirm()}704                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}705                autocomplete="new-password"706                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}707              />708              <Show when={changePwError()}>709                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>710              </Show>711              <Show when={changePwSuccess()}>712                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>713              </Show>714              <button715                class="peach-btn peach-btn--accent peach-btn--sm"716                style="align-self:flex-start"717                onClick={() => void handleChangePassword()}718                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}719              >720                {changePwSaving() ? "saving…" : "save new password"}721              </button>722            </div>723          </Show>724725          <p class="peach-settings-sub-label">726            DURESS PASSWORD727            <Show when={!isPro()}>728              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>729            </Show>730          </p>731          <div class="peach-settings-row peach-settings-row--top">732            <div class="peach-settings-row-main">733              <span>734                Duress password735                <Show when={!isPro()}>736                  <span class="peach-pro-badge">Pro</span>737                </Show>738              </span>739              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">740                If entered under coercion, shows a decoy empty vault instead of your real one.741              </span>742            </div>743            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>744              {duressEnabled() ? "on" : "off"}745            </div>746          </div>747748          <Show when={duressEnabled() && !showDuressSetup()}>749            <div class="peach-settings-row peach-settings-row--meta">750              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">751                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.752              </span>753            </div>754          </Show>755756          <Show when={duressSuccess()}>757            <div class="peach-settings-row peach-settings-row--meta">758              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>759            </div>760          </Show>761762          <Show when={!showDuressSetup()}>763            <div class="peach-settings-row peach-settings-row--meta">764              <button765                class="peach-btn peach-btn--subtle peach-btn--sm"766                onClick={() => {767                  // Pro gate entry-point: free users are redirected to the768                  // upgrade screen before the setup form opens. The server-769                  // side SETUP_DURESS handler is the single enforcement770                  // point; this is UI redirection so the form never appears771                  // for a user who can't complete it.772                  if (!isPro() && !duressEnabled()) {773                    props.onProGate?.("duress_vault");774                    setActiveCard("pro");775                    return;776                  }777                  setShowDuressSetup(true);778                  setDuressError(null);779                  setDuressSuccess(null);780                  setDuressPassword("");781                  setDuressConfirm("");782                  setDuressMainPassword("");783                }}784              >785                {duressEnabled() ? "Change / disable" : "Set up"}786              </button>787            </div>788          </Show>789790          <Show when={showDuressSetup()}>791            <div class="peach-duress-form">792              <label class="peach-label">main password (to confirm)</label>793              <input794                class="peach-settings-input"795                type="password"796                placeholder="your current master password"797                autocomplete="current-password"798                value={duressMainPassword()}799                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}800              />801              <Show when={!duressEnabled()}>802                <label class="peach-label">new duress password</label>803                <input804                  class="peach-settings-input"805                  type="password"806                  placeholder="duress password (min 8 chars)"807                  autocomplete="new-password"808                  value={duressPassword()}809                  onInput={(e) => setDuressPassword(e.currentTarget.value)}810                />811                <label class="peach-label">confirm duress password</label>812                <input813                  class="peach-settings-input"814                  type="password"815                  placeholder="confirm duress password"816                  autocomplete="new-password"817                  value={duressConfirm()}818                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}819                />820              </Show>821822              <Show when={duressError()}>823                <p class="peach-hint peach-hint-error">{duressError()}</p>824              </Show>825826              <div class="peach-duress-actions">827                <button828                  class="peach-btn peach-btn--subtle peach-btn--sm"829                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}830                >831                  cancel832                </button>833                <Show when={duressEnabled()}>834                  <button835                    class="peach-btn peach-btn--ghost peach-btn--sm"836                    style="color:var(--peach-breach)"837                    onClick={() => void handleDisableDuress()}838                    disabled={duressSaving()}839                  >840                    {duressSaving() ? "disabling..." : "disable duress"}841                  </button>842                </Show>843                <Show when={!duressEnabled()}>844                  <button845                    class="peach-btn peach-btn--accent peach-btn--sm"846                    onClick={() => void handleSetupDuress()}847                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}848                  >849                    {duressSaving() ? "saving..." : "save"}850                  </button>851                </Show>852              </div>853            </div>854          </Show>855856          <p class="peach-settings-sub-label">857            CANARY ENTRIES858            <Show when={!isPro()}>859              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>860            </Show>861          </p>862          <div class="peach-settings-row peach-settings-row--top">863            <div class="peach-settings-row-main">864              <span>865                Canary entries866                <Show when={!isPro()}>867                  <span class="peach-pro-badge">Pro</span>868                </Show>869              </span>870              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">871                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.872              </span>873            </div>874            <span class="peach-settings-value" style="flex-shrink:0">875              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}876            </span>877          </div>878          <Show when={canaryEntries().length > 0}>879            <For each={canaryEntries()}>880              {(entry) => (881                <div class="peach-settings-row peach-settings-row--meta">882                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>883                  <button884                    class="peach-btn peach-btn--subtle peach-btn--sm"885                    onClick={() => void handleTestCanary(entry)}886                    disabled={canaryTestingId() === entry.id}887                    style="flex-shrink:0"888                  >889                    {canaryTestingId() === entry.id890                      ? "sending…"891                      : canaryTestResult()?.id === entry.id892                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")893                        : "test"}894                  </button>895                </div>896              )}897            </For>898          </Show>899900          <p class="peach-settings-sub-label">PASSKEYS</p>901          <div class="peach-settings-row peach-settings-row--top">902            <div class="peach-settings-row-main">903              <span>Intercept passkey requests</span>904              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">905                Peach can handle passkey sign-in and creation on supported Chromium browsers.906              </span>907            </div>908            <button909              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}910              onClick={() => void handleTogglePasskeys()}911              aria-label="Toggle passkey interception"912              disabled={!passkeyState()?.supported || passkeySaving()}913            >914              <span class="peach-toggle-knob"></span>915            </button>916          </div>917          <div class="peach-settings-row peach-settings-row--meta">918            <span>Status</span>919            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>920          </div>921          <div class="peach-settings-row peach-settings-row--meta">922            <span>Browser</span>923            <span class="peach-settings-value">924              {passkeyState()?.browserLabel ?? "Unknown"}925              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}926            </span>927          </div>928          <div class="peach-info-notice" style="margin-top:8px">929            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.930          </div>931          <Show when={passkeyState()?.conflict}>932            <div class="peach-settings-row peach-settings-row--meta">933              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>934            </div>935          </Show>936        </div>937      </div>938    );939  }940941  function renderVault() {942    return (943      <div class="peach-settings-body">944        <div class="peach-settings-group">945          <p class="peach-settings-sub-label">ENTRIES</p>946          <div class="peach-settings-row peach-settings-row--meta">947            <span>Entries</span>948            <span class="peach-settings-value">{props.entriesCount}</span>949          </div>950          <div class="peach-settings-row peach-settings-row--meta">951            <span>Duplicates</span>952            <Show when={dedupDone() !== null} fallback={953              <button954                class="peach-btn peach-btn--subtle peach-btn--sm"955                onClick={() => void handleCountDuplicates()}956                disabled={dedupRunning()}957              >958                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}959              </button>960            }>961              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">962                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}963              </span>964            </Show>965          </div>966          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>967            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">968              <div class="peach-restore-warning">969                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.970              </div>971              <div style="display:flex;gap:6px">972                <button973                  class="peach-btn peach-btn--subtle peach-btn--sm"974                  onClick={() => setDedupCount(null)}975                >976                  cancel977                </button>978                <button979                  class="peach-btn peach-btn--accent peach-btn--sm"980                  onClick={() => void handleDeduplicate()}981                  disabled={dedupRunning()}982                >983                  {dedupRunning() ? "removing..." : "remove duplicates"}984                </button>985              </div>986            </div>987          </Show>988989          <p class="peach-settings-sub-label">MAINTENANCE</p>990          <div class="peach-settings-row peach-settings-row--meta">991            <span>Trash auto-delete</span>992            <select993              class="peach-settings-select"994              value={String(props.trashRetentionDays)}995              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}996            >997              <option value="7">7 days</option>998              <option value="30">30 days</option>999              <option value="90">90 days</option>1000              <option value="0">Never</option>1001            </select>1002          </div>1003          <div class="peach-settings-row peach-settings-row--meta">1004            <span>Vault Health</span>1005            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1006              open1007            </button>1008          </div>1009          <Show when={declinedCardCount() > 0}>1010            <div class="peach-settings-row peach-settings-row--meta">1011              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1012              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1013                Clear1014              </button>1015            </div>1016          </Show>10171018          <p class="peach-settings-sub-label">DATA</p>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Import passwords</span>1021            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1022              open1023            </button>1024          </div>1025          <div class="peach-settings-row peach-settings-row--meta">1026            <span>Export backup</span>1027            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1028              open1029            </button>1030          </div>1031          <div class="peach-settings-row peach-settings-row--meta">1032            <span>1033              Peach Codex1034              <Show when={!isPro()}>1035                <span class="peach-pro-badge">Pro</span>1036              </Show>1037            </span>1038            <button1039              class="peach-btn peach-btn--subtle peach-btn--sm"1040              onClick={() => props.onOpenCodexGenerate?.()}1041            >1042              open1043            </button>1044          </div>1045          {/* Restore from Codex: intentionally never gated — a printed1046              Codex must remain restorable regardless of license state. */}1047          <div class="peach-settings-row peach-settings-row--meta">1048            <span>Restore from Codex</span>1049            <button1050              class="peach-btn peach-btn--subtle peach-btn--sm"1051              onClick={() => props.onOpenCodexRestore?.()}1052            >1053              open1054            </button>1055          </div>10561057          <p class="peach-settings-sub-label">VAULT HISTORY</p>1058          <Show when={historyLoading()}>1059            <p class="peach-settings-hint">Loading…</p>1060          </Show>1061          <Show when={!historyLoading() && historySnapshots().length === 0}>1062            <p class="peach-settings-hint">1063              No snapshots yet. Peach saves automatically before every change —1064              saves, syncs, and imports.1065            </p>1066          </Show>1067          <Show when={!historyLoading() && historySnapshots().length > 0}>1068            <p class="peach-settings-hint">1069              Restoring locks your vault. Unlock with the password that1070              encrypted that snapshot to confirm.1071            </p>1072            <div class="peach-history-list">1073              <For each={historySnapshots()}>1074                {(snap) => (1075                  <div class="peach-history-row">1076                    <div class="peach-history-meta">1077                      <span class="peach-history-time">1078                        {formatSnapshotTime(snap.savedAt)}1079                      </span>1080                      <span class="peach-history-sub">1081                        {[1082                          snap.entryCount !== null1083                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1084                            : null,1085                          reasonLabel(snap.reason),1086                        ]1087                          .filter(Boolean)1088                          .join(" · ")}1089                      </span>1090                    </div>1091                    <button1092                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1093                        historyRestoring() === snap.id ? " is-busy" : ""1094                      }`}1095                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1096                      disabled={historyRestoring() !== null}1097                    >1098                      <Show when={historyRestoring() === snap.id} fallback="Restore">1099                        …1100                      </Show>1101                    </button>1102                  </div>1103                )}1104              </For>1105            </div>1106            <p class="peach-history-footer">1107              {historySnapshots().length} of 10 snapshots1108            </p>1109          </Show>11101111          <button1112            class="peach-settings-advanced-toggle"1113            onClick={() => setShowVaultAdvanced((v) => !v)}1114            aria-expanded={showVaultAdvanced()}1115          >1116            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1117            <span class="peach-settings-advanced-chevron">1118              {showVaultAdvanced() ? "▲" : "▼"}1119            </span>1120          </button>11211122          <Show when={showVaultAdvanced()}>1123            <div class="peach-danger-card">1124              <div class="peach-danger-card-head">1125                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1126                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1127              </div>1128              <p class="peach-danger-card-copy">1129                Wipes the current vault and restores from a backup file.1130                Recoverable only if your backup is good.1131              </p>1132              <button1133                class="peach-danger-card-button"1134                onClick={() => props.onOpenFileImport?.()}1135              >1136                Choose backup file1137              </button>1138            </div>11391140            <Show when={props.onOpenPanic}>1141              <div class="peach-danger-card peach-danger-card--hazard">1142                <div class="peach-danger-card-head">1143                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1144                  <h3 class="peach-danger-card-title">Destroy vault</h3>1145                </div>1146                <p class="peach-danger-card-copy">1147                  Permanently wipes every entry, setting, and license on this1148                  device. There is no recovery.1149                </p>1150                <button1151                  class="peach-danger-card-button"1152                  onClick={() => props.onOpenPanic?.()}1153                >1154                  Begin destruction1155                </button>1156              </div>1157            </Show>1158          </Show>1159        </div>1160      </div>1161    );1162  }11631164  function renderSync() {1165    return (1166      <div class="peach-settings-body">1167        <div class="peach-settings-group">1168          <p class="peach-settings-sub-label">PAIRING</p>1169          <div class="peach-settings-row peach-settings-row--top">1170            <div class="peach-settings-row-main">1171              <span>1172                Peach Sync1173                <Show when={!isPro()}>1174                  <span class="peach-pro-badge">Pro</span>1175                </Show>1176              </span>1177              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1178                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1179              </span>1180            </div>1181            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1182          </div>1183          <div class="peach-settings-row peach-settings-row--meta">1184            <span>Status</span>1185            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1186          </div>1187          <div class="peach-settings-row peach-settings-row--meta">1188            <span>Sync hub</span>1189            <button1190              class="peach-btn peach-btn--subtle peach-btn--sm"1191              onClick={() => props.onOpenSyncPairing?.()}1192            >1193              open1194            </button>1195          </div>11961197          <p class="peach-settings-sub-label">DEVICE FLOW</p>1198          <div class="peach-settings-row peach-settings-row--top">1199            <div class="peach-settings-row-main">1200              <span>Pair new device</span>1201              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1202                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1203              </span>1204            </div>1205            <button1206              class="peach-btn peach-btn--subtle peach-btn--sm"1207              onClick={() => props.onOpenSyncPairing?.()}1208            >1209              pair1210            </button>1211          </div>1212          <div class="peach-info-notice" style="margin-top:8px">1213            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1214          </div>1215        </div>1216      </div>1217    );1218  }12191220  function renderAppearance() {1221    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1222      { id: "small", label: "Small" },1223      { id: "medium", label: "Default" },1224      { id: "large", label: "Large" },1225    ];1226    return (1227      <div class="peach-settings-body">1228        <div class="peach-settings-group">1229          <p class="peach-settings-sub-label">THEME</p>1230          <div class="peach-settings-row peach-settings-row--meta">1231            <span>Theme</span>1232            <button1233              class="peach-btn peach-btn--subtle peach-btn--sm"1234              onClick={() =>1235                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1236              }1237            >1238              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1239            </button>1240          </div>12411242          <p class="peach-settings-sub-label">ACCENT</p>1243          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1244            <span>Accent</span>1245            <div class="peach-accent-picker">1246              <For each={ACCENT_COLORS}>1247                {(color) => (1248                  <button1249                    class="peach-accent-swatch peach-swatch"1250                    classList={{ "is-active": props.accentColor === color.name }}1251                    style={{ background: color.hex }}1252                    onClick={() => void props.onAccentChange(color.name)}1253                    aria-label={color.name}1254                    title={color.name}1255                  />1256                )}1257              </For>1258            </div>1259          </div>12601261          <p class="peach-settings-sub-label">TEXT SIZE</p>1262          <div class="peach-settings-row peach-settings-row--meta">1263            <span>Size</span>1264            <div class="peach-pill-group">1265              <For each={sizeOptions}>1266                {(opt) => (1267                  <button1268                    class="peach-btn peach-btn--ghost peach-btn--sm"1269                    classList={{ "is-active": props.textSize === opt.id }}1270                    onClick={() => void props.onTextSizeChange(opt.id)}1271                  >1272                    {opt.label}1273                  </button>1274                )}1275              </For>1276            </div>1277          </div>12781279          <p class="peach-settings-sub-label">MOTION</p>1280          <div class="peach-settings-row peach-settings-row--meta">1281            <span>Reduce motion</span>1282            <button1283              class="peach-btn peach-btn--subtle peach-btn--sm"1284              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1285            >1286              {props.reduceMotion ? "On" : "Off"}1287            </button>1288          </div>1289        </div>1290      </div>1291    );1292  }12931294  function renderShortcuts() {1295    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1296    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12971298    function startCapture(action: ShortcutAction) {1299      setConflictMsg(null);1300      setCapturingAction(action);1301    }1302    function cancelCapture() {1303      setCapturingAction(null);1304      setConflictMsg(null);1305    }1306    function commitBinding(action: ShortcutAction, binding: Keybinding) {1307      const conflict = findConflict(props.shortcuts, binding, action);1308      if (conflict) {1309        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1310        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1311        return;1312      }1313      const next = { ...props.shortcuts, [action]: binding };1314      setCapturingAction(null);1315      setConflictMsg(null);1316      void props.onShortcutsChange(next);1317    }1318    function resetAction(action: ShortcutAction) {1319      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1320      void props.onShortcutsChange(next);1321    }1322    function resetAll() {1323      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1324    }13251326    // Capture-mode key handler — wired via ref onto the chip element so it1327    // catches the next non-modifier keypress and ignores other bindings.1328    function handleCaptureKey(e: KeyboardEvent) {1329      e.preventDefault();1330      e.stopPropagation();1331      if (e.key === "Escape") { cancelCapture(); return; }1332      const binding = keybindingFromEvent(e);1333      if (!binding) return; // bare modifier — keep waiting1334      const action = capturingAction();1335      if (action) commitBinding(action, binding);1336    }13371338    return (1339      <div class="peach-settings-body">1340        <div class="peach-settings-group peach-shortcuts-group">1341          <p class="peach-settings-sub-label">ENTRY VIEW</p>1342          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1343            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1344          </For>1345        </div>13461347        <div class="peach-settings-group peach-shortcuts-group">1348          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1349          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1350            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1351          </For>1352        </div>13531354        <Show when={conflictMsg()}>1355          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1356        </Show>13571358        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1359          <div class="peach-settings-row peach-settings-row--meta">1360            <span>Inline autofill on page</span>1361            <a1362              class="peach-settings-link"1363              href="chrome://extensions/shortcuts"1364              onClick={(e) => {1365                e.preventDefault();1366                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1367              }}1368            >1369              ⌘⇧P — customize ↗1370            </a>1371          </div>1372          <button1373            class="peach-btn peach-btn--subtle peach-btn--sm"1374            style="align-self:flex-start"1375            onClick={resetAll}1376          >1377            Reset all to defaults1378          </button>1379        </div>1380      </div>1381    );13821383    function renderRow(1384      desc: typeof SHORTCUT_DESCRIPTORS[number],1385      capturing: () => ShortcutAction | null,1386      onStart: (a: ShortcutAction) => void,1387      onCancel: () => void,1388      onReset: (a: ShortcutAction) => void,1389      onKey: (e: KeyboardEvent) => void,1390    ) {1391      const isActive = () => capturing() === desc.action;1392      const binding = () => props.shortcuts[desc.action];1393      const isCustom = () => {1394        const b = binding();1395        const d = DEFAULT_BINDINGS[desc.action];1396        return b.key !== d.key1397          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1398          || Boolean(b.meta)  !== Boolean(d.meta)1399          || Boolean(b.shift) !== Boolean(d.shift)1400          || Boolean(b.alt)   !== Boolean(d.alt);1401      };1402      return (1403        <div class="peach-shortcut-row">1404          <span class="peach-shortcut-row-label">{desc.label}</span>1405          <button1406            class="peach-shortcut-chip"1407            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1408            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1409            onKeyDown={(e) => { if (isActive()) onKey(e); }}1410            ref={(el) => {1411              // Auto-focus when entering capture mode so the keydown listener catches the next press.1412              if (el) {1413                queueMicrotask(() => { if (isActive()) el.focus(); });1414              }1415            }}1416          >1417            {isActive() ? "Press a key…" : formatKeybinding(binding())}1418          </button>1419          <Show when={isCustom() && !isActive()}>1420            <button1421              class="peach-shortcut-reset"1422              title="Reset to default"1423              onClick={() => onReset(desc.action)}1424            >1425              ↺1426            </button>1427          </Show>1428        </div>1429      );1430    }1431  }14321433  const PRO_FEATURES: Array<{1434    feature: ProFeature;1435    name: string;1436    desc: string;1437  }> = [1438    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1439    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1440    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1441    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1442    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1443    { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1444  ];14451446  const PRO_SECURITY_FEATURES: Array<{1447    feature: ProFeature;1448    name: string;1449    desc: string;1450  }> = [1451    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1452    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1453  ];14541455  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1456  const [showKey, setShowKey] = createSignal(false);14571458  const [deactivating, setDeactivating] = createSignal(false);14591460  async function handleDeactivateLicense() {1461    setDeactivating(true);1462    await sendMessage({ type: "DEACTIVATE_LICENSE" });1463    setLicenseInfo(null);1464    await props.onLicenseChange?.();1465    setDeactivating(false);1466  }14671468  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1469    setCanaryTestingId(entry.id);1470    setCanaryTestResult(null);1471    const res = await sendMessage({1472      type: "FIRE_CANARY_PING",1473      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1474    }) as { ok: boolean };1475    setCanaryTestResult({ id: entry.id, ok: res.ok });1476    setCanaryTestingId(null);1477    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1478  }14791480  function renderLicense() {1481    async function onActivate() {1482      await handleActivateLicense();1483      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1484        await props.onLicenseChange?.();1485      }1486    }14871488    return (1489      <div class="peach-settings-body">1490        <Show1491          when={isPro()}1492          fallback={1493                <div class="peach-pro-upgrade">1494                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14951496                  <h2 class="peach-pro-headline">1497                    Every<em>thing,</em><br />every<em>where.</em>1498                  </h2>14991500                  <p class="peach-pro-subtitle">1501                    One key unlocks every Pro feature — for the lifetime of the app.1502                  </p>15031504                  <div class="peach-pro-grid">1505                    <For each={PRO_FEATURES}>1506                      {(item, i) => (1507                        <div1508                          class="peach-pro-grid-item"1509                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1510                        >1511                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1512                          <span class="peach-pro-grid-name">{item.name}</span>1513                          <span class="peach-pro-grid-desc">{item.desc}</span>1514                        </div>1515                      )}1516                    </For>1517                  </div>15181519                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1520                  <div class="peach-pro-grid">1521                    <For each={PRO_SECURITY_FEATURES}>1522                      {(item, i) => (1523                        <div1524                          class="peach-pro-grid-item"1525                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1526                        >1527                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1528                          <span class="peach-pro-grid-name">{item.name}</span>1529                          <span class="peach-pro-grid-desc">{item.desc}</span>1530                        </div>1531                      )}1532                    </For>1533                  </div>15341535                  <div class="peach-pro-upgrade-actions">1536                    <p class="peach-pro-pricing">1537                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1538                    </p>1539                    <a1540                      href="https://peachpasswords.com/#pricing"1541                      target="_blank"1542                      rel="noopener noreferrer"1543                      class="peach-pro-upgrade-cta"1544                    >1545                      Get Peach Pro1546                    </a>1547                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1548                    <div class="peach-pro-key-inline">1549                      <input1550                        class="peach-pro-key-inline-input"1551                        type="text"1552                        placeholder="PEACH-XXXX-XXXX-XXXX"1553                        value={licenseKeyInput()}1554                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1555                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1556                        autocomplete="off"1557                        spellcheck={false}1558                      />1559                      <button1560                        class="peach-pro-key-apply"1561                        onClick={() => void onActivate()}1562                        disabled={activatingLicense() || !licenseKeyInput().trim()}1563                      >1564                        {activatingLicense() ? "…" : "APPLY"}1565                      </button>1566                    </div>1567                    <Show when={licenseError()}>1568                      <p class="peach-pro-key-error">{licenseError()}</p>1569                    </Show>1570                  </div>1571                </div>1572          }1573        >1574          <div class="peach-pro-active">1575            <div class="peach-pro-active-hero">1576              <div class="peach-pro-active-mark-wrap">1577                <PeachMark class="peach-pro-active-mark" />1578              </div>1579              <h2 class="peach-pro-active-title">Peach Pro</h2>1580              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1581            </div>15821583            <Show when={licenseKey()}>1584              <div class="peach-settings-license">1585                <span class="peach-settings-label">LICENSE KEY</span>1586                <div class="peach-settings-license-row">1587                  <Show1588                    when={showKey()}1589                    fallback={1590                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1591                        ••••••••••••••••••••••••••••1592                      </span>1593                    }1594                  >1595                    <span class="peach-settings-license-key">{licenseKey()!}</span>1596                  </Show>1597                  <div class="peach-settings-license-actions">1598                    <button1599                      class="peach-settings-license-copy"1600                      title="Copy license key"1601                      onClick={() => copyLicenseKey(licenseKey()!)}1602                    >1603                      {copied() ? "✓" : "copy"}1604                    </button>1605                    <button1606                      class="peach-settings-license-toggle"1607                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1608                      title={showKey() ? "Hide" : "Reveal"}1609                      onClick={() => setShowKey(v => !v)}1610                    >1611                      <Show1612                        when={showKey()}1613                        fallback={1614                          <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">1615                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1616                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1617                          </svg>1618                        }1619                      >1620                        <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">1621                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1622                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1623                          <line x1="2" y1="2" x2="12" y2="12" />1624                        </svg>1625                      </Show>1626                    </button>1627                  </div>1628                </div>1629              </div>1630            </Show>16311632            <div class="peach-pro-active-features">1633              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1634                {(item) => (1635                  <div class="peach-pro-active-feature">1636                    <span class="peach-pro-active-feature-mark">✦</span>1637                    <span class="peach-pro-active-feature-name">{item.name}</span>1638                  </div>1639                )}1640              </For>1641            </div>16421643            <button1644              class="peach-pro-deactivate"1645              onClick={() => void handleDeactivateLicense()}1646              disabled={deactivating()}1647            >1648              {deactivating() ? "Deactivating…" : "Deactivate"}1649            </button>1650          </div>1651        </Show>1652      </div>1653    );1654  }16551656  function renderFeedback() {1657    return (1658      <div class="peach-settings-body">1659        <div class="peach-settings-group peach-feedback-group">1660          <p class="peach-feedback-intro">1661            Help us improve Peach. Every report goes directly to our inbox.1662          </p>16631664          <p class="peach-settings-sub-label">TYPE</p>1665          <div class="peach-feedback-chips">1666            <For each={FEEDBACK_CATEGORIES}>1667              {(cat) => (1668                <button1669                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1670                  onClick={() => setFbCategory(cat)}1671                >1672                  {cat}1673                </button>1674              )}1675            </For>1676          </div>16771678          <p class="peach-settings-sub-label">MESSAGE</p>1679          <textarea1680            class="peach-feedback-textarea"1681            placeholder="Describe the issue or idea..."1682            value={fbMessage()}1683            onInput={(e) => setFbMessage(e.currentTarget.value)}1684          />16851686          <p class="peach-feedback-meta">1687            Version {version} · will be included automatically1688          </p>16891690          <Show when={fbResult() === "success"}>1691            <p class="peach-feedback-result peach-feedback-result--ok">1692              Sent — thank you1693            </p>1694          </Show>1695          <Show when={fbResult() === "error"}>1696            <p class="peach-feedback-result peach-feedback-result--err">1697              Failed to send — try again1698            </p>1699          </Show>17001701          <button1702            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1703            onClick={() => void handleSubmitFeedback()}1704            disabled={fbSubmitting() || !fbMessage().trim()}1705          >1706            {fbSubmitting() ? "Sending..." : "Send feedback"}1707          </button>17081709          <p class="peach-feedback-anon">1710            Sent anonymously · no account required1711          </p>1712        </div>1713      </div>1714    );1715  }17161717  const bodies: StackBodies = {1718    protection: renderSecurity(),1719    vault: renderVault(),1720    sync: renderSync(),1721    appearance: renderAppearance(),1722    shortcuts: renderShortcuts(),1723    pro: renderLicense(),1724    feedback: renderFeedback(),1725  };17261727  return (1728    <Show1729      when={showNewRecoveryWords()}1730      fallback={1731        <div1732          class="peach-overlay-view"1733          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1734          style="display:flex;flex-direction:column"1735        >1736          <StackDeck1737            active={activeCard()}1738            onActiveChange={setActiveCard}1739            onBack={props.onBack}1740            cards={cards()}1741            bodies={bodies}1742          />1743        </div>1744      }1745    >1746      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1747    </Show>1748  );1749}