Settings.tsx

68.1 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  onBack: () => void;57  onImport: () => void;58  onExport: () => void;59  onBreachCheckDone: () => void;60  onOpenHealth?: () => void;61  onOpenSyncPairing?: () => void;62  onOpenFileImport?: () => void;63  onOpenPanic?: () => void;64  onOpenCodexGenerate?: () => void;65  onOpenCodexRestore?: () => void;66  /** Invoked from within Settings when a Pro-gated control is tapped on67   *  free tier. App owns the upgrade navigation + highlight state, so this68   *  callback is the single way for Settings to request that navigation. */69  onProGate?: (feature: ProFeature) => void;70}7172const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7374function formatActivatedSince(issued: string | undefined): string {75  if (!issued) return "Active";76  try {77    const d = new Date(issued);78    if (Number.isNaN(d.getTime())) return "Active";79    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;80  } catch {81    return "Active";82  }83}8485function initialSectionToCard(value: string | null | undefined): CardId | null {86  if (!value) return null;87  if (value === "security") return "protection";88  if (value === "license") return "pro";89  if (90    value === "protection" ||91    value === "vault" ||92    value === "sync" ||93    value === "appearance" ||94    value === "shortcuts" ||95    value === "pro" ||96    value === "feedback"97  ) {98    return value;99  }100  return null;101}102103export function SettingsView(props: SettingsProps) {104  const manifest = chrome.runtime.getManifest();105  const version = manifest.version;106  const initial = props.initialSection;107  const [activeCard, setActiveCard] = createSignal<CardId>(108    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"109  );110111  const [checkingBreaches, setCheckingBreaches] = createSignal(false);112  const [breachResult, setBreachResult] = createSignal<string | null>(null);113  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);114  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");115  const [licenseError, setLicenseError] = createSignal<string | null>(null);116  const [, setLicenseSuccess] = createSignal<string | null>(null);117  const [activatingLicense, setActivatingLicense] = createSignal(false);118119  const isPro = () => props.isPro ?? false;120  const cards = createMemo<StackCardSpec[]>(() => [121    {122      id: "protection",123      num: "I · PROTECT",124      title: "Protection",125      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,126      bgVar: "--stack-protect-bg",127      fgVar: "--stack-protect-fg",128      icon: "shield",129    },130    {131      id: "vault",132      num: "II · KEEP",133      title: "Vault",134      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,135      bgVar: "--stack-vault-bg",136      fgVar: "--stack-vault-fg",137      icon: "lock",138    },139    {140      id: "sync",141      num: "III · ECHO",142      title: "Sync",143      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",144      bgVar: "--stack-sync-bg",145      fgVar: "--stack-sync-fg",146      icon: "sync",147    },148    {149      id: "appearance",150      num: "IV · DRESS",151      title: "Appearance",152      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,153      bgVar: "--stack-appear-bg",154      fgVar: "--stack-appear-fg",155      icon: "sun",156    },157    {158      id: "shortcuts",159      num: "V · KEYS",160      title: "Shortcuts",161      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,162      bgVar: "--stack-shortcut-bg",163      fgVar: "--stack-shortcut-fg",164      icon: "keys",165    },166    {167      id: "pro",168      num: "VI · FOREVER",169      title: "Peach Pro",170      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",171      bgVar: "--stack-pro-bg",172      fgVar: "--stack-pro-fg",173      icon: "spark",174    },175    {176      id: "feedback",177      num: "VII · ECHO",178      title: "Feedback",179      lede: `version ${version} · tell us what would make it better`,180      bgVar: "--stack-feedback-bg",181      fgVar: "--stack-feedback-fg",182      icon: "bubble",183    },184  ]);185  const [biometricSupported, setBiometricSupported] = createSignal(false);186  const [biometricStored, setBiometricStored] = createSignal(false);187  const [biometricBusy, setBiometricBusy] = createSignal(false);188  const [biometricError, setBiometricError] = createSignal<string | null>(null);189  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);190  const [passkeySaving, setPasskeySaving] = createSignal(false);191192  const [dedupCount, setDedupCount] = createSignal<number | null>(null);193  const [dedupRunning, setDedupRunning] = createSignal(false);194  const [dedupDone, setDedupDone] = createSignal<number | null>(null);195196  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);197198  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted199  // BEFORE every real-mode vault write so the user can roll back if a sync200  // or import overwrites the wrong state.201  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);202  const [historyLoading, setHistoryLoading] = createSignal(false);203  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);204205  async function loadVaultHistory() {206    setHistoryLoading(true);207    try {208      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });209      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];210      setHistorySnapshots(list);211    } finally {212      setHistoryLoading(false);213    }214  }215216  async function handleRestoreVaultSnapshot(id: string) {217    if (historyRestoring()) return;218    setHistoryRestoring(id);219    const res = await sendMessage({220      type: "RESTORE_VAULT_SNAPSHOT",221      payload: { snapshotId: id },222    });223    setHistoryRestoring(null);224    if ((res as { ok: boolean }).ok) {225      // Vault is now locked — let the parent close Settings; App.tsx's226      // VAULT_LOCKED listener will route back to the lock screen.227      props.onBack();228    }229  }230231  function formatSnapshotTime(savedAt: number): string {232    const diff = Date.now() - savedAt;233    if (diff < 60_000) return "Just now";234    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;235    const d = new Date(savedAt);236    const isToday = new Date().toDateString() === d.toDateString();237    const isYest =238      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();239    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });240    if (isToday) return `Today ${t}`;241    if (isYest) return `Yesterday ${t}`;242    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;243  }244245  function reasonLabel(reason: VaultSnapshot["reason"]): string {246    return (247      {248        local_save: "saved",249        sync_received: "synced",250        import: "imported",251        restore: "restored",252      } as const253    )[reason] ?? reason;254  }255256  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);257258  const [showChangePw, setShowChangePw] = createSignal(false);259  const [changePwCurrent, setChangePwCurrent] = createSignal("");260  const [changePwNew, setChangePwNew] = createSignal("");261  const [changePwConfirm, setChangePwConfirm] = createSignal("");262  const [changePwSaving, setChangePwSaving] = createSignal(false);263  const [changePwError, setChangePwError] = createSignal<string | null>(null);264  const [changePwSuccess] = createSignal(false);265  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);266267  const [duressEnabled, setDuressEnabled] = createSignal(false);268  const [showDuressSetup, setShowDuressSetup] = createSignal(false);269  const [duressPassword, setDuressPassword] = createSignal("");270  const [duressConfirm, setDuressConfirm] = createSignal("");271  const [duressMainPassword, setDuressMainPassword] = createSignal("");272  const [duressSaving, setDuressSaving] = createSignal(false);273  const [duressError, setDuressError] = createSignal<string | null>(null);274  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);275276  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);277278  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);279  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);280281  const [fbCategory, setFbCategory] = createSignal("Bug report");282  const [fbMessage, setFbMessage] = createSignal("");283  const [fbSubmitting, setFbSubmitting] = createSignal(false);284  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);285286  createEffect(() => {287    if (props.upgradeHighlight) {288      setActiveCard("pro");289    }290  });291292  onMount(async () => {293    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([294      sendMessage({ type: "GET_LICENSE" }),295      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),296      sendMessage({ type: "IS_DURESS_ENABLED" }),297      sendMessage({ type: "GET_SETTINGS" }),298      sendMessage({ type: "GET_LICENSE_KEY" }),299    ]);300301    // Vault history loads in the background — the Vault sub-view picks it up302    // when the user navigates in. Loading it lazily keeps Settings boot fast.303    void loadVaultHistory();304305    if ((licenseRes as { ok: boolean }).ok) {306      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);307    }308309    if ((passkeyRes as { ok: boolean }).ok) {310      setPasskeyState(311        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null312      );313    }314315    if ((duressRes as { ok: boolean }).ok) {316      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);317    }318319    if ((settingsRes as { ok: boolean }).ok) {320      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;321      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);322    }323324    if ((keyRes as { ok: boolean }).ok) {325      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);326    }327328    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });329    if ((entriesRes as { ok: boolean }).ok) {330      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];331      setCanaryEntries(332        entries333          .filter((e) => e.canary?.webhookUrl)334          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))335      );336    }337338339    if (isNativePlatform()) {340      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");341      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");342      try {343        if (biometric) {344          const a = await biometric.isAvailable();345          setBiometricSupported(a.available);346        }347        if (keystore) {348          const k = await keystore.hasMasterKey();349          setBiometricStored(k.exists);350        }351      } catch { /* missing plugins — leave defaults */ }352    }353  });354355  async function handleEnableBiometrics() {356    setBiometricBusy(true);357    setBiometricError(null);358    try {359      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {360        ok: boolean;361        data?: { masterKeyHex: string };362        error?: string;363      };364      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {365        setBiometricError(keyRes.error === "vault_locked"366          ? "unlock the vault first"367          : "couldn't read derived key");368        return;369      }370      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");371      if (!keystore) {372        setBiometricError("biometric plugin unavailable");373        return;374      }375      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });376      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });377      setBiometricStored(true);378    } catch (e) {379      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");380    } finally {381      setBiometricBusy(false);382    }383  }384385  async function handleDisableBiometrics() {386    setBiometricBusy(true);387    setBiometricError(null);388    try {389      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");390      if (keystore) await keystore.clearMasterKey();391      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });392      setBiometricStored(false);393    } catch (e) {394      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");395    } finally {396      setBiometricBusy(false);397    }398  }399400  async function handleChangePassword() {401    setChangePwError(null);402    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }403    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }404    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }405    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }406    setChangePwSaving(true);407    const res = await sendMessage({408      type: "CHANGE_PASSWORD",409      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },410    }) as { ok: boolean; error?: string };411    if (res.ok) {412      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");413      setShowChangePw(false);414      setShowNewRecoveryWords(true);415    } else {416      setChangePwError(res.error ?? "failed to change password");417    }418    setChangePwSaving(false);419  }420421  const [copied, setCopied] = createSignal(false);422423  async function copyLicenseKey(key: string) {424    await navigator.clipboard.writeText(key);425    setCopied(true);426    setTimeout(() => setCopied(false), 2000);427  }428429  async function handleActivateLicense() {430    const key = licenseKeyInput().trim();431    if (!key) return;432    setActivatingLicense(true);433    setLicenseError(null);434    setLicenseSuccess(null);435    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });436    setActivatingLicense(false);437    if ((res as { ok: boolean }).ok) {438      const data = (res as { ok: boolean; data?: LicenseInfo }).data;439      if (data?.valid) {440        setLicenseInfo(data);441        setLicenseKey(key);442        setLicenseSuccess("License activated!");443        setLicenseKeyInput("");444      } else {445        setLicenseError("Invalid license key");446      }447    } else {448      setLicenseError("Activation failed");449    }450  }451452  async function handleTogglePasskeys() {453    const current = passkeyState();454    if (!current || !current.supported) return;455    setPasskeySaving(true);456    const res = await sendMessage({457      type: "SET_PASSKEY_PROVIDER_ENABLED",458      payload: { enabled: !current.enabled },459    });460    setPasskeySaving(false);461    if ((res as { ok: boolean }).ok) {462      setPasskeyState(463        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null464      );465    }466  }467468  async function handleCheckAllBreaches() {469    setCheckingBreaches(true);470    setBreachResult(null);471    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });472    setCheckingBreaches(false);473    if ((res as { ok: boolean }).ok) {474      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};475      const breached = Object.values(data).filter((count) => count > 0).length;476      setBreachResult(477        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"478      );479      props.onBreachCheckDone();480    }481  }482483484  async function handleSetupDuress() {485    const mainPw = duressMainPassword().trim();486    const duressPw = duressPassword().trim();487    const confirmPw = duressConfirm().trim();488489    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }490    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }491    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }492    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }493494    setDuressSaving(true);495    setDuressError(null);496497    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };498    if (!verifyRes.ok || !verifyRes.data) {499      setDuressError("incorrect main password");500      setDuressSaving(false);501      return;502    }503504    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };505    setDuressSaving(false);506    if (res.ok) {507      setDuressEnabled(true);508      setShowDuressSetup(false);509      setDuressPassword("");510      setDuressConfirm("");511      setDuressMainPassword("");512      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");513      setTimeout(() => setDuressSuccess(null), 5000);514    } else {515      setDuressError(res.error ?? "failed to set duress password");516    }517  }518519  async function handleClearDeclinedCards() {520    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });521    setDeclinedCardCount(0);522  }523524  async function handleCountDuplicates() {525    setDedupRunning(true);526    setDedupDone(null);527    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };528    setDedupRunning(false);529    if (res.ok) setDedupCount(res.data ?? 0);530  }531532  async function handleDeduplicate() {533    setDedupRunning(true);534    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };535    setDedupRunning(false);536    if (res.ok) {537      setDedupDone(res.data ?? 0);538      setDedupCount(0);539    }540  }541542  async function handleDisableDuress() {543    const mainPw = duressMainPassword().trim();544    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }545546    setDuressSaving(true);547    setDuressError(null);548549    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };550    if (!verifyRes.ok || !verifyRes.data) {551      setDuressError("incorrect main password");552      setDuressSaving(false);553      return;554    }555556    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };557    setDuressSaving(false);558    if (res.ok) {559      setDuressEnabled(false);560      setShowDuressSetup(false);561      setDuressMainPassword("");562      setDuressSuccess("Duress password disabled.");563      setTimeout(() => setDuressSuccess(null), 4000);564    } else {565      setDuressError(res.error ?? "failed to disable duress password");566    }567  }568569  async function handleSubmitFeedback() {570    if (!fbMessage().trim() || fbSubmitting()) return;571    setFbSubmitting(true);572    setFbResult(null);573    const res = await sendMessage({574      type: "SUBMIT_FEEDBACK",575      payload: {576        category: fbCategory(),577        message: fbMessage().trim(),578        version,579        browser: navigator.userAgent,580      },581    }) as { ok: boolean; error?: string };582    setFbSubmitting(false);583    if (res.ok) {584      setFbResult("success");585      setFbMessage("");586      setTimeout(() => setFbResult(null), 4000);587    } else {588      setFbResult("error");589    }590  }591592  function renderSecurity() {593    return (594      <div class="peach-settings-body">595        <div class="peach-settings-group">596          <p class="peach-settings-sub-label">LOCKING</p>597          <div class="peach-settings-row">598            <span>Lock on close</span>599            <button600              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}601              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}602              aria-label="Toggle lock on close"603            >604              <span class="peach-toggle-knob"></span>605            </button>606          </div>607          <div class="peach-settings-row">608            <span>Auto-lock after</span>609            <select610              class="peach-settings-select"611              value={String(props.autoLockMinutes)}612              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}613            >614              <option value="1">1 minute</option>615              <option value="5">5 minutes</option>616              <option value="15">15 minutes</option>617              <option value="30">30 minutes</option>618              <option value="0">Never</option>619            </select>620          </div>621622          <Show when={isNativePlatform() && biometricSupported()}>623            <p class="peach-settings-sub-label">BIOMETRICS</p>624            <div class="peach-settings-row">625              <span>Unlock with biometrics</span>626              <button627                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}628                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}629                disabled={biometricBusy()}630                aria-label="Toggle biometric unlock"631              >632                <span class="peach-toggle-knob"></span>633              </button>634            </div>635            <Show when={biometricError()}>636              <div class="peach-settings-row peach-settings-row--meta">637                <span class="peach-hint peach-hint-error">{biometricError()}</span>638              </div>639            </Show>640          </Show>641642          <p class="peach-settings-sub-label">BREACH PROTECTION</p>643          <div class="peach-settings-row">644            <span>Breach monitoring</span>645            <button646              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}647              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}648              aria-label="Toggle breach monitoring"649            >650              <span class="peach-toggle-knob"></span>651            </button>652          </div>653          <div class="peach-settings-row peach-settings-row--meta">654            <span>Check all passwords</span>655            <button656              class="peach-btn peach-btn--subtle peach-btn--sm"657              onClick={() => void handleCheckAllBreaches()}658              disabled={checkingBreaches()}659            >660              {checkingBreaches() ? "checking..." : "run"}661            </button>662          </div>663          <Show when={breachResult()}>664            <div class="peach-settings-row peach-settings-row--meta">665              <span>Last check</span>666              <span class="peach-settings-value">{breachResult()}</span>667            </div>668          </Show>669670          <p class="peach-settings-sub-label">MASTER PASSWORD</p>671          <div class="peach-settings-row peach-settings-row--meta">672            <span>Master password</span>673            <button674              class="peach-btn peach-btn--subtle peach-btn--sm"675              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}676            >677              {showChangePw() ? "cancel" : "change"}678            </button>679          </div>680          <Show when={showChangePw()}>681            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">682              <input683                class="peach-settings-input"684                type="password"685                placeholder="current password"686                value={changePwCurrent()}687                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}688                autocomplete="current-password"689              />690              <input691                class="peach-settings-input"692                type="password"693                placeholder="new password (min 12 chars)"694                value={changePwNew()}695                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}696                autocomplete="new-password"697              />698              <input699                class="peach-settings-input"700                type="password"701                placeholder="confirm new password"702                value={changePwConfirm()}703                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}704                autocomplete="new-password"705                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}706              />707              <Show when={changePwError()}>708                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>709              </Show>710              <Show when={changePwSuccess()}>711                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>712              </Show>713              <button714                class="peach-btn peach-btn--accent peach-btn--sm"715                style="align-self:flex-start"716                onClick={() => void handleChangePassword()}717                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}718              >719                {changePwSaving() ? "saving…" : "save new password"}720              </button>721            </div>722          </Show>723724          <p class="peach-settings-sub-label">725            DURESS PASSWORD726            <Show when={!isPro()}>727              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>728            </Show>729          </p>730          <div class="peach-settings-row peach-settings-row--top">731            <div class="peach-settings-row-main">732              <span>733                Duress password734                <Show when={!isPro()}>735                  <span class="peach-pro-badge">Pro</span>736                </Show>737              </span>738              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">739                If entered under coercion, shows a decoy empty vault instead of your real one.740              </span>741            </div>742            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>743              {duressEnabled() ? "on" : "off"}744            </div>745          </div>746747          <Show when={duressEnabled() && !showDuressSetup()}>748            <div class="peach-settings-row peach-settings-row--meta">749              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">750                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.751              </span>752            </div>753          </Show>754755          <Show when={duressSuccess()}>756            <div class="peach-settings-row peach-settings-row--meta">757              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>758            </div>759          </Show>760761          <Show when={!showDuressSetup()}>762            <div class="peach-settings-row peach-settings-row--meta">763              <button764                class="peach-btn peach-btn--subtle peach-btn--sm"765                onClick={() => {766                  // Pro gate entry-point: free users are redirected to the767                  // upgrade screen before the setup form opens. The server-768                  // side SETUP_DURESS handler is the single enforcement769                  // point; this is UI redirection so the form never appears770                  // for a user who can't complete it.771                  if (!isPro() && !duressEnabled()) {772                    props.onProGate?.("duress_vault");773                    setActiveCard("pro");774                    return;775                  }776                  setShowDuressSetup(true);777                  setDuressError(null);778                  setDuressSuccess(null);779                  setDuressPassword("");780                  setDuressConfirm("");781                  setDuressMainPassword("");782                }}783              >784                {duressEnabled() ? "Change / disable" : "Set up"}785              </button>786            </div>787          </Show>788789          <Show when={showDuressSetup()}>790            <div class="peach-duress-form">791              <label class="peach-label">main password (to confirm)</label>792              <input793                class="peach-settings-input"794                type="password"795                placeholder="your current master password"796                autocomplete="current-password"797                value={duressMainPassword()}798                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}799              />800              <Show when={!duressEnabled()}>801                <label class="peach-label">new duress password</label>802                <input803                  class="peach-settings-input"804                  type="password"805                  placeholder="duress password (min 8 chars)"806                  autocomplete="new-password"807                  value={duressPassword()}808                  onInput={(e) => setDuressPassword(e.currentTarget.value)}809                />810                <label class="peach-label">confirm duress password</label>811                <input812                  class="peach-settings-input"813                  type="password"814                  placeholder="confirm duress password"815                  autocomplete="new-password"816                  value={duressConfirm()}817                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}818                />819              </Show>820821              <Show when={duressError()}>822                <p class="peach-hint peach-hint-error">{duressError()}</p>823              </Show>824825              <div class="peach-duress-actions">826                <button827                  class="peach-btn peach-btn--subtle peach-btn--sm"828                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}829                >830                  cancel831                </button>832                <Show when={duressEnabled()}>833                  <button834                    class="peach-btn peach-btn--ghost peach-btn--sm"835                    style="color:var(--peach-breach)"836                    onClick={() => void handleDisableDuress()}837                    disabled={duressSaving()}838                  >839                    {duressSaving() ? "disabling..." : "disable duress"}840                  </button>841                </Show>842                <Show when={!duressEnabled()}>843                  <button844                    class="peach-btn peach-btn--accent peach-btn--sm"845                    onClick={() => void handleSetupDuress()}846                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}847                  >848                    {duressSaving() ? "saving..." : "save"}849                  </button>850                </Show>851              </div>852            </div>853          </Show>854855          <p class="peach-settings-sub-label">856            CANARY ENTRIES857            <Show when={!isPro()}>858              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>859            </Show>860          </p>861          <div class="peach-settings-row peach-settings-row--top">862            <div class="peach-settings-row-main">863              <span>864                Canary entries865                <Show when={!isPro()}>866                  <span class="peach-pro-badge">Pro</span>867                </Show>868              </span>869              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">870                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.871              </span>872            </div>873            <span class="peach-settings-value" style="flex-shrink:0">874              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}875            </span>876          </div>877          <Show when={canaryEntries().length > 0}>878            <For each={canaryEntries()}>879              {(entry) => (880                <div class="peach-settings-row peach-settings-row--meta">881                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>882                  <button883                    class="peach-btn peach-btn--subtle peach-btn--sm"884                    onClick={() => void handleTestCanary(entry)}885                    disabled={canaryTestingId() === entry.id}886                    style="flex-shrink:0"887                  >888                    {canaryTestingId() === entry.id889                      ? "sending…"890                      : canaryTestResult()?.id === entry.id891                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")892                        : "test"}893                  </button>894                </div>895              )}896            </For>897          </Show>898899          <p class="peach-settings-sub-label">PASSKEYS</p>900          <div class="peach-settings-row peach-settings-row--top">901            <div class="peach-settings-row-main">902              <span>Intercept passkey requests</span>903              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">904                Peach can handle passkey sign-in and creation on supported Chromium browsers.905              </span>906            </div>907            <button908              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}909              onClick={() => void handleTogglePasskeys()}910              aria-label="Toggle passkey interception"911              disabled={!passkeyState()?.supported || passkeySaving()}912            >913              <span class="peach-toggle-knob"></span>914            </button>915          </div>916          <div class="peach-settings-row peach-settings-row--meta">917            <span>Status</span>918            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>919          </div>920          <div class="peach-settings-row peach-settings-row--meta">921            <span>Browser</span>922            <span class="peach-settings-value">923              {passkeyState()?.browserLabel ?? "Unknown"}924              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}925            </span>926          </div>927          <div class="peach-info-notice" style="margin-top:8px">928            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.929          </div>930          <Show when={passkeyState()?.conflict}>931            <div class="peach-settings-row peach-settings-row--meta">932              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>933            </div>934          </Show>935        </div>936      </div>937    );938  }939940  function renderVault() {941    return (942      <div class="peach-settings-body">943        <div class="peach-settings-group">944          <p class="peach-settings-sub-label">ENTRIES</p>945          <div class="peach-settings-row peach-settings-row--meta">946            <span>Entries</span>947            <span class="peach-settings-value">{props.entriesCount}</span>948          </div>949          <div class="peach-settings-row peach-settings-row--meta">950            <span>Duplicates</span>951            <Show when={dedupDone() !== null} fallback={952              <button953                class="peach-btn peach-btn--subtle peach-btn--sm"954                onClick={() => void handleCountDuplicates()}955                disabled={dedupRunning()}956              >957                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}958              </button>959            }>960              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">961                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}962              </span>963            </Show>964          </div>965          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>966            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">967              <div class="peach-restore-warning">968                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.969              </div>970              <div style="display:flex;gap:6px">971                <button972                  class="peach-btn peach-btn--subtle peach-btn--sm"973                  onClick={() => setDedupCount(null)}974                >975                  cancel976                </button>977                <button978                  class="peach-btn peach-btn--accent peach-btn--sm"979                  onClick={() => void handleDeduplicate()}980                  disabled={dedupRunning()}981                >982                  {dedupRunning() ? "removing..." : "remove duplicates"}983                </button>984              </div>985            </div>986          </Show>987988          <p class="peach-settings-sub-label">MAINTENANCE</p>989          <div class="peach-settings-row peach-settings-row--meta">990            <span>Trash auto-delete</span>991            <select992              class="peach-settings-select"993              value={String(props.trashRetentionDays)}994              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}995            >996              <option value="7">7 days</option>997              <option value="30">30 days</option>998              <option value="90">90 days</option>999              <option value="0">Never</option>1000            </select>1001          </div>1002          <div class="peach-settings-row peach-settings-row--meta">1003            <span>Vault Health</span>1004            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1005              open1006            </button>1007          </div>1008          <Show when={declinedCardCount() > 0}>1009            <div class="peach-settings-row peach-settings-row--meta">1010              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1011              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1012                Clear1013              </button>1014            </div>1015          </Show>10161017          <p class="peach-settings-sub-label">DATA</p>1018          <div class="peach-settings-row peach-settings-row--meta">1019            <span>Import passwords</span>1020            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1021              open1022            </button>1023          </div>1024          <div class="peach-settings-row peach-settings-row--meta">1025            <span>Export backup</span>1026            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1027              open1028            </button>1029          </div>1030          <div class="peach-settings-row peach-settings-row--meta">1031            <span>1032              Peach Codex1033              <Show when={!isPro()}>1034                <span class="peach-pro-badge">Pro</span>1035              </Show>1036            </span>1037            <button1038              class="peach-btn peach-btn--subtle peach-btn--sm"1039              onClick={() => props.onOpenCodexGenerate?.()}1040            >1041              open1042            </button>1043          </div>1044          {/* Restore from Codex: intentionally never gated — a printed1045              Codex must remain restorable regardless of license state. */}1046          <div class="peach-settings-row peach-settings-row--meta">1047            <span>Restore from Codex</span>1048            <button1049              class="peach-btn peach-btn--subtle peach-btn--sm"1050              onClick={() => props.onOpenCodexRestore?.()}1051            >1052              open1053            </button>1054          </div>10551056          <p class="peach-settings-sub-label">VAULT HISTORY</p>1057          <Show when={historyLoading()}>1058            <p class="peach-settings-hint">Loading…</p>1059          </Show>1060          <Show when={!historyLoading() && historySnapshots().length === 0}>1061            <p class="peach-settings-hint">1062              No snapshots yet. Peach saves automatically before every change —1063              saves, syncs, and imports.1064            </p>1065          </Show>1066          <Show when={!historyLoading() && historySnapshots().length > 0}>1067            <p class="peach-settings-hint">1068              Restoring locks your vault. Unlock with the password that1069              encrypted that snapshot to confirm.1070            </p>1071            <div class="peach-history-list">1072              <For each={historySnapshots()}>1073                {(snap) => (1074                  <div class="peach-history-row">1075                    <div class="peach-history-meta">1076                      <span class="peach-history-time">1077                        {formatSnapshotTime(snap.savedAt)}1078                      </span>1079                      <span class="peach-history-sub">1080                        {[1081                          snap.entryCount !== null1082                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1083                            : null,1084                          reasonLabel(snap.reason),1085                        ]1086                          .filter(Boolean)1087                          .join(" · ")}1088                      </span>1089                    </div>1090                    <button1091                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1092                        historyRestoring() === snap.id ? " is-busy" : ""1093                      }`}1094                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1095                      disabled={historyRestoring() !== null}1096                    >1097                      <Show when={historyRestoring() === snap.id} fallback="Restore">1098                        …1099                      </Show>1100                    </button>1101                  </div>1102                )}1103              </For>1104            </div>1105            <p class="peach-history-footer">1106              {historySnapshots().length} of 10 snapshots1107            </p>1108          </Show>11091110          <button1111            class="peach-settings-advanced-toggle"1112            onClick={() => setShowVaultAdvanced((v) => !v)}1113            aria-expanded={showVaultAdvanced()}1114          >1115            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1116            <span class="peach-settings-advanced-chevron">1117              {showVaultAdvanced() ? "▲" : "▼"}1118            </span>1119          </button>11201121          <Show when={showVaultAdvanced()}>1122            <div class="peach-danger-card">1123              <div class="peach-danger-card-head">1124                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1125                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1126              </div>1127              <p class="peach-danger-card-copy">1128                Wipes the current vault and restores from a backup file.1129                Recoverable only if your backup is good.1130              </p>1131              <button1132                class="peach-danger-card-button"1133                onClick={() => props.onOpenFileImport?.()}1134              >1135                Choose backup file1136              </button>1137            </div>11381139            <Show when={props.onOpenPanic}>1140              <div class="peach-danger-card peach-danger-card--hazard">1141                <div class="peach-danger-card-head">1142                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1143                  <h3 class="peach-danger-card-title">Destroy vault</h3>1144                </div>1145                <p class="peach-danger-card-copy">1146                  Permanently wipes every entry, setting, and license on this1147                  device. There is no recovery.1148                </p>1149                <button1150                  class="peach-danger-card-button"1151                  onClick={() => props.onOpenPanic?.()}1152                >1153                  Begin destruction1154                </button>1155              </div>1156            </Show>1157          </Show>1158        </div>1159      </div>1160    );1161  }11621163  function renderSync() {1164    return (1165      <div class="peach-settings-body">1166        <div class="peach-settings-group">1167          <p class="peach-settings-sub-label">PAIRING</p>1168          <div class="peach-settings-row peach-settings-row--top">1169            <div class="peach-settings-row-main">1170              <span>1171                Peach Sync1172                <Show when={!isPro()}>1173                  <span class="peach-pro-badge">Pro</span>1174                </Show>1175              </span>1176              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1177                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1178              </span>1179            </div>1180            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1181          </div>1182          <div class="peach-settings-row peach-settings-row--meta">1183            <span>Status</span>1184            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1185          </div>1186          <div class="peach-settings-row peach-settings-row--meta">1187            <span>Sync hub</span>1188            <button1189              class="peach-btn peach-btn--subtle peach-btn--sm"1190              onClick={() => props.onOpenSyncPairing?.()}1191            >1192              open1193            </button>1194          </div>11951196          <p class="peach-settings-sub-label">DEVICE FLOW</p>1197          <div class="peach-settings-row peach-settings-row--top">1198            <div class="peach-settings-row-main">1199              <span>Pair new device</span>1200              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1201                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1202              </span>1203            </div>1204            <button1205              class="peach-btn peach-btn--subtle peach-btn--sm"1206              onClick={() => props.onOpenSyncPairing?.()}1207            >1208              pair1209            </button>1210          </div>1211          <div class="peach-info-notice" style="margin-top:8px">1212            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1213          </div>1214        </div>1215      </div>1216    );1217  }12181219  function renderAppearance() {1220    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1221      { id: "small", label: "Small" },1222      { id: "medium", label: "Default" },1223      { id: "large", label: "Large" },1224    ];1225    return (1226      <div class="peach-settings-body">1227        <div class="peach-settings-group">1228          <p class="peach-settings-sub-label">THEME</p>1229          <div class="peach-settings-row peach-settings-row--meta">1230            <span>Theme</span>1231            <button1232              class="peach-btn peach-btn--subtle peach-btn--sm"1233              onClick={() =>1234                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1235              }1236            >1237              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1238            </button>1239          </div>12401241          <p class="peach-settings-sub-label">ACCENT</p>1242          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1243            <span>Accent</span>1244            <div class="peach-accent-picker">1245              <For each={ACCENT_COLORS}>1246                {(color) => (1247                  <button1248                    class="peach-accent-swatch peach-swatch"1249                    classList={{ "is-active": props.accentColor === color.name }}1250                    style={{ background: color.hex }}1251                    onClick={() => void props.onAccentChange(color.name)}1252                    aria-label={color.name}1253                    title={color.name}1254                  />1255                )}1256              </For>1257            </div>1258          </div>12591260          <p class="peach-settings-sub-label">TEXT SIZE</p>1261          <div class="peach-settings-row peach-settings-row--meta">1262            <span>Size</span>1263            <div class="peach-pill-group">1264              <For each={sizeOptions}>1265                {(opt) => (1266                  <button1267                    class="peach-btn peach-btn--ghost peach-btn--sm"1268                    classList={{ "is-active": props.textSize === opt.id }}1269                    onClick={() => void props.onTextSizeChange(opt.id)}1270                  >1271                    {opt.label}1272                  </button>1273                )}1274              </For>1275            </div>1276          </div>12771278          <p class="peach-settings-sub-label">MOTION</p>1279          <div class="peach-settings-row peach-settings-row--meta">1280            <span>Reduce motion</span>1281            <button1282              class="peach-btn peach-btn--subtle peach-btn--sm"1283              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1284            >1285              {props.reduceMotion ? "On" : "Off"}1286            </button>1287          </div>1288        </div>1289      </div>1290    );1291  }12921293  function renderShortcuts() {1294    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1295    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12961297    function startCapture(action: ShortcutAction) {1298      setConflictMsg(null);1299      setCapturingAction(action);1300    }1301    function cancelCapture() {1302      setCapturingAction(null);1303      setConflictMsg(null);1304    }1305    function commitBinding(action: ShortcutAction, binding: Keybinding) {1306      const conflict = findConflict(props.shortcuts, binding, action);1307      if (conflict) {1308        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1309        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1310        return;1311      }1312      const next = { ...props.shortcuts, [action]: binding };1313      setCapturingAction(null);1314      setConflictMsg(null);1315      void props.onShortcutsChange(next);1316    }1317    function resetAction(action: ShortcutAction) {1318      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1319      void props.onShortcutsChange(next);1320    }1321    function resetAll() {1322      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1323    }13241325    // Capture-mode key handler — wired via ref onto the chip element so it1326    // catches the next non-modifier keypress and ignores other bindings.1327    function handleCaptureKey(e: KeyboardEvent) {1328      e.preventDefault();1329      e.stopPropagation();1330      if (e.key === "Escape") { cancelCapture(); return; }1331      const binding = keybindingFromEvent(e);1332      if (!binding) return; // bare modifier — keep waiting1333      const action = capturingAction();1334      if (action) commitBinding(action, binding);1335    }13361337    return (1338      <div class="peach-settings-body">1339        <div class="peach-settings-group peach-shortcuts-group">1340          <p class="peach-settings-sub-label">ENTRY VIEW</p>1341          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1342            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1343          </For>1344        </div>13451346        <div class="peach-settings-group peach-shortcuts-group">1347          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1348          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1349            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1350          </For>1351        </div>13521353        <Show when={conflictMsg()}>1354          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1355        </Show>13561357        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1358          <div class="peach-settings-row peach-settings-row--meta">1359            <span>Inline autofill on page</span>1360            <a1361              class="peach-settings-link"1362              href="chrome://extensions/shortcuts"1363              onClick={(e) => {1364                e.preventDefault();1365                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1366              }}1367            >1368              ⌘⇧P — customize ↗1369            </a>1370          </div>1371          <button1372            class="peach-btn peach-btn--subtle peach-btn--sm"1373            style="align-self:flex-start"1374            onClick={resetAll}1375          >1376            Reset all to defaults1377          </button>1378        </div>1379      </div>1380    );13811382    function renderRow(1383      desc: typeof SHORTCUT_DESCRIPTORS[number],1384      capturing: () => ShortcutAction | null,1385      onStart: (a: ShortcutAction) => void,1386      onCancel: () => void,1387      onReset: (a: ShortcutAction) => void,1388      onKey: (e: KeyboardEvent) => void,1389    ) {1390      const isActive = () => capturing() === desc.action;1391      const binding = () => props.shortcuts[desc.action];1392      const isCustom = () => {1393        const b = binding();1394        const d = DEFAULT_BINDINGS[desc.action];1395        return b.key !== d.key1396          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1397          || Boolean(b.meta)  !== Boolean(d.meta)1398          || Boolean(b.shift) !== Boolean(d.shift)1399          || Boolean(b.alt)   !== Boolean(d.alt);1400      };1401      return (1402        <div class="peach-shortcut-row">1403          <span class="peach-shortcut-row-label">{desc.label}</span>1404          <button1405            class="peach-shortcut-chip"1406            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1407            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1408            onKeyDown={(e) => { if (isActive()) onKey(e); }}1409            ref={(el) => {1410              // Auto-focus when entering capture mode so the keydown listener catches the next press.1411              if (el) {1412                queueMicrotask(() => { if (isActive()) el.focus(); });1413              }1414            }}1415          >1416            {isActive() ? "Press a key…" : formatKeybinding(binding())}1417          </button>1418          <Show when={isCustom() && !isActive()}>1419            <button1420              class="peach-shortcut-reset"1421              title="Reset to default"1422              onClick={() => onReset(desc.action)}1423            >1424              ↺1425            </button>1426          </Show>1427        </div>1428      );1429    }1430  }14311432  const PRO_FEATURES: Array<{1433    feature: ProFeature;1434    name: string;1435    desc: string;1436  }> = [1437    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1438    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1439    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1440    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1441    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1442    { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1443  ];14441445  const PRO_SECURITY_FEATURES: Array<{1446    feature: ProFeature;1447    name: string;1448    desc: string;1449  }> = [1450    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1451    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1452  ];14531454  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1455  const [showKey, setShowKey] = createSignal(false);14561457  const [deactivating, setDeactivating] = createSignal(false);14581459  async function handleDeactivateLicense() {1460    setDeactivating(true);1461    await sendMessage({ type: "DEACTIVATE_LICENSE" });1462    setLicenseInfo(null);1463    await props.onLicenseChange?.();1464    setDeactivating(false);1465  }14661467  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1468    setCanaryTestingId(entry.id);1469    setCanaryTestResult(null);1470    const res = await sendMessage({1471      type: "FIRE_CANARY_PING",1472      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1473    }) as { ok: boolean };1474    setCanaryTestResult({ id: entry.id, ok: res.ok });1475    setCanaryTestingId(null);1476    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1477  }14781479  function renderLicense() {1480    async function onActivate() {1481      await handleActivateLicense();1482      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1483        await props.onLicenseChange?.();1484      }1485    }14861487    return (1488      <div class="peach-settings-body">1489        <Show1490          when={isPro()}1491          fallback={1492                <div class="peach-pro-upgrade">1493                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14941495                  <h2 class="peach-pro-headline">1496                    Every<em>thing,</em><br />every<em>where.</em>1497                  </h2>14981499                  <p class="peach-pro-subtitle">1500                    One key unlocks every Pro feature — for the lifetime of the app.1501                  </p>15021503                  <div class="peach-pro-grid">1504                    <For each={PRO_FEATURES}>1505                      {(item, i) => (1506                        <div1507                          class="peach-pro-grid-item"1508                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1509                        >1510                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1511                          <span class="peach-pro-grid-name">{item.name}</span>1512                          <span class="peach-pro-grid-desc">{item.desc}</span>1513                        </div>1514                      )}1515                    </For>1516                  </div>15171518                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1519                  <div class="peach-pro-grid">1520                    <For each={PRO_SECURITY_FEATURES}>1521                      {(item, i) => (1522                        <div1523                          class="peach-pro-grid-item"1524                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1525                        >1526                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1527                          <span class="peach-pro-grid-name">{item.name}</span>1528                          <span class="peach-pro-grid-desc">{item.desc}</span>1529                        </div>1530                      )}1531                    </For>1532                  </div>15331534                  <div class="peach-pro-upgrade-actions">1535                    <p class="peach-pro-pricing">1536                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1537                    </p>1538                    <a1539                      href="https://peachpasswords.com/#pricing"1540                      target="_blank"1541                      rel="noopener noreferrer"1542                      class="peach-pro-upgrade-cta"1543                    >1544                      Get Peach Pro1545                    </a>1546                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1547                    <div class="peach-pro-key-inline">1548                      <input1549                        class="peach-pro-key-inline-input"1550                        type="text"1551                        placeholder="PEACH-XXXX-XXXX-XXXX"1552                        value={licenseKeyInput()}1553                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1554                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1555                        autocomplete="off"1556                        spellcheck={false}1557                      />1558                      <button1559                        class="peach-pro-key-apply"1560                        onClick={() => void onActivate()}1561                        disabled={activatingLicense() || !licenseKeyInput().trim()}1562                      >1563                        {activatingLicense() ? "…" : "APPLY"}1564                      </button>1565                    </div>1566                    <Show when={licenseError()}>1567                      <p class="peach-pro-key-error">{licenseError()}</p>1568                    </Show>1569                  </div>1570                </div>1571          }1572        >1573          <div class="peach-pro-active">1574            <div class="peach-pro-active-hero">1575              <div class="peach-pro-active-mark-wrap">1576                <PeachMark class="peach-pro-active-mark" />1577              </div>1578              <h2 class="peach-pro-active-title">Peach Pro</h2>1579              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1580            </div>15811582            <Show when={licenseKey()}>1583              <div class="peach-settings-license">1584                <span class="peach-settings-label">LICENSE KEY</span>1585                <div class="peach-settings-license-row">1586                  <Show1587                    when={showKey()}1588                    fallback={1589                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1590                        ••••••••••••••••••••••••••••1591                      </span>1592                    }1593                  >1594                    <span class="peach-settings-license-key">{licenseKey()!}</span>1595                  </Show>1596                  <div class="peach-settings-license-actions">1597                    <button1598                      class="peach-settings-license-copy"1599                      title="Copy license key"1600                      onClick={() => copyLicenseKey(licenseKey()!)}1601                    >1602                      {copied() ? "✓" : "copy"}1603                    </button>1604                    <button1605                      class="peach-settings-license-toggle"1606                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1607                      title={showKey() ? "Hide" : "Reveal"}1608                      onClick={() => setShowKey(v => !v)}1609                    >1610                      <Show1611                        when={showKey()}1612                        fallback={1613                          <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">1614                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1615                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1616                          </svg>1617                        }1618                      >1619                        <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">1620                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1621                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1622                          <line x1="2" y1="2" x2="12" y2="12" />1623                        </svg>1624                      </Show>1625                    </button>1626                  </div>1627                </div>1628              </div>1629            </Show>16301631            <div class="peach-pro-active-features">1632              <For each={PRO_FEATURES}>1633                {(item) => (1634                  <div class="peach-pro-active-feature">1635                    <span class="peach-pro-active-feature-mark">✦</span>1636                    <span class="peach-pro-active-feature-name">{item.name}</span>1637                  </div>1638                )}1639              </For>1640            </div>16411642            <button1643              class="peach-pro-deactivate"1644              onClick={() => void handleDeactivateLicense()}1645              disabled={deactivating()}1646            >1647              {deactivating() ? "Deactivating…" : "Deactivate"}1648            </button>1649          </div>1650        </Show>1651      </div>1652    );1653  }16541655  function renderFeedback() {1656    return (1657      <div class="peach-settings-body">1658        <div class="peach-settings-group peach-feedback-group">1659          <p class="peach-feedback-intro">1660            Help us improve Peach. Every report goes directly to our inbox.1661          </p>16621663          <p class="peach-settings-sub-label">TYPE</p>1664          <div class="peach-feedback-chips">1665            <For each={FEEDBACK_CATEGORIES}>1666              {(cat) => (1667                <button1668                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1669                  onClick={() => setFbCategory(cat)}1670                >1671                  {cat}1672                </button>1673              )}1674            </For>1675          </div>16761677          <p class="peach-settings-sub-label">MESSAGE</p>1678          <textarea1679            class="peach-feedback-textarea"1680            placeholder="Describe the issue or idea..."1681            value={fbMessage()}1682            onInput={(e) => setFbMessage(e.currentTarget.value)}1683          />16841685          <p class="peach-feedback-meta">1686            Version {version} · will be included automatically1687          </p>16881689          <Show when={fbResult() === "success"}>1690            <p class="peach-feedback-result peach-feedback-result--ok">1691              Sent — thank you1692            </p>1693          </Show>1694          <Show when={fbResult() === "error"}>1695            <p class="peach-feedback-result peach-feedback-result--err">1696              Failed to send — try again1697            </p>1698          </Show>16991700          <button1701            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1702            onClick={() => void handleSubmitFeedback()}1703            disabled={fbSubmitting() || !fbMessage().trim()}1704          >1705            {fbSubmitting() ? "Sending..." : "Send feedback"}1706          </button>17071708          <p class="peach-feedback-anon">1709            Sent anonymously · no account required1710          </p>1711        </div>1712      </div>1713    );1714  }17151716  const bodies: StackBodies = {1717    protection: renderSecurity(),1718    vault: renderVault(),1719    sync: renderSync(),1720    appearance: renderAppearance(),1721    shortcuts: renderShortcuts(),1722    pro: renderLicense(),1723    feedback: renderFeedback(),1724  };17251726  return (1727    <Show1728      when={showNewRecoveryWords()}1729      fallback={1730        <div class="peach-overlay-view ov-enter-right" style="display:flex;flex-direction:column">1731          <StackDeck1732            active={activeCard()}1733            onActiveChange={setActiveCard}1734            onBack={props.onBack}1735            cards={cards()}1736            bodies={bodies}1737          />1738        </div>1739      }1740    >1741      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1742    </Show>1743  );1744}