Settings.tsx

85.2 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  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.394  const [showViewWords, setShowViewWords] = createSignal(false);395  const [viewWordsPassword, setViewWordsPassword] = createSignal("");396  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);397  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);398  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);399400  async function handleViewRecoveryWords() {401    setViewWordsError(null);402    if (!viewWordsPassword()) {403      setViewWordsError("Enter your master password.");404      return;405    }406    setViewWordsBusy(true);407    try {408      const r = (await sendMessage({409        type: "GET_RECOVERY_WORDS",410        payload: { password: viewWordsPassword() },411      })) as { ok: boolean; data?: string[]; error?: string };412      if (r.ok) {413        setViewedWords(r.data ?? []);414        setViewWordsPassword("");415        return;416      }417      switch (r.error) {418        case "wrong_password":419          setViewWordsError("Incorrect password");420          break;421        case "password_required":422          setViewWordsError("Enter your master password.");423          break;424        case "recovery_not_setup":425          setViewWordsError("Recovery words aren't set up for this vault yet.");426          break;427        default:428          setViewWordsError("Couldn't retrieve recovery words.");429      }430    } finally {431      setViewWordsBusy(false);432    }433  }434  function closeViewWords() {435    setShowViewWords(false);436    setViewWordsPassword("");437    setViewWordsError(null);438    setViewedWords(null);439  }440441  const [duressEnabled, setDuressEnabled] = createSignal(false);442  const [showDuressSetup, setShowDuressSetup] = createSignal(false);443  const [duressPassword, setDuressPassword] = createSignal("");444  const [duressConfirm, setDuressConfirm] = createSignal("");445  const [duressMainPassword, setDuressMainPassword] = createSignal("");446  const [duressSaving, setDuressSaving] = createSignal(false);447  const [duressError, setDuressError] = createSignal<string | null>(null);448  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);449450  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);451452  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);453  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);454455  const [fbCategory, setFbCategory] = createSignal("Bug report");456  const [fbMessage, setFbMessage] = createSignal("");457  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';458  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');459460  createEffect(() => {461    if (props.upgradeHighlight) {462      setActiveCard("pro");463    }464  });465466  onMount(async () => {467    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([468      sendMessage({ type: "GET_LICENSE" }),469      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),470      sendMessage({ type: "IS_DURESS_ENABLED" }),471      sendMessage({ type: "GET_SETTINGS" }),472      sendMessage({ type: "GET_LICENSE_KEY" }),473    ]);474475    // Vault history loads in the background — the Vault sub-view picks it up476    // when the user navigates in. Loading it lazily keeps Settings boot fast.477    void loadVaultHistory();478479    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the480    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.481    void loadPendingVouched();482    const onBroadcast = (msg: unknown) => {483      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {484        void loadPendingVouched();485      }486    };487    chrome.runtime?.onMessage?.addListener(onBroadcast);488    onCleanup(() => {489      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }490    });491492    if ((licenseRes as { ok: boolean }).ok) {493      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);494    }495496    if ((passkeyRes as { ok: boolean }).ok) {497      setPasskeyState(498        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null499      );500    }501502    if ((duressRes as { ok: boolean }).ok) {503      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);504    }505506    if ((settingsRes as { ok: boolean }).ok) {507      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;508      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);509    }510511    if ((keyRes as { ok: boolean }).ok) {512      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);513    }514515    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });516    if ((entriesRes as { ok: boolean }).ok) {517      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];518      setCanaryEntries(519        entries520          .filter((e) => e.canary?.webhookUrl)521          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))522      );523    }524525526    if (isNativePlatform()) {527      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");528      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");529      try {530        if (biometric) {531          const a = await biometric.isAvailable();532          setBiometricSupported(a.available);533        }534        if (keystore) {535          const k = await keystore.hasMasterKey();536          setBiometricStored(k.exists);537        }538      } catch { /* missing plugins — leave defaults */ }539    }540  });541542  async function handleEnableBiometrics() {543    setBiometricBusy(true);544    setBiometricError(null);545    try {546      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {547        ok: boolean;548        data?: { masterKeyHex: string };549        error?: string;550      };551      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {552        setBiometricError(keyRes.error === "vault_locked"553          ? "unlock the vault first"554          : "couldn't read derived key");555        return;556      }557      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");558      if (!keystore) {559        setBiometricError("biometric plugin unavailable");560        return;561      }562      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });563      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });564      setBiometricStored(true);565    } catch (e) {566      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");567    } finally {568      setBiometricBusy(false);569    }570  }571572  async function handleDisableBiometrics() {573    setBiometricBusy(true);574    setBiometricError(null);575    try {576      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");577      if (keystore) await keystore.clearMasterKey();578      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });579      setBiometricStored(false);580    } catch (e) {581      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");582    } finally {583      setBiometricBusy(false);584    }585  }586587  async function handleChangePassword() {588    setChangePwError(null);589    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }590    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }591    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }592    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }593    setChangePwSaving(true);594    const res = await sendMessage({595      type: "CHANGE_PASSWORD",596      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },597    }) as { ok: boolean; error?: string };598    if (res.ok) {599      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");600      setShowChangePw(false);601      // LV-4: the user just authenticated (current password), so the recovery602      // words reveal that follows is exempt from the re-auth gate. Set the603      // pending flag the same way the create/recovery flow does; the display604      // clears it on confirm.605      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });606      setShowNewRecoveryWords(true);607    } else {608      setChangePwError(res.error ?? "failed to change password");609    }610    setChangePwSaving(false);611  }612613  const [copied, setCopied] = createSignal(false);614615  async function copyLicenseKey(key: string) {616    await navigator.clipboard.writeText(key);617    setCopied(true);618    setTimeout(() => setCopied(false), 2000);619  }620621  async function handleActivateLicense() {622    const key = licenseKeyInput().trim();623    if (!key) return;624    setActivatingLicense(true);625    setLicenseError(null);626    setLicenseSuccess(null);627    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });628    setActivatingLicense(false);629    if ((res as { ok: boolean }).ok) {630      const data = (res as { ok: boolean; data?: LicenseInfo }).data;631      if (data?.valid) {632        setLicenseInfo(data);633        setLicenseKey(key);634        setLicenseSuccess("License activated!");635        setLicenseKeyInput("");636      } else {637        setLicenseError("Invalid license key");638      }639    } else {640      setLicenseError("Activation failed");641    }642  }643644  async function handleTogglePasskeys() {645    const current = passkeyState();646    if (!current || !current.supported) return;647    setPasskeySaving(true);648    const res = await sendMessage({649      type: "SET_PASSKEY_PROVIDER_ENABLED",650      payload: { enabled: !current.enabled },651    });652    setPasskeySaving(false);653    if ((res as { ok: boolean }).ok) {654      setPasskeyState(655        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null656      );657    }658  }659660  async function handleCheckAllBreaches() {661    setCheckingBreaches(true);662    setBreachResult(null);663    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });664    setCheckingBreaches(false);665    if ((res as { ok: boolean }).ok) {666      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};667      const breached = Object.values(data).filter((count) => count > 0).length;668      setBreachResult(669        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"670      );671      props.onBreachCheckDone();672    }673  }674675676  async function handleSetupDuress() {677    const mainPw = duressMainPassword().trim();678    const duressPw = duressPassword().trim();679    const confirmPw = duressConfirm().trim();680681    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }682    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }683    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }684    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }685686    setDuressSaving(true);687    setDuressError(null);688689    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };690    if (!verifyRes.ok || !verifyRes.data) {691      setDuressError("incorrect main password");692      setDuressSaving(false);693      return;694    }695696    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };697    setDuressSaving(false);698    if (res.ok) {699      setDuressEnabled(true);700      setShowDuressSetup(false);701      setDuressPassword("");702      setDuressConfirm("");703      setDuressMainPassword("");704      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");705      setTimeout(() => setDuressSuccess(null), 5000);706    } else {707      setDuressError(708        res.error === "session_expired"709          ? "Vault session expired — lock and unlock Peach, then try again"710          : res.error ?? "failed to set duress password"711      );712    }713  }714715  async function handleClearDeclinedCards() {716    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });717    setDeclinedCardCount(0);718  }719720  async function handleCountDuplicates() {721    setDedupRunning(true);722    setDedupDone(null);723    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };724    setDedupRunning(false);725    if (res.ok) setDedupCount(res.data ?? 0);726  }727728  async function handleDeduplicate() {729    setDedupRunning(true);730    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };731    setDedupRunning(false);732    if (res.ok) {733      setDedupDone(res.data ?? 0);734      setDedupCount(0);735    }736  }737738  async function handleDisableDuress() {739    const mainPw = duressMainPassword().trim();740    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }741742    setDuressSaving(true);743    setDuressError(null);744745    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };746    if (!verifyRes.ok || !verifyRes.data) {747      setDuressError("incorrect main password");748      setDuressSaving(false);749      return;750    }751752    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };753    setDuressSaving(false);754    if (res.ok) {755      setDuressEnabled(false);756      setShowDuressSetup(false);757      setDuressMainPassword("");758      setDuressSuccess("Duress password disabled.");759      setTimeout(() => setDuressSuccess(null), 4000);760    } else {761      setDuressError(res.error ?? "failed to disable duress password");762    }763  }764765  async function handleSubmitFeedback() {766    const message = sanitizeFeedbackMessage(fbMessage());767    if (!message) return;768769    setFeedbackState('sending');770771    try {772      const hasDataPermission = await requestFeedbackDataPermission();773      if (!hasDataPermission) {774        setFeedbackState('idle');775        return;776      }777778      const category = FEEDBACK_CATEGORIES.includes(fbCategory())779        ? fbCategory()780        : FEEDBACK_CATEGORIES[0];781      const res = await fetch('https://api.web3forms.com/submit', {782        method: 'POST',783        headers: { 'Content-Type': 'application/json' },784        body: JSON.stringify({785          access_key: WEB3FORMS_KEY,786          subject: `Peach Feedback - ${category}`,787          message,788          from_name: 'Peach User',789          // Honeypot — leave blank790          botcheck: '',791        }),792      });793794      if (!res.ok) throw new Error(`HTTP ${res.status}`);795      const data = await res.json() as { success: boolean; message?: string };796      if (!data.success) throw new Error(data.message ?? 'unknown error');797798      setFeedbackState('sent');799      setFbMessage('');800      setFbCategory(FEEDBACK_CATEGORIES[0]);801    } catch (err) {802      console.error('[feedback]', err);803      setFeedbackState('error');804      setTimeout(() => setFeedbackState('idle'), 4000);805    }806  }807808  function renderSecurity() {809    return (810      <div class="peach-settings-body">811        <div class="peach-settings-group">812          <p class="peach-settings-sub-label">LOCKING</p>813          <div class="peach-settings-row">814            <span>Lock on close</span>815            <button816              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}817              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}818              aria-label="Toggle lock on close"819            >820              <span class="peach-toggle-knob"></span>821            </button>822          </div>823          <div class="peach-settings-row">824            <span>Auto-lock after</span>825            <select826              class="peach-settings-select"827              value={String(props.autoLockMinutes)}828              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}829            >830              <option value="1">1 minute</option>831              <option value="5">5 minutes</option>832              <option value="15">15 minutes</option>833              <option value="30">30 minutes</option>834              <option value="0">Never</option>835            </select>836          </div>837838          <Show when={isNativePlatform() && biometricSupported()}>839            <p class="peach-settings-sub-label">BIOMETRICS</p>840            <div class="peach-settings-row">841              <span>Unlock with biometrics</span>842              <button843                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}844                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}845                disabled={biometricBusy()}846                aria-label="Toggle biometric unlock"847              >848                <span class="peach-toggle-knob"></span>849              </button>850            </div>851            <Show when={biometricError()}>852              <div class="peach-settings-row peach-settings-row--meta">853                <span class="peach-hint peach-hint-error">{biometricError()}</span>854              </div>855            </Show>856          </Show>857858          <p class="peach-settings-sub-label">BREACH PROTECTION</p>859          <div class="peach-settings-row">860            <span>Breach monitoring</span>861            <button862              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}863              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}864              aria-label="Toggle breach monitoring"865            >866              <span class="peach-toggle-knob"></span>867            </button>868          </div>869          <div class="peach-settings-row peach-settings-row--meta">870            <span>Check all passwords</span>871            <button872              class="peach-btn peach-btn--subtle peach-btn--sm"873              onClick={() => void handleCheckAllBreaches()}874              disabled={checkingBreaches()}875            >876              {checkingBreaches() ? "checking..." : "run"}877            </button>878          </div>879          <Show when={breachResult()}>880            <div class="peach-settings-row peach-settings-row--meta">881              <span>Last check</span>882              <span class="peach-settings-value">{breachResult()}</span>883            </div>884          </Show>885886          <p class="peach-settings-sub-label">MASTER PASSWORD</p>887          <div class="peach-settings-row peach-settings-row--meta">888            <span>Master password</span>889            <button890              class="peach-btn peach-btn--subtle peach-btn--sm"891              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}892            >893              {showChangePw() ? "cancel" : "change"}894            </button>895          </div>896          <Show when={showChangePw()}>897            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">898              <input899                class="peach-settings-input"900                type="password"901                placeholder="current password"902                value={changePwCurrent()}903                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}904                autocomplete="current-password"905              />906              <input907                class="peach-settings-input"908                type="password"909                placeholder="new password (min 12 chars)"910                value={changePwNew()}911                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}912                autocomplete="new-password"913              />914              <input915                class="peach-settings-input"916                type="password"917                placeholder="confirm new password"918                value={changePwConfirm()}919                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}920                autocomplete="new-password"921                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}922              />923              <Show when={changePwError()}>924                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>925              </Show>926              <Show when={changePwSuccess()}>927                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>928              </Show>929              <button930                class="peach-btn peach-btn--accent peach-btn--sm"931                style="align-self:flex-start"932                onClick={() => void handleChangePassword()}933                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}934              >935                {changePwSaving() ? "saving…" : "save new password"}936              </button>937            </div>938          </Show>939940          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}941          <div class="peach-settings-row peach-settings-row--meta">942            <span>Recovery words</span>943            <button944              class="peach-btn peach-btn--subtle peach-btn--sm"945              onClick={() => {946                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }947              }}948            >949              {showViewWords() ? "cancel" : "view"}950            </button>951          </div>952          <Show when={showViewWords()}>953            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">954              <Show955                when={viewedWords()}956                fallback={957                  <>958                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">959                      Enter your master password to reveal your recovery words.960                    </p>961                    <input962                      class="peach-settings-input"963                      type="password"964                      placeholder="master password"965                      value={viewWordsPassword()}966                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}967                      autocomplete="current-password"968                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}969                    />970                    <Show when={viewWordsError()}>971                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>972                    </Show>973                    <button974                      class="peach-btn peach-btn--accent peach-btn--sm"975                      style="align-self:flex-start"976                      onClick={() => void handleViewRecoveryWords()}977                      disabled={viewWordsBusy() || !viewWordsPassword()}978                    >979                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}980                    </button>981                  </>982                }983              >984                <div class="peach-recovery-words-grid">985                  <For each={viewedWords()!}>986                    {(word, i) => (987                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>988                    )}989                  </For>990                </div>991                <button992                  class="peach-btn peach-btn--subtle peach-btn--sm"993                  style="align-self:flex-start"994                  onClick={closeViewWords}995                >done</button>996              </Show>997            </div>998          </Show>9991000          <p class="peach-settings-sub-label">1001            DURESS PASSWORD1002            <Show when={!isPro()}>1003              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1004            </Show>1005          </p>1006          <div class="peach-settings-row peach-settings-row--top">1007            <div class="peach-settings-row-main">1008              <span>1009                Duress password1010                <Show when={!isPro()}>1011                  <span class="peach-pro-badge">Pro</span>1012                </Show>1013              </span>1014              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1015                If entered under coercion, shows a decoy empty vault instead of your real one.1016              </span>1017            </div>1018            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1019              {duressEnabled() ? "on" : "off"}1020            </div>1021          </div>10221023          <Show when={duressEnabled() && !showDuressSetup()}>1024            <div class="peach-settings-row peach-settings-row--meta">1025              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1026                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.1027              </span>1028            </div>1029          </Show>10301031          <Show when={duressSuccess()}>1032            <div class="peach-settings-row peach-settings-row--meta">1033              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1034            </div>1035          </Show>10361037          <Show when={!showDuressSetup()}>1038            <div class="peach-settings-row peach-settings-row--meta">1039              <button1040                class="peach-btn peach-btn--subtle peach-btn--sm"1041                onClick={() => {1042                  // Pro gate entry-point: free users are redirected to the1043                  // upgrade screen before the setup form opens. The server-1044                  // side SETUP_DURESS handler is the single enforcement1045                  // point; this is UI redirection so the form never appears1046                  // for a user who can't complete it.1047                  if (!isPro() && !duressEnabled()) {1048                    props.onProGate?.("duress_vault");1049                    setActiveCard("pro");1050                    return;1051                  }1052                  setShowDuressSetup(true);1053                  setDuressError(null);1054                  setDuressSuccess(null);1055                  setDuressPassword("");1056                  setDuressConfirm("");1057                  setDuressMainPassword("");1058                }}1059              >1060                {duressEnabled() ? "Change / disable" : "Set up"}1061              </button>1062            </div>1063          </Show>10641065          <Show when={showDuressSetup()}>1066            <div class="peach-duress-form">1067              <label class="peach-label">main password (to confirm)</label>1068              <input1069                class="peach-settings-input"1070                type="password"1071                placeholder="your current master password"1072                autocomplete="current-password"1073                value={duressMainPassword()}1074                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1075              />1076              <Show when={!duressEnabled()}>1077                <label class="peach-label">new duress password</label>1078                <input1079                  class="peach-settings-input"1080                  type="password"1081                  placeholder="duress password (min 8 chars)"1082                  autocomplete="new-password"1083                  value={duressPassword()}1084                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1085                />1086                <label class="peach-label">confirm duress password</label>1087                <input1088                  class="peach-settings-input"1089                  type="password"1090                  placeholder="confirm duress password"1091                  autocomplete="new-password"1092                  value={duressConfirm()}1093                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1094                />1095              </Show>10961097              <Show when={duressError()}>1098                <p class="peach-hint peach-hint-error">{duressError()}</p>1099              </Show>11001101              <div class="peach-duress-actions">1102                <button1103                  class="peach-btn peach-btn--subtle peach-btn--sm"1104                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1105                >1106                  cancel1107                </button>1108                <Show when={duressEnabled()}>1109                  <button1110                    class="peach-btn peach-btn--ghost peach-btn--sm"1111                    style="color:var(--peach-breach)"1112                    onClick={() => void handleDisableDuress()}1113                    disabled={duressSaving()}1114                  >1115                    {duressSaving() ? "disabling..." : "disable duress"}1116                  </button>1117                </Show>1118                <Show when={!duressEnabled()}>1119                  <button1120                    class="peach-btn peach-btn--accent peach-btn--sm"1121                    onClick={() => void handleSetupDuress()}1122                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1123                  >1124                    {duressSaving() ? "saving..." : "save"}1125                  </button>1126                </Show>1127              </div>1128            </div>1129          </Show>11301131          <p class="peach-settings-sub-label">1132            CANARY ENTRIES1133            <Show when={!isPro()}>1134              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1135            </Show>1136          </p>1137          <div class="peach-settings-row peach-settings-row--top">1138            <div class="peach-settings-row-main">1139              <span>1140                Canary entries1141                <Show when={!isPro()}>1142                  <span class="peach-pro-badge">Pro</span>1143                </Show>1144              </span>1145              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1146                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1147              </span>1148            </div>1149            <span class="peach-settings-value" style="flex-shrink:0">1150              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1151            </span>1152          </div>1153          <Show when={canaryEntries().length > 0}>1154            <For each={canaryEntries()}>1155              {(entry) => (1156                <div class="peach-settings-row peach-settings-row--meta">1157                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1158                  <button1159                    class="peach-btn peach-btn--subtle peach-btn--sm"1160                    onClick={() => void handleTestCanary(entry)}1161                    disabled={canaryTestingId() === entry.id}1162                    style="flex-shrink:0"1163                  >1164                    {canaryTestingId() === entry.id1165                      ? "sending…"1166                      : canaryTestResult()?.id === entry.id1167                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1168                        : "test"}1169                  </button>1170                </div>1171              )}1172            </For>1173          </Show>11741175          <p class="peach-settings-sub-label">PASSKEYS</p>1176          <div class="peach-settings-row peach-settings-row--top">1177            <div class="peach-settings-row-main">1178              <span>Intercept passkey requests</span>1179              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1180                Peach can handle passkey sign-in and creation on supported Chromium browsers.1181              </span>1182            </div>1183            <button1184              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1185              onClick={() => void handleTogglePasskeys()}1186              aria-label="Toggle passkey interception"1187              disabled={!passkeyState()?.supported || passkeySaving()}1188            >1189              <span class="peach-toggle-knob"></span>1190            </button>1191          </div>1192          <div class="peach-settings-row peach-settings-row--meta">1193            <span>Status</span>1194            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1195          </div>1196          <div class="peach-settings-row peach-settings-row--meta">1197            <span>Browser</span>1198            <span class="peach-settings-value">1199              {passkeyState()?.browserLabel ?? "Unknown"}1200              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1201            </span>1202          </div>1203          <div class="peach-info-notice" style="margin-top:8px">1204            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1205          </div>1206          <Show when={passkeyState()?.conflict}>1207            <div class="peach-settings-row peach-settings-row--meta">1208              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1209            </div>1210          </Show>1211        </div>1212      </div>1213    );1214  }12151216  function renderVault() {1217    return (1218      <div class="peach-settings-body">1219        <div class="peach-settings-group">1220          <p class="peach-settings-sub-label">ENTRIES</p>1221          <div class="peach-settings-row peach-settings-row--meta">1222            <span>Entries</span>1223            <span class="peach-settings-value">{props.entriesCount}</span>1224          </div>1225          <div class="peach-settings-row peach-settings-row--meta">1226            <span>Duplicates</span>1227            <Show when={dedupDone() !== null} fallback={1228              <button1229                class="peach-btn peach-btn--subtle peach-btn--sm"1230                onClick={() => void handleCountDuplicates()}1231                disabled={dedupRunning()}1232              >1233                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1234              </button>1235            }>1236              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1237                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1238              </span>1239            </Show>1240          </div>1241          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1242            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1243              <div class="peach-restore-warning">1244                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1245              </div>1246              <div style="display:flex;gap:6px">1247                <button1248                  class="peach-btn peach-btn--subtle peach-btn--sm"1249                  onClick={() => setDedupCount(null)}1250                >1251                  cancel1252                </button>1253                <button1254                  class="peach-btn peach-btn--accent peach-btn--sm"1255                  onClick={() => void handleDeduplicate()}1256                  disabled={dedupRunning()}1257                >1258                  {dedupRunning() ? "removing..." : "remove duplicates"}1259                </button>1260              </div>1261            </div>1262          </Show>12631264          <p class="peach-settings-sub-label">MAINTENANCE</p>1265          <div class="peach-settings-row peach-settings-row--meta">1266            <span>Trash auto-delete</span>1267            <select1268              class="peach-settings-select"1269              value={String(props.trashRetentionDays)}1270              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1271            >1272              <option value="7">7 days</option>1273              <option value="30">30 days</option>1274              <option value="90">90 days</option>1275              <option value="0">Never</option>1276            </select>1277          </div>1278          <div class="peach-settings-row peach-settings-row--meta">1279            <span>Advanced Vault Health</span>1280            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1281              open1282            </button>1283          </div>1284          <Show when={declinedCardCount() > 0}>1285            <div class="peach-settings-row peach-settings-row--meta">1286              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1287              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1288                Clear1289              </button>1290            </div>1291          </Show>12921293          <p class="peach-settings-sub-label">DATA</p>1294          <div class="peach-settings-row peach-settings-row--meta">1295            <span>Import passwords</span>1296            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1297              open1298            </button>1299          </div>1300          <div class="peach-settings-row peach-settings-row--meta">1301            <span>Export backup</span>1302            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1303              open1304            </button>1305          </div>1306          <div class="peach-settings-row peach-settings-row--meta">1307            <span>1308              Peach Codex1309              <Show when={!isPro()}>1310                <span class="peach-pro-badge">Pro</span>1311              </Show>1312            </span>1313            <button1314              class="peach-btn peach-btn--subtle peach-btn--sm"1315              onClick={() => props.onOpenCodexGenerate?.()}1316            >1317              open1318            </button>1319          </div>1320          {/* Restore from Codex: intentionally never gated — a printed1321              Codex must remain restorable regardless of license state. */}1322          <div class="peach-settings-row peach-settings-row--meta">1323            <span>Restore from Codex</span>1324            <button1325              class="peach-btn peach-btn--subtle peach-btn--sm"1326              onClick={() => props.onOpenCodexRestore?.()}1327            >1328              open1329            </button>1330          </div>13311332          <span class="peach-settings-sub-label">VAULT HISTORY</span>1333          <Show when={historyLoading()}>1334            <p class="peach-settings-hint">Loading…</p>1335          </Show>1336          <Show when={!historyLoading() && historySnapshots().length === 0}>1337            <p class="peach-settings-hint">1338              No snapshots yet. Peach saves automatically before every change —1339              saves, syncs, and imports.1340            </p>1341          </Show>1342          <Show when={!historyLoading() && historySnapshots().length > 0}>1343            <p class="peach-settings-hint">1344              Restoring locks your vault. Unlock with the password that1345              encrypted that snapshot to confirm.1346            </p>1347            <div class="peach-history-list">1348              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1349                {(snap) => (1350                  <div class="peach-history-row">1351                    <div class="peach-history-meta">1352                      <span class="peach-history-time">1353                        {formatSnapshotTime(snap.savedAt)}1354                      </span>1355                      <span class="peach-history-sub">1356                        {[1357                          snap.entryCount !== null1358                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1359                            : null,1360                          reasonLabel(snap.reason),1361                        ]1362                          .filter(Boolean)1363                          .join(" · ")}1364                      </span>1365                    </div>1366                    <button1367                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1368                        historyRestoring() === snap.id ? " is-busy" : ""1369                      }`}1370                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1371                      disabled={historyRestoring() !== null}1372                    >1373                      <Show when={historyRestoring() === snap.id} fallback="Restore">1374                        …1375                      </Show>1376                    </button>1377                  </div>1378                )}1379              </For>1380            </div>1381            <Show when={historySnapshots().length > 3}>1382              <button1383                class="peach-settings-advanced-toggle"1384                onClick={() => setHistoryShowAll((v) => !v)}1385                aria-expanded={historyShowAll()}1386              >1387                <span class="peach-settings-hint" style="margin:0">1388                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1389                </span>1390                <span class="peach-settings-advanced-chevron">1391                  {historyShowAll() ? "▲" : "▼"}1392                </span>1393              </button>1394            </Show>1395            <p class="peach-history-footer">1396              {historySnapshots().length} of 10 snapshots1397            </p>1398          </Show>13991400          <button1401            class="peach-settings-advanced-toggle"1402            onClick={() => setShowVaultAdvanced((v) => !v)}1403            aria-expanded={showVaultAdvanced()}1404          >1405            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1406            <span class="peach-settings-advanced-chevron">1407              {showVaultAdvanced() ? "▲" : "▼"}1408            </span>1409          </button>14101411          <Show when={showVaultAdvanced()}>1412            <div class="peach-danger-card">1413              <div class="peach-danger-card-head">1414                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1415                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1416              </div>1417              <p class="peach-danger-card-copy">1418                Wipes the current vault and restores from a backup file.1419                Recoverable only if your backup is good.1420              </p>1421              <button1422                class="peach-danger-card-button"1423                onClick={() => props.onOpenFileImport?.()}1424              >1425                Choose backup file1426              </button>1427            </div>14281429            <Show when={props.onOpenPanic}>1430              <div class="peach-danger-card peach-danger-card--hazard">1431                <div class="peach-danger-card-head">1432                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1433                  <h3 class="peach-danger-card-title">Destroy vault</h3>1434                </div>1435                <p class="peach-danger-card-copy">1436                  Permanently wipes every entry, setting, and license on this1437                  device. There is no recovery.1438                </p>1439                <button1440                  class="peach-danger-card-button"1441                  onClick={() => props.onOpenPanic?.()}1442                >1443                  Begin destruction1444                </button>1445              </div>1446            </Show>1447          </Show>1448        </div>1449      </div>1450    );1451  }14521453  function renderSync() {1454    return (1455      <div class="peach-settings-body">1456        <div class="peach-settings-group">1457          <p class="peach-settings-sub-label">PAIRING</p>1458          <div class="peach-settings-row peach-settings-row--top">1459            <div class="peach-settings-row-main">1460              <span>1461                Peach Sync1462                <Show when={!isPro()}>1463                  <span class="peach-pro-badge">Pro</span>1464                </Show>1465              </span>1466              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1467                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1468              </span>1469            </div>1470            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1471          </div>1472          <div class="peach-settings-row peach-settings-row--meta">1473            <span>Status</span>1474            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1475          </div>1476          <div class="peach-settings-row peach-settings-row--meta">1477            <span>Sync hub</span>1478            <button1479              class="peach-btn peach-btn--subtle peach-btn--sm"1480              onClick={() => props.onOpenSyncPairing?.()}1481            >1482              open1483            </button>1484          </div>14851486          <p class="peach-settings-sub-label">DEVICE FLOW</p>1487          <div class="peach-settings-row peach-settings-row--top">1488            <div class="peach-settings-row-main">1489              <span>Pair new device</span>1490              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1491                Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1492              </span>1493            </div>1494            <button1495              class="peach-btn peach-btn--subtle peach-btn--sm"1496              onClick={() => props.onOpenSyncPairing?.()}1497            >1498              pair1499            </button>1500          </div>1501          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1502          <Show when={pendingVouched().length > 0}>1503            <p class="peach-settings-sub-label">1504              PENDING DEVICES1505              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1506            </p>1507            <For each={pendingVouched()}>1508              {(dev) => (1509                <div class="peach-settings-row peach-settings-row--top">1510                  <div class="peach-settings-row-main">1511                    <span>{dev.deviceName}</span>1512                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1513                      Was added by another of your devices. Allow it to sync with this vault?1514                    </span>1515                  </div>1516                  <div style="display:flex;gap:6px;flex-shrink:0">1517                    <button1518                      class="peach-btn peach-btn--sm"1519                      disabled={vouchedBusy() === dev.deviceId}1520                      onClick={() => void confirmVouched(dev.deviceId)}1521                    >Allow</button>1522                    <button1523                      class="peach-btn peach-btn--subtle peach-btn--sm"1524                      disabled={vouchedBusy() === dev.deviceId}1525                      onClick={() => void rejectVouched(dev.deviceId)}1526                    >Reject</button>1527                  </div>1528                </div>1529              )}1530            </For>1531          </Show>15321533          <div class="peach-info-notice" style="margin-top:8px">1534            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1535          </div>15361537          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1538          <p class="peach-settings-sub-label">RECOVERY</p>1539          <Show1540            when={rotateConfirmOpen()}1541            fallback={1542              <div class="peach-settings-row peach-settings-row--top">1543                <div class="peach-settings-row-main">1544                  <span>Rotate sync identity</span>1545                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1546                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1547                  </span>1548                </div>1549                <button1550                  class="peach-btn peach-btn--subtle peach-btn--sm"1551                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1552                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1553                >rotate</button>1554              </div>1555            }1556          >1557            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1558              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.1559              <Show when={rotateError()}>1560                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1561              </Show>1562              <div style="display:flex;gap:6px;margin-top:10px">1563                <button1564                  class="peach-btn peach-btn--subtle peach-btn--sm"1565                  disabled={rotating()}1566                  onClick={() => setRotateConfirmOpen(false)}1567                >Cancel</button>1568                <button1569                  class="peach-btn peach-btn--sm"1570                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1571                  disabled={rotating()}1572                  onClick={() => void doRotateSyncIdentity()}1573                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1574              </div>1575            </div>1576          </Show>1577        </div>1578      </div>1579    );1580  }15811582  function renderAppearance() {1583    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1584      { id: "medium", label: "Default" },1585      { id: "large", label: "Large" },1586    ];1587    return (1588      <div class="peach-settings-body">1589        <div class="peach-settings-group">1590          <p class="peach-settings-sub-label">THEME</p>1591          <div class="peach-settings-row peach-settings-row--meta">1592            <span>Theme</span>1593            <button1594              class="peach-btn peach-btn--subtle peach-btn--sm"1595              onClick={() =>1596                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1597              }1598            >1599              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1600            </button>1601          </div>16021603          <p class="peach-settings-sub-label">ACCENT</p>1604          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1605            <span>Accent</span>1606            <div class="peach-accent-picker">1607              <For each={ACCENT_COLORS}>1608                {(color) => (1609                  <button1610                    class="peach-accent-swatch peach-swatch"1611                    classList={{ "is-active": props.accentColor === color.name }}1612                    style={{ background: color.hex }}1613                    onClick={() => void props.onAccentChange(color.name)}1614                    aria-label={color.name}1615                    title={color.name}1616                  />1617                )}1618              </For>1619            </div>1620          </div>16211622          <p class="peach-settings-sub-label">TEXT SIZE</p>1623          <div class="peach-settings-row peach-settings-row--meta">1624            <span>Size</span>1625            <div class="peach-pill-group">1626              <For each={sizeOptions}>1627                {(opt) => (1628                  <button1629                    class="peach-btn peach-btn--ghost peach-btn--sm"1630                    classList={{ "is-active": props.textSize === opt.id }}1631                    onClick={() => void props.onTextSizeChange(opt.id)}1632                  >1633                    {opt.label}1634                  </button>1635                )}1636              </For>1637            </div>1638          </div>16391640          <p class="peach-settings-sub-label">MOTION</p>1641          <div class="peach-settings-row peach-settings-row--meta">1642            <span>Reduce motion</span>1643            <button1644              class="peach-btn peach-btn--subtle peach-btn--sm"1645              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1646            >1647              {props.reduceMotion ? "On" : "Off"}1648            </button>1649          </div>1650        </div>1651      </div>1652    );1653  }16541655  function renderShortcuts() {1656    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1657    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);16581659    function startCapture(action: ShortcutAction) {1660      setConflictMsg(null);1661      setCapturingAction(action);1662    }1663    function cancelCapture() {1664      setCapturingAction(null);1665      setConflictMsg(null);1666    }1667    function commitBinding(action: ShortcutAction, binding: Keybinding) {1668      const conflict = findConflict(props.shortcuts, binding, action);1669      if (conflict) {1670        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1671        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1672        return;1673      }1674      const next = { ...props.shortcuts, [action]: binding };1675      setCapturingAction(null);1676      setConflictMsg(null);1677      void props.onShortcutsChange(next);1678    }1679    function resetAction(action: ShortcutAction) {1680      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1681      void props.onShortcutsChange(next);1682    }1683    function resetAll() {1684      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1685    }16861687    // Capture-mode key handler — wired via ref onto the chip element so it1688    // catches the next non-modifier keypress and ignores other bindings.1689    function handleCaptureKey(e: KeyboardEvent) {1690      e.preventDefault();1691      e.stopPropagation();1692      if (e.key === "Escape") { cancelCapture(); return; }1693      const binding = keybindingFromEvent(e);1694      if (!binding) return; // bare modifier — keep waiting1695      const action = capturingAction();1696      if (action) commitBinding(action, binding);1697    }16981699    return (1700      <div class="peach-settings-body">1701        <div class="peach-settings-group peach-shortcuts-group">1702          <p class="peach-settings-sub-label">ENTRY VIEW</p>1703          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1704            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1705          </For>1706        </div>17071708        <div class="peach-settings-group peach-shortcuts-group">1709          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1710          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1711            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1712          </For>1713        </div>17141715        <Show when={conflictMsg()}>1716          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1717        </Show>17181719        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1720          <div class="peach-settings-row peach-settings-row--meta">1721            <span>Inline autofill on page</span>1722            <a1723              class="peach-settings-link"1724              href="chrome://extensions/shortcuts"1725              onClick={(e) => {1726                e.preventDefault();1727                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1728              }}1729            >1730              ⌘⇧P — customize ↗1731            </a>1732          </div>1733          <button1734            class="peach-btn peach-btn--subtle peach-btn--sm"1735            style="align-self:flex-start"1736            onClick={resetAll}1737          >1738            Reset all to defaults1739          </button>1740        </div>1741      </div>1742    );17431744    function renderRow(1745      desc: typeof SHORTCUT_DESCRIPTORS[number],1746      capturing: () => ShortcutAction | null,1747      onStart: (a: ShortcutAction) => void,1748      onCancel: () => void,1749      onReset: (a: ShortcutAction) => void,1750      onKey: (e: KeyboardEvent) => void,1751    ) {1752      const isActive = () => capturing() === desc.action;1753      const binding = () => props.shortcuts[desc.action];1754      const isCustom = () => {1755        const b = binding();1756        const d = DEFAULT_BINDINGS[desc.action];1757        return b.key !== d.key1758          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1759          || Boolean(b.meta)  !== Boolean(d.meta)1760          || Boolean(b.shift) !== Boolean(d.shift)1761          || Boolean(b.alt)   !== Boolean(d.alt);1762      };1763      return (1764        <div class="peach-shortcut-row">1765          <span class="peach-shortcut-row-label">{desc.label}</span>1766          <button1767            class="peach-shortcut-chip"1768            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1769            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1770            onKeyDown={(e) => { if (isActive()) onKey(e); }}1771            ref={(el) => {1772              // Auto-focus when entering capture mode so the keydown listener catches the next press.1773              if (el) {1774                queueMicrotask(() => { if (isActive()) el.focus(); });1775              }1776            }}1777          >1778            {isActive() ? "Press a key…" : formatKeybinding(binding())}1779          </button>1780          <Show when={isCustom() && !isActive()}>1781            <button1782              class="peach-shortcut-reset"1783              title="Reset to default"1784              onClick={() => onReset(desc.action)}1785            >1786              ↺1787            </button>1788          </Show>1789        </div>1790      );1791    }1792  }17931794  const PRO_FEATURES: Array<{1795    feature: ProFeature;1796    name: string;1797    desc: string;1798  }> = [1799    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1800    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1801    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1802    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1803  ];18041805  const PRO_SECURITY_FEATURES: Array<{1806    feature: ProFeature;1807    name: string;1808    desc: string;1809  }> = [1810    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1811    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1812  ];18131814  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1815  const [showKey, setShowKey] = createSignal(false);18161817  const [deactivating, setDeactivating] = createSignal(false);18181819  async function handleDeactivateLicense() {1820    setDeactivating(true);1821    await sendMessage({ type: "DEACTIVATE_LICENSE" });1822    setLicenseInfo(null);1823    await props.onLicenseChange?.();1824    setDeactivating(false);1825  }18261827  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1828    setCanaryTestingId(entry.id);1829    setCanaryTestResult(null);1830    const res = await sendMessage({1831      type: "FIRE_CANARY_PING",1832      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1833    }) as { ok: boolean };1834    setCanaryTestResult({ id: entry.id, ok: res.ok });1835    setCanaryTestingId(null);1836    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1837  }18381839  function renderLicense() {1840    async function onActivate() {1841      await handleActivateLicense();1842      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1843        await props.onLicenseChange?.();1844      }1845    }18461847    return (1848      <div class="peach-settings-body">1849        <Show1850          when={isPro()}1851          fallback={1852                <div class="peach-pro-upgrade">1853                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>18541855                  <h2 class="peach-pro-headline">1856                    Every<em>thing,</em><br />every<em>where.</em>1857                  </h2>18581859                  <p class="peach-pro-subtitle">1860                    One key unlocks every Pro feature — for the lifetime of the app.1861                  </p>18621863                  <div class="peach-pro-grid">1864                    <For each={PRO_FEATURES}>1865                      {(item, i) => (1866                        <div1867                          class="peach-pro-grid-item"1868                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1869                        >1870                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1871                          <span class="peach-pro-grid-name">{item.name}</span>1872                          <span class="peach-pro-grid-desc">{item.desc}</span>1873                        </div>1874                      )}1875                    </For>1876                  </div>18771878                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1879                  <div class="peach-pro-grid">1880                    <For each={PRO_SECURITY_FEATURES}>1881                      {(item, i) => (1882                        <div1883                          class="peach-pro-grid-item"1884                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1885                        >1886                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1887                          <span class="peach-pro-grid-name">{item.name}</span>1888                          <span class="peach-pro-grid-desc">{item.desc}</span>1889                        </div>1890                      )}1891                    </For>1892                  </div>18931894                  <div class="peach-pro-upgrade-actions">1895                    <p class="peach-pro-pricing">1896                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1897                    </p>1898                    <a1899                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1900                      target="_blank"1901                      rel="noopener noreferrer"1902                      class="peach-pro-upgrade-cta"1903                    >1904                      Get Peach Pro1905                    </a>1906                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1907                    <div class="peach-pro-key-inline">1908                      <input1909                        class="peach-pro-key-inline-input"1910                        type="text"1911                        placeholder="PEACH-XXXX-XXXX-XXXX"1912                        value={licenseKeyInput()}1913                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1914                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1915                        autocomplete="off"1916                        spellcheck={false}1917                      />1918                      <button1919                        class="peach-pro-key-apply"1920                        onClick={() => void onActivate()}1921                        disabled={activatingLicense() || !licenseKeyInput().trim()}1922                      >1923                        {activatingLicense() ? "…" : "APPLY"}1924                      </button>1925                    </div>1926                    <Show when={licenseError()}>1927                      <p class="peach-pro-key-error">{licenseError()}</p>1928                    </Show>1929                  </div>1930                </div>1931          }1932        >1933          <div class="peach-pro-active">1934            <div class="peach-pro-active-hero">1935              <button1936                class="peach-pro-active-mark-wrap"1937                type="button"1938                aria-label="Celebrate"1939                onClick={handleProCelebrationClick}1940              >1941                <PeachMark class="peach-pro-active-mark" />1942                <Show when={justActivated()}>1943                  <div class="peach-pro-sparks" aria-hidden="true">1944                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1945                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1946                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1947                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1948                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1949                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1950                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1951                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1952                  </div>1953                </Show>1954              </button>1955              <h2 class="peach-pro-active-title">Peach Pro</h2>1956              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1957              <Show when={justActivated()}>1958                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1959              </Show>1960            </div>19611962            <Show when={licenseKey()}>1963              <div class="peach-settings-license">1964                <span class="peach-settings-label">LICENSE KEY</span>1965                <div class="peach-settings-license-row">1966                  <Show1967                    when={showKey()}1968                    fallback={1969                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1970                        ••••••••••••••••••••••••••••1971                      </span>1972                    }1973                  >1974                    <span class="peach-settings-license-key">{licenseKey()!}</span>1975                  </Show>1976                  <div class="peach-settings-license-actions">1977                    <button1978                      class="peach-settings-license-copy"1979                      title="Copy license key"1980                      onClick={() => copyLicenseKey(licenseKey()!)}1981                    >1982                      {copied() ? "✓" : "copy"}1983                    </button>1984                    <button1985                      class="peach-settings-license-toggle"1986                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1987                      title={showKey() ? "Hide" : "Reveal"}1988                      onClick={() => setShowKey(v => !v)}1989                    >1990                      <Show1991                        when={showKey()}1992                        fallback={1993                          <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">1994                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1995                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1996                          </svg>1997                        }1998                      >1999                        <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">2000                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2001                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2002                          <line x1="2" y1="2" x2="12" y2="12" />2003                        </svg>2004                      </Show>2005                    </button>2006                  </div>2007                </div>2008              </div>2009            </Show>20102011            <div class="peach-pro-active-features">2012              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2013                {(item) => (2014                  <div class="peach-pro-active-feature">2015                    <span class="peach-pro-active-feature-mark">✦</span>2016                    <span class="peach-pro-active-feature-name">{item.name}</span>2017                  </div>2018                )}2019              </For>2020            </div>20212022            <button2023              class="peach-pro-deactivate"2024              onClick={() => void handleDeactivateLicense()}2025              disabled={deactivating()}2026            >2027              {deactivating() ? "Deactivating…" : "Deactivate"}2028            </button>2029          </div>2030        </Show>2031      </div>2032    );2033  }20342035  function renderFeedback() {2036    return (2037      <div class="peach-settings-body">2038        <div class="peach-settings-group peach-feedback-group">2039          <Show2040            when={feedbackState() === 'sent'}2041            fallback={2042              <>2043                <p class="peach-feedback-intro">2044                  Help us improve Peach. Every report goes directly to our inbox.2045                </p>20462047                <p class="peach-settings-sub-label">TYPE</p>2048                <div class="peach-feedback-chips">2049                  <For each={FEEDBACK_CATEGORIES}>2050                    {(cat) => {2051                      const selected = () => fbCategory() === cat;2052                      return (2053                        <button2054                          type="button"2055                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2056                          aria-pressed={selected()}2057                          onClick={() => setFbCategory(cat)}2058                        >2059                          {cat}2060                        </button>2061                      );2062                    }}2063                  </For>2064                </div>20652066                <p class="peach-settings-sub-label">MESSAGE</p>2067                <textarea2068                  class="peach-feedback-textarea"2069                  placeholder="Describe the issue or idea..."2070                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2071                  value={fbMessage()}2072                  onInput={(e) => setFbMessage(e.currentTarget.value)}2073                />20742075                <p class="peach-feedback-meta">2076                  Version {version} · will be included automatically2077                </p>20782079                <button2080                  type="button"2081                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2082                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2083                  onClick={() => void handleSubmitFeedback()}2084                >2085                  <Switch>2086                    <Match when={feedbackState() === 'idle'}>Send</Match>2087                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2088                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2089                  </Switch>2090                </button>20912092                <p class="peach-feedback-anon">2093                  Sent anonymously · no account required2094                </p>2095              </>2096            }2097          >2098            <div class="peach-feedback-thanks" role="status" aria-live="polite">2099              <p class="peach-feedback-thanks-title">Thank you</p>2100              <p>2101                Your note is in the queue. Someone will process it soon.2102              </p>2103            </div>2104          </Show>2105        </div>2106      </div>2107    );2108  }21092110  const bodies: StackBodies = {2111    protection: renderSecurity(),2112    vault: renderVault(),2113    sync: renderSync(),2114    appearance: renderAppearance(),2115    shortcuts: renderShortcuts(),2116    pro: renderLicense(),2117    feedback: renderFeedback(),2118  };21192120  return (2121    <Show2122      when={showNewRecoveryWords()}2123      fallback={2124        <div2125          class="peach-overlay-view peach-settings-overlay"2126          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2127          style="display:flex;flex-direction:column"2128        >2129          <StackDeck2130            active={activeCard()}2131            onActiveChange={setActiveCard}2132            onBack={props.onBack}2133            cards={cards()}2134            bodies={bodies}2135            version={version}2136            isFanning={isFanning()}2137            celebratingCardId={justActivated() ? "pro" : null}2138            isPro={isPro()}2139          />2140        </div>2141      }2142    >2143      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2144    </Show>2145  );2146}