Settings.tsx

68.8 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);205  const [historyOpen, setHistoryOpen] = createSignal(false);206207  async function loadVaultHistory() {208    setHistoryLoading(true);209    try {210      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });211      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];212      setHistorySnapshots(list);213    } finally {214      setHistoryLoading(false);215    }216  }217218  async function handleRestoreVaultSnapshot(id: string) {219    if (historyRestoring()) return;220    setHistoryRestoring(id);221    const res = await sendMessage({222      type: "RESTORE_VAULT_SNAPSHOT",223      payload: { snapshotId: id },224    });225    setHistoryRestoring(null);226    if ((res as { ok: boolean }).ok) {227      // Vault is now locked — let the parent close Settings; App.tsx's228      // VAULT_LOCKED listener will route back to the lock screen.229      props.onBack();230    }231  }232233  function formatSnapshotTime(savedAt: number): string {234    const diff = Date.now() - savedAt;235    if (diff < 60_000) return "Just now";236    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;237    const d = new Date(savedAt);238    const isToday = new Date().toDateString() === d.toDateString();239    const isYest =240      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();241    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });242    if (isToday) return `Today ${t}`;243    if (isYest) return `Yesterday ${t}`;244    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;245  }246247  function reasonLabel(reason: VaultSnapshot["reason"]): string {248    return (249      {250        local_save: "saved",251        sync_received: "synced",252        import: "imported",253        restore: "restored",254      } as const255    )[reason] ?? reason;256  }257258  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);259260  const [showChangePw, setShowChangePw] = createSignal(false);261  const [changePwCurrent, setChangePwCurrent] = createSignal("");262  const [changePwNew, setChangePwNew] = createSignal("");263  const [changePwConfirm, setChangePwConfirm] = createSignal("");264  const [changePwSaving, setChangePwSaving] = createSignal(false);265  const [changePwError, setChangePwError] = createSignal<string | null>(null);266  const [changePwSuccess] = createSignal(false);267  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);268269  const [duressEnabled, setDuressEnabled] = createSignal(false);270  const [showDuressSetup, setShowDuressSetup] = createSignal(false);271  const [duressPassword, setDuressPassword] = createSignal("");272  const [duressConfirm, setDuressConfirm] = createSignal("");273  const [duressMainPassword, setDuressMainPassword] = createSignal("");274  const [duressSaving, setDuressSaving] = createSignal(false);275  const [duressError, setDuressError] = createSignal<string | null>(null);276  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);277278  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);279280  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);281  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);282283  const [fbCategory, setFbCategory] = createSignal("Bug report");284  const [fbMessage, setFbMessage] = createSignal("");285  const [fbSubmitting, setFbSubmitting] = createSignal(false);286  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);287288  createEffect(() => {289    if (props.upgradeHighlight) {290      setActiveCard("pro");291    }292  });293294  onMount(async () => {295    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([296      sendMessage({ type: "GET_LICENSE" }),297      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),298      sendMessage({ type: "IS_DURESS_ENABLED" }),299      sendMessage({ type: "GET_SETTINGS" }),300      sendMessage({ type: "GET_LICENSE_KEY" }),301    ]);302303    // Vault history loads in the background — the Vault sub-view picks it up304    // when the user navigates in. Loading it lazily keeps Settings boot fast.305    void loadVaultHistory();306307    if ((licenseRes as { ok: boolean }).ok) {308      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);309    }310311    if ((passkeyRes as { ok: boolean }).ok) {312      setPasskeyState(313        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null314      );315    }316317    if ((duressRes as { ok: boolean }).ok) {318      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);319    }320321    if ((settingsRes as { ok: boolean }).ok) {322      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;323      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);324    }325326    if ((keyRes as { ok: boolean }).ok) {327      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);328    }329330    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });331    if ((entriesRes as { ok: boolean }).ok) {332      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];333      setCanaryEntries(334        entries335          .filter((e) => e.canary?.webhookUrl)336          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))337      );338    }339340341    if (isNativePlatform()) {342      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");343      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");344      try {345        if (biometric) {346          const a = await biometric.isAvailable();347          setBiometricSupported(a.available);348        }349        if (keystore) {350          const k = await keystore.hasMasterKey();351          setBiometricStored(k.exists);352        }353      } catch { /* missing plugins — leave defaults */ }354    }355  });356357  async function handleEnableBiometrics() {358    setBiometricBusy(true);359    setBiometricError(null);360    try {361      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {362        ok: boolean;363        data?: { masterKeyHex: string };364        error?: string;365      };366      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {367        setBiometricError(keyRes.error === "vault_locked"368          ? "unlock the vault first"369          : "couldn't read derived key");370        return;371      }372      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");373      if (!keystore) {374        setBiometricError("biometric plugin unavailable");375        return;376      }377      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });378      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });379      setBiometricStored(true);380    } catch (e) {381      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");382    } finally {383      setBiometricBusy(false);384    }385  }386387  async function handleDisableBiometrics() {388    setBiometricBusy(true);389    setBiometricError(null);390    try {391      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");392      if (keystore) await keystore.clearMasterKey();393      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });394      setBiometricStored(false);395    } catch (e) {396      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");397    } finally {398      setBiometricBusy(false);399    }400  }401402  async function handleChangePassword() {403    setChangePwError(null);404    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }405    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }406    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }407    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }408    setChangePwSaving(true);409    const res = await sendMessage({410      type: "CHANGE_PASSWORD",411      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },412    }) as { ok: boolean; error?: string };413    if (res.ok) {414      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");415      setShowChangePw(false);416      setShowNewRecoveryWords(true);417    } else {418      setChangePwError(res.error ?? "failed to change password");419    }420    setChangePwSaving(false);421  }422423  const [copied, setCopied] = createSignal(false);424425  async function copyLicenseKey(key: string) {426    await navigator.clipboard.writeText(key);427    setCopied(true);428    setTimeout(() => setCopied(false), 2000);429  }430431  async function handleActivateLicense() {432    const key = licenseKeyInput().trim();433    if (!key) return;434    setActivatingLicense(true);435    setLicenseError(null);436    setLicenseSuccess(null);437    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });438    setActivatingLicense(false);439    if ((res as { ok: boolean }).ok) {440      const data = (res as { ok: boolean; data?: LicenseInfo }).data;441      if (data?.valid) {442        setLicenseInfo(data);443        setLicenseKey(key);444        setLicenseSuccess("License activated!");445        setLicenseKeyInput("");446      } else {447        setLicenseError("Invalid license key");448      }449    } else {450      setLicenseError("Activation failed");451    }452  }453454  async function handleTogglePasskeys() {455    const current = passkeyState();456    if (!current || !current.supported) return;457    setPasskeySaving(true);458    const res = await sendMessage({459      type: "SET_PASSKEY_PROVIDER_ENABLED",460      payload: { enabled: !current.enabled },461    });462    setPasskeySaving(false);463    if ((res as { ok: boolean }).ok) {464      setPasskeyState(465        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null466      );467    }468  }469470  async function handleCheckAllBreaches() {471    setCheckingBreaches(true);472    setBreachResult(null);473    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });474    setCheckingBreaches(false);475    if ((res as { ok: boolean }).ok) {476      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};477      const breached = Object.values(data).filter((count) => count > 0).length;478      setBreachResult(479        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"480      );481      props.onBreachCheckDone();482    }483  }484485486  async function handleSetupDuress() {487    const mainPw = duressMainPassword().trim();488    const duressPw = duressPassword().trim();489    const confirmPw = duressConfirm().trim();490491    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }492    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }493    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }494    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }495496    setDuressSaving(true);497    setDuressError(null);498499    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };500    if (!verifyRes.ok || !verifyRes.data) {501      setDuressError("incorrect main password");502      setDuressSaving(false);503      return;504    }505506    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };507    setDuressSaving(false);508    if (res.ok) {509      setDuressEnabled(true);510      setShowDuressSetup(false);511      setDuressPassword("");512      setDuressConfirm("");513      setDuressMainPassword("");514      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");515      setTimeout(() => setDuressSuccess(null), 5000);516    } else {517      setDuressError(res.error ?? "failed to set duress password");518    }519  }520521  async function handleClearDeclinedCards() {522    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });523    setDeclinedCardCount(0);524  }525526  async function handleCountDuplicates() {527    setDedupRunning(true);528    setDedupDone(null);529    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };530    setDedupRunning(false);531    if (res.ok) setDedupCount(res.data ?? 0);532  }533534  async function handleDeduplicate() {535    setDedupRunning(true);536    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };537    setDedupRunning(false);538    if (res.ok) {539      setDedupDone(res.data ?? 0);540      setDedupCount(0);541    }542  }543544  async function handleDisableDuress() {545    const mainPw = duressMainPassword().trim();546    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }547548    setDuressSaving(true);549    setDuressError(null);550551    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };552    if (!verifyRes.ok || !verifyRes.data) {553      setDuressError("incorrect main password");554      setDuressSaving(false);555      return;556    }557558    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };559    setDuressSaving(false);560    if (res.ok) {561      setDuressEnabled(false);562      setShowDuressSetup(false);563      setDuressMainPassword("");564      setDuressSuccess("Duress password disabled.");565      setTimeout(() => setDuressSuccess(null), 4000);566    } else {567      setDuressError(res.error ?? "failed to disable duress password");568    }569  }570571  async function handleSubmitFeedback() {572    if (!fbMessage().trim() || fbSubmitting()) return;573    setFbSubmitting(true);574    setFbResult(null);575    const res = await sendMessage({576      type: "SUBMIT_FEEDBACK",577      payload: {578        category: fbCategory(),579        message: fbMessage().trim(),580        version,581        browser: navigator.userAgent,582      },583    }) as { ok: boolean; error?: string };584    setFbSubmitting(false);585    if (res.ok) {586      setFbResult("success");587      setFbMessage("");588      setTimeout(() => setFbResult(null), 4000);589    } else {590      setFbResult("error");591    }592  }593594  function renderSecurity() {595    return (596      <div class="peach-settings-body">597        <div class="peach-settings-group">598          <p class="peach-settings-sub-label">LOCKING</p>599          <div class="peach-settings-row">600            <span>Lock on close</span>601            <button602              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}603              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}604              aria-label="Toggle lock on close"605            >606              <span class="peach-toggle-knob"></span>607            </button>608          </div>609          <div class="peach-settings-row">610            <span>Auto-lock after</span>611            <select612              class="peach-settings-select"613              value={String(props.autoLockMinutes)}614              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}615            >616              <option value="1">1 minute</option>617              <option value="5">5 minutes</option>618              <option value="15">15 minutes</option>619              <option value="30">30 minutes</option>620              <option value="0">Never</option>621            </select>622          </div>623624          <Show when={isNativePlatform() && biometricSupported()}>625            <p class="peach-settings-sub-label">BIOMETRICS</p>626            <div class="peach-settings-row">627              <span>Unlock with biometrics</span>628              <button629                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}630                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}631                disabled={biometricBusy()}632                aria-label="Toggle biometric unlock"633              >634                <span class="peach-toggle-knob"></span>635              </button>636            </div>637            <Show when={biometricError()}>638              <div class="peach-settings-row peach-settings-row--meta">639                <span class="peach-hint peach-hint-error">{biometricError()}</span>640              </div>641            </Show>642          </Show>643644          <p class="peach-settings-sub-label">BREACH PROTECTION</p>645          <div class="peach-settings-row">646            <span>Breach monitoring</span>647            <button648              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}649              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}650              aria-label="Toggle breach monitoring"651            >652              <span class="peach-toggle-knob"></span>653            </button>654          </div>655          <div class="peach-settings-row peach-settings-row--meta">656            <span>Check all passwords</span>657            <button658              class="peach-btn peach-btn--subtle peach-btn--sm"659              onClick={() => void handleCheckAllBreaches()}660              disabled={checkingBreaches()}661            >662              {checkingBreaches() ? "checking..." : "run"}663            </button>664          </div>665          <Show when={breachResult()}>666            <div class="peach-settings-row peach-settings-row--meta">667              <span>Last check</span>668              <span class="peach-settings-value">{breachResult()}</span>669            </div>670          </Show>671672          <p class="peach-settings-sub-label">MASTER PASSWORD</p>673          <div class="peach-settings-row peach-settings-row--meta">674            <span>Master password</span>675            <button676              class="peach-btn peach-btn--subtle peach-btn--sm"677              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}678            >679              {showChangePw() ? "cancel" : "change"}680            </button>681          </div>682          <Show when={showChangePw()}>683            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">684              <input685                class="peach-settings-input"686                type="password"687                placeholder="current password"688                value={changePwCurrent()}689                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}690                autocomplete="current-password"691              />692              <input693                class="peach-settings-input"694                type="password"695                placeholder="new password (min 12 chars)"696                value={changePwNew()}697                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}698                autocomplete="new-password"699              />700              <input701                class="peach-settings-input"702                type="password"703                placeholder="confirm new password"704                value={changePwConfirm()}705                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}706                autocomplete="new-password"707                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}708              />709              <Show when={changePwError()}>710                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>711              </Show>712              <Show when={changePwSuccess()}>713                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>714              </Show>715              <button716                class="peach-btn peach-btn--accent peach-btn--sm"717                style="align-self:flex-start"718                onClick={() => void handleChangePassword()}719                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}720              >721                {changePwSaving() ? "saving…" : "save new password"}722              </button>723            </div>724          </Show>725726          <p class="peach-settings-sub-label">727            DURESS PASSWORD728            <Show when={!isPro()}>729              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>730            </Show>731          </p>732          <div class="peach-settings-row peach-settings-row--top">733            <div class="peach-settings-row-main">734              <span>735                Duress password736                <Show when={!isPro()}>737                  <span class="peach-pro-badge">Pro</span>738                </Show>739              </span>740              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">741                If entered under coercion, shows a decoy empty vault instead of your real one.742              </span>743            </div>744            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>745              {duressEnabled() ? "on" : "off"}746            </div>747          </div>748749          <Show when={duressEnabled() && !showDuressSetup()}>750            <div class="peach-settings-row peach-settings-row--meta">751              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">752                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.753              </span>754            </div>755          </Show>756757          <Show when={duressSuccess()}>758            <div class="peach-settings-row peach-settings-row--meta">759              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>760            </div>761          </Show>762763          <Show when={!showDuressSetup()}>764            <div class="peach-settings-row peach-settings-row--meta">765              <button766                class="peach-btn peach-btn--subtle peach-btn--sm"767                onClick={() => {768                  // Pro gate entry-point: free users are redirected to the769                  // upgrade screen before the setup form opens. The server-770                  // side SETUP_DURESS handler is the single enforcement771                  // point; this is UI redirection so the form never appears772                  // for a user who can't complete it.773                  if (!isPro() && !duressEnabled()) {774                    props.onProGate?.("duress_vault");775                    setActiveCard("pro");776                    return;777                  }778                  setShowDuressSetup(true);779                  setDuressError(null);780                  setDuressSuccess(null);781                  setDuressPassword("");782                  setDuressConfirm("");783                  setDuressMainPassword("");784                }}785              >786                {duressEnabled() ? "Change / disable" : "Set up"}787              </button>788            </div>789          </Show>790791          <Show when={showDuressSetup()}>792            <div class="peach-duress-form">793              <label class="peach-label">main password (to confirm)</label>794              <input795                class="peach-settings-input"796                type="password"797                placeholder="your current master password"798                autocomplete="current-password"799                value={duressMainPassword()}800                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}801              />802              <Show when={!duressEnabled()}>803                <label class="peach-label">new duress password</label>804                <input805                  class="peach-settings-input"806                  type="password"807                  placeholder="duress password (min 8 chars)"808                  autocomplete="new-password"809                  value={duressPassword()}810                  onInput={(e) => setDuressPassword(e.currentTarget.value)}811                />812                <label class="peach-label">confirm duress password</label>813                <input814                  class="peach-settings-input"815                  type="password"816                  placeholder="confirm duress password"817                  autocomplete="new-password"818                  value={duressConfirm()}819                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}820                />821              </Show>822823              <Show when={duressError()}>824                <p class="peach-hint peach-hint-error">{duressError()}</p>825              </Show>826827              <div class="peach-duress-actions">828                <button829                  class="peach-btn peach-btn--subtle peach-btn--sm"830                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}831                >832                  cancel833                </button>834                <Show when={duressEnabled()}>835                  <button836                    class="peach-btn peach-btn--ghost peach-btn--sm"837                    style="color:var(--peach-breach)"838                    onClick={() => void handleDisableDuress()}839                    disabled={duressSaving()}840                  >841                    {duressSaving() ? "disabling..." : "disable duress"}842                  </button>843                </Show>844                <Show when={!duressEnabled()}>845                  <button846                    class="peach-btn peach-btn--accent peach-btn--sm"847                    onClick={() => void handleSetupDuress()}848                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}849                  >850                    {duressSaving() ? "saving..." : "save"}851                  </button>852                </Show>853              </div>854            </div>855          </Show>856857          <p class="peach-settings-sub-label">858            CANARY ENTRIES859            <Show when={!isPro()}>860              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>861            </Show>862          </p>863          <div class="peach-settings-row peach-settings-row--top">864            <div class="peach-settings-row-main">865              <span>866                Canary entries867                <Show when={!isPro()}>868                  <span class="peach-pro-badge">Pro</span>869                </Show>870              </span>871              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">872                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.873              </span>874            </div>875            <span class="peach-settings-value" style="flex-shrink:0">876              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}877            </span>878          </div>879          <Show when={canaryEntries().length > 0}>880            <For each={canaryEntries()}>881              {(entry) => (882                <div class="peach-settings-row peach-settings-row--meta">883                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>884                  <button885                    class="peach-btn peach-btn--subtle peach-btn--sm"886                    onClick={() => void handleTestCanary(entry)}887                    disabled={canaryTestingId() === entry.id}888                    style="flex-shrink:0"889                  >890                    {canaryTestingId() === entry.id891                      ? "sending…"892                      : canaryTestResult()?.id === entry.id893                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")894                        : "test"}895                  </button>896                </div>897              )}898            </For>899          </Show>900901          <p class="peach-settings-sub-label">PASSKEYS</p>902          <div class="peach-settings-row peach-settings-row--top">903            <div class="peach-settings-row-main">904              <span>Intercept passkey requests</span>905              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">906                Peach can handle passkey sign-in and creation on supported Chromium browsers.907              </span>908            </div>909            <button910              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}911              onClick={() => void handleTogglePasskeys()}912              aria-label="Toggle passkey interception"913              disabled={!passkeyState()?.supported || passkeySaving()}914            >915              <span class="peach-toggle-knob"></span>916            </button>917          </div>918          <div class="peach-settings-row peach-settings-row--meta">919            <span>Status</span>920            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>921          </div>922          <div class="peach-settings-row peach-settings-row--meta">923            <span>Browser</span>924            <span class="peach-settings-value">925              {passkeyState()?.browserLabel ?? "Unknown"}926              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}927            </span>928          </div>929          <div class="peach-info-notice" style="margin-top:8px">930            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.931          </div>932          <Show when={passkeyState()?.conflict}>933            <div class="peach-settings-row peach-settings-row--meta">934              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>935            </div>936          </Show>937        </div>938      </div>939    );940  }941942  function renderVault() {943    return (944      <div class="peach-settings-body">945        <div class="peach-settings-group">946          <p class="peach-settings-sub-label">ENTRIES</p>947          <div class="peach-settings-row peach-settings-row--meta">948            <span>Entries</span>949            <span class="peach-settings-value">{props.entriesCount}</span>950          </div>951          <div class="peach-settings-row peach-settings-row--meta">952            <span>Duplicates</span>953            <Show when={dedupDone() !== null} fallback={954              <button955                class="peach-btn peach-btn--subtle peach-btn--sm"956                onClick={() => void handleCountDuplicates()}957                disabled={dedupRunning()}958              >959                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}960              </button>961            }>962              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">963                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}964              </span>965            </Show>966          </div>967          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>968            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">969              <div class="peach-restore-warning">970                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.971              </div>972              <div style="display:flex;gap:6px">973                <button974                  class="peach-btn peach-btn--subtle peach-btn--sm"975                  onClick={() => setDedupCount(null)}976                >977                  cancel978                </button>979                <button980                  class="peach-btn peach-btn--accent peach-btn--sm"981                  onClick={() => void handleDeduplicate()}982                  disabled={dedupRunning()}983                >984                  {dedupRunning() ? "removing..." : "remove duplicates"}985                </button>986              </div>987            </div>988          </Show>989990          <p class="peach-settings-sub-label">MAINTENANCE</p>991          <div class="peach-settings-row peach-settings-row--meta">992            <span>Trash auto-delete</span>993            <select994              class="peach-settings-select"995              value={String(props.trashRetentionDays)}996              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}997            >998              <option value="7">7 days</option>999              <option value="30">30 days</option>1000              <option value="90">90 days</option>1001              <option value="0">Never</option>1002            </select>1003          </div>1004          <div class="peach-settings-row peach-settings-row--meta">1005            <span>Vault Health</span>1006            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1007              open1008            </button>1009          </div>1010          <Show when={declinedCardCount() > 0}>1011            <div class="peach-settings-row peach-settings-row--meta">1012              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1013              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1014                Clear1015              </button>1016            </div>1017          </Show>10181019          <p class="peach-settings-sub-label">DATA</p>1020          <div class="peach-settings-row peach-settings-row--meta">1021            <span>Import passwords</span>1022            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1023              open1024            </button>1025          </div>1026          <div class="peach-settings-row peach-settings-row--meta">1027            <span>Export backup</span>1028            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1029              open1030            </button>1031          </div>1032          <div class="peach-settings-row peach-settings-row--meta">1033            <span>1034              Peach Codex1035              <Show when={!isPro()}>1036                <span class="peach-pro-badge">Pro</span>1037              </Show>1038            </span>1039            <button1040              class="peach-btn peach-btn--subtle peach-btn--sm"1041              onClick={() => props.onOpenCodexGenerate?.()}1042            >1043              open1044            </button>1045          </div>1046          {/* Restore from Codex: intentionally never gated — a printed1047              Codex must remain restorable regardless of license state. */}1048          <div class="peach-settings-row peach-settings-row--meta">1049            <span>Restore from Codex</span>1050            <button1051              class="peach-btn peach-btn--subtle peach-btn--sm"1052              onClick={() => props.onOpenCodexRestore?.()}1053            >1054              open1055            </button>1056          </div>10571058          <button1059            class="peach-settings-advanced-toggle"1060            onClick={() => setHistoryOpen((v) => !v)}1061            aria-expanded={historyOpen()}1062          >1063            <span class="peach-settings-sub-label" style="margin:0">VAULT HISTORY</span>1064            <span class="peach-settings-advanced-chevron">1065              {historyOpen() ? "▲" : "▼"}1066            </span>1067          </button>1068          <Show when={historyOpen()}>1069            <Show when={historyLoading()}>1070              <p class="peach-settings-hint">Loading…</p>1071            </Show>1072            <Show when={!historyLoading() && historySnapshots().length === 0}>1073              <p class="peach-settings-hint">1074                No snapshots yet. Peach saves automatically before every change —1075                saves, syncs, and imports.1076              </p>1077            </Show>1078            <Show when={!historyLoading() && historySnapshots().length > 0}>1079              <p class="peach-settings-hint">1080                Restoring locks your vault. Unlock with the password that1081                encrypted that snapshot to confirm.1082              </p>1083              <div class="peach-history-list">1084                <For each={historySnapshots()}>1085                  {(snap) => (1086                    <div class="peach-history-row">1087                      <div class="peach-history-meta">1088                        <span class="peach-history-time">1089                          {formatSnapshotTime(snap.savedAt)}1090                        </span>1091                        <span class="peach-history-sub">1092                          {[1093                            snap.entryCount !== null1094                              ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1095                              : null,1096                            reasonLabel(snap.reason),1097                          ]1098                            .filter(Boolean)1099                            .join(" · ")}1100                        </span>1101                      </div>1102                      <button1103                        class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1104                          historyRestoring() === snap.id ? " is-busy" : ""1105                        }`}1106                        onClick={() => void handleRestoreVaultSnapshot(snap.id)}1107                        disabled={historyRestoring() !== null}1108                      >1109                        <Show when={historyRestoring() === snap.id} fallback="Restore">1110                          …1111                        </Show>1112                      </button>1113                    </div>1114                  )}1115                </For>1116              </div>1117              <p class="peach-history-footer">1118                {historySnapshots().length} of 10 snapshots1119              </p>1120            </Show>1121          </Show>11221123          <button1124            class="peach-settings-advanced-toggle"1125            onClick={() => setShowVaultAdvanced((v) => !v)}1126            aria-expanded={showVaultAdvanced()}1127          >1128            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1129            <span class="peach-settings-advanced-chevron">1130              {showVaultAdvanced() ? "▲" : "▼"}1131            </span>1132          </button>11331134          <Show when={showVaultAdvanced()}>1135            <div class="peach-danger-card">1136              <div class="peach-danger-card-head">1137                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1138                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1139              </div>1140              <p class="peach-danger-card-copy">1141                Wipes the current vault and restores from a backup file.1142                Recoverable only if your backup is good.1143              </p>1144              <button1145                class="peach-danger-card-button"1146                onClick={() => props.onOpenFileImport?.()}1147              >1148                Choose backup file1149              </button>1150            </div>11511152            <Show when={props.onOpenPanic}>1153              <div class="peach-danger-card peach-danger-card--hazard">1154                <div class="peach-danger-card-head">1155                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1156                  <h3 class="peach-danger-card-title">Destroy vault</h3>1157                </div>1158                <p class="peach-danger-card-copy">1159                  Permanently wipes every entry, setting, and license on this1160                  device. There is no recovery.1161                </p>1162                <button1163                  class="peach-danger-card-button"1164                  onClick={() => props.onOpenPanic?.()}1165                >1166                  Begin destruction1167                </button>1168              </div>1169            </Show>1170          </Show>1171        </div>1172      </div>1173    );1174  }11751176  function renderSync() {1177    return (1178      <div class="peach-settings-body">1179        <div class="peach-settings-group">1180          <p class="peach-settings-sub-label">PAIRING</p>1181          <div class="peach-settings-row peach-settings-row--top">1182            <div class="peach-settings-row-main">1183              <span>1184                Peach Sync1185                <Show when={!isPro()}>1186                  <span class="peach-pro-badge">Pro</span>1187                </Show>1188              </span>1189              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1190                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1191              </span>1192            </div>1193            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1194          </div>1195          <div class="peach-settings-row peach-settings-row--meta">1196            <span>Status</span>1197            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1198          </div>1199          <div class="peach-settings-row peach-settings-row--meta">1200            <span>Sync hub</span>1201            <button1202              class="peach-btn peach-btn--subtle peach-btn--sm"1203              onClick={() => props.onOpenSyncPairing?.()}1204            >1205              open1206            </button>1207          </div>12081209          <p class="peach-settings-sub-label">DEVICE FLOW</p>1210          <div class="peach-settings-row peach-settings-row--top">1211            <div class="peach-settings-row-main">1212              <span>Pair new device</span>1213              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1214                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1215              </span>1216            </div>1217            <button1218              class="peach-btn peach-btn--subtle peach-btn--sm"1219              onClick={() => props.onOpenSyncPairing?.()}1220            >1221              pair1222            </button>1223          </div>1224          <div class="peach-info-notice" style="margin-top:8px">1225            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1226          </div>1227        </div>1228      </div>1229    );1230  }12311232  function renderAppearance() {1233    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1234      { id: "small", label: "Small" },1235      { id: "medium", label: "Default" },1236      { id: "large", label: "Large" },1237    ];1238    return (1239      <div class="peach-settings-body">1240        <div class="peach-settings-group">1241          <p class="peach-settings-sub-label">THEME</p>1242          <div class="peach-settings-row peach-settings-row--meta">1243            <span>Theme</span>1244            <button1245              class="peach-btn peach-btn--subtle peach-btn--sm"1246              onClick={() =>1247                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1248              }1249            >1250              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1251            </button>1252          </div>12531254          <p class="peach-settings-sub-label">ACCENT</p>1255          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1256            <span>Accent</span>1257            <div class="peach-accent-picker">1258              <For each={ACCENT_COLORS}>1259                {(color) => (1260                  <button1261                    class="peach-accent-swatch peach-swatch"1262                    classList={{ "is-active": props.accentColor === color.name }}1263                    style={{ background: color.hex }}1264                    onClick={() => void props.onAccentChange(color.name)}1265                    aria-label={color.name}1266                    title={color.name}1267                  />1268                )}1269              </For>1270            </div>1271          </div>12721273          <p class="peach-settings-sub-label">TEXT SIZE</p>1274          <div class="peach-settings-row peach-settings-row--meta">1275            <span>Size</span>1276            <div class="peach-pill-group">1277              <For each={sizeOptions}>1278                {(opt) => (1279                  <button1280                    class="peach-btn peach-btn--ghost peach-btn--sm"1281                    classList={{ "is-active": props.textSize === opt.id }}1282                    onClick={() => void props.onTextSizeChange(opt.id)}1283                  >1284                    {opt.label}1285                  </button>1286                )}1287              </For>1288            </div>1289          </div>12901291          <p class="peach-settings-sub-label">MOTION</p>1292          <div class="peach-settings-row peach-settings-row--meta">1293            <span>Reduce motion</span>1294            <button1295              class="peach-btn peach-btn--subtle peach-btn--sm"1296              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1297            >1298              {props.reduceMotion ? "On" : "Off"}1299            </button>1300          </div>1301        </div>1302      </div>1303    );1304  }13051306  function renderShortcuts() {1307    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1308    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13091310    function startCapture(action: ShortcutAction) {1311      setConflictMsg(null);1312      setCapturingAction(action);1313    }1314    function cancelCapture() {1315      setCapturingAction(null);1316      setConflictMsg(null);1317    }1318    function commitBinding(action: ShortcutAction, binding: Keybinding) {1319      const conflict = findConflict(props.shortcuts, binding, action);1320      if (conflict) {1321        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1322        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1323        return;1324      }1325      const next = { ...props.shortcuts, [action]: binding };1326      setCapturingAction(null);1327      setConflictMsg(null);1328      void props.onShortcutsChange(next);1329    }1330    function resetAction(action: ShortcutAction) {1331      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1332      void props.onShortcutsChange(next);1333    }1334    function resetAll() {1335      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1336    }13371338    // Capture-mode key handler — wired via ref onto the chip element so it1339    // catches the next non-modifier keypress and ignores other bindings.1340    function handleCaptureKey(e: KeyboardEvent) {1341      e.preventDefault();1342      e.stopPropagation();1343      if (e.key === "Escape") { cancelCapture(); return; }1344      const binding = keybindingFromEvent(e);1345      if (!binding) return; // bare modifier — keep waiting1346      const action = capturingAction();1347      if (action) commitBinding(action, binding);1348    }13491350    return (1351      <div class="peach-settings-body">1352        <div class="peach-settings-group peach-shortcuts-group">1353          <p class="peach-settings-sub-label">ENTRY VIEW</p>1354          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1355            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1356          </For>1357        </div>13581359        <div class="peach-settings-group peach-shortcuts-group">1360          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1361          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1362            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1363          </For>1364        </div>13651366        <Show when={conflictMsg()}>1367          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1368        </Show>13691370        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1371          <div class="peach-settings-row peach-settings-row--meta">1372            <span>Inline autofill on page</span>1373            <a1374              class="peach-settings-link"1375              href="chrome://extensions/shortcuts"1376              onClick={(e) => {1377                e.preventDefault();1378                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1379              }}1380            >1381              ⌘⇧P — customize ↗1382            </a>1383          </div>1384          <button1385            class="peach-btn peach-btn--subtle peach-btn--sm"1386            style="align-self:flex-start"1387            onClick={resetAll}1388          >1389            Reset all to defaults1390          </button>1391        </div>1392      </div>1393    );13941395    function renderRow(1396      desc: typeof SHORTCUT_DESCRIPTORS[number],1397      capturing: () => ShortcutAction | null,1398      onStart: (a: ShortcutAction) => void,1399      onCancel: () => void,1400      onReset: (a: ShortcutAction) => void,1401      onKey: (e: KeyboardEvent) => void,1402    ) {1403      const isActive = () => capturing() === desc.action;1404      const binding = () => props.shortcuts[desc.action];1405      const isCustom = () => {1406        const b = binding();1407        const d = DEFAULT_BINDINGS[desc.action];1408        return b.key !== d.key1409          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1410          || Boolean(b.meta)  !== Boolean(d.meta)1411          || Boolean(b.shift) !== Boolean(d.shift)1412          || Boolean(b.alt)   !== Boolean(d.alt);1413      };1414      return (1415        <div class="peach-shortcut-row">1416          <span class="peach-shortcut-row-label">{desc.label}</span>1417          <button1418            class="peach-shortcut-chip"1419            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1420            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1421            onKeyDown={(e) => { if (isActive()) onKey(e); }}1422            ref={(el) => {1423              // Auto-focus when entering capture mode so the keydown listener catches the next press.1424              if (el) {1425                queueMicrotask(() => { if (isActive()) el.focus(); });1426              }1427            }}1428          >1429            {isActive() ? "Press a key…" : formatKeybinding(binding())}1430          </button>1431          <Show when={isCustom() && !isActive()}>1432            <button1433              class="peach-shortcut-reset"1434              title="Reset to default"1435              onClick={() => onReset(desc.action)}1436            >1437              ↺1438            </button>1439          </Show>1440        </div>1441      );1442    }1443  }14441445  const PRO_FEATURES: Array<{1446    feature: ProFeature;1447    name: string;1448    desc: string;1449  }> = [1450    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1451    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1452    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1453    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1454    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1455    { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1456  ];14571458  const PRO_SECURITY_FEATURES: Array<{1459    feature: ProFeature;1460    name: string;1461    desc: string;1462  }> = [1463    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1464    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1465  ];14661467  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1468  const [showKey, setShowKey] = createSignal(false);14691470  const [deactivating, setDeactivating] = createSignal(false);14711472  async function handleDeactivateLicense() {1473    setDeactivating(true);1474    await sendMessage({ type: "DEACTIVATE_LICENSE" });1475    setLicenseInfo(null);1476    await props.onLicenseChange?.();1477    setDeactivating(false);1478  }14791480  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1481    setCanaryTestingId(entry.id);1482    setCanaryTestResult(null);1483    const res = await sendMessage({1484      type: "FIRE_CANARY_PING",1485      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1486    }) as { ok: boolean };1487    setCanaryTestResult({ id: entry.id, ok: res.ok });1488    setCanaryTestingId(null);1489    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1490  }14911492  function renderLicense() {1493    async function onActivate() {1494      await handleActivateLicense();1495      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1496        await props.onLicenseChange?.();1497      }1498    }14991500    return (1501      <div class="peach-settings-body">1502        <Show1503          when={isPro()}1504          fallback={1505                <div class="peach-pro-upgrade">1506                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15071508                  <h2 class="peach-pro-headline">1509                    Every<em>thing,</em><br />every<em>where.</em>1510                  </h2>15111512                  <p class="peach-pro-subtitle">1513                    One key unlocks every Pro feature — for the lifetime of the app.1514                  </p>15151516                  <div class="peach-pro-grid">1517                    <For each={PRO_FEATURES}>1518                      {(item, i) => (1519                        <div1520                          class="peach-pro-grid-item"1521                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1522                        >1523                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1524                          <span class="peach-pro-grid-name">{item.name}</span>1525                          <span class="peach-pro-grid-desc">{item.desc}</span>1526                        </div>1527                      )}1528                    </For>1529                  </div>15301531                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1532                  <div class="peach-pro-grid">1533                    <For each={PRO_SECURITY_FEATURES}>1534                      {(item, i) => (1535                        <div1536                          class="peach-pro-grid-item"1537                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1538                        >1539                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1540                          <span class="peach-pro-grid-name">{item.name}</span>1541                          <span class="peach-pro-grid-desc">{item.desc}</span>1542                        </div>1543                      )}1544                    </For>1545                  </div>15461547                  <div class="peach-pro-upgrade-actions">1548                    <p class="peach-pro-pricing">1549                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1550                    </p>1551                    <a1552                      href="https://peachpasswords.com/#pricing"1553                      target="_blank"1554                      rel="noopener noreferrer"1555                      class="peach-pro-upgrade-cta"1556                    >1557                      Get Peach Pro1558                    </a>1559                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1560                    <div class="peach-pro-key-inline">1561                      <input1562                        class="peach-pro-key-inline-input"1563                        type="text"1564                        placeholder="PEACH-XXXX-XXXX-XXXX"1565                        value={licenseKeyInput()}1566                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1567                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1568                        autocomplete="off"1569                        spellcheck={false}1570                      />1571                      <button1572                        class="peach-pro-key-apply"1573                        onClick={() => void onActivate()}1574                        disabled={activatingLicense() || !licenseKeyInput().trim()}1575                      >1576                        {activatingLicense() ? "…" : "APPLY"}1577                      </button>1578                    </div>1579                    <Show when={licenseError()}>1580                      <p class="peach-pro-key-error">{licenseError()}</p>1581                    </Show>1582                  </div>1583                </div>1584          }1585        >1586          <div class="peach-pro-active">1587            <div class="peach-pro-active-hero">1588              <div class="peach-pro-active-mark-wrap">1589                <PeachMark class="peach-pro-active-mark" />1590              </div>1591              <h2 class="peach-pro-active-title">Peach Pro</h2>1592              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1593            </div>15941595            <Show when={licenseKey()}>1596              <div class="peach-settings-license">1597                <span class="peach-settings-label">LICENSE KEY</span>1598                <div class="peach-settings-license-row">1599                  <Show1600                    when={showKey()}1601                    fallback={1602                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1603                        ••••••••••••••••••••••••••••1604                      </span>1605                    }1606                  >1607                    <span class="peach-settings-license-key">{licenseKey()!}</span>1608                  </Show>1609                  <div class="peach-settings-license-actions">1610                    <button1611                      class="peach-settings-license-copy"1612                      title="Copy license key"1613                      onClick={() => copyLicenseKey(licenseKey()!)}1614                    >1615                      {copied() ? "✓" : "copy"}1616                    </button>1617                    <button1618                      class="peach-settings-license-toggle"1619                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1620                      title={showKey() ? "Hide" : "Reveal"}1621                      onClick={() => setShowKey(v => !v)}1622                    >1623                      <Show1624                        when={showKey()}1625                        fallback={1626                          <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">1627                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1628                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1629                          </svg>1630                        }1631                      >1632                        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1633                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1634                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1635                          <line x1="2" y1="2" x2="12" y2="12" />1636                        </svg>1637                      </Show>1638                    </button>1639                  </div>1640                </div>1641              </div>1642            </Show>16431644            <div class="peach-pro-active-features">1645              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1646                {(item) => (1647                  <div class="peach-pro-active-feature">1648                    <span class="peach-pro-active-feature-mark">✦</span>1649                    <span class="peach-pro-active-feature-name">{item.name}</span>1650                  </div>1651                )}1652              </For>1653            </div>16541655            <button1656              class="peach-pro-deactivate"1657              onClick={() => void handleDeactivateLicense()}1658              disabled={deactivating()}1659            >1660              {deactivating() ? "Deactivating…" : "Deactivate"}1661            </button>1662          </div>1663        </Show>1664      </div>1665    );1666  }16671668  function renderFeedback() {1669    return (1670      <div class="peach-settings-body">1671        <div class="peach-settings-group peach-feedback-group">1672          <p class="peach-feedback-intro">1673            Help us improve Peach. Every report goes directly to our inbox.1674          </p>16751676          <p class="peach-settings-sub-label">TYPE</p>1677          <div class="peach-feedback-chips">1678            <For each={FEEDBACK_CATEGORIES}>1679              {(cat) => (1680                <button1681                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1682                  onClick={() => setFbCategory(cat)}1683                >1684                  {cat}1685                </button>1686              )}1687            </For>1688          </div>16891690          <p class="peach-settings-sub-label">MESSAGE</p>1691          <textarea1692            class="peach-feedback-textarea"1693            placeholder="Describe the issue or idea..."1694            value={fbMessage()}1695            onInput={(e) => setFbMessage(e.currentTarget.value)}1696          />16971698          <p class="peach-feedback-meta">1699            Version {version} · will be included automatically1700          </p>17011702          <Show when={fbResult() === "success"}>1703            <p class="peach-feedback-result peach-feedback-result--ok">1704              Sent — thank you1705            </p>1706          </Show>1707          <Show when={fbResult() === "error"}>1708            <p class="peach-feedback-result peach-feedback-result--err">1709              Failed to send — try again1710            </p>1711          </Show>17121713          <button1714            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1715            onClick={() => void handleSubmitFeedback()}1716            disabled={fbSubmitting() || !fbMessage().trim()}1717          >1718            {fbSubmitting() ? "Sending..." : "Send feedback"}1719          </button>17201721          <p class="peach-feedback-anon">1722            Sent anonymously · no account required1723          </p>1724        </div>1725      </div>1726    );1727  }17281729  const bodies: StackBodies = {1730    protection: renderSecurity(),1731    vault: renderVault(),1732    sync: renderSync(),1733    appearance: renderAppearance(),1734    shortcuts: renderShortcuts(),1735    pro: renderLicense(),1736    feedback: renderFeedback(),1737  };17381739  return (1740    <Show1741      when={showNewRecoveryWords()}1742      fallback={1743        <div1744          class="peach-overlay-view"1745          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1746          style="display:flex;flex-direction:column"1747        >1748          <StackDeck1749            active={activeCard()}1750            onActiveChange={setActiveCard}1751            onBack={props.onBack}1752            cards={cards()}1753            bodies={bodies}1754          />1755        </div>1756      }1757    >1758      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1759    </Show>1760  );1761}