Settings.tsx

81.0 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, 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  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting262  // explicit confirmation before they become sync targets.263  interface PendingVouched { deviceId: string; deviceName: string }264  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);265  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);266  // UI-4 / MV-8: rotate sync identity confirmation dialog.267  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);268  const [rotating, setRotating] = createSignal(false);269  const [rotateError, setRotateError] = createSignal<string | null>(null);270271  async function loadPendingVouched() {272    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {273      ok: boolean;274      data?: PendingVouched[];275    };276    if (r.ok) setPendingVouched(r.data ?? []);277  }278  async function confirmVouched(deviceId: string) {279    setVouchedBusy(deviceId);280    try {281      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });282      await loadPendingVouched();283    } finally {284      setVouchedBusy(null);285    }286  }287  async function rejectVouched(deviceId: string) {288    setVouchedBusy(deviceId);289    try {290      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });291      await loadPendingVouched();292    } finally {293      setVouchedBusy(null);294    }295  }296  async function doRotateSyncIdentity() {297    setRotateError(null);298    setRotating(true);299    try {300      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {301        ok: boolean;302        error?: string;303      };304      if (!r.ok) {305        setRotateError(r.error ?? "Rotation failed.");306        return;307      }308      setRotateConfirmOpen(false);309      // Backend set peach_recovery_pending; reuse the existing recovery-words310      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).311      setShowNewRecoveryWords(true);312    } finally {313      setRotating(false);314    }315  }316317  const [dedupCount, setDedupCount] = createSignal<number | null>(null);318  const [dedupRunning, setDedupRunning] = createSignal(false);319  const [dedupDone, setDedupDone] = createSignal<number | null>(null);320321  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);322323  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted324  // BEFORE every real-mode vault write so the user can roll back if a sync325  // or import overwrites the wrong state.326  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);327  const [historyLoading, setHistoryLoading] = createSignal(false);328  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);329  const [historyShowAll, setHistoryShowAll] = createSignal(false);330331  async function loadVaultHistory() {332    setHistoryLoading(true);333    try {334      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });335      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];336      setHistorySnapshots(list);337    } finally {338      setHistoryLoading(false);339    }340  }341342  async function handleRestoreVaultSnapshot(id: string) {343    if (historyRestoring()) return;344    setHistoryRestoring(id);345    const res = await sendMessage({346      type: "RESTORE_VAULT_SNAPSHOT",347      payload: { snapshotId: id },348    });349    setHistoryRestoring(null);350    if ((res as { ok: boolean }).ok) {351      // Vault is now locked — let the parent close Settings; App.tsx's352      // VAULT_LOCKED listener will route back to the lock screen.353      props.onBack();354    }355  }356357  function formatSnapshotTime(savedAt: number): string {358    const diff = Date.now() - savedAt;359    if (diff < 60_000) return "Just now";360    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;361    const d = new Date(savedAt);362    const isToday = new Date().toDateString() === d.toDateString();363    const isYest =364      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();365    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });366    if (isToday) return `Today ${t}`;367    if (isYest) return `Yesterday ${t}`;368    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;369  }370371  function reasonLabel(reason: VaultSnapshot["reason"]): string {372    return (373      {374        local_save: "saved",375        sync_received: "synced",376        import: "imported",377        restore: "restored",378      } as const379    )[reason] ?? reason;380  }381382  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);383384  const [showChangePw, setShowChangePw] = createSignal(false);385  const [changePwCurrent, setChangePwCurrent] = createSignal("");386  const [changePwNew, setChangePwNew] = createSignal("");387  const [changePwConfirm, setChangePwConfirm] = createSignal("");388  const [changePwSaving, setChangePwSaving] = createSignal(false);389  const [changePwError, setChangePwError] = createSignal<string | null>(null);390  const [changePwSuccess] = createSignal(false);391  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);392393  const [duressEnabled, setDuressEnabled] = createSignal(false);394  const [showDuressSetup, setShowDuressSetup] = createSignal(false);395  const [duressPassword, setDuressPassword] = createSignal("");396  const [duressConfirm, setDuressConfirm] = createSignal("");397  const [duressMainPassword, setDuressMainPassword] = createSignal("");398  const [duressSaving, setDuressSaving] = createSignal(false);399  const [duressError, setDuressError] = createSignal<string | null>(null);400  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);401402  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);403404  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);405  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);406407  const [fbCategory, setFbCategory] = createSignal("Bug report");408  const [fbMessage, setFbMessage] = createSignal("");409  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';410  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');411412  createEffect(() => {413    if (props.upgradeHighlight) {414      setActiveCard("pro");415    }416  });417418  onMount(async () => {419    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([420      sendMessage({ type: "GET_LICENSE" }),421      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),422      sendMessage({ type: "IS_DURESS_ENABLED" }),423      sendMessage({ type: "GET_SETTINGS" }),424      sendMessage({ type: "GET_LICENSE_KEY" }),425    ]);426427    // Vault history loads in the background — the Vault sub-view picks it up428    // when the user navigates in. Loading it lazily keeps Settings boot fast.429    void loadVaultHistory();430431    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the432    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.433    void loadPendingVouched();434    const onBroadcast = (msg: unknown) => {435      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {436        void loadPendingVouched();437      }438    };439    chrome.runtime?.onMessage?.addListener(onBroadcast);440    onCleanup(() => {441      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }442    });443444    if ((licenseRes as { ok: boolean }).ok) {445      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);446    }447448    if ((passkeyRes as { ok: boolean }).ok) {449      setPasskeyState(450        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null451      );452    }453454    if ((duressRes as { ok: boolean }).ok) {455      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);456    }457458    if ((settingsRes as { ok: boolean }).ok) {459      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;460      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);461    }462463    if ((keyRes as { ok: boolean }).ok) {464      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);465    }466467    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });468    if ((entriesRes as { ok: boolean }).ok) {469      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];470      setCanaryEntries(471        entries472          .filter((e) => e.canary?.webhookUrl)473          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))474      );475    }476477478    if (isNativePlatform()) {479      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");480      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");481      try {482        if (biometric) {483          const a = await biometric.isAvailable();484          setBiometricSupported(a.available);485        }486        if (keystore) {487          const k = await keystore.hasMasterKey();488          setBiometricStored(k.exists);489        }490      } catch { /* missing plugins — leave defaults */ }491    }492  });493494  async function handleEnableBiometrics() {495    setBiometricBusy(true);496    setBiometricError(null);497    try {498      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {499        ok: boolean;500        data?: { masterKeyHex: string };501        error?: string;502      };503      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {504        setBiometricError(keyRes.error === "vault_locked"505          ? "unlock the vault first"506          : "couldn't read derived key");507        return;508      }509      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");510      if (!keystore) {511        setBiometricError("biometric plugin unavailable");512        return;513      }514      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });515      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });516      setBiometricStored(true);517    } catch (e) {518      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");519    } finally {520      setBiometricBusy(false);521    }522  }523524  async function handleDisableBiometrics() {525    setBiometricBusy(true);526    setBiometricError(null);527    try {528      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");529      if (keystore) await keystore.clearMasterKey();530      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });531      setBiometricStored(false);532    } catch (e) {533      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");534    } finally {535      setBiometricBusy(false);536    }537  }538539  async function handleChangePassword() {540    setChangePwError(null);541    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }542    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }543    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }544    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }545    setChangePwSaving(true);546    const res = await sendMessage({547      type: "CHANGE_PASSWORD",548      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },549    }) as { ok: boolean; error?: string };550    if (res.ok) {551      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");552      setShowChangePw(false);553      // LV-4: the user just authenticated (current password), so the recovery554      // words reveal that follows is exempt from the re-auth gate. Set the555      // pending flag the same way the create/recovery flow does; the display556      // clears it on confirm.557      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });558      setShowNewRecoveryWords(true);559    } else {560      setChangePwError(res.error ?? "failed to change password");561    }562    setChangePwSaving(false);563  }564565  const [copied, setCopied] = createSignal(false);566567  async function copyLicenseKey(key: string) {568    await navigator.clipboard.writeText(key);569    setCopied(true);570    setTimeout(() => setCopied(false), 2000);571  }572573  async function handleActivateLicense() {574    const key = licenseKeyInput().trim();575    if (!key) return;576    setActivatingLicense(true);577    setLicenseError(null);578    setLicenseSuccess(null);579    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });580    setActivatingLicense(false);581    if ((res as { ok: boolean }).ok) {582      const data = (res as { ok: boolean; data?: LicenseInfo }).data;583      if (data?.valid) {584        setLicenseInfo(data);585        setLicenseKey(key);586        setLicenseSuccess("License activated!");587        setLicenseKeyInput("");588      } else {589        setLicenseError("Invalid license key");590      }591    } else {592      setLicenseError("Activation failed");593    }594  }595596  async function handleTogglePasskeys() {597    const current = passkeyState();598    if (!current || !current.supported) return;599    setPasskeySaving(true);600    const res = await sendMessage({601      type: "SET_PASSKEY_PROVIDER_ENABLED",602      payload: { enabled: !current.enabled },603    });604    setPasskeySaving(false);605    if ((res as { ok: boolean }).ok) {606      setPasskeyState(607        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null608      );609    }610  }611612  async function handleCheckAllBreaches() {613    setCheckingBreaches(true);614    setBreachResult(null);615    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });616    setCheckingBreaches(false);617    if ((res as { ok: boolean }).ok) {618      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};619      const breached = Object.values(data).filter((count) => count > 0).length;620      setBreachResult(621        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"622      );623      props.onBreachCheckDone();624    }625  }626627628  async function handleSetupDuress() {629    const mainPw = duressMainPassword().trim();630    const duressPw = duressPassword().trim();631    const confirmPw = duressConfirm().trim();632633    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }634    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }635    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }636    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }637638    setDuressSaving(true);639    setDuressError(null);640641    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };642    if (!verifyRes.ok || !verifyRes.data) {643      setDuressError("incorrect main password");644      setDuressSaving(false);645      return;646    }647648    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };649    setDuressSaving(false);650    if (res.ok) {651      setDuressEnabled(true);652      setShowDuressSetup(false);653      setDuressPassword("");654      setDuressConfirm("");655      setDuressMainPassword("");656      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");657      setTimeout(() => setDuressSuccess(null), 5000);658    } else {659      setDuressError(660        res.error === "session_expired"661          ? "Vault session expired — lock and unlock Peach, then try again"662          : res.error ?? "failed to set duress password"663      );664    }665  }666667  async function handleClearDeclinedCards() {668    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });669    setDeclinedCardCount(0);670  }671672  async function handleCountDuplicates() {673    setDedupRunning(true);674    setDedupDone(null);675    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };676    setDedupRunning(false);677    if (res.ok) setDedupCount(res.data ?? 0);678  }679680  async function handleDeduplicate() {681    setDedupRunning(true);682    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };683    setDedupRunning(false);684    if (res.ok) {685      setDedupDone(res.data ?? 0);686      setDedupCount(0);687    }688  }689690  async function handleDisableDuress() {691    const mainPw = duressMainPassword().trim();692    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }693694    setDuressSaving(true);695    setDuressError(null);696697    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };698    if (!verifyRes.ok || !verifyRes.data) {699      setDuressError("incorrect main password");700      setDuressSaving(false);701      return;702    }703704    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };705    setDuressSaving(false);706    if (res.ok) {707      setDuressEnabled(false);708      setShowDuressSetup(false);709      setDuressMainPassword("");710      setDuressSuccess("Duress password disabled.");711      setTimeout(() => setDuressSuccess(null), 4000);712    } else {713      setDuressError(res.error ?? "failed to disable duress password");714    }715  }716717  async function handleSubmitFeedback() {718    const message = sanitizeFeedbackMessage(fbMessage());719    if (!message) return;720721    setFeedbackState('sending');722723    try {724      const hasDataPermission = await requestFeedbackDataPermission();725      if (!hasDataPermission) {726        setFeedbackState('idle');727        return;728      }729730      const category = FEEDBACK_CATEGORIES.includes(fbCategory())731        ? fbCategory()732        : FEEDBACK_CATEGORIES[0];733      const res = await fetch('https://api.web3forms.com/submit', {734        method: 'POST',735        headers: { 'Content-Type': 'application/json' },736        body: JSON.stringify({737          access_key: WEB3FORMS_KEY,738          subject: `Peach Feedback - ${category}`,739          message,740          from_name: 'Peach User',741          // Honeypot — leave blank742          botcheck: '',743        }),744      });745746      if (!res.ok) throw new Error(`HTTP ${res.status}`);747      const data = await res.json() as { success: boolean; message?: string };748      if (!data.success) throw new Error(data.message ?? 'unknown error');749750      setFeedbackState('sent');751      setFbMessage('');752      setFbCategory(FEEDBACK_CATEGORIES[0]);753    } catch (err) {754      console.error('[feedback]', err);755      setFeedbackState('error');756      setTimeout(() => setFeedbackState('idle'), 4000);757    }758  }759760  function renderSecurity() {761    return (762      <div class="peach-settings-body">763        <div class="peach-settings-group">764          <p class="peach-settings-sub-label">LOCKING</p>765          <div class="peach-settings-row">766            <span>Lock on close</span>767            <button768              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}769              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}770              aria-label="Toggle lock on close"771            >772              <span class="peach-toggle-knob"></span>773            </button>774          </div>775          <div class="peach-settings-row">776            <span>Auto-lock after</span>777            <select778              class="peach-settings-select"779              value={String(props.autoLockMinutes)}780              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}781            >782              <option value="1">1 minute</option>783              <option value="5">5 minutes</option>784              <option value="15">15 minutes</option>785              <option value="30">30 minutes</option>786              <option value="0">Never</option>787            </select>788          </div>789790          <Show when={isNativePlatform() && biometricSupported()}>791            <p class="peach-settings-sub-label">BIOMETRICS</p>792            <div class="peach-settings-row">793              <span>Unlock with biometrics</span>794              <button795                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}796                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}797                disabled={biometricBusy()}798                aria-label="Toggle biometric unlock"799              >800                <span class="peach-toggle-knob"></span>801              </button>802            </div>803            <Show when={biometricError()}>804              <div class="peach-settings-row peach-settings-row--meta">805                <span class="peach-hint peach-hint-error">{biometricError()}</span>806              </div>807            </Show>808          </Show>809810          <p class="peach-settings-sub-label">BREACH PROTECTION</p>811          <div class="peach-settings-row">812            <span>Breach monitoring</span>813            <button814              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}815              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}816              aria-label="Toggle breach monitoring"817            >818              <span class="peach-toggle-knob"></span>819            </button>820          </div>821          <div class="peach-settings-row peach-settings-row--meta">822            <span>Check all passwords</span>823            <button824              class="peach-btn peach-btn--subtle peach-btn--sm"825              onClick={() => void handleCheckAllBreaches()}826              disabled={checkingBreaches()}827            >828              {checkingBreaches() ? "checking..." : "run"}829            </button>830          </div>831          <Show when={breachResult()}>832            <div class="peach-settings-row peach-settings-row--meta">833              <span>Last check</span>834              <span class="peach-settings-value">{breachResult()}</span>835            </div>836          </Show>837838          <p class="peach-settings-sub-label">MASTER PASSWORD</p>839          <div class="peach-settings-row peach-settings-row--meta">840            <span>Master password</span>841            <button842              class="peach-btn peach-btn--subtle peach-btn--sm"843              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}844            >845              {showChangePw() ? "cancel" : "change"}846            </button>847          </div>848          <Show when={showChangePw()}>849            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">850              <input851                class="peach-settings-input"852                type="password"853                placeholder="current password"854                value={changePwCurrent()}855                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}856                autocomplete="current-password"857              />858              <input859                class="peach-settings-input"860                type="password"861                placeholder="new password (min 12 chars)"862                value={changePwNew()}863                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}864                autocomplete="new-password"865              />866              <input867                class="peach-settings-input"868                type="password"869                placeholder="confirm new password"870                value={changePwConfirm()}871                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}872                autocomplete="new-password"873                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}874              />875              <Show when={changePwError()}>876                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>877              </Show>878              <Show when={changePwSuccess()}>879                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>880              </Show>881              <button882                class="peach-btn peach-btn--accent peach-btn--sm"883                style="align-self:flex-start"884                onClick={() => void handleChangePassword()}885                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}886              >887                {changePwSaving() ? "saving…" : "save new password"}888              </button>889            </div>890          </Show>891892          <p class="peach-settings-sub-label">893            DURESS PASSWORD894            <Show when={!isPro()}>895              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>896            </Show>897          </p>898          <div class="peach-settings-row peach-settings-row--top">899            <div class="peach-settings-row-main">900              <span>901                Duress password902                <Show when={!isPro()}>903                  <span class="peach-pro-badge">Pro</span>904                </Show>905              </span>906              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">907                If entered under coercion, shows a decoy empty vault instead of your real one.908              </span>909            </div>910            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>911              {duressEnabled() ? "on" : "off"}912            </div>913          </div>914915          <Show when={duressEnabled() && !showDuressSetup()}>916            <div class="peach-settings-row peach-settings-row--meta">917              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">918                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.919              </span>920            </div>921          </Show>922923          <Show when={duressSuccess()}>924            <div class="peach-settings-row peach-settings-row--meta">925              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>926            </div>927          </Show>928929          <Show when={!showDuressSetup()}>930            <div class="peach-settings-row peach-settings-row--meta">931              <button932                class="peach-btn peach-btn--subtle peach-btn--sm"933                onClick={() => {934                  // Pro gate entry-point: free users are redirected to the935                  // upgrade screen before the setup form opens. The server-936                  // side SETUP_DURESS handler is the single enforcement937                  // point; this is UI redirection so the form never appears938                  // for a user who can't complete it.939                  if (!isPro() && !duressEnabled()) {940                    props.onProGate?.("duress_vault");941                    setActiveCard("pro");942                    return;943                  }944                  setShowDuressSetup(true);945                  setDuressError(null);946                  setDuressSuccess(null);947                  setDuressPassword("");948                  setDuressConfirm("");949                  setDuressMainPassword("");950                }}951              >952                {duressEnabled() ? "Change / disable" : "Set up"}953              </button>954            </div>955          </Show>956957          <Show when={showDuressSetup()}>958            <div class="peach-duress-form">959              <label class="peach-label">main password (to confirm)</label>960              <input961                class="peach-settings-input"962                type="password"963                placeholder="your current master password"964                autocomplete="current-password"965                value={duressMainPassword()}966                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}967              />968              <Show when={!duressEnabled()}>969                <label class="peach-label">new duress password</label>970                <input971                  class="peach-settings-input"972                  type="password"973                  placeholder="duress password (min 8 chars)"974                  autocomplete="new-password"975                  value={duressPassword()}976                  onInput={(e) => setDuressPassword(e.currentTarget.value)}977                />978                <label class="peach-label">confirm duress password</label>979                <input980                  class="peach-settings-input"981                  type="password"982                  placeholder="confirm duress password"983                  autocomplete="new-password"984                  value={duressConfirm()}985                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}986                />987              </Show>988989              <Show when={duressError()}>990                <p class="peach-hint peach-hint-error">{duressError()}</p>991              </Show>992993              <div class="peach-duress-actions">994                <button995                  class="peach-btn peach-btn--subtle peach-btn--sm"996                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}997                >998                  cancel999                </button>1000                <Show when={duressEnabled()}>1001                  <button1002                    class="peach-btn peach-btn--ghost peach-btn--sm"1003                    style="color:var(--peach-breach)"1004                    onClick={() => void handleDisableDuress()}1005                    disabled={duressSaving()}1006                  >1007                    {duressSaving() ? "disabling..." : "disable duress"}1008                  </button>1009                </Show>1010                <Show when={!duressEnabled()}>1011                  <button1012                    class="peach-btn peach-btn--accent peach-btn--sm"1013                    onClick={() => void handleSetupDuress()}1014                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1015                  >1016                    {duressSaving() ? "saving..." : "save"}1017                  </button>1018                </Show>1019              </div>1020            </div>1021          </Show>10221023          <p class="peach-settings-sub-label">1024            CANARY ENTRIES1025            <Show when={!isPro()}>1026              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1027            </Show>1028          </p>1029          <div class="peach-settings-row peach-settings-row--top">1030            <div class="peach-settings-row-main">1031              <span>1032                Canary entries1033                <Show when={!isPro()}>1034                  <span class="peach-pro-badge">Pro</span>1035                </Show>1036              </span>1037              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1038                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1039              </span>1040            </div>1041            <span class="peach-settings-value" style="flex-shrink:0">1042              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1043            </span>1044          </div>1045          <Show when={canaryEntries().length > 0}>1046            <For each={canaryEntries()}>1047              {(entry) => (1048                <div class="peach-settings-row peach-settings-row--meta">1049                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1050                  <button1051                    class="peach-btn peach-btn--subtle peach-btn--sm"1052                    onClick={() => void handleTestCanary(entry)}1053                    disabled={canaryTestingId() === entry.id}1054                    style="flex-shrink:0"1055                  >1056                    {canaryTestingId() === entry.id1057                      ? "sending…"1058                      : canaryTestResult()?.id === entry.id1059                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1060                        : "test"}1061                  </button>1062                </div>1063              )}1064            </For>1065          </Show>10661067          <p class="peach-settings-sub-label">PASSKEYS</p>1068          <div class="peach-settings-row peach-settings-row--top">1069            <div class="peach-settings-row-main">1070              <span>Intercept passkey requests</span>1071              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1072                Peach can handle passkey sign-in and creation on supported Chromium browsers.1073              </span>1074            </div>1075            <button1076              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1077              onClick={() => void handleTogglePasskeys()}1078              aria-label="Toggle passkey interception"1079              disabled={!passkeyState()?.supported || passkeySaving()}1080            >1081              <span class="peach-toggle-knob"></span>1082            </button>1083          </div>1084          <div class="peach-settings-row peach-settings-row--meta">1085            <span>Status</span>1086            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1087          </div>1088          <div class="peach-settings-row peach-settings-row--meta">1089            <span>Browser</span>1090            <span class="peach-settings-value">1091              {passkeyState()?.browserLabel ?? "Unknown"}1092              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1093            </span>1094          </div>1095          <div class="peach-info-notice" style="margin-top:8px">1096            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1097          </div>1098          <Show when={passkeyState()?.conflict}>1099            <div class="peach-settings-row peach-settings-row--meta">1100              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1101            </div>1102          </Show>1103        </div>1104      </div>1105    );1106  }11071108  function renderVault() {1109    return (1110      <div class="peach-settings-body">1111        <div class="peach-settings-group">1112          <p class="peach-settings-sub-label">ENTRIES</p>1113          <div class="peach-settings-row peach-settings-row--meta">1114            <span>Entries</span>1115            <span class="peach-settings-value">{props.entriesCount}</span>1116          </div>1117          <div class="peach-settings-row peach-settings-row--meta">1118            <span>Duplicates</span>1119            <Show when={dedupDone() !== null} fallback={1120              <button1121                class="peach-btn peach-btn--subtle peach-btn--sm"1122                onClick={() => void handleCountDuplicates()}1123                disabled={dedupRunning()}1124              >1125                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1126              </button>1127            }>1128              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1129                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1130              </span>1131            </Show>1132          </div>1133          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1134            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1135              <div class="peach-restore-warning">1136                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1137              </div>1138              <div style="display:flex;gap:6px">1139                <button1140                  class="peach-btn peach-btn--subtle peach-btn--sm"1141                  onClick={() => setDedupCount(null)}1142                >1143                  cancel1144                </button>1145                <button1146                  class="peach-btn peach-btn--accent peach-btn--sm"1147                  onClick={() => void handleDeduplicate()}1148                  disabled={dedupRunning()}1149                >1150                  {dedupRunning() ? "removing..." : "remove duplicates"}1151                </button>1152              </div>1153            </div>1154          </Show>11551156          <p class="peach-settings-sub-label">MAINTENANCE</p>1157          <div class="peach-settings-row peach-settings-row--meta">1158            <span>Trash auto-delete</span>1159            <select1160              class="peach-settings-select"1161              value={String(props.trashRetentionDays)}1162              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1163            >1164              <option value="7">7 days</option>1165              <option value="30">30 days</option>1166              <option value="90">90 days</option>1167              <option value="0">Never</option>1168            </select>1169          </div>1170          <div class="peach-settings-row peach-settings-row--meta">1171            <span>Advanced Vault Health</span>1172            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1173              open1174            </button>1175          </div>1176          <Show when={declinedCardCount() > 0}>1177            <div class="peach-settings-row peach-settings-row--meta">1178              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1179              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1180                Clear1181              </button>1182            </div>1183          </Show>11841185          <p class="peach-settings-sub-label">DATA</p>1186          <div class="peach-settings-row peach-settings-row--meta">1187            <span>Import passwords</span>1188            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1189              open1190            </button>1191          </div>1192          <div class="peach-settings-row peach-settings-row--meta">1193            <span>Export backup</span>1194            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1195              open1196            </button>1197          </div>1198          <div class="peach-settings-row peach-settings-row--meta">1199            <span>1200              Peach Codex1201              <Show when={!isPro()}>1202                <span class="peach-pro-badge">Pro</span>1203              </Show>1204            </span>1205            <button1206              class="peach-btn peach-btn--subtle peach-btn--sm"1207              onClick={() => props.onOpenCodexGenerate?.()}1208            >1209              open1210            </button>1211          </div>1212          {/* Restore from Codex: intentionally never gated — a printed1213              Codex must remain restorable regardless of license state. */}1214          <div class="peach-settings-row peach-settings-row--meta">1215            <span>Restore from Codex</span>1216            <button1217              class="peach-btn peach-btn--subtle peach-btn--sm"1218              onClick={() => props.onOpenCodexRestore?.()}1219            >1220              open1221            </button>1222          </div>12231224          <span class="peach-settings-sub-label">VAULT HISTORY</span>1225          <Show when={historyLoading()}>1226            <p class="peach-settings-hint">Loading…</p>1227          </Show>1228          <Show when={!historyLoading() && historySnapshots().length === 0}>1229            <p class="peach-settings-hint">1230              No snapshots yet. Peach saves automatically before every change —1231              saves, syncs, and imports.1232            </p>1233          </Show>1234          <Show when={!historyLoading() && historySnapshots().length > 0}>1235            <p class="peach-settings-hint">1236              Restoring locks your vault. Unlock with the password that1237              encrypted that snapshot to confirm.1238            </p>1239            <div class="peach-history-list">1240              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1241                {(snap) => (1242                  <div class="peach-history-row">1243                    <div class="peach-history-meta">1244                      <span class="peach-history-time">1245                        {formatSnapshotTime(snap.savedAt)}1246                      </span>1247                      <span class="peach-history-sub">1248                        {[1249                          snap.entryCount !== null1250                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1251                            : null,1252                          reasonLabel(snap.reason),1253                        ]1254                          .filter(Boolean)1255                          .join(" · ")}1256                      </span>1257                    </div>1258                    <button1259                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1260                        historyRestoring() === snap.id ? " is-busy" : ""1261                      }`}1262                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1263                      disabled={historyRestoring() !== null}1264                    >1265                      <Show when={historyRestoring() === snap.id} fallback="Restore">1266                        …1267                      </Show>1268                    </button>1269                  </div>1270                )}1271              </For>1272            </div>1273            <Show when={historySnapshots().length > 3}>1274              <button1275                class="peach-settings-advanced-toggle"1276                onClick={() => setHistoryShowAll((v) => !v)}1277                aria-expanded={historyShowAll()}1278              >1279                <span class="peach-settings-hint" style="margin:0">1280                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1281                </span>1282                <span class="peach-settings-advanced-chevron">1283                  {historyShowAll() ? "▲" : "▼"}1284                </span>1285              </button>1286            </Show>1287            <p class="peach-history-footer">1288              {historySnapshots().length} of 10 snapshots1289            </p>1290          </Show>12911292          <button1293            class="peach-settings-advanced-toggle"1294            onClick={() => setShowVaultAdvanced((v) => !v)}1295            aria-expanded={showVaultAdvanced()}1296          >1297            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1298            <span class="peach-settings-advanced-chevron">1299              {showVaultAdvanced() ? "▲" : "▼"}1300            </span>1301          </button>13021303          <Show when={showVaultAdvanced()}>1304            <div class="peach-danger-card">1305              <div class="peach-danger-card-head">1306                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1307                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1308              </div>1309              <p class="peach-danger-card-copy">1310                Wipes the current vault and restores from a backup file.1311                Recoverable only if your backup is good.1312              </p>1313              <button1314                class="peach-danger-card-button"1315                onClick={() => props.onOpenFileImport?.()}1316              >1317                Choose backup file1318              </button>1319            </div>13201321            <Show when={props.onOpenPanic}>1322              <div class="peach-danger-card peach-danger-card--hazard">1323                <div class="peach-danger-card-head">1324                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1325                  <h3 class="peach-danger-card-title">Destroy vault</h3>1326                </div>1327                <p class="peach-danger-card-copy">1328                  Permanently wipes every entry, setting, and license on this1329                  device. There is no recovery.1330                </p>1331                <button1332                  class="peach-danger-card-button"1333                  onClick={() => props.onOpenPanic?.()}1334                >1335                  Begin destruction1336                </button>1337              </div>1338            </Show>1339          </Show>1340        </div>1341      </div>1342    );1343  }13441345  function renderSync() {1346    return (1347      <div class="peach-settings-body">1348        <div class="peach-settings-group">1349          <p class="peach-settings-sub-label">PAIRING</p>1350          <div class="peach-settings-row peach-settings-row--top">1351            <div class="peach-settings-row-main">1352              <span>1353                Peach Sync1354                <Show when={!isPro()}>1355                  <span class="peach-pro-badge">Pro</span>1356                </Show>1357              </span>1358              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1359                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1360              </span>1361            </div>1362            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1363          </div>1364          <div class="peach-settings-row peach-settings-row--meta">1365            <span>Status</span>1366            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1367          </div>1368          <div class="peach-settings-row peach-settings-row--meta">1369            <span>Sync hub</span>1370            <button1371              class="peach-btn peach-btn--subtle peach-btn--sm"1372              onClick={() => props.onOpenSyncPairing?.()}1373            >1374              open1375            </button>1376          </div>13771378          <p class="peach-settings-sub-label">DEVICE FLOW</p>1379          <div class="peach-settings-row peach-settings-row--top">1380            <div class="peach-settings-row-main">1381              <span>Pair new device</span>1382              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1383                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1384              </span>1385            </div>1386            <button1387              class="peach-btn peach-btn--subtle peach-btn--sm"1388              onClick={() => props.onOpenSyncPairing?.()}1389            >1390              pair1391            </button>1392          </div>1393          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1394          <Show when={pendingVouched().length > 0}>1395            <p class="peach-settings-sub-label">1396              PENDING DEVICES1397              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1398            </p>1399            <For each={pendingVouched()}>1400              {(dev) => (1401                <div class="peach-settings-row peach-settings-row--top">1402                  <div class="peach-settings-row-main">1403                    <span>{dev.deviceName}</span>1404                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1405                      Was added by another of your devices. Allow it to sync with this vault?1406                    </span>1407                  </div>1408                  <div style="display:flex;gap:6px;flex-shrink:0">1409                    <button1410                      class="peach-btn peach-btn--sm"1411                      disabled={vouchedBusy() === dev.deviceId}1412                      onClick={() => void confirmVouched(dev.deviceId)}1413                    >Allow</button>1414                    <button1415                      class="peach-btn peach-btn--subtle peach-btn--sm"1416                      disabled={vouchedBusy() === dev.deviceId}1417                      onClick={() => void rejectVouched(dev.deviceId)}1418                    >Reject</button>1419                  </div>1420                </div>1421              )}1422            </For>1423          </Show>14241425          <div class="peach-info-notice" style="margin-top:8px">1426            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1427          </div>14281429          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1430          <p class="peach-settings-sub-label">RECOVERY</p>1431          <Show1432            when={rotateConfirmOpen()}1433            fallback={1434              <div class="peach-settings-row peach-settings-row--top">1435                <div class="peach-settings-row-main">1436                  <span>Rotate sync identity</span>1437                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1438                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1439                  </span>1440                </div>1441                <button1442                  class="peach-btn peach-btn--subtle peach-btn--sm"1443                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1444                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1445                >rotate</button>1446              </div>1447            }1448          >1449            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1450              This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1451              <Show when={rotateError()}>1452                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1453              </Show>1454              <div style="display:flex;gap:6px;margin-top:10px">1455                <button1456                  class="peach-btn peach-btn--subtle peach-btn--sm"1457                  disabled={rotating()}1458                  onClick={() => setRotateConfirmOpen(false)}1459                >Cancel</button>1460                <button1461                  class="peach-btn peach-btn--sm"1462                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1463                  disabled={rotating()}1464                  onClick={() => void doRotateSyncIdentity()}1465                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1466              </div>1467            </div>1468          </Show>1469        </div>1470      </div>1471    );1472  }14731474  function renderAppearance() {1475    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1476      { id: "medium", label: "Default" },1477      { id: "large", label: "Large" },1478    ];1479    return (1480      <div class="peach-settings-body">1481        <div class="peach-settings-group">1482          <p class="peach-settings-sub-label">THEME</p>1483          <div class="peach-settings-row peach-settings-row--meta">1484            <span>Theme</span>1485            <button1486              class="peach-btn peach-btn--subtle peach-btn--sm"1487              onClick={() =>1488                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1489              }1490            >1491              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1492            </button>1493          </div>14941495          <p class="peach-settings-sub-label">ACCENT</p>1496          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1497            <span>Accent</span>1498            <div class="peach-accent-picker">1499              <For each={ACCENT_COLORS}>1500                {(color) => (1501                  <button1502                    class="peach-accent-swatch peach-swatch"1503                    classList={{ "is-active": props.accentColor === color.name }}1504                    style={{ background: color.hex }}1505                    onClick={() => void props.onAccentChange(color.name)}1506                    aria-label={color.name}1507                    title={color.name}1508                  />1509                )}1510              </For>1511            </div>1512          </div>15131514          <p class="peach-settings-sub-label">TEXT SIZE</p>1515          <div class="peach-settings-row peach-settings-row--meta">1516            <span>Size</span>1517            <div class="peach-pill-group">1518              <For each={sizeOptions}>1519                {(opt) => (1520                  <button1521                    class="peach-btn peach-btn--ghost peach-btn--sm"1522                    classList={{ "is-active": props.textSize === opt.id }}1523                    onClick={() => void props.onTextSizeChange(opt.id)}1524                  >1525                    {opt.label}1526                  </button>1527                )}1528              </For>1529            </div>1530          </div>15311532          <p class="peach-settings-sub-label">MOTION</p>1533          <div class="peach-settings-row peach-settings-row--meta">1534            <span>Reduce motion</span>1535            <button1536              class="peach-btn peach-btn--subtle peach-btn--sm"1537              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1538            >1539              {props.reduceMotion ? "On" : "Off"}1540            </button>1541          </div>1542        </div>1543      </div>1544    );1545  }15461547  function renderShortcuts() {1548    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1549    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);15501551    function startCapture(action: ShortcutAction) {1552      setConflictMsg(null);1553      setCapturingAction(action);1554    }1555    function cancelCapture() {1556      setCapturingAction(null);1557      setConflictMsg(null);1558    }1559    function commitBinding(action: ShortcutAction, binding: Keybinding) {1560      const conflict = findConflict(props.shortcuts, binding, action);1561      if (conflict) {1562        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1563        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1564        return;1565      }1566      const next = { ...props.shortcuts, [action]: binding };1567      setCapturingAction(null);1568      setConflictMsg(null);1569      void props.onShortcutsChange(next);1570    }1571    function resetAction(action: ShortcutAction) {1572      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1573      void props.onShortcutsChange(next);1574    }1575    function resetAll() {1576      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1577    }15781579    // Capture-mode key handler — wired via ref onto the chip element so it1580    // catches the next non-modifier keypress and ignores other bindings.1581    function handleCaptureKey(e: KeyboardEvent) {1582      e.preventDefault();1583      e.stopPropagation();1584      if (e.key === "Escape") { cancelCapture(); return; }1585      const binding = keybindingFromEvent(e);1586      if (!binding) return; // bare modifier — keep waiting1587      const action = capturingAction();1588      if (action) commitBinding(action, binding);1589    }15901591    return (1592      <div class="peach-settings-body">1593        <div class="peach-settings-group peach-shortcuts-group">1594          <p class="peach-settings-sub-label">ENTRY VIEW</p>1595          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1596            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1597          </For>1598        </div>15991600        <div class="peach-settings-group peach-shortcuts-group">1601          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1602          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1603            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1604          </For>1605        </div>16061607        <Show when={conflictMsg()}>1608          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1609        </Show>16101611        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1612          <div class="peach-settings-row peach-settings-row--meta">1613            <span>Inline autofill on page</span>1614            <a1615              class="peach-settings-link"1616              href="chrome://extensions/shortcuts"1617              onClick={(e) => {1618                e.preventDefault();1619                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1620              }}1621            >1622              ⌘⇧P — customize ↗1623            </a>1624          </div>1625          <button1626            class="peach-btn peach-btn--subtle peach-btn--sm"1627            style="align-self:flex-start"1628            onClick={resetAll}1629          >1630            Reset all to defaults1631          </button>1632        </div>1633      </div>1634    );16351636    function renderRow(1637      desc: typeof SHORTCUT_DESCRIPTORS[number],1638      capturing: () => ShortcutAction | null,1639      onStart: (a: ShortcutAction) => void,1640      onCancel: () => void,1641      onReset: (a: ShortcutAction) => void,1642      onKey: (e: KeyboardEvent) => void,1643    ) {1644      const isActive = () => capturing() === desc.action;1645      const binding = () => props.shortcuts[desc.action];1646      const isCustom = () => {1647        const b = binding();1648        const d = DEFAULT_BINDINGS[desc.action];1649        return b.key !== d.key1650          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1651          || Boolean(b.meta)  !== Boolean(d.meta)1652          || Boolean(b.shift) !== Boolean(d.shift)1653          || Boolean(b.alt)   !== Boolean(d.alt);1654      };1655      return (1656        <div class="peach-shortcut-row">1657          <span class="peach-shortcut-row-label">{desc.label}</span>1658          <button1659            class="peach-shortcut-chip"1660            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1661            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1662            onKeyDown={(e) => { if (isActive()) onKey(e); }}1663            ref={(el) => {1664              // Auto-focus when entering capture mode so the keydown listener catches the next press.1665              if (el) {1666                queueMicrotask(() => { if (isActive()) el.focus(); });1667              }1668            }}1669          >1670            {isActive() ? "Press a key…" : formatKeybinding(binding())}1671          </button>1672          <Show when={isCustom() && !isActive()}>1673            <button1674              class="peach-shortcut-reset"1675              title="Reset to default"1676              onClick={() => onReset(desc.action)}1677            >1678              ↺1679            </button>1680          </Show>1681        </div>1682      );1683    }1684  }16851686  const PRO_FEATURES: Array<{1687    feature: ProFeature;1688    name: string;1689    desc: string;1690  }> = [1691    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1692    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1693    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1694    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1695  ];16961697  const PRO_SECURITY_FEATURES: Array<{1698    feature: ProFeature;1699    name: string;1700    desc: string;1701  }> = [1702    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1703    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1704  ];17051706  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1707  const [showKey, setShowKey] = createSignal(false);17081709  const [deactivating, setDeactivating] = createSignal(false);17101711  async function handleDeactivateLicense() {1712    setDeactivating(true);1713    await sendMessage({ type: "DEACTIVATE_LICENSE" });1714    setLicenseInfo(null);1715    await props.onLicenseChange?.();1716    setDeactivating(false);1717  }17181719  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1720    setCanaryTestingId(entry.id);1721    setCanaryTestResult(null);1722    const res = await sendMessage({1723      type: "FIRE_CANARY_PING",1724      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1725    }) as { ok: boolean };1726    setCanaryTestResult({ id: entry.id, ok: res.ok });1727    setCanaryTestingId(null);1728    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1729  }17301731  function renderLicense() {1732    async function onActivate() {1733      await handleActivateLicense();1734      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1735        await props.onLicenseChange?.();1736      }1737    }17381739    return (1740      <div class="peach-settings-body">1741        <Show1742          when={isPro()}1743          fallback={1744                <div class="peach-pro-upgrade">1745                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>17461747                  <h2 class="peach-pro-headline">1748                    Every<em>thing,</em><br />every<em>where.</em>1749                  </h2>17501751                  <p class="peach-pro-subtitle">1752                    One key unlocks every Pro feature — for the lifetime of the app.1753                  </p>17541755                  <div class="peach-pro-grid">1756                    <For each={PRO_FEATURES}>1757                      {(item, i) => (1758                        <div1759                          class="peach-pro-grid-item"1760                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1761                        >1762                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1763                          <span class="peach-pro-grid-name">{item.name}</span>1764                          <span class="peach-pro-grid-desc">{item.desc}</span>1765                        </div>1766                      )}1767                    </For>1768                  </div>17691770                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1771                  <div class="peach-pro-grid">1772                    <For each={PRO_SECURITY_FEATURES}>1773                      {(item, i) => (1774                        <div1775                          class="peach-pro-grid-item"1776                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1777                        >1778                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1779                          <span class="peach-pro-grid-name">{item.name}</span>1780                          <span class="peach-pro-grid-desc">{item.desc}</span>1781                        </div>1782                      )}1783                    </For>1784                  </div>17851786                  <div class="peach-pro-upgrade-actions">1787                    <p class="peach-pro-pricing">1788                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1789                    </p>1790                    <a1791                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1792                      target="_blank"1793                      rel="noopener noreferrer"1794                      class="peach-pro-upgrade-cta"1795                    >1796                      Get Peach Pro1797                    </a>1798                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1799                    <div class="peach-pro-key-inline">1800                      <input1801                        class="peach-pro-key-inline-input"1802                        type="text"1803                        placeholder="PEACH-XXXX-XXXX-XXXX"1804                        value={licenseKeyInput()}1805                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1806                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1807                        autocomplete="off"1808                        spellcheck={false}1809                      />1810                      <button1811                        class="peach-pro-key-apply"1812                        onClick={() => void onActivate()}1813                        disabled={activatingLicense() || !licenseKeyInput().trim()}1814                      >1815                        {activatingLicense() ? "…" : "APPLY"}1816                      </button>1817                    </div>1818                    <Show when={licenseError()}>1819                      <p class="peach-pro-key-error">{licenseError()}</p>1820                    </Show>1821                  </div>1822                </div>1823          }1824        >1825          <div class="peach-pro-active">1826            <div class="peach-pro-active-hero">1827              <button1828                class="peach-pro-active-mark-wrap"1829                type="button"1830                aria-label="Celebrate"1831                onClick={handleProCelebrationClick}1832              >1833                <PeachMark class="peach-pro-active-mark" />1834                <Show when={justActivated()}>1835                  <div class="peach-pro-sparks" aria-hidden="true">1836                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1837                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1838                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1839                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1840                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1841                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1842                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1843                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1844                  </div>1845                </Show>1846              </button>1847              <h2 class="peach-pro-active-title">Peach Pro</h2>1848              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1849              <Show when={justActivated()}>1850                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1851              </Show>1852            </div>18531854            <Show when={licenseKey()}>1855              <div class="peach-settings-license">1856                <span class="peach-settings-label">LICENSE KEY</span>1857                <div class="peach-settings-license-row">1858                  <Show1859                    when={showKey()}1860                    fallback={1861                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1862                        ••••••••••••••••••••••••••••1863                      </span>1864                    }1865                  >1866                    <span class="peach-settings-license-key">{licenseKey()!}</span>1867                  </Show>1868                  <div class="peach-settings-license-actions">1869                    <button1870                      class="peach-settings-license-copy"1871                      title="Copy license key"1872                      onClick={() => copyLicenseKey(licenseKey()!)}1873                    >1874                      {copied() ? "✓" : "copy"}1875                    </button>1876                    <button1877                      class="peach-settings-license-toggle"1878                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1879                      title={showKey() ? "Hide" : "Reveal"}1880                      onClick={() => setShowKey(v => !v)}1881                    >1882                      <Show1883                        when={showKey()}1884                        fallback={1885                          <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">1886                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1887                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1888                          </svg>1889                        }1890                      >1891                        <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">1892                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1893                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1894                          <line x1="2" y1="2" x2="12" y2="12" />1895                        </svg>1896                      </Show>1897                    </button>1898                  </div>1899                </div>1900              </div>1901            </Show>19021903            <div class="peach-pro-active-features">1904              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1905                {(item) => (1906                  <div class="peach-pro-active-feature">1907                    <span class="peach-pro-active-feature-mark">✦</span>1908                    <span class="peach-pro-active-feature-name">{item.name}</span>1909                  </div>1910                )}1911              </For>1912            </div>19131914            <button1915              class="peach-pro-deactivate"1916              onClick={() => void handleDeactivateLicense()}1917              disabled={deactivating()}1918            >1919              {deactivating() ? "Deactivating…" : "Deactivate"}1920            </button>1921          </div>1922        </Show>1923      </div>1924    );1925  }19261927  function renderFeedback() {1928    return (1929      <div class="peach-settings-body">1930        <div class="peach-settings-group peach-feedback-group">1931          <Show1932            when={feedbackState() === 'sent'}1933            fallback={1934              <>1935                <p class="peach-feedback-intro">1936                  Help us improve Peach. Every report goes directly to our inbox.1937                </p>19381939                <p class="peach-settings-sub-label">TYPE</p>1940                <div class="peach-feedback-chips">1941                  <For each={FEEDBACK_CATEGORIES}>1942                    {(cat) => {1943                      const selected = () => fbCategory() === cat;1944                      return (1945                        <button1946                          type="button"1947                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1948                          aria-pressed={selected()}1949                          onClick={() => setFbCategory(cat)}1950                        >1951                          {cat}1952                        </button>1953                      );1954                    }}1955                  </For>1956                </div>19571958                <p class="peach-settings-sub-label">MESSAGE</p>1959                <textarea1960                  class="peach-feedback-textarea"1961                  placeholder="Describe the issue or idea..."1962                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1963                  value={fbMessage()}1964                  onInput={(e) => setFbMessage(e.currentTarget.value)}1965                />19661967                <p class="peach-feedback-meta">1968                  Version {version} · will be included automatically1969                </p>19701971                <button1972                  type="button"1973                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1974                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}1975                  onClick={() => void handleSubmitFeedback()}1976                >1977                  <Switch>1978                    <Match when={feedbackState() === 'idle'}>Send</Match>1979                    <Match when={feedbackState() === 'sending'}>Sending...</Match>1980                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>1981                  </Switch>1982                </button>19831984                <p class="peach-feedback-anon">1985                  Sent anonymously · no account required1986                </p>1987              </>1988            }1989          >1990            <div class="peach-feedback-thanks" role="status" aria-live="polite">1991              <p class="peach-feedback-thanks-title">Thank you</p>1992              <p>1993                Your note is in the queue. Someone will process it soon.1994              </p>1995            </div>1996          </Show>1997        </div>1998      </div>1999    );2000  }20012002  const bodies: StackBodies = {2003    protection: renderSecurity(),2004    vault: renderVault(),2005    sync: renderSync(),2006    appearance: renderAppearance(),2007    shortcuts: renderShortcuts(),2008    pro: renderLicense(),2009    feedback: renderFeedback(),2010  };20112012  return (2013    <Show2014      when={showNewRecoveryWords()}2015      fallback={2016        <div2017          class="peach-overlay-view peach-settings-overlay"2018          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2019          style="display:flex;flex-direction:column"2020        >2021          <StackDeck2022            active={activeCard()}2023            onActiveChange={setActiveCard}2024            onBack={props.onBack}2025            cards={cards()}2026            bodies={bodies}2027            version={version}2028            isFanning={isFanning()}2029            celebratingCardId={justActivated() ? "pro" : null}2030            isPro={isPro()}2031          />2032        </div>2033      }2034    >2035      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2036    </Show>2037  );2038}