Settings.tsx

74.8 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import {16  DEFAULT_BINDINGS,17  findConflict,18  formatKeybinding,19  keybindingFromEvent,20  SHORTCUT_DESCRIPTORS,21  type Keybinding,22  type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28  valid: boolean;29  plan: "free" | "pro";30  issued?: string;31}3233interface SettingsProps {34  entriesCount: number;35  breachMonitoring: boolean;36  lockOnClose: boolean;37  autoLockMinutes: number;38  trashRetentionDays: number;39  theme: "dark" | "light";40  accentColor: AccentColorName;41  textSize: "small" | "medium" | "large";42  reduceMotion: boolean;43  isPro?: boolean;44  initialSection?: string | null;45  upgradeHighlight?: ProFeature | null;46  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49  onTrashRetentionChange: (days: number) => void | Promise<void>;50  onThemeChange: (theme: "dark" | "light") => void;51  onAccentChange: (name: AccentColorName) => void | Promise<void>;52  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54  shortcuts: Record<ShortcutAction, Keybinding>;55  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56  onLicenseChange?: () => void | Promise<void>;57  isExiting?: boolean;58  onBack: () => void;59  onImport: () => void;60  onExport: () => void;61  onBreachCheckDone: () => void;62  onOpenHealth?: () => void;63  onOpenSyncPairing?: () => void;64  onOpenFileImport?: () => void;65  onOpenPanic?: () => void;66  onOpenCodexGenerate?: () => void;67  onOpenCodexRestore?: () => void;68  /** Invoked from within Settings when a Pro-gated control is tapped on69   *  free tier. App owns the upgrade navigation + highlight state, so this70   *  callback is the single way for Settings to request that navigation. */71  onProGate?: (feature: ProFeature) => void;72  onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";77const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;78const PRO_CELEBRATION_CARD_STEP_MS = 150;79const PRO_CELEBRATION_THANK_YOU_MS = 3200;8081function sanitizeFeedbackMessage(value: string): string {82  return value83    .trim()84    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)85    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")86    .replace(/&/g, "&amp;")87    .replace(/</g, "&lt;")88    .replace(/>/g, "&gt;")89    .replace(/"/g, "&quot;")90    .replace(/'/g, "&#39;");91}9293type DataCollectionPermissionApi = {94  permissions?: {95    getAll?: () => Promise<{ data_collection?: string[] }>;96    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;97  };98};99100async function requestFeedbackDataPermission(): Promise<boolean> {101  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??102    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;103  if (!api?.permissions?.getAll) return true;104105  try {106    const permissions = await api.permissions.getAll();107    if (!("data_collection" in permissions)) return true;108    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;109    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));110  } catch {111    return false;112  }113}114115function formatActivatedSince(issued: string | undefined): string {116  if (!issued) return "Active";117  try {118    const d = new Date(issued);119    if (Number.isNaN(d.getTime())) return "Active";120    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;121  } catch {122    return "Active";123  }124}125126function initialSectionToCard(value: string | null | undefined): CardId | null {127  if (!value) return null;128  if (value === "security") return "protection";129  if (value === "license") return "pro";130  if (131    value === "protection" ||132    value === "vault" ||133    value === "sync" ||134    value === "appearance" ||135    value === "shortcuts" ||136    value === "pro" ||137    value === "feedback"138  ) {139    return value;140  }141  return null;142}143144export function SettingsView(props: SettingsProps) {145  const manifest = chrome.runtime.getManifest();146  const version = manifest.version;147  const initial = props.initialSection;148  const [activeCard, setActiveCard] = createSignal<CardId>(149    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"150  );151152  createEffect(() => { props.onCardChange?.(activeCard()); });153154  const [justActivated, setJustActivated] = createSignal(false);155  const [isFanning, setIsFanning] = createSignal(false);156157  function triggerCelebration() {158    if (isFanning()) return;159    setJustActivated(false);160    setIsFanning(true);161    setActiveCard("pro");162    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];163    fanOrder.forEach((id, i) => {164      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);165    });166    window.setTimeout(() => {167      setActiveCard("pro");168      setIsFanning(false);169      setJustActivated(true);170      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);171    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);172  }173174  function handleProCelebrationClick(e: MouseEvent) {175    e.stopPropagation();176    triggerCelebration();177  }178179  const [checkingBreaches, setCheckingBreaches] = createSignal(false);180  const [breachResult, setBreachResult] = createSignal<string | null>(null);181  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);182  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");183  const [licenseError, setLicenseError] = createSignal<string | null>(null);184  const [, setLicenseSuccess] = createSignal<string | null>(null);185  const [activatingLicense, setActivatingLicense] = createSignal(false);186187  const isPro = () => props.isPro ?? false;188  const cards = createMemo<StackCardSpec[]>(() => [189    {190      id: "pro",191      num: "I · FOREVER",192      title: "Peach Pro",193      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",194      bgVar: "--stack-pro-bg",195      fgVar: "--stack-pro-fg",196      icon: "spark",197      onIconClick: isPro() ? triggerCelebration : undefined,198    },199    {200      id: "protection",201      num: "II · PROTECT",202      title: "Protection",203      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,204      bgVar: "--stack-protect-bg",205      fgVar: "--stack-protect-fg",206      icon: "shield",207    },208    {209      id: "vault",210      num: "III · KEEP",211      title: "Vault",212      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,213      bgVar: "--stack-vault-bg",214      fgVar: "--stack-vault-fg",215      icon: "lock",216    },217    {218      id: "sync",219      num: "IV · ECHO",220      title: "Sync",221      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",222      bgVar: "--stack-sync-bg",223      fgVar: "--stack-sync-fg",224      icon: "sync",225    },226    {227      id: "appearance",228      num: "V · DRESS",229      title: "Appearance",230      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,231      bgVar: "--stack-appear-bg",232      fgVar: "--stack-appear-fg",233      icon: "sun",234    },235    {236      id: "shortcuts",237      num: "VI · KEYS",238      title: "Shortcuts",239      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,240      bgVar: "--stack-shortcut-bg",241      fgVar: "--stack-shortcut-fg",242      icon: "keys",243    },244    {245      id: "feedback",246      num: "VII · ECHO",247      title: "Feedback",248      lede: `version ${version} · tell us what would make it better`,249      bgVar: "--stack-feedback-bg",250      fgVar: "--stack-feedback-fg",251      icon: "bubble",252    },253  ]);254  const [biometricSupported, setBiometricSupported] = createSignal(false);255  const [biometricStored, setBiometricStored] = createSignal(false);256  const [biometricBusy, setBiometricBusy] = createSignal(false);257  const [biometricError, setBiometricError] = createSignal<string | null>(null);258  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);259  const [passkeySaving, setPasskeySaving] = createSignal(false);260261  const [dedupCount, setDedupCount] = createSignal<number | null>(null);262  const [dedupRunning, setDedupRunning] = createSignal(false);263  const [dedupDone, setDedupDone] = createSignal<number | null>(null);264265  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);266267  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted268  // BEFORE every real-mode vault write so the user can roll back if a sync269  // or import overwrites the wrong state.270  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);271  const [historyLoading, setHistoryLoading] = createSignal(false);272  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);273  const [historyShowAll, setHistoryShowAll] = createSignal(false);274275  async function loadVaultHistory() {276    setHistoryLoading(true);277    try {278      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });279      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];280      setHistorySnapshots(list);281    } finally {282      setHistoryLoading(false);283    }284  }285286  async function handleRestoreVaultSnapshot(id: string) {287    if (historyRestoring()) return;288    setHistoryRestoring(id);289    const res = await sendMessage({290      type: "RESTORE_VAULT_SNAPSHOT",291      payload: { snapshotId: id },292    });293    setHistoryRestoring(null);294    if ((res as { ok: boolean }).ok) {295      // Vault is now locked — let the parent close Settings; App.tsx's296      // VAULT_LOCKED listener will route back to the lock screen.297      props.onBack();298    }299  }300301  function formatSnapshotTime(savedAt: number): string {302    const diff = Date.now() - savedAt;303    if (diff < 60_000) return "Just now";304    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;305    const d = new Date(savedAt);306    const isToday = new Date().toDateString() === d.toDateString();307    const isYest =308      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();309    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });310    if (isToday) return `Today ${t}`;311    if (isYest) return `Yesterday ${t}`;312    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;313  }314315  function reasonLabel(reason: VaultSnapshot["reason"]): string {316    return (317      {318        local_save: "saved",319        sync_received: "synced",320        import: "imported",321        restore: "restored",322      } as const323    )[reason] ?? reason;324  }325326  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);327328  const [showChangePw, setShowChangePw] = createSignal(false);329  const [changePwCurrent, setChangePwCurrent] = createSignal("");330  const [changePwNew, setChangePwNew] = createSignal("");331  const [changePwConfirm, setChangePwConfirm] = createSignal("");332  const [changePwSaving, setChangePwSaving] = createSignal(false);333  const [changePwError, setChangePwError] = createSignal<string | null>(null);334  const [changePwSuccess] = createSignal(false);335  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);336337  const [duressEnabled, setDuressEnabled] = createSignal(false);338  const [showDuressSetup, setShowDuressSetup] = createSignal(false);339  const [duressPassword, setDuressPassword] = createSignal("");340  const [duressConfirm, setDuressConfirm] = createSignal("");341  const [duressMainPassword, setDuressMainPassword] = createSignal("");342  const [duressSaving, setDuressSaving] = createSignal(false);343  const [duressError, setDuressError] = createSignal<string | null>(null);344  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);345346  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);347348  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);349  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);350351  const [fbCategory, setFbCategory] = createSignal("Bug report");352  const [fbMessage, setFbMessage] = createSignal("");353  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';354  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');355356  createEffect(() => {357    if (props.upgradeHighlight) {358      setActiveCard("pro");359    }360  });361362  onMount(async () => {363    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([364      sendMessage({ type: "GET_LICENSE" }),365      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),366      sendMessage({ type: "IS_DURESS_ENABLED" }),367      sendMessage({ type: "GET_SETTINGS" }),368      sendMessage({ type: "GET_LICENSE_KEY" }),369    ]);370371    // Vault history loads in the background — the Vault sub-view picks it up372    // when the user navigates in. Loading it lazily keeps Settings boot fast.373    void loadVaultHistory();374375    if ((licenseRes as { ok: boolean }).ok) {376      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);377    }378379    if ((passkeyRes as { ok: boolean }).ok) {380      setPasskeyState(381        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null382      );383    }384385    if ((duressRes as { ok: boolean }).ok) {386      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);387    }388389    if ((settingsRes as { ok: boolean }).ok) {390      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;391      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);392    }393394    if ((keyRes as { ok: boolean }).ok) {395      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);396    }397398    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });399    if ((entriesRes as { ok: boolean }).ok) {400      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];401      setCanaryEntries(402        entries403          .filter((e) => e.canary?.webhookUrl)404          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))405      );406    }407408409    if (isNativePlatform()) {410      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");411      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");412      try {413        if (biometric) {414          const a = await biometric.isAvailable();415          setBiometricSupported(a.available);416        }417        if (keystore) {418          const k = await keystore.hasMasterKey();419          setBiometricStored(k.exists);420        }421      } catch { /* missing plugins — leave defaults */ }422    }423  });424425  async function handleEnableBiometrics() {426    setBiometricBusy(true);427    setBiometricError(null);428    try {429      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {430        ok: boolean;431        data?: { masterKeyHex: string };432        error?: string;433      };434      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {435        setBiometricError(keyRes.error === "vault_locked"436          ? "unlock the vault first"437          : "couldn't read derived key");438        return;439      }440      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");441      if (!keystore) {442        setBiometricError("biometric plugin unavailable");443        return;444      }445      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });446      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });447      setBiometricStored(true);448    } catch (e) {449      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");450    } finally {451      setBiometricBusy(false);452    }453  }454455  async function handleDisableBiometrics() {456    setBiometricBusy(true);457    setBiometricError(null);458    try {459      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");460      if (keystore) await keystore.clearMasterKey();461      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });462      setBiometricStored(false);463    } catch (e) {464      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");465    } finally {466      setBiometricBusy(false);467    }468  }469470  async function handleChangePassword() {471    setChangePwError(null);472    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }473    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }474    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }475    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }476    setChangePwSaving(true);477    const res = await sendMessage({478      type: "CHANGE_PASSWORD",479      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },480    }) as { ok: boolean; error?: string };481    if (res.ok) {482      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");483      setShowChangePw(false);484      // LV-4: the user just authenticated (current password), so the recovery485      // words reveal that follows is exempt from the re-auth gate. Set the486      // pending flag the same way the create/recovery flow does; the display487      // clears it on confirm.488      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });489      setShowNewRecoveryWords(true);490    } else {491      setChangePwError(res.error ?? "failed to change password");492    }493    setChangePwSaving(false);494  }495496  const [copied, setCopied] = createSignal(false);497498  async function copyLicenseKey(key: string) {499    await navigator.clipboard.writeText(key);500    setCopied(true);501    setTimeout(() => setCopied(false), 2000);502  }503504  async function handleActivateLicense() {505    const key = licenseKeyInput().trim();506    if (!key) return;507    setActivatingLicense(true);508    setLicenseError(null);509    setLicenseSuccess(null);510    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });511    setActivatingLicense(false);512    if ((res as { ok: boolean }).ok) {513      const data = (res as { ok: boolean; data?: LicenseInfo }).data;514      if (data?.valid) {515        setLicenseInfo(data);516        setLicenseKey(key);517        setLicenseSuccess("License activated!");518        setLicenseKeyInput("");519      } else {520        setLicenseError("Invalid license key");521      }522    } else {523      setLicenseError("Activation failed");524    }525  }526527  async function handleTogglePasskeys() {528    const current = passkeyState();529    if (!current || !current.supported) return;530    setPasskeySaving(true);531    const res = await sendMessage({532      type: "SET_PASSKEY_PROVIDER_ENABLED",533      payload: { enabled: !current.enabled },534    });535    setPasskeySaving(false);536    if ((res as { ok: boolean }).ok) {537      setPasskeyState(538        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null539      );540    }541  }542543  async function handleCheckAllBreaches() {544    setCheckingBreaches(true);545    setBreachResult(null);546    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });547    setCheckingBreaches(false);548    if ((res as { ok: boolean }).ok) {549      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};550      const breached = Object.values(data).filter((count) => count > 0).length;551      setBreachResult(552        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"553      );554      props.onBreachCheckDone();555    }556  }557558559  async function handleSetupDuress() {560    const mainPw = duressMainPassword().trim();561    const duressPw = duressPassword().trim();562    const confirmPw = duressConfirm().trim();563564    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }565    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }566    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }567    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }568569    setDuressSaving(true);570    setDuressError(null);571572    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };573    if (!verifyRes.ok || !verifyRes.data) {574      setDuressError("incorrect main password");575      setDuressSaving(false);576      return;577    }578579    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };580    setDuressSaving(false);581    if (res.ok) {582      setDuressEnabled(true);583      setShowDuressSetup(false);584      setDuressPassword("");585      setDuressConfirm("");586      setDuressMainPassword("");587      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");588      setTimeout(() => setDuressSuccess(null), 5000);589    } else {590      setDuressError(591        res.error === "session_expired"592          ? "Vault session expired — lock and unlock Peach, then try again"593          : res.error ?? "failed to set duress password"594      );595    }596  }597598  async function handleClearDeclinedCards() {599    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });600    setDeclinedCardCount(0);601  }602603  async function handleCountDuplicates() {604    setDedupRunning(true);605    setDedupDone(null);606    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };607    setDedupRunning(false);608    if (res.ok) setDedupCount(res.data ?? 0);609  }610611  async function handleDeduplicate() {612    setDedupRunning(true);613    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };614    setDedupRunning(false);615    if (res.ok) {616      setDedupDone(res.data ?? 0);617      setDedupCount(0);618    }619  }620621  async function handleDisableDuress() {622    const mainPw = duressMainPassword().trim();623    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }624625    setDuressSaving(true);626    setDuressError(null);627628    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };629    if (!verifyRes.ok || !verifyRes.data) {630      setDuressError("incorrect main password");631      setDuressSaving(false);632      return;633    }634635    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };636    setDuressSaving(false);637    if (res.ok) {638      setDuressEnabled(false);639      setShowDuressSetup(false);640      setDuressMainPassword("");641      setDuressSuccess("Duress password disabled.");642      setTimeout(() => setDuressSuccess(null), 4000);643    } else {644      setDuressError(res.error ?? "failed to disable duress password");645    }646  }647648  async function handleSubmitFeedback() {649    const message = sanitizeFeedbackMessage(fbMessage());650    if (!message) return;651652    setFeedbackState('sending');653654    try {655      const hasDataPermission = await requestFeedbackDataPermission();656      if (!hasDataPermission) {657        setFeedbackState('idle');658        return;659      }660661      const category = FEEDBACK_CATEGORIES.includes(fbCategory())662        ? fbCategory()663        : FEEDBACK_CATEGORIES[0];664      const res = await fetch('https://api.web3forms.com/submit', {665        method: 'POST',666        headers: { 'Content-Type': 'application/json' },667        body: JSON.stringify({668          access_key: WEB3FORMS_KEY,669          subject: `Peach Feedback - ${category}`,670          message,671          from_name: 'Peach User',672          // Honeypot — leave blank673          botcheck: '',674        }),675      });676677      if (!res.ok) throw new Error(`HTTP ${res.status}`);678      const data = await res.json() as { success: boolean; message?: string };679      if (!data.success) throw new Error(data.message ?? 'unknown error');680681      setFeedbackState('sent');682      setFbMessage('');683      setFbCategory(FEEDBACK_CATEGORIES[0]);684    } catch (err) {685      console.error('[feedback]', err);686      setFeedbackState('error');687      setTimeout(() => setFeedbackState('idle'), 4000);688    }689  }690691  function renderSecurity() {692    return (693      <div class="peach-settings-body">694        <div class="peach-settings-group">695          <p class="peach-settings-sub-label">LOCKING</p>696          <div class="peach-settings-row">697            <span>Lock on close</span>698            <button699              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}700              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}701              aria-label="Toggle lock on close"702            >703              <span class="peach-toggle-knob"></span>704            </button>705          </div>706          <div class="peach-settings-row">707            <span>Auto-lock after</span>708            <select709              class="peach-settings-select"710              value={String(props.autoLockMinutes)}711              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}712            >713              <option value="1">1 minute</option>714              <option value="5">5 minutes</option>715              <option value="15">15 minutes</option>716              <option value="30">30 minutes</option>717              <option value="0">Never</option>718            </select>719          </div>720721          <Show when={isNativePlatform() && biometricSupported()}>722            <p class="peach-settings-sub-label">BIOMETRICS</p>723            <div class="peach-settings-row">724              <span>Unlock with biometrics</span>725              <button726                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}727                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}728                disabled={biometricBusy()}729                aria-label="Toggle biometric unlock"730              >731                <span class="peach-toggle-knob"></span>732              </button>733            </div>734            <Show when={biometricError()}>735              <div class="peach-settings-row peach-settings-row--meta">736                <span class="peach-hint peach-hint-error">{biometricError()}</span>737              </div>738            </Show>739          </Show>740741          <p class="peach-settings-sub-label">BREACH PROTECTION</p>742          <div class="peach-settings-row">743            <span>Breach monitoring</span>744            <button745              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}746              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}747              aria-label="Toggle breach monitoring"748            >749              <span class="peach-toggle-knob"></span>750            </button>751          </div>752          <div class="peach-settings-row peach-settings-row--meta">753            <span>Check all passwords</span>754            <button755              class="peach-btn peach-btn--subtle peach-btn--sm"756              onClick={() => void handleCheckAllBreaches()}757              disabled={checkingBreaches()}758            >759              {checkingBreaches() ? "checking..." : "run"}760            </button>761          </div>762          <Show when={breachResult()}>763            <div class="peach-settings-row peach-settings-row--meta">764              <span>Last check</span>765              <span class="peach-settings-value">{breachResult()}</span>766            </div>767          </Show>768769          <p class="peach-settings-sub-label">MASTER PASSWORD</p>770          <div class="peach-settings-row peach-settings-row--meta">771            <span>Master password</span>772            <button773              class="peach-btn peach-btn--subtle peach-btn--sm"774              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}775            >776              {showChangePw() ? "cancel" : "change"}777            </button>778          </div>779          <Show when={showChangePw()}>780            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">781              <input782                class="peach-settings-input"783                type="password"784                placeholder="current password"785                value={changePwCurrent()}786                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}787                autocomplete="current-password"788              />789              <input790                class="peach-settings-input"791                type="password"792                placeholder="new password (min 12 chars)"793                value={changePwNew()}794                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}795                autocomplete="new-password"796              />797              <input798                class="peach-settings-input"799                type="password"800                placeholder="confirm new password"801                value={changePwConfirm()}802                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}803                autocomplete="new-password"804                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}805              />806              <Show when={changePwError()}>807                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>808              </Show>809              <Show when={changePwSuccess()}>810                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>811              </Show>812              <button813                class="peach-btn peach-btn--accent peach-btn--sm"814                style="align-self:flex-start"815                onClick={() => void handleChangePassword()}816                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}817              >818                {changePwSaving() ? "saving…" : "save new password"}819              </button>820            </div>821          </Show>822823          <p class="peach-settings-sub-label">824            DURESS PASSWORD825            <Show when={!isPro()}>826              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>827            </Show>828          </p>829          <div class="peach-settings-row peach-settings-row--top">830            <div class="peach-settings-row-main">831              <span>832                Duress password833                <Show when={!isPro()}>834                  <span class="peach-pro-badge">Pro</span>835                </Show>836              </span>837              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">838                If entered under coercion, shows a decoy empty vault instead of your real one.839              </span>840            </div>841            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>842              {duressEnabled() ? "on" : "off"}843            </div>844          </div>845846          <Show when={duressEnabled() && !showDuressSetup()}>847            <div class="peach-settings-row peach-settings-row--meta">848              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">849                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.850              </span>851            </div>852          </Show>853854          <Show when={duressSuccess()}>855            <div class="peach-settings-row peach-settings-row--meta">856              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>857            </div>858          </Show>859860          <Show when={!showDuressSetup()}>861            <div class="peach-settings-row peach-settings-row--meta">862              <button863                class="peach-btn peach-btn--subtle peach-btn--sm"864                onClick={() => {865                  // Pro gate entry-point: free users are redirected to the866                  // upgrade screen before the setup form opens. The server-867                  // side SETUP_DURESS handler is the single enforcement868                  // point; this is UI redirection so the form never appears869                  // for a user who can't complete it.870                  if (!isPro() && !duressEnabled()) {871                    props.onProGate?.("duress_vault");872                    setActiveCard("pro");873                    return;874                  }875                  setShowDuressSetup(true);876                  setDuressError(null);877                  setDuressSuccess(null);878                  setDuressPassword("");879                  setDuressConfirm("");880                  setDuressMainPassword("");881                }}882              >883                {duressEnabled() ? "Change / disable" : "Set up"}884              </button>885            </div>886          </Show>887888          <Show when={showDuressSetup()}>889            <div class="peach-duress-form">890              <label class="peach-label">main password (to confirm)</label>891              <input892                class="peach-settings-input"893                type="password"894                placeholder="your current master password"895                autocomplete="current-password"896                value={duressMainPassword()}897                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}898              />899              <Show when={!duressEnabled()}>900                <label class="peach-label">new duress password</label>901                <input902                  class="peach-settings-input"903                  type="password"904                  placeholder="duress password (min 8 chars)"905                  autocomplete="new-password"906                  value={duressPassword()}907                  onInput={(e) => setDuressPassword(e.currentTarget.value)}908                />909                <label class="peach-label">confirm duress password</label>910                <input911                  class="peach-settings-input"912                  type="password"913                  placeholder="confirm duress password"914                  autocomplete="new-password"915                  value={duressConfirm()}916                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}917                />918              </Show>919920              <Show when={duressError()}>921                <p class="peach-hint peach-hint-error">{duressError()}</p>922              </Show>923924              <div class="peach-duress-actions">925                <button926                  class="peach-btn peach-btn--subtle peach-btn--sm"927                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}928                >929                  cancel930                </button>931                <Show when={duressEnabled()}>932                  <button933                    class="peach-btn peach-btn--ghost peach-btn--sm"934                    style="color:var(--peach-breach)"935                    onClick={() => void handleDisableDuress()}936                    disabled={duressSaving()}937                  >938                    {duressSaving() ? "disabling..." : "disable duress"}939                  </button>940                </Show>941                <Show when={!duressEnabled()}>942                  <button943                    class="peach-btn peach-btn--accent peach-btn--sm"944                    onClick={() => void handleSetupDuress()}945                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}946                  >947                    {duressSaving() ? "saving..." : "save"}948                  </button>949                </Show>950              </div>951            </div>952          </Show>953954          <p class="peach-settings-sub-label">955            CANARY ENTRIES956            <Show when={!isPro()}>957              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>958            </Show>959          </p>960          <div class="peach-settings-row peach-settings-row--top">961            <div class="peach-settings-row-main">962              <span>963                Canary entries964                <Show when={!isPro()}>965                  <span class="peach-pro-badge">Pro</span>966                </Show>967              </span>968              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">969                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.970              </span>971            </div>972            <span class="peach-settings-value" style="flex-shrink:0">973              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}974            </span>975          </div>976          <Show when={canaryEntries().length > 0}>977            <For each={canaryEntries()}>978              {(entry) => (979                <div class="peach-settings-row peach-settings-row--meta">980                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>981                  <button982                    class="peach-btn peach-btn--subtle peach-btn--sm"983                    onClick={() => void handleTestCanary(entry)}984                    disabled={canaryTestingId() === entry.id}985                    style="flex-shrink:0"986                  >987                    {canaryTestingId() === entry.id988                      ? "sending…"989                      : canaryTestResult()?.id === entry.id990                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")991                        : "test"}992                  </button>993                </div>994              )}995            </For>996          </Show>997998          <p class="peach-settings-sub-label">PASSKEYS</p>999          <div class="peach-settings-row peach-settings-row--top">1000            <div class="peach-settings-row-main">1001              <span>Intercept passkey requests</span>1002              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1003                Peach can handle passkey sign-in and creation on supported Chromium browsers.1004              </span>1005            </div>1006            <button1007              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1008              onClick={() => void handleTogglePasskeys()}1009              aria-label="Toggle passkey interception"1010              disabled={!passkeyState()?.supported || passkeySaving()}1011            >1012              <span class="peach-toggle-knob"></span>1013            </button>1014          </div>1015          <div class="peach-settings-row peach-settings-row--meta">1016            <span>Status</span>1017            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1018          </div>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Browser</span>1021            <span class="peach-settings-value">1022              {passkeyState()?.browserLabel ?? "Unknown"}1023              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1024            </span>1025          </div>1026          <div class="peach-info-notice" style="margin-top:8px">1027            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1028          </div>1029          <Show when={passkeyState()?.conflict}>1030            <div class="peach-settings-row peach-settings-row--meta">1031              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1032            </div>1033          </Show>1034        </div>1035      </div>1036    );1037  }10381039  function renderVault() {1040    return (1041      <div class="peach-settings-body">1042        <div class="peach-settings-group">1043          <p class="peach-settings-sub-label">ENTRIES</p>1044          <div class="peach-settings-row peach-settings-row--meta">1045            <span>Entries</span>1046            <span class="peach-settings-value">{props.entriesCount}</span>1047          </div>1048          <div class="peach-settings-row peach-settings-row--meta">1049            <span>Duplicates</span>1050            <Show when={dedupDone() !== null} fallback={1051              <button1052                class="peach-btn peach-btn--subtle peach-btn--sm"1053                onClick={() => void handleCountDuplicates()}1054                disabled={dedupRunning()}1055              >1056                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1057              </button>1058            }>1059              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1060                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1061              </span>1062            </Show>1063          </div>1064          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1065            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1066              <div class="peach-restore-warning">1067                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1068              </div>1069              <div style="display:flex;gap:6px">1070                <button1071                  class="peach-btn peach-btn--subtle peach-btn--sm"1072                  onClick={() => setDedupCount(null)}1073                >1074                  cancel1075                </button>1076                <button1077                  class="peach-btn peach-btn--accent peach-btn--sm"1078                  onClick={() => void handleDeduplicate()}1079                  disabled={dedupRunning()}1080                >1081                  {dedupRunning() ? "removing..." : "remove duplicates"}1082                </button>1083              </div>1084            </div>1085          </Show>10861087          <p class="peach-settings-sub-label">MAINTENANCE</p>1088          <div class="peach-settings-row peach-settings-row--meta">1089            <span>Trash auto-delete</span>1090            <select1091              class="peach-settings-select"1092              value={String(props.trashRetentionDays)}1093              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1094            >1095              <option value="7">7 days</option>1096              <option value="30">30 days</option>1097              <option value="90">90 days</option>1098              <option value="0">Never</option>1099            </select>1100          </div>1101          <div class="peach-settings-row peach-settings-row--meta">1102            <span>Advanced Vault Health</span>1103            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1104              open1105            </button>1106          </div>1107          <Show when={declinedCardCount() > 0}>1108            <div class="peach-settings-row peach-settings-row--meta">1109              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1110              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1111                Clear1112              </button>1113            </div>1114          </Show>11151116          <p class="peach-settings-sub-label">DATA</p>1117          <div class="peach-settings-row peach-settings-row--meta">1118            <span>Import passwords</span>1119            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1120              open1121            </button>1122          </div>1123          <div class="peach-settings-row peach-settings-row--meta">1124            <span>Export backup</span>1125            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1126              open1127            </button>1128          </div>1129          <div class="peach-settings-row peach-settings-row--meta">1130            <span>1131              Peach Codex1132              <Show when={!isPro()}>1133                <span class="peach-pro-badge">Pro</span>1134              </Show>1135            </span>1136            <button1137              class="peach-btn peach-btn--subtle peach-btn--sm"1138              onClick={() => props.onOpenCodexGenerate?.()}1139            >1140              open1141            </button>1142          </div>1143          {/* Restore from Codex: intentionally never gated — a printed1144              Codex must remain restorable regardless of license state. */}1145          <div class="peach-settings-row peach-settings-row--meta">1146            <span>Restore from Codex</span>1147            <button1148              class="peach-btn peach-btn--subtle peach-btn--sm"1149              onClick={() => props.onOpenCodexRestore?.()}1150            >1151              open1152            </button>1153          </div>11541155          <span class="peach-settings-sub-label">VAULT HISTORY</span>1156          <Show when={historyLoading()}>1157            <p class="peach-settings-hint">Loading…</p>1158          </Show>1159          <Show when={!historyLoading() && historySnapshots().length === 0}>1160            <p class="peach-settings-hint">1161              No snapshots yet. Peach saves automatically before every change —1162              saves, syncs, and imports.1163            </p>1164          </Show>1165          <Show when={!historyLoading() && historySnapshots().length > 0}>1166            <p class="peach-settings-hint">1167              Restoring locks your vault. Unlock with the password that1168              encrypted that snapshot to confirm.1169            </p>1170            <div class="peach-history-list">1171              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1172                {(snap) => (1173                  <div class="peach-history-row">1174                    <div class="peach-history-meta">1175                      <span class="peach-history-time">1176                        {formatSnapshotTime(snap.savedAt)}1177                      </span>1178                      <span class="peach-history-sub">1179                        {[1180                          snap.entryCount !== null1181                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1182                            : null,1183                          reasonLabel(snap.reason),1184                        ]1185                          .filter(Boolean)1186                          .join(" · ")}1187                      </span>1188                    </div>1189                    <button1190                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1191                        historyRestoring() === snap.id ? " is-busy" : ""1192                      }`}1193                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1194                      disabled={historyRestoring() !== null}1195                    >1196                      <Show when={historyRestoring() === snap.id} fallback="Restore">1197                        …1198                      </Show>1199                    </button>1200                  </div>1201                )}1202              </For>1203            </div>1204            <Show when={historySnapshots().length > 3}>1205              <button1206                class="peach-settings-advanced-toggle"1207                onClick={() => setHistoryShowAll((v) => !v)}1208                aria-expanded={historyShowAll()}1209              >1210                <span class="peach-settings-hint" style="margin:0">1211                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1212                </span>1213                <span class="peach-settings-advanced-chevron">1214                  {historyShowAll() ? "▲" : "▼"}1215                </span>1216              </button>1217            </Show>1218            <p class="peach-history-footer">1219              {historySnapshots().length} of 10 snapshots1220            </p>1221          </Show>12221223          <button1224            class="peach-settings-advanced-toggle"1225            onClick={() => setShowVaultAdvanced((v) => !v)}1226            aria-expanded={showVaultAdvanced()}1227          >1228            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1229            <span class="peach-settings-advanced-chevron">1230              {showVaultAdvanced() ? "▲" : "▼"}1231            </span>1232          </button>12331234          <Show when={showVaultAdvanced()}>1235            <div class="peach-danger-card">1236              <div class="peach-danger-card-head">1237                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1238                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1239              </div>1240              <p class="peach-danger-card-copy">1241                Wipes the current vault and restores from a backup file.1242                Recoverable only if your backup is good.1243              </p>1244              <button1245                class="peach-danger-card-button"1246                onClick={() => props.onOpenFileImport?.()}1247              >1248                Choose backup file1249              </button>1250            </div>12511252            <Show when={props.onOpenPanic}>1253              <div class="peach-danger-card peach-danger-card--hazard">1254                <div class="peach-danger-card-head">1255                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1256                  <h3 class="peach-danger-card-title">Destroy vault</h3>1257                </div>1258                <p class="peach-danger-card-copy">1259                  Permanently wipes every entry, setting, and license on this1260                  device. There is no recovery.1261                </p>1262                <button1263                  class="peach-danger-card-button"1264                  onClick={() => props.onOpenPanic?.()}1265                >1266                  Begin destruction1267                </button>1268              </div>1269            </Show>1270          </Show>1271        </div>1272      </div>1273    );1274  }12751276  function renderSync() {1277    return (1278      <div class="peach-settings-body">1279        <div class="peach-settings-group">1280          <p class="peach-settings-sub-label">PAIRING</p>1281          <div class="peach-settings-row peach-settings-row--top">1282            <div class="peach-settings-row-main">1283              <span>1284                Peach Sync1285                <Show when={!isPro()}>1286                  <span class="peach-pro-badge">Pro</span>1287                </Show>1288              </span>1289              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1290                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1291              </span>1292            </div>1293            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1294          </div>1295          <div class="peach-settings-row peach-settings-row--meta">1296            <span>Status</span>1297            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1298          </div>1299          <div class="peach-settings-row peach-settings-row--meta">1300            <span>Sync hub</span>1301            <button1302              class="peach-btn peach-btn--subtle peach-btn--sm"1303              onClick={() => props.onOpenSyncPairing?.()}1304            >1305              open1306            </button>1307          </div>13081309          <p class="peach-settings-sub-label">DEVICE FLOW</p>1310          <div class="peach-settings-row peach-settings-row--top">1311            <div class="peach-settings-row-main">1312              <span>Pair new device</span>1313              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1314                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1315              </span>1316            </div>1317            <button1318              class="peach-btn peach-btn--subtle peach-btn--sm"1319              onClick={() => props.onOpenSyncPairing?.()}1320            >1321              pair1322            </button>1323          </div>1324          <div class="peach-info-notice" style="margin-top:8px">1325            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1326          </div>1327        </div>1328      </div>1329    );1330  }13311332  function renderAppearance() {1333    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1334      { id: "medium", label: "Default" },1335      { id: "large", label: "Large" },1336    ];1337    return (1338      <div class="peach-settings-body">1339        <div class="peach-settings-group">1340          <p class="peach-settings-sub-label">THEME</p>1341          <div class="peach-settings-row peach-settings-row--meta">1342            <span>Theme</span>1343            <button1344              class="peach-btn peach-btn--subtle peach-btn--sm"1345              onClick={() =>1346                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1347              }1348            >1349              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1350            </button>1351          </div>13521353          <p class="peach-settings-sub-label">ACCENT</p>1354          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1355            <span>Accent</span>1356            <div class="peach-accent-picker">1357              <For each={ACCENT_COLORS}>1358                {(color) => (1359                  <button1360                    class="peach-accent-swatch peach-swatch"1361                    classList={{ "is-active": props.accentColor === color.name }}1362                    style={{ background: color.hex }}1363                    onClick={() => void props.onAccentChange(color.name)}1364                    aria-label={color.name}1365                    title={color.name}1366                  />1367                )}1368              </For>1369            </div>1370          </div>13711372          <p class="peach-settings-sub-label">TEXT SIZE</p>1373          <div class="peach-settings-row peach-settings-row--meta">1374            <span>Size</span>1375            <div class="peach-pill-group">1376              <For each={sizeOptions}>1377                {(opt) => (1378                  <button1379                    class="peach-btn peach-btn--ghost peach-btn--sm"1380                    classList={{ "is-active": props.textSize === opt.id }}1381                    onClick={() => void props.onTextSizeChange(opt.id)}1382                  >1383                    {opt.label}1384                  </button>1385                )}1386              </For>1387            </div>1388          </div>13891390          <p class="peach-settings-sub-label">MOTION</p>1391          <div class="peach-settings-row peach-settings-row--meta">1392            <span>Reduce motion</span>1393            <button1394              class="peach-btn peach-btn--subtle peach-btn--sm"1395              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1396            >1397              {props.reduceMotion ? "On" : "Off"}1398            </button>1399          </div>1400        </div>1401      </div>1402    );1403  }14041405  function renderShortcuts() {1406    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1407    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);14081409    function startCapture(action: ShortcutAction) {1410      setConflictMsg(null);1411      setCapturingAction(action);1412    }1413    function cancelCapture() {1414      setCapturingAction(null);1415      setConflictMsg(null);1416    }1417    function commitBinding(action: ShortcutAction, binding: Keybinding) {1418      const conflict = findConflict(props.shortcuts, binding, action);1419      if (conflict) {1420        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1421        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1422        return;1423      }1424      const next = { ...props.shortcuts, [action]: binding };1425      setCapturingAction(null);1426      setConflictMsg(null);1427      void props.onShortcutsChange(next);1428    }1429    function resetAction(action: ShortcutAction) {1430      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1431      void props.onShortcutsChange(next);1432    }1433    function resetAll() {1434      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1435    }14361437    // Capture-mode key handler — wired via ref onto the chip element so it1438    // catches the next non-modifier keypress and ignores other bindings.1439    function handleCaptureKey(e: KeyboardEvent) {1440      e.preventDefault();1441      e.stopPropagation();1442      if (e.key === "Escape") { cancelCapture(); return; }1443      const binding = keybindingFromEvent(e);1444      if (!binding) return; // bare modifier — keep waiting1445      const action = capturingAction();1446      if (action) commitBinding(action, binding);1447    }14481449    return (1450      <div class="peach-settings-body">1451        <div class="peach-settings-group peach-shortcuts-group">1452          <p class="peach-settings-sub-label">ENTRY VIEW</p>1453          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1454            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1455          </For>1456        </div>14571458        <div class="peach-settings-group peach-shortcuts-group">1459          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1460          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1461            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1462          </For>1463        </div>14641465        <Show when={conflictMsg()}>1466          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1467        </Show>14681469        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1470          <div class="peach-settings-row peach-settings-row--meta">1471            <span>Inline autofill on page</span>1472            <a1473              class="peach-settings-link"1474              href="chrome://extensions/shortcuts"1475              onClick={(e) => {1476                e.preventDefault();1477                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1478              }}1479            >1480              ⌘⇧P — customize ↗1481            </a>1482          </div>1483          <button1484            class="peach-btn peach-btn--subtle peach-btn--sm"1485            style="align-self:flex-start"1486            onClick={resetAll}1487          >1488            Reset all to defaults1489          </button>1490        </div>1491      </div>1492    );14931494    function renderRow(1495      desc: typeof SHORTCUT_DESCRIPTORS[number],1496      capturing: () => ShortcutAction | null,1497      onStart: (a: ShortcutAction) => void,1498      onCancel: () => void,1499      onReset: (a: ShortcutAction) => void,1500      onKey: (e: KeyboardEvent) => void,1501    ) {1502      const isActive = () => capturing() === desc.action;1503      const binding = () => props.shortcuts[desc.action];1504      const isCustom = () => {1505        const b = binding();1506        const d = DEFAULT_BINDINGS[desc.action];1507        return b.key !== d.key1508          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1509          || Boolean(b.meta)  !== Boolean(d.meta)1510          || Boolean(b.shift) !== Boolean(d.shift)1511          || Boolean(b.alt)   !== Boolean(d.alt);1512      };1513      return (1514        <div class="peach-shortcut-row">1515          <span class="peach-shortcut-row-label">{desc.label}</span>1516          <button1517            class="peach-shortcut-chip"1518            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1519            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1520            onKeyDown={(e) => { if (isActive()) onKey(e); }}1521            ref={(el) => {1522              // Auto-focus when entering capture mode so the keydown listener catches the next press.1523              if (el) {1524                queueMicrotask(() => { if (isActive()) el.focus(); });1525              }1526            }}1527          >1528            {isActive() ? "Press a key…" : formatKeybinding(binding())}1529          </button>1530          <Show when={isCustom() && !isActive()}>1531            <button1532              class="peach-shortcut-reset"1533              title="Reset to default"1534              onClick={() => onReset(desc.action)}1535            >1536              ↺1537            </button>1538          </Show>1539        </div>1540      );1541    }1542  }15431544  const PRO_FEATURES: Array<{1545    feature: ProFeature;1546    name: string;1547    desc: string;1548  }> = [1549    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1550    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1551    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1552    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1553  ];15541555  const PRO_SECURITY_FEATURES: Array<{1556    feature: ProFeature;1557    name: string;1558    desc: string;1559  }> = [1560    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1561    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1562  ];15631564  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1565  const [showKey, setShowKey] = createSignal(false);15661567  const [deactivating, setDeactivating] = createSignal(false);15681569  async function handleDeactivateLicense() {1570    setDeactivating(true);1571    await sendMessage({ type: "DEACTIVATE_LICENSE" });1572    setLicenseInfo(null);1573    await props.onLicenseChange?.();1574    setDeactivating(false);1575  }15761577  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1578    setCanaryTestingId(entry.id);1579    setCanaryTestResult(null);1580    const res = await sendMessage({1581      type: "FIRE_CANARY_PING",1582      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1583    }) as { ok: boolean };1584    setCanaryTestResult({ id: entry.id, ok: res.ok });1585    setCanaryTestingId(null);1586    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1587  }15881589  function renderLicense() {1590    async function onActivate() {1591      await handleActivateLicense();1592      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1593        await props.onLicenseChange?.();1594      }1595    }15961597    return (1598      <div class="peach-settings-body">1599        <Show1600          when={isPro()}1601          fallback={1602                <div class="peach-pro-upgrade">1603                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>16041605                  <h2 class="peach-pro-headline">1606                    Every<em>thing,</em><br />every<em>where.</em>1607                  </h2>16081609                  <p class="peach-pro-subtitle">1610                    One key unlocks every Pro feature — for the lifetime of the app.1611                  </p>16121613                  <div class="peach-pro-grid">1614                    <For each={PRO_FEATURES}>1615                      {(item, i) => (1616                        <div1617                          class="peach-pro-grid-item"1618                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1619                        >1620                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1621                          <span class="peach-pro-grid-name">{item.name}</span>1622                          <span class="peach-pro-grid-desc">{item.desc}</span>1623                        </div>1624                      )}1625                    </For>1626                  </div>16271628                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1629                  <div class="peach-pro-grid">1630                    <For each={PRO_SECURITY_FEATURES}>1631                      {(item, i) => (1632                        <div1633                          class="peach-pro-grid-item"1634                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1635                        >1636                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1637                          <span class="peach-pro-grid-name">{item.name}</span>1638                          <span class="peach-pro-grid-desc">{item.desc}</span>1639                        </div>1640                      )}1641                    </For>1642                  </div>16431644                  <div class="peach-pro-upgrade-actions">1645                    <p class="peach-pro-pricing">1646                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1647                    </p>1648                    <a1649                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1650                      target="_blank"1651                      rel="noopener noreferrer"1652                      class="peach-pro-upgrade-cta"1653                    >1654                      Get Peach Pro1655                    </a>1656                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1657                    <div class="peach-pro-key-inline">1658                      <input1659                        class="peach-pro-key-inline-input"1660                        type="text"1661                        placeholder="PEACH-XXXX-XXXX-XXXX"1662                        value={licenseKeyInput()}1663                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1664                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1665                        autocomplete="off"1666                        spellcheck={false}1667                      />1668                      <button1669                        class="peach-pro-key-apply"1670                        onClick={() => void onActivate()}1671                        disabled={activatingLicense() || !licenseKeyInput().trim()}1672                      >1673                        {activatingLicense() ? "…" : "APPLY"}1674                      </button>1675                    </div>1676                    <Show when={licenseError()}>1677                      <p class="peach-pro-key-error">{licenseError()}</p>1678                    </Show>1679                  </div>1680                </div>1681          }1682        >1683          <div class="peach-pro-active">1684            <div class="peach-pro-active-hero">1685              <button1686                class="peach-pro-active-mark-wrap"1687                type="button"1688                aria-label="Celebrate"1689                onClick={handleProCelebrationClick}1690              >1691                <PeachMark class="peach-pro-active-mark" />1692                <Show when={justActivated()}>1693                  <div class="peach-pro-sparks" aria-hidden="true">1694                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1695                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1696                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1697                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1698                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1699                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1700                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1701                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1702                  </div>1703                </Show>1704              </button>1705              <h2 class="peach-pro-active-title">Peach Pro</h2>1706              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1707              <Show when={justActivated()}>1708                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1709              </Show>1710            </div>17111712            <Show when={licenseKey()}>1713              <div class="peach-settings-license">1714                <span class="peach-settings-label">LICENSE KEY</span>1715                <div class="peach-settings-license-row">1716                  <Show1717                    when={showKey()}1718                    fallback={1719                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1720                        ••••••••••••••••••••••••••••1721                      </span>1722                    }1723                  >1724                    <span class="peach-settings-license-key">{licenseKey()!}</span>1725                  </Show>1726                  <div class="peach-settings-license-actions">1727                    <button1728                      class="peach-settings-license-copy"1729                      title="Copy license key"1730                      onClick={() => copyLicenseKey(licenseKey()!)}1731                    >1732                      {copied() ? "✓" : "copy"}1733                    </button>1734                    <button1735                      class="peach-settings-license-toggle"1736                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1737                      title={showKey() ? "Hide" : "Reveal"}1738                      onClick={() => setShowKey(v => !v)}1739                    >1740                      <Show1741                        when={showKey()}1742                        fallback={1743                          <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">1744                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1745                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1746                          </svg>1747                        }1748                      >1749                        <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">1750                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1751                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1752                          <line x1="2" y1="2" x2="12" y2="12" />1753                        </svg>1754                      </Show>1755                    </button>1756                  </div>1757                </div>1758              </div>1759            </Show>17601761            <div class="peach-pro-active-features">1762              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1763                {(item) => (1764                  <div class="peach-pro-active-feature">1765                    <span class="peach-pro-active-feature-mark">✦</span>1766                    <span class="peach-pro-active-feature-name">{item.name}</span>1767                  </div>1768                )}1769              </For>1770            </div>17711772            <button1773              class="peach-pro-deactivate"1774              onClick={() => void handleDeactivateLicense()}1775              disabled={deactivating()}1776            >1777              {deactivating() ? "Deactivating…" : "Deactivate"}1778            </button>1779          </div>1780        </Show>1781      </div>1782    );1783  }17841785  function renderFeedback() {1786    return (1787      <div class="peach-settings-body">1788        <div class="peach-settings-group peach-feedback-group">1789          <Show1790            when={feedbackState() === 'sent'}1791            fallback={1792              <>1793                <p class="peach-feedback-intro">1794                  Help us improve Peach. Every report goes directly to our inbox.1795                </p>17961797                <p class="peach-settings-sub-label">TYPE</p>1798                <div class="peach-feedback-chips">1799                  <For each={FEEDBACK_CATEGORIES}>1800                    {(cat) => {1801                      const selected = () => fbCategory() === cat;1802                      return (1803                        <button1804                          type="button"1805                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1806                          aria-pressed={selected()}1807                          onClick={() => setFbCategory(cat)}1808                        >1809                          {cat}1810                        </button>1811                      );1812                    }}1813                  </For>1814                </div>18151816                <p class="peach-settings-sub-label">MESSAGE</p>1817                <textarea1818                  class="peach-feedback-textarea"1819                  placeholder="Describe the issue or idea..."1820                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1821                  value={fbMessage()}1822                  onInput={(e) => setFbMessage(e.currentTarget.value)}1823                />18241825                <p class="peach-feedback-meta">1826                  Version {version} · will be included automatically1827                </p>18281829                <button1830                  type="button"1831                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1832                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1833                  onClick={() => void handleSubmitFeedback()}1834                >1835                  <Switch>1836                    <Match when={feedbackState() === 'idle'}>Send</Match>1837                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1838                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1839                  </Switch>1840                </button>18411842                <p class="peach-feedback-anon">1843                  Sent anonymously · no account required1844                </p>1845              </>1846            }1847          >1848            <div class="peach-feedback-thanks" role="status" aria-live="polite">1849              <p class="peach-feedback-thanks-title">Thank you</p>1850              <p>1851                Your note is in the queue. Someone will process it soon.1852              </p>1853            </div>1854          </Show>1855        </div>1856      </div>1857    );1858  }18591860  const bodies: StackBodies = {1861    protection: renderSecurity(),1862    vault: renderVault(),1863    sync: renderSync(),1864    appearance: renderAppearance(),1865    shortcuts: renderShortcuts(),1866    pro: renderLicense(),1867    feedback: renderFeedback(),1868  };18691870  return (1871    <Show1872      when={showNewRecoveryWords()}1873      fallback={1874        <div1875          class="peach-overlay-view peach-settings-overlay"1876          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1877          style="display:flex;flex-direction:column"1878        >1879          <StackDeck1880            active={activeCard()}1881            onActiveChange={setActiveCard}1882            onBack={props.onBack}1883            cards={cards()}1884            bodies={bodies}1885            version={version}1886            isFanning={isFanning()}1887            celebratingCardId={justActivated() ? "pro" : null}1888            isPro={isPro()}1889          />1890        </div>1891      }1892    >1893      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1894    </Show>1895  );1896}