Settings.tsx

74.4 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      setShowNewRecoveryWords(true);485    } else {486      setChangePwError(res.error ?? "failed to change password");487    }488    setChangePwSaving(false);489  }490491  const [copied, setCopied] = createSignal(false);492493  async function copyLicenseKey(key: string) {494    await navigator.clipboard.writeText(key);495    setCopied(true);496    setTimeout(() => setCopied(false), 2000);497  }498499  async function handleActivateLicense() {500    const key = licenseKeyInput().trim();501    if (!key) return;502    setActivatingLicense(true);503    setLicenseError(null);504    setLicenseSuccess(null);505    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });506    setActivatingLicense(false);507    if ((res as { ok: boolean }).ok) {508      const data = (res as { ok: boolean; data?: LicenseInfo }).data;509      if (data?.valid) {510        setLicenseInfo(data);511        setLicenseKey(key);512        setLicenseSuccess("License activated!");513        setLicenseKeyInput("");514      } else {515        setLicenseError("Invalid license key");516      }517    } else {518      setLicenseError("Activation failed");519    }520  }521522  async function handleTogglePasskeys() {523    const current = passkeyState();524    if (!current || !current.supported) return;525    setPasskeySaving(true);526    const res = await sendMessage({527      type: "SET_PASSKEY_PROVIDER_ENABLED",528      payload: { enabled: !current.enabled },529    });530    setPasskeySaving(false);531    if ((res as { ok: boolean }).ok) {532      setPasskeyState(533        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null534      );535    }536  }537538  async function handleCheckAllBreaches() {539    setCheckingBreaches(true);540    setBreachResult(null);541    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });542    setCheckingBreaches(false);543    if ((res as { ok: boolean }).ok) {544      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};545      const breached = Object.values(data).filter((count) => count > 0).length;546      setBreachResult(547        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"548      );549      props.onBreachCheckDone();550    }551  }552553554  async function handleSetupDuress() {555    const mainPw = duressMainPassword().trim();556    const duressPw = duressPassword().trim();557    const confirmPw = duressConfirm().trim();558559    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }560    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }561    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }562    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }563564    setDuressSaving(true);565    setDuressError(null);566567    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };568    if (!verifyRes.ok || !verifyRes.data) {569      setDuressError("incorrect main password");570      setDuressSaving(false);571      return;572    }573574    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };575    setDuressSaving(false);576    if (res.ok) {577      setDuressEnabled(true);578      setShowDuressSetup(false);579      setDuressPassword("");580      setDuressConfirm("");581      setDuressMainPassword("");582      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");583      setTimeout(() => setDuressSuccess(null), 5000);584    } else {585      setDuressError(586        res.error === "session_expired"587          ? "Vault session expired — lock and unlock Peach, then try again"588          : res.error ?? "failed to set duress password"589      );590    }591  }592593  async function handleClearDeclinedCards() {594    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });595    setDeclinedCardCount(0);596  }597598  async function handleCountDuplicates() {599    setDedupRunning(true);600    setDedupDone(null);601    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };602    setDedupRunning(false);603    if (res.ok) setDedupCount(res.data ?? 0);604  }605606  async function handleDeduplicate() {607    setDedupRunning(true);608    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };609    setDedupRunning(false);610    if (res.ok) {611      setDedupDone(res.data ?? 0);612      setDedupCount(0);613    }614  }615616  async function handleDisableDuress() {617    const mainPw = duressMainPassword().trim();618    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }619620    setDuressSaving(true);621    setDuressError(null);622623    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };624    if (!verifyRes.ok || !verifyRes.data) {625      setDuressError("incorrect main password");626      setDuressSaving(false);627      return;628    }629630    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };631    setDuressSaving(false);632    if (res.ok) {633      setDuressEnabled(false);634      setShowDuressSetup(false);635      setDuressMainPassword("");636      setDuressSuccess("Duress password disabled.");637      setTimeout(() => setDuressSuccess(null), 4000);638    } else {639      setDuressError(res.error ?? "failed to disable duress password");640    }641  }642643  async function handleSubmitFeedback() {644    const message = sanitizeFeedbackMessage(fbMessage());645    if (!message) return;646647    setFeedbackState('sending');648649    try {650      const hasDataPermission = await requestFeedbackDataPermission();651      if (!hasDataPermission) {652        setFeedbackState('idle');653        return;654      }655656      const category = FEEDBACK_CATEGORIES.includes(fbCategory())657        ? fbCategory()658        : FEEDBACK_CATEGORIES[0];659      const res = await fetch('https://api.web3forms.com/submit', {660        method: 'POST',661        headers: { 'Content-Type': 'application/json' },662        body: JSON.stringify({663          access_key: WEB3FORMS_KEY,664          subject: `Peach Feedback - ${category}`,665          message,666          from_name: 'Peach User',667          // Honeypot — leave blank668          botcheck: '',669        }),670      });671672      if (!res.ok) throw new Error(`HTTP ${res.status}`);673      const data = await res.json() as { success: boolean; message?: string };674      if (!data.success) throw new Error(data.message ?? 'unknown error');675676      setFeedbackState('sent');677      setFbMessage('');678      setFbCategory(FEEDBACK_CATEGORIES[0]);679    } catch (err) {680      console.error('[feedback]', err);681      setFeedbackState('error');682      setTimeout(() => setFeedbackState('idle'), 4000);683    }684  }685686  function renderSecurity() {687    return (688      <div class="peach-settings-body">689        <div class="peach-settings-group">690          <p class="peach-settings-sub-label">LOCKING</p>691          <div class="peach-settings-row">692            <span>Lock on close</span>693            <button694              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}695              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}696              aria-label="Toggle lock on close"697            >698              <span class="peach-toggle-knob"></span>699            </button>700          </div>701          <div class="peach-settings-row">702            <span>Auto-lock after</span>703            <select704              class="peach-settings-select"705              value={String(props.autoLockMinutes)}706              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}707            >708              <option value="1">1 minute</option>709              <option value="5">5 minutes</option>710              <option value="15">15 minutes</option>711              <option value="30">30 minutes</option>712              <option value="0">Never</option>713            </select>714          </div>715716          <Show when={isNativePlatform() && biometricSupported()}>717            <p class="peach-settings-sub-label">BIOMETRICS</p>718            <div class="peach-settings-row">719              <span>Unlock with biometrics</span>720              <button721                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}722                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}723                disabled={biometricBusy()}724                aria-label="Toggle biometric unlock"725              >726                <span class="peach-toggle-knob"></span>727              </button>728            </div>729            <Show when={biometricError()}>730              <div class="peach-settings-row peach-settings-row--meta">731                <span class="peach-hint peach-hint-error">{biometricError()}</span>732              </div>733            </Show>734          </Show>735736          <p class="peach-settings-sub-label">BREACH PROTECTION</p>737          <div class="peach-settings-row">738            <span>Breach monitoring</span>739            <button740              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}741              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}742              aria-label="Toggle breach monitoring"743            >744              <span class="peach-toggle-knob"></span>745            </button>746          </div>747          <div class="peach-settings-row peach-settings-row--meta">748            <span>Check all passwords</span>749            <button750              class="peach-btn peach-btn--subtle peach-btn--sm"751              onClick={() => void handleCheckAllBreaches()}752              disabled={checkingBreaches()}753            >754              {checkingBreaches() ? "checking..." : "run"}755            </button>756          </div>757          <Show when={breachResult()}>758            <div class="peach-settings-row peach-settings-row--meta">759              <span>Last check</span>760              <span class="peach-settings-value">{breachResult()}</span>761            </div>762          </Show>763764          <p class="peach-settings-sub-label">MASTER PASSWORD</p>765          <div class="peach-settings-row peach-settings-row--meta">766            <span>Master password</span>767            <button768              class="peach-btn peach-btn--subtle peach-btn--sm"769              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}770            >771              {showChangePw() ? "cancel" : "change"}772            </button>773          </div>774          <Show when={showChangePw()}>775            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">776              <input777                class="peach-settings-input"778                type="password"779                placeholder="current password"780                value={changePwCurrent()}781                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}782                autocomplete="current-password"783              />784              <input785                class="peach-settings-input"786                type="password"787                placeholder="new password (min 12 chars)"788                value={changePwNew()}789                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}790                autocomplete="new-password"791              />792              <input793                class="peach-settings-input"794                type="password"795                placeholder="confirm new password"796                value={changePwConfirm()}797                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}798                autocomplete="new-password"799                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}800              />801              <Show when={changePwError()}>802                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>803              </Show>804              <Show when={changePwSuccess()}>805                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>806              </Show>807              <button808                class="peach-btn peach-btn--accent peach-btn--sm"809                style="align-self:flex-start"810                onClick={() => void handleChangePassword()}811                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}812              >813                {changePwSaving() ? "saving…" : "save new password"}814              </button>815            </div>816          </Show>817818          <p class="peach-settings-sub-label">819            DURESS PASSWORD820            <Show when={!isPro()}>821              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>822            </Show>823          </p>824          <div class="peach-settings-row peach-settings-row--top">825            <div class="peach-settings-row-main">826              <span>827                Duress password828                <Show when={!isPro()}>829                  <span class="peach-pro-badge">Pro</span>830                </Show>831              </span>832              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">833                If entered under coercion, shows a decoy empty vault instead of your real one.834              </span>835            </div>836            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>837              {duressEnabled() ? "on" : "off"}838            </div>839          </div>840841          <Show when={duressEnabled() && !showDuressSetup()}>842            <div class="peach-settings-row peach-settings-row--meta">843              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">844                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.845              </span>846            </div>847          </Show>848849          <Show when={duressSuccess()}>850            <div class="peach-settings-row peach-settings-row--meta">851              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>852            </div>853          </Show>854855          <Show when={!showDuressSetup()}>856            <div class="peach-settings-row peach-settings-row--meta">857              <button858                class="peach-btn peach-btn--subtle peach-btn--sm"859                onClick={() => {860                  // Pro gate entry-point: free users are redirected to the861                  // upgrade screen before the setup form opens. The server-862                  // side SETUP_DURESS handler is the single enforcement863                  // point; this is UI redirection so the form never appears864                  // for a user who can't complete it.865                  if (!isPro() && !duressEnabled()) {866                    props.onProGate?.("duress_vault");867                    setActiveCard("pro");868                    return;869                  }870                  setShowDuressSetup(true);871                  setDuressError(null);872                  setDuressSuccess(null);873                  setDuressPassword("");874                  setDuressConfirm("");875                  setDuressMainPassword("");876                }}877              >878                {duressEnabled() ? "Change / disable" : "Set up"}879              </button>880            </div>881          </Show>882883          <Show when={showDuressSetup()}>884            <div class="peach-duress-form">885              <label class="peach-label">main password (to confirm)</label>886              <input887                class="peach-settings-input"888                type="password"889                placeholder="your current master password"890                autocomplete="current-password"891                value={duressMainPassword()}892                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}893              />894              <Show when={!duressEnabled()}>895                <label class="peach-label">new duress password</label>896                <input897                  class="peach-settings-input"898                  type="password"899                  placeholder="duress password (min 8 chars)"900                  autocomplete="new-password"901                  value={duressPassword()}902                  onInput={(e) => setDuressPassword(e.currentTarget.value)}903                />904                <label class="peach-label">confirm duress password</label>905                <input906                  class="peach-settings-input"907                  type="password"908                  placeholder="confirm duress password"909                  autocomplete="new-password"910                  value={duressConfirm()}911                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}912                />913              </Show>914915              <Show when={duressError()}>916                <p class="peach-hint peach-hint-error">{duressError()}</p>917              </Show>918919              <div class="peach-duress-actions">920                <button921                  class="peach-btn peach-btn--subtle peach-btn--sm"922                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}923                >924                  cancel925                </button>926                <Show when={duressEnabled()}>927                  <button928                    class="peach-btn peach-btn--ghost peach-btn--sm"929                    style="color:var(--peach-breach)"930                    onClick={() => void handleDisableDuress()}931                    disabled={duressSaving()}932                  >933                    {duressSaving() ? "disabling..." : "disable duress"}934                  </button>935                </Show>936                <Show when={!duressEnabled()}>937                  <button938                    class="peach-btn peach-btn--accent peach-btn--sm"939                    onClick={() => void handleSetupDuress()}940                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}941                  >942                    {duressSaving() ? "saving..." : "save"}943                  </button>944                </Show>945              </div>946            </div>947          </Show>948949          <p class="peach-settings-sub-label">950            CANARY ENTRIES951            <Show when={!isPro()}>952              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>953            </Show>954          </p>955          <div class="peach-settings-row peach-settings-row--top">956            <div class="peach-settings-row-main">957              <span>958                Canary entries959                <Show when={!isPro()}>960                  <span class="peach-pro-badge">Pro</span>961                </Show>962              </span>963              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">964                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.965              </span>966            </div>967            <span class="peach-settings-value" style="flex-shrink:0">968              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}969            </span>970          </div>971          <Show when={canaryEntries().length > 0}>972            <For each={canaryEntries()}>973              {(entry) => (974                <div class="peach-settings-row peach-settings-row--meta">975                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>976                  <button977                    class="peach-btn peach-btn--subtle peach-btn--sm"978                    onClick={() => void handleTestCanary(entry)}979                    disabled={canaryTestingId() === entry.id}980                    style="flex-shrink:0"981                  >982                    {canaryTestingId() === entry.id983                      ? "sending…"984                      : canaryTestResult()?.id === entry.id985                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")986                        : "test"}987                  </button>988                </div>989              )}990            </For>991          </Show>992993          <p class="peach-settings-sub-label">PASSKEYS</p>994          <div class="peach-settings-row peach-settings-row--top">995            <div class="peach-settings-row-main">996              <span>Intercept passkey requests</span>997              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">998                Peach can handle passkey sign-in and creation on supported Chromium browsers.999              </span>1000            </div>1001            <button1002              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1003              onClick={() => void handleTogglePasskeys()}1004              aria-label="Toggle passkey interception"1005              disabled={!passkeyState()?.supported || passkeySaving()}1006            >1007              <span class="peach-toggle-knob"></span>1008            </button>1009          </div>1010          <div class="peach-settings-row peach-settings-row--meta">1011            <span>Status</span>1012            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1013          </div>1014          <div class="peach-settings-row peach-settings-row--meta">1015            <span>Browser</span>1016            <span class="peach-settings-value">1017              {passkeyState()?.browserLabel ?? "Unknown"}1018              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1019            </span>1020          </div>1021          <div class="peach-info-notice" style="margin-top:8px">1022            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1023          </div>1024          <Show when={passkeyState()?.conflict}>1025            <div class="peach-settings-row peach-settings-row--meta">1026              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1027            </div>1028          </Show>1029        </div>1030      </div>1031    );1032  }10331034  function renderVault() {1035    return (1036      <div class="peach-settings-body">1037        <div class="peach-settings-group">1038          <p class="peach-settings-sub-label">ENTRIES</p>1039          <div class="peach-settings-row peach-settings-row--meta">1040            <span>Entries</span>1041            <span class="peach-settings-value">{props.entriesCount}</span>1042          </div>1043          <div class="peach-settings-row peach-settings-row--meta">1044            <span>Duplicates</span>1045            <Show when={dedupDone() !== null} fallback={1046              <button1047                class="peach-btn peach-btn--subtle peach-btn--sm"1048                onClick={() => void handleCountDuplicates()}1049                disabled={dedupRunning()}1050              >1051                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1052              </button>1053            }>1054              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1055                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1056              </span>1057            </Show>1058          </div>1059          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1060            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1061              <div class="peach-restore-warning">1062                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1063              </div>1064              <div style="display:flex;gap:6px">1065                <button1066                  class="peach-btn peach-btn--subtle peach-btn--sm"1067                  onClick={() => setDedupCount(null)}1068                >1069                  cancel1070                </button>1071                <button1072                  class="peach-btn peach-btn--accent peach-btn--sm"1073                  onClick={() => void handleDeduplicate()}1074                  disabled={dedupRunning()}1075                >1076                  {dedupRunning() ? "removing..." : "remove duplicates"}1077                </button>1078              </div>1079            </div>1080          </Show>10811082          <p class="peach-settings-sub-label">MAINTENANCE</p>1083          <div class="peach-settings-row peach-settings-row--meta">1084            <span>Trash auto-delete</span>1085            <select1086              class="peach-settings-select"1087              value={String(props.trashRetentionDays)}1088              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1089            >1090              <option value="7">7 days</option>1091              <option value="30">30 days</option>1092              <option value="90">90 days</option>1093              <option value="0">Never</option>1094            </select>1095          </div>1096          <div class="peach-settings-row peach-settings-row--meta">1097            <span>Advanced Vault Health</span>1098            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1099              open1100            </button>1101          </div>1102          <Show when={declinedCardCount() > 0}>1103            <div class="peach-settings-row peach-settings-row--meta">1104              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1105              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1106                Clear1107              </button>1108            </div>1109          </Show>11101111          <p class="peach-settings-sub-label">DATA</p>1112          <div class="peach-settings-row peach-settings-row--meta">1113            <span>Import passwords</span>1114            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1115              open1116            </button>1117          </div>1118          <div class="peach-settings-row peach-settings-row--meta">1119            <span>Export backup</span>1120            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1121              open1122            </button>1123          </div>1124          <div class="peach-settings-row peach-settings-row--meta">1125            <span>1126              Peach Codex1127              <Show when={!isPro()}>1128                <span class="peach-pro-badge">Pro</span>1129              </Show>1130            </span>1131            <button1132              class="peach-btn peach-btn--subtle peach-btn--sm"1133              onClick={() => props.onOpenCodexGenerate?.()}1134            >1135              open1136            </button>1137          </div>1138          {/* Restore from Codex: intentionally never gated — a printed1139              Codex must remain restorable regardless of license state. */}1140          <div class="peach-settings-row peach-settings-row--meta">1141            <span>Restore from Codex</span>1142            <button1143              class="peach-btn peach-btn--subtle peach-btn--sm"1144              onClick={() => props.onOpenCodexRestore?.()}1145            >1146              open1147            </button>1148          </div>11491150          <span class="peach-settings-sub-label">VAULT HISTORY</span>1151          <Show when={historyLoading()}>1152            <p class="peach-settings-hint">Loading…</p>1153          </Show>1154          <Show when={!historyLoading() && historySnapshots().length === 0}>1155            <p class="peach-settings-hint">1156              No snapshots yet. Peach saves automatically before every change —1157              saves, syncs, and imports.1158            </p>1159          </Show>1160          <Show when={!historyLoading() && historySnapshots().length > 0}>1161            <p class="peach-settings-hint">1162              Restoring locks your vault. Unlock with the password that1163              encrypted that snapshot to confirm.1164            </p>1165            <div class="peach-history-list">1166              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1167                {(snap) => (1168                  <div class="peach-history-row">1169                    <div class="peach-history-meta">1170                      <span class="peach-history-time">1171                        {formatSnapshotTime(snap.savedAt)}1172                      </span>1173                      <span class="peach-history-sub">1174                        {[1175                          snap.entryCount !== null1176                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1177                            : null,1178                          reasonLabel(snap.reason),1179                        ]1180                          .filter(Boolean)1181                          .join(" · ")}1182                      </span>1183                    </div>1184                    <button1185                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1186                        historyRestoring() === snap.id ? " is-busy" : ""1187                      }`}1188                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1189                      disabled={historyRestoring() !== null}1190                    >1191                      <Show when={historyRestoring() === snap.id} fallback="Restore">1192                        …1193                      </Show>1194                    </button>1195                  </div>1196                )}1197              </For>1198            </div>1199            <Show when={historySnapshots().length > 3}>1200              <button1201                class="peach-settings-advanced-toggle"1202                onClick={() => setHistoryShowAll((v) => !v)}1203                aria-expanded={historyShowAll()}1204              >1205                <span class="peach-settings-hint" style="margin:0">1206                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1207                </span>1208                <span class="peach-settings-advanced-chevron">1209                  {historyShowAll() ? "▲" : "▼"}1210                </span>1211              </button>1212            </Show>1213            <p class="peach-history-footer">1214              {historySnapshots().length} of 10 snapshots1215            </p>1216          </Show>12171218          <button1219            class="peach-settings-advanced-toggle"1220            onClick={() => setShowVaultAdvanced((v) => !v)}1221            aria-expanded={showVaultAdvanced()}1222          >1223            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1224            <span class="peach-settings-advanced-chevron">1225              {showVaultAdvanced() ? "▲" : "▼"}1226            </span>1227          </button>12281229          <Show when={showVaultAdvanced()}>1230            <div class="peach-danger-card">1231              <div class="peach-danger-card-head">1232                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1233                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1234              </div>1235              <p class="peach-danger-card-copy">1236                Wipes the current vault and restores from a backup file.1237                Recoverable only if your backup is good.1238              </p>1239              <button1240                class="peach-danger-card-button"1241                onClick={() => props.onOpenFileImport?.()}1242              >1243                Choose backup file1244              </button>1245            </div>12461247            <Show when={props.onOpenPanic}>1248              <div class="peach-danger-card peach-danger-card--hazard">1249                <div class="peach-danger-card-head">1250                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1251                  <h3 class="peach-danger-card-title">Destroy vault</h3>1252                </div>1253                <p class="peach-danger-card-copy">1254                  Permanently wipes every entry, setting, and license on this1255                  device. There is no recovery.1256                </p>1257                <button1258                  class="peach-danger-card-button"1259                  onClick={() => props.onOpenPanic?.()}1260                >1261                  Begin destruction1262                </button>1263              </div>1264            </Show>1265          </Show>1266        </div>1267      </div>1268    );1269  }12701271  function renderSync() {1272    return (1273      <div class="peach-settings-body">1274        <div class="peach-settings-group">1275          <p class="peach-settings-sub-label">PAIRING</p>1276          <div class="peach-settings-row peach-settings-row--top">1277            <div class="peach-settings-row-main">1278              <span>1279                Peach Sync1280                <Show when={!isPro()}>1281                  <span class="peach-pro-badge">Pro</span>1282                </Show>1283              </span>1284              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1285                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1286              </span>1287            </div>1288            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1289          </div>1290          <div class="peach-settings-row peach-settings-row--meta">1291            <span>Status</span>1292            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1293          </div>1294          <div class="peach-settings-row peach-settings-row--meta">1295            <span>Sync hub</span>1296            <button1297              class="peach-btn peach-btn--subtle peach-btn--sm"1298              onClick={() => props.onOpenSyncPairing?.()}1299            >1300              open1301            </button>1302          </div>13031304          <p class="peach-settings-sub-label">DEVICE FLOW</p>1305          <div class="peach-settings-row peach-settings-row--top">1306            <div class="peach-settings-row-main">1307              <span>Pair new device</span>1308              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1309                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1310              </span>1311            </div>1312            <button1313              class="peach-btn peach-btn--subtle peach-btn--sm"1314              onClick={() => props.onOpenSyncPairing?.()}1315            >1316              pair1317            </button>1318          </div>1319          <div class="peach-info-notice" style="margin-top:8px">1320            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1321          </div>1322        </div>1323      </div>1324    );1325  }13261327  function renderAppearance() {1328    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1329      { id: "medium", label: "Default" },1330      { id: "large", label: "Large" },1331    ];1332    return (1333      <div class="peach-settings-body">1334        <div class="peach-settings-group">1335          <p class="peach-settings-sub-label">THEME</p>1336          <div class="peach-settings-row peach-settings-row--meta">1337            <span>Theme</span>1338            <button1339              class="peach-btn peach-btn--subtle peach-btn--sm"1340              onClick={() =>1341                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1342              }1343            >1344              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1345            </button>1346          </div>13471348          <p class="peach-settings-sub-label">ACCENT</p>1349          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1350            <span>Accent</span>1351            <div class="peach-accent-picker">1352              <For each={ACCENT_COLORS}>1353                {(color) => (1354                  <button1355                    class="peach-accent-swatch peach-swatch"1356                    classList={{ "is-active": props.accentColor === color.name }}1357                    style={{ background: color.hex }}1358                    onClick={() => void props.onAccentChange(color.name)}1359                    aria-label={color.name}1360                    title={color.name}1361                  />1362                )}1363              </For>1364            </div>1365          </div>13661367          <p class="peach-settings-sub-label">TEXT SIZE</p>1368          <div class="peach-settings-row peach-settings-row--meta">1369            <span>Size</span>1370            <div class="peach-pill-group">1371              <For each={sizeOptions}>1372                {(opt) => (1373                  <button1374                    class="peach-btn peach-btn--ghost peach-btn--sm"1375                    classList={{ "is-active": props.textSize === opt.id }}1376                    onClick={() => void props.onTextSizeChange(opt.id)}1377                  >1378                    {opt.label}1379                  </button>1380                )}1381              </For>1382            </div>1383          </div>13841385          <p class="peach-settings-sub-label">MOTION</p>1386          <div class="peach-settings-row peach-settings-row--meta">1387            <span>Reduce motion</span>1388            <button1389              class="peach-btn peach-btn--subtle peach-btn--sm"1390              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1391            >1392              {props.reduceMotion ? "On" : "Off"}1393            </button>1394          </div>1395        </div>1396      </div>1397    );1398  }13991400  function renderShortcuts() {1401    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1402    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);14031404    function startCapture(action: ShortcutAction) {1405      setConflictMsg(null);1406      setCapturingAction(action);1407    }1408    function cancelCapture() {1409      setCapturingAction(null);1410      setConflictMsg(null);1411    }1412    function commitBinding(action: ShortcutAction, binding: Keybinding) {1413      const conflict = findConflict(props.shortcuts, binding, action);1414      if (conflict) {1415        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1416        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1417        return;1418      }1419      const next = { ...props.shortcuts, [action]: binding };1420      setCapturingAction(null);1421      setConflictMsg(null);1422      void props.onShortcutsChange(next);1423    }1424    function resetAction(action: ShortcutAction) {1425      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1426      void props.onShortcutsChange(next);1427    }1428    function resetAll() {1429      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1430    }14311432    // Capture-mode key handler — wired via ref onto the chip element so it1433    // catches the next non-modifier keypress and ignores other bindings.1434    function handleCaptureKey(e: KeyboardEvent) {1435      e.preventDefault();1436      e.stopPropagation();1437      if (e.key === "Escape") { cancelCapture(); return; }1438      const binding = keybindingFromEvent(e);1439      if (!binding) return; // bare modifier — keep waiting1440      const action = capturingAction();1441      if (action) commitBinding(action, binding);1442    }14431444    return (1445      <div class="peach-settings-body">1446        <div class="peach-settings-group peach-shortcuts-group">1447          <p class="peach-settings-sub-label">ENTRY VIEW</p>1448          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1449            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1450          </For>1451        </div>14521453        <div class="peach-settings-group peach-shortcuts-group">1454          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1455          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1456            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1457          </For>1458        </div>14591460        <Show when={conflictMsg()}>1461          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1462        </Show>14631464        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1465          <div class="peach-settings-row peach-settings-row--meta">1466            <span>Inline autofill on page</span>1467            <a1468              class="peach-settings-link"1469              href="chrome://extensions/shortcuts"1470              onClick={(e) => {1471                e.preventDefault();1472                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1473              }}1474            >1475              ⌘⇧P — customize ↗1476            </a>1477          </div>1478          <button1479            class="peach-btn peach-btn--subtle peach-btn--sm"1480            style="align-self:flex-start"1481            onClick={resetAll}1482          >1483            Reset all to defaults1484          </button>1485        </div>1486      </div>1487    );14881489    function renderRow(1490      desc: typeof SHORTCUT_DESCRIPTORS[number],1491      capturing: () => ShortcutAction | null,1492      onStart: (a: ShortcutAction) => void,1493      onCancel: () => void,1494      onReset: (a: ShortcutAction) => void,1495      onKey: (e: KeyboardEvent) => void,1496    ) {1497      const isActive = () => capturing() === desc.action;1498      const binding = () => props.shortcuts[desc.action];1499      const isCustom = () => {1500        const b = binding();1501        const d = DEFAULT_BINDINGS[desc.action];1502        return b.key !== d.key1503          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1504          || Boolean(b.meta)  !== Boolean(d.meta)1505          || Boolean(b.shift) !== Boolean(d.shift)1506          || Boolean(b.alt)   !== Boolean(d.alt);1507      };1508      return (1509        <div class="peach-shortcut-row">1510          <span class="peach-shortcut-row-label">{desc.label}</span>1511          <button1512            class="peach-shortcut-chip"1513            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1514            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1515            onKeyDown={(e) => { if (isActive()) onKey(e); }}1516            ref={(el) => {1517              // Auto-focus when entering capture mode so the keydown listener catches the next press.1518              if (el) {1519                queueMicrotask(() => { if (isActive()) el.focus(); });1520              }1521            }}1522          >1523            {isActive() ? "Press a key…" : formatKeybinding(binding())}1524          </button>1525          <Show when={isCustom() && !isActive()}>1526            <button1527              class="peach-shortcut-reset"1528              title="Reset to default"1529              onClick={() => onReset(desc.action)}1530            >1531              ↺1532            </button>1533          </Show>1534        </div>1535      );1536    }1537  }15381539  const PRO_FEATURES: Array<{1540    feature: ProFeature;1541    name: string;1542    desc: string;1543  }> = [1544    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1545    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1546    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1547    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1548  ];15491550  const PRO_SECURITY_FEATURES: Array<{1551    feature: ProFeature;1552    name: string;1553    desc: string;1554  }> = [1555    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1556    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1557  ];15581559  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1560  const [showKey, setShowKey] = createSignal(false);15611562  const [deactivating, setDeactivating] = createSignal(false);15631564  async function handleDeactivateLicense() {1565    setDeactivating(true);1566    await sendMessage({ type: "DEACTIVATE_LICENSE" });1567    setLicenseInfo(null);1568    await props.onLicenseChange?.();1569    setDeactivating(false);1570  }15711572  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1573    setCanaryTestingId(entry.id);1574    setCanaryTestResult(null);1575    const res = await sendMessage({1576      type: "FIRE_CANARY_PING",1577      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1578    }) as { ok: boolean };1579    setCanaryTestResult({ id: entry.id, ok: res.ok });1580    setCanaryTestingId(null);1581    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1582  }15831584  function renderLicense() {1585    async function onActivate() {1586      await handleActivateLicense();1587      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1588        await props.onLicenseChange?.();1589      }1590    }15911592    return (1593      <div class="peach-settings-body">1594        <Show1595          when={isPro()}1596          fallback={1597                <div class="peach-pro-upgrade">1598                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15991600                  <h2 class="peach-pro-headline">1601                    Every<em>thing,</em><br />every<em>where.</em>1602                  </h2>16031604                  <p class="peach-pro-subtitle">1605                    One key unlocks every Pro feature — for the lifetime of the app.1606                  </p>16071608                  <div class="peach-pro-grid">1609                    <For each={PRO_FEATURES}>1610                      {(item, i) => (1611                        <div1612                          class="peach-pro-grid-item"1613                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1614                        >1615                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1616                          <span class="peach-pro-grid-name">{item.name}</span>1617                          <span class="peach-pro-grid-desc">{item.desc}</span>1618                        </div>1619                      )}1620                    </For>1621                  </div>16221623                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1624                  <div class="peach-pro-grid">1625                    <For each={PRO_SECURITY_FEATURES}>1626                      {(item, i) => (1627                        <div1628                          class="peach-pro-grid-item"1629                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1630                        >1631                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1632                          <span class="peach-pro-grid-name">{item.name}</span>1633                          <span class="peach-pro-grid-desc">{item.desc}</span>1634                        </div>1635                      )}1636                    </For>1637                  </div>16381639                  <div class="peach-pro-upgrade-actions">1640                    <p class="peach-pro-pricing">1641                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1642                    </p>1643                    <a1644                      href="https://peachpasswords.com/#pricing"1645                      target="_blank"1646                      rel="noopener noreferrer"1647                      class="peach-pro-upgrade-cta"1648                    >1649                      Get Peach Pro1650                    </a>1651                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1652                    <div class="peach-pro-key-inline">1653                      <input1654                        class="peach-pro-key-inline-input"1655                        type="text"1656                        placeholder="PEACH-XXXX-XXXX-XXXX"1657                        value={licenseKeyInput()}1658                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1659                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1660                        autocomplete="off"1661                        spellcheck={false}1662                      />1663                      <button1664                        class="peach-pro-key-apply"1665                        onClick={() => void onActivate()}1666                        disabled={activatingLicense() || !licenseKeyInput().trim()}1667                      >1668                        {activatingLicense() ? "…" : "APPLY"}1669                      </button>1670                    </div>1671                    <Show when={licenseError()}>1672                      <p class="peach-pro-key-error">{licenseError()}</p>1673                    </Show>1674                  </div>1675                </div>1676          }1677        >1678          <div class="peach-pro-active">1679            <div class="peach-pro-active-hero">1680              <button1681                class="peach-pro-active-mark-wrap"1682                type="button"1683                aria-label="Celebrate"1684                onClick={handleProCelebrationClick}1685              >1686                <PeachMark class="peach-pro-active-mark" />1687                <Show when={justActivated()}>1688                  <div class="peach-pro-sparks" aria-hidden="true">1689                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1690                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1691                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1692                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1693                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1694                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1695                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1696                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1697                  </div>1698                </Show>1699              </button>1700              <h2 class="peach-pro-active-title">Peach Pro</h2>1701              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1702              <Show when={justActivated()}>1703                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1704              </Show>1705            </div>17061707            <Show when={licenseKey()}>1708              <div class="peach-settings-license">1709                <span class="peach-settings-label">LICENSE KEY</span>1710                <div class="peach-settings-license-row">1711                  <Show1712                    when={showKey()}1713                    fallback={1714                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1715                        ••••••••••••••••••••••••••••1716                      </span>1717                    }1718                  >1719                    <span class="peach-settings-license-key">{licenseKey()!}</span>1720                  </Show>1721                  <div class="peach-settings-license-actions">1722                    <button1723                      class="peach-settings-license-copy"1724                      title="Copy license key"1725                      onClick={() => copyLicenseKey(licenseKey()!)}1726                    >1727                      {copied() ? "✓" : "copy"}1728                    </button>1729                    <button1730                      class="peach-settings-license-toggle"1731                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1732                      title={showKey() ? "Hide" : "Reveal"}1733                      onClick={() => setShowKey(v => !v)}1734                    >1735                      <Show1736                        when={showKey()}1737                        fallback={1738                          <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">1739                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1740                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1741                          </svg>1742                        }1743                      >1744                        <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">1745                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1746                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1747                          <line x1="2" y1="2" x2="12" y2="12" />1748                        </svg>1749                      </Show>1750                    </button>1751                  </div>1752                </div>1753              </div>1754            </Show>17551756            <div class="peach-pro-active-features">1757              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1758                {(item) => (1759                  <div class="peach-pro-active-feature">1760                    <span class="peach-pro-active-feature-mark">✦</span>1761                    <span class="peach-pro-active-feature-name">{item.name}</span>1762                  </div>1763                )}1764              </For>1765            </div>17661767            <button1768              class="peach-pro-deactivate"1769              onClick={() => void handleDeactivateLicense()}1770              disabled={deactivating()}1771            >1772              {deactivating() ? "Deactivating…" : "Deactivate"}1773            </button>1774          </div>1775        </Show>1776      </div>1777    );1778  }17791780  function renderFeedback() {1781    return (1782      <div class="peach-settings-body">1783        <div class="peach-settings-group peach-feedback-group">1784          <Show1785            when={feedbackState() === 'sent'}1786            fallback={1787              <>1788                <p class="peach-feedback-intro">1789                  Help us improve Peach. Every report goes directly to our inbox.1790                </p>17911792                <p class="peach-settings-sub-label">TYPE</p>1793                <div class="peach-feedback-chips">1794                  <For each={FEEDBACK_CATEGORIES}>1795                    {(cat) => {1796                      const selected = () => fbCategory() === cat;1797                      return (1798                        <button1799                          type="button"1800                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1801                          aria-pressed={selected()}1802                          onClick={() => setFbCategory(cat)}1803                        >1804                          {cat}1805                        </button>1806                      );1807                    }}1808                  </For>1809                </div>18101811                <p class="peach-settings-sub-label">MESSAGE</p>1812                <textarea1813                  class="peach-feedback-textarea"1814                  placeholder="Describe the issue or idea..."1815                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1816                  value={fbMessage()}1817                  onInput={(e) => setFbMessage(e.currentTarget.value)}1818                />18191820                <p class="peach-feedback-meta">1821                  Version {version} · will be included automatically1822                </p>18231824                <button1825                  type="button"1826                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1827                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1828                  onClick={() => void handleSubmitFeedback()}1829                >1830                  <Switch>1831                    <Match when={feedbackState() === 'idle'}>Send</Match>1832                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1833                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1834                  </Switch>1835                </button>18361837                <p class="peach-feedback-anon">1838                  Sent anonymously · no account required1839                </p>1840              </>1841            }1842          >1843            <div class="peach-feedback-thanks" role="status" aria-live="polite">1844              <p class="peach-feedback-thanks-title">Thank you</p>1845              <p>1846                Your note is in the queue. Someone will process it soon.1847              </p>1848            </div>1849          </Show>1850        </div>1851      </div>1852    );1853  }18541855  const bodies: StackBodies = {1856    protection: renderSecurity(),1857    vault: renderVault(),1858    sync: renderSync(),1859    appearance: renderAppearance(),1860    shortcuts: renderShortcuts(),1861    pro: renderLicense(),1862    feedback: renderFeedback(),1863  };18641865  return (1866    <Show1867      when={showNewRecoveryWords()}1868      fallback={1869        <div1870          class="peach-overlay-view peach-settings-overlay"1871          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1872          style="display:flex;flex-direction:column"1873        >1874          <StackDeck1875            active={activeCard()}1876            onActiveChange={setActiveCard}1877            onBack={props.onBack}1878            cards={cards()}1879            bodies={bodies}1880            version={version}1881            isFanning={isFanning()}1882            celebratingCardId={justActivated() ? "pro" : null}1883            isPro={isPro()}1884          />1885        </div>1886      }1887    >1888      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1889    </Show>1890  );1891}