Settings.tsx

91.4 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 type { PairedDevice } from "../../shared/sync-types";16import {17  DEFAULT_BINDINGS,18  findConflict,19  formatKeybinding,20  keybindingFromEvent,21  SHORTCUT_DESCRIPTORS,22  type Keybinding,23  type ShortcutAction,24} from "../../shared/shortcuts";25import { isNativePlatform, nativePlugin } from "../../shared/native";26import { WEB3FORMS_KEY } from "../../shared/config";2728interface LicenseInfo {29  valid: boolean;30  plan: "free" | "pro";31  issued?: string;32}3334interface SettingsProps {35  entriesCount: number;36  breachMonitoring: boolean;37  lockOnClose: boolean;38  autoLockMinutes: number;39  trashRetentionDays: number;40  theme: "dark" | "light";41  accentColor: AccentColorName;42  textSize: "small" | "medium" | "large";43  reduceMotion: boolean;44  isPro?: boolean;45  initialSection?: string | null;46  upgradeHighlight?: ProFeature | null;47  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;48  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;49  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;50  onTrashRetentionChange: (days: number) => void | Promise<void>;51  onThemeChange: (theme: "dark" | "light") => void;52  onAccentChange: (name: AccentColorName) => void | Promise<void>;53  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;54  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;55  shortcuts: Record<ShortcutAction, Keybinding>;56  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;57  onLicenseChange?: () => void | Promise<void>;58  isExiting?: boolean;59  onBack: () => void;60  onImport: () => void;61  onExport: () => void;62  onBreachCheckDone: () => void;63  onOpenHealth?: () => void;64  onOpenSyncPairing?: (method?: "chirp" | "desktop") => void;65  onOpenFileImport?: () => void;66  onOpenPanic?: () => void;67  onOpenCodexGenerate?: () => void;68  onOpenCodexRestore?: () => void;69  /** Invoked from within Settings when a Pro-gated control is tapped on70   *  free tier. App owns the upgrade navigation + highlight state, so this71   *  callback is the single way for Settings to request that navigation. */72  onProGate?: (feature: ProFeature) => void;73  onCardChange?: (id: CardId) => void;74}7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];77const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";78const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;79const PRO_CELEBRATION_CARD_STEP_MS = 150;80const PRO_CELEBRATION_THANK_YOU_MS = 3200;8182function sanitizeFeedbackMessage(value: string): string {83  return value84    .trim()85    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)86    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")87    .replace(/&/g, "&amp;")88    .replace(/</g, "&lt;")89    .replace(/>/g, "&gt;")90    .replace(/"/g, "&quot;")91    .replace(/'/g, "&#39;");92}9394type DataCollectionPermissionApi = {95  permissions?: {96    getAll?: () => Promise<{ data_collection?: string[] }>;97    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;98  };99};100101async function requestFeedbackDataPermission(): Promise<boolean> {102  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??103    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;104  if (!api?.permissions?.getAll) return true;105106  try {107    const permissions = await api.permissions.getAll();108    if (!("data_collection" in permissions)) return true;109    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;110    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));111  } catch {112    return false;113  }114}115116function formatActivatedSince(issued: string | undefined): string {117  if (!issued) return "Active";118  try {119    const d = new Date(issued);120    if (Number.isNaN(d.getTime())) return "Active";121    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;122  } catch {123    return "Active";124  }125}126127function initialSectionToCard(value: string | null | undefined): CardId | null {128  if (!value) return null;129  if (value === "security") return "protection";130  if (value === "license") return "pro";131  if (132    value === "protection" ||133    value === "vault" ||134    value === "sync" ||135    value === "appearance" ||136    value === "shortcuts" ||137    value === "pro" ||138    value === "feedback"139  ) {140    return value;141  }142  return null;143}144145export function SettingsView(props: SettingsProps) {146  const manifest = chrome.runtime.getManifest();147  const version = manifest.version;148  const initial = props.initialSection;149  const [activeCard, setActiveCard] = createSignal<CardId>(150    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"151  );152153  createEffect(() => { props.onCardChange?.(activeCard()); });154155  const [justActivated, setJustActivated] = createSignal(false);156  const [isFanning, setIsFanning] = createSignal(false);157158  function triggerCelebration() {159    if (isFanning()) return;160    setJustActivated(false);161    setIsFanning(true);162    setActiveCard("pro");163    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];164    fanOrder.forEach((id, i) => {165      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);166    });167    window.setTimeout(() => {168      setActiveCard("pro");169      setIsFanning(false);170      setJustActivated(true);171      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);172    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);173  }174175  function handleProCelebrationClick(e: MouseEvent) {176    e.stopPropagation();177    triggerCelebration();178  }179180  const [checkingBreaches, setCheckingBreaches] = createSignal(false);181  const [breachResult, setBreachResult] = createSignal<string | null>(null);182  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);183  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");184  const [licenseError, setLicenseError] = createSignal<string | null>(null);185  const [, setLicenseSuccess] = createSignal<string | null>(null);186  const [activatingLicense, setActivatingLicense] = createSignal(false);187188  const isPro = () => props.isPro ?? false;189  const cards = createMemo<StackCardSpec[]>(() => [190    {191      id: "pro",192      num: "I · FOREVER",193      title: "Peach Pro",194      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",195      bgVar: "--stack-pro-bg",196      fgVar: "--stack-pro-fg",197      icon: "spark",198      onIconClick: isPro() ? triggerCelebration : undefined,199    },200    {201      id: "protection",202      num: "II · PROTECT",203      title: "Protection",204      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,205      bgVar: "--stack-protect-bg",206      fgVar: "--stack-protect-fg",207      icon: "shield",208    },209    {210      id: "vault",211      num: "III · KEEP",212      title: "Vault",213      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,214      bgVar: "--stack-vault-bg",215      fgVar: "--stack-vault-fg",216      icon: "lock",217    },218    {219      id: "sync",220      num: "IV · ECHO",221      title: "Sync",222      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",223      bgVar: "--stack-sync-bg",224      fgVar: "--stack-sync-fg",225      icon: "sync",226    },227    {228      id: "appearance",229      num: "V · DRESS",230      title: "Appearance",231      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,232      bgVar: "--stack-appear-bg",233      fgVar: "--stack-appear-fg",234      icon: "sun",235    },236    {237      id: "shortcuts",238      num: "VI · KEYS",239      title: "Shortcuts",240      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,241      bgVar: "--stack-shortcut-bg",242      fgVar: "--stack-shortcut-fg",243      icon: "keys",244    },245    {246      id: "feedback",247      num: "VII · ECHO",248      title: "Feedback",249      lede: `version ${version} · tell us what would make it better`,250      bgVar: "--stack-feedback-bg",251      fgVar: "--stack-feedback-fg",252      icon: "bubble",253    },254  ]);255  const [biometricSupported, setBiometricSupported] = createSignal(false);256  const [biometricStored, setBiometricStored] = createSignal(false);257  const [biometricBusy, setBiometricBusy] = createSignal(false);258  const [biometricError, setBiometricError] = createSignal<string | null>(null);259  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);260  const [passkeySaving, setPasskeySaving] = createSignal(false);261262  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting263  // explicit confirmation before they become sync targets.264  interface PendingVouched { deviceId: string; deviceName: string }265  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);266  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);267  // Paired-device management lives here — Settings is the sync hub.268  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);269  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272273  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;274  function syncRelativeTime(when: number | undefined): string {275    if (!when) return "Not synced yet";276    const elapsed = Date.now() - when;277    if (elapsed < 60_000) return "Just now";278    const minutes = Math.floor(elapsed / 60_000);279    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;280    const hours = Math.floor(minutes / 60);281    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;282    const days = Math.floor(hours / 24);283    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";284  }285  function isSyncStale(d: PairedDevice): boolean {286    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;287  }288  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {289    switch (t) {290      case "android": return "Android";291      case "ios": return "iPhone";292      case "desktop": return "Desktop helper";293      case "extension": return "Browser";294      default: return "Device";295    }296  }297298  async function loadPairedDevices() {299    if (!isPro()) return;300    const r = (await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" })) as {301      ok: boolean;302      data?: { devices: PairedDevice[] };303    };304    if (r.ok) setPairedDevices(r.data?.devices ?? []);305  }306  async function unpairDevice(deviceId: string) {307    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });308    setConfirmUnpair(null);309    await loadPairedDevices();310  }311  async function syncNow() {312    setSyncStatus("syncing");313    setSyncFlash(null);314    const res = (await sendMessage({315      type: "TRIGGER_SYNC",316      payload: { reason: "manual", force: true },317    })) as { ok: boolean; error?: string };318    setTimeout(async () => {319      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {320        ok: boolean;321        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };322      };323      const s = state.data;324      if (!s) return;325      setSyncStatus(s.status);326      if (s.status === "idle") {327        setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");328        setTimeout(() => setSyncFlash(null), 3000);329        await loadPairedDevices();330      } else if (s.status === "error") {331        setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);332        setTimeout(() => setSyncFlash(null), 4000);333      }334    }, 250);335    if (!res.ok) {336      setSyncStatus("error");337      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);338      setTimeout(() => setSyncFlash(null), 4000);339    }340  }341  // UI-4 / MV-8: rotate sync identity confirmation dialog.342  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);343  const [rotating, setRotating] = createSignal(false);344  const [rotateError, setRotateError] = createSignal<string | null>(null);345346  async function loadPendingVouched() {347    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {348      ok: boolean;349      data?: PendingVouched[];350    };351    if (r.ok) setPendingVouched(r.data ?? []);352  }353  async function confirmVouched(deviceId: string) {354    setVouchedBusy(deviceId);355    try {356      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });357      await loadPendingVouched();358    } finally {359      setVouchedBusy(null);360    }361  }362  async function rejectVouched(deviceId: string) {363    setVouchedBusy(deviceId);364    try {365      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });366      await loadPendingVouched();367    } finally {368      setVouchedBusy(null);369    }370  }371  async function doRotateSyncIdentity() {372    setRotateError(null);373    setRotating(true);374    try {375      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {376        ok: boolean;377        error?: string;378      };379      if (!r.ok) {380        setRotateError(r.error ?? "Rotation failed.");381        return;382      }383      setRotateConfirmOpen(false);384      // Backend set peach_recovery_pending; reuse the existing recovery-words385      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).386      setShowNewRecoveryWords(true);387    } finally {388      setRotating(false);389    }390  }391392  const [dedupCount, setDedupCount] = createSignal<number | null>(null);393  const [dedupRunning, setDedupRunning] = createSignal(false);394  const [dedupDone, setDedupDone] = createSignal<number | null>(null);395396  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);397398  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted399  // BEFORE every real-mode vault write so the user can roll back if a sync400  // or import overwrites the wrong state.401  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);402  const [historyLoading, setHistoryLoading] = createSignal(false);403  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);404  const [historyShowAll, setHistoryShowAll] = createSignal(false);405406  async function loadVaultHistory() {407    setHistoryLoading(true);408    try {409      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });410      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];411      setHistorySnapshots(list);412    } finally {413      setHistoryLoading(false);414    }415  }416417  async function handleRestoreVaultSnapshot(id: string) {418    if (historyRestoring()) return;419    setHistoryRestoring(id);420    const res = await sendMessage({421      type: "RESTORE_VAULT_SNAPSHOT",422      payload: { snapshotId: id },423    });424    setHistoryRestoring(null);425    if ((res as { ok: boolean }).ok) {426      // Vault is now locked — let the parent close Settings; App.tsx's427      // VAULT_LOCKED listener will route back to the lock screen.428      props.onBack();429    }430  }431432  function formatSnapshotTime(savedAt: number): string {433    const diff = Date.now() - savedAt;434    if (diff < 60_000) return "Just now";435    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;436    const d = new Date(savedAt);437    const isToday = new Date().toDateString() === d.toDateString();438    const isYest =439      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();440    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });441    if (isToday) return `Today ${t}`;442    if (isYest) return `Yesterday ${t}`;443    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;444  }445446  function reasonLabel(reason: VaultSnapshot["reason"]): string {447    return (448      {449        local_save: "saved",450        sync_received: "synced",451        import: "imported",452        restore: "restored",453      } as const454    )[reason] ?? reason;455  }456457  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);458459  const [showChangePw, setShowChangePw] = createSignal(false);460  const [changePwCurrent, setChangePwCurrent] = createSignal("");461  const [changePwNew, setChangePwNew] = createSignal("");462  const [changePwConfirm, setChangePwConfirm] = createSignal("");463  const [changePwSaving, setChangePwSaving] = createSignal(false);464  const [changePwError, setChangePwError] = createSignal<string | null>(null);465  const [changePwSuccess] = createSignal(false);466  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);467468  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.469  const [showViewWords, setShowViewWords] = createSignal(false);470  const [viewWordsPassword, setViewWordsPassword] = createSignal("");471  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);472  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);473  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);474475  async function handleViewRecoveryWords() {476    setViewWordsError(null);477    if (!viewWordsPassword()) {478      setViewWordsError("Enter your master password.");479      return;480    }481    setViewWordsBusy(true);482    try {483      const r = (await sendMessage({484        type: "GET_RECOVERY_WORDS",485        payload: { password: viewWordsPassword() },486      })) as { ok: boolean; data?: string[]; error?: string };487      if (r.ok) {488        setViewedWords(r.data ?? []);489        setViewWordsPassword("");490        return;491      }492      switch (r.error) {493        case "wrong_password":494          setViewWordsError("Incorrect password");495          break;496        case "password_required":497          setViewWordsError("Enter your master password.");498          break;499        case "recovery_not_setup":500          setViewWordsError("Recovery words aren't set up for this vault yet.");501          break;502        default:503          setViewWordsError("Couldn't retrieve recovery words.");504      }505    } finally {506      setViewWordsBusy(false);507    }508  }509  function closeViewWords() {510    setShowViewWords(false);511    setViewWordsPassword("");512    setViewWordsError(null);513    setViewedWords(null);514  }515516  const [duressEnabled, setDuressEnabled] = createSignal(false);517  const [showDuressSetup, setShowDuressSetup] = createSignal(false);518  const [duressPassword, setDuressPassword] = createSignal("");519  const [duressConfirm, setDuressConfirm] = createSignal("");520  const [duressMainPassword, setDuressMainPassword] = createSignal("");521  const [duressSaving, setDuressSaving] = createSignal(false);522  const [duressError, setDuressError] = createSignal<string | null>(null);523  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);524525  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);526527  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);528  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);529530  const [fbCategory, setFbCategory] = createSignal("Bug report");531  const [fbMessage, setFbMessage] = createSignal("");532  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';533  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');534535  createEffect(() => {536    if (props.upgradeHighlight) {537      setActiveCard("pro");538    }539  });540541  onMount(async () => {542    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([543      sendMessage({ type: "GET_LICENSE" }),544      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),545      sendMessage({ type: "IS_DURESS_ENABLED" }),546      sendMessage({ type: "GET_SETTINGS" }),547      sendMessage({ type: "GET_LICENSE_KEY" }),548    ]);549550    // Vault history loads in the background — the Vault sub-view picks it up551    // when the user navigates in. Loading it lazily keeps Settings boot fast.552    void loadVaultHistory();553554    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the555    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.556    void loadPendingVouched();557    void loadPairedDevices();558    const onBroadcast = (msg: unknown) => {559      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {560        void loadPendingVouched();561      }562    };563    chrome.runtime?.onMessage?.addListener(onBroadcast);564    onCleanup(() => {565      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }566    });567568    if ((licenseRes as { ok: boolean }).ok) {569      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);570    }571572    if ((passkeyRes as { ok: boolean }).ok) {573      setPasskeyState(574        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null575      );576    }577578    if ((duressRes as { ok: boolean }).ok) {579      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);580    }581582    if ((settingsRes as { ok: boolean }).ok) {583      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;584      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);585    }586587    if ((keyRes as { ok: boolean }).ok) {588      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);589    }590591    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });592    if ((entriesRes as { ok: boolean }).ok) {593      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];594      setCanaryEntries(595        entries596          .filter((e) => e.canary?.webhookUrl)597          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))598      );599    }600601602    if (isNativePlatform()) {603      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");604      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");605      try {606        if (biometric) {607          const a = await biometric.isAvailable();608          setBiometricSupported(a.available);609        }610        if (keystore) {611          const k = await keystore.hasMasterKey();612          setBiometricStored(k.exists);613        }614      } catch { /* missing plugins — leave defaults */ }615    }616  });617618  async function handleEnableBiometrics() {619    setBiometricBusy(true);620    setBiometricError(null);621    try {622      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {623        ok: boolean;624        data?: { masterKeyHex: string };625        error?: string;626      };627      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {628        setBiometricError(keyRes.error === "vault_locked"629          ? "unlock the vault first"630          : "couldn't read derived key");631        return;632      }633      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");634      if (!keystore) {635        setBiometricError("biometric plugin unavailable");636        return;637      }638      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });639      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });640      setBiometricStored(true);641    } catch (e) {642      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");643    } finally {644      setBiometricBusy(false);645    }646  }647648  async function handleDisableBiometrics() {649    setBiometricBusy(true);650    setBiometricError(null);651    try {652      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");653      if (keystore) await keystore.clearMasterKey();654      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });655      setBiometricStored(false);656    } catch (e) {657      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");658    } finally {659      setBiometricBusy(false);660    }661  }662663  async function handleChangePassword() {664    setChangePwError(null);665    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }666    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }667    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }668    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }669    setChangePwSaving(true);670    const res = await sendMessage({671      type: "CHANGE_PASSWORD",672      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },673    }) as { ok: boolean; error?: string };674    if (res.ok) {675      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");676      setShowChangePw(false);677      // LV-4: the user just authenticated (current password), so the recovery678      // words reveal that follows is exempt from the re-auth gate. Set the679      // pending flag the same way the create/recovery flow does; the display680      // clears it on confirm.681      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });682      setShowNewRecoveryWords(true);683    } else {684      setChangePwError(res.error ?? "failed to change password");685    }686    setChangePwSaving(false);687  }688689  const [copied, setCopied] = createSignal(false);690691  async function copyLicenseKey(key: string) {692    await navigator.clipboard.writeText(key);693    setCopied(true);694    setTimeout(() => setCopied(false), 2000);695  }696697  async function handleActivateLicense() {698    const key = licenseKeyInput().trim();699    if (!key) return;700    setActivatingLicense(true);701    setLicenseError(null);702    setLicenseSuccess(null);703    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });704    setActivatingLicense(false);705    if ((res as { ok: boolean }).ok) {706      const data = (res as { ok: boolean; data?: LicenseInfo }).data;707      if (data?.valid) {708        setLicenseInfo(data);709        setLicenseKey(key);710        setLicenseSuccess("License activated!");711        setLicenseKeyInput("");712      } else {713        setLicenseError("Invalid license key");714      }715    } else {716      setLicenseError("Activation failed");717    }718  }719720  async function handleTogglePasskeys() {721    const current = passkeyState();722    if (!current || !current.supported) return;723    setPasskeySaving(true);724    const res = await sendMessage({725      type: "SET_PASSKEY_PROVIDER_ENABLED",726      payload: { enabled: !current.enabled },727    });728    setPasskeySaving(false);729    if ((res as { ok: boolean }).ok) {730      setPasskeyState(731        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null732      );733    }734  }735736  async function handleCheckAllBreaches() {737    setCheckingBreaches(true);738    setBreachResult(null);739    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });740    setCheckingBreaches(false);741    if ((res as { ok: boolean }).ok) {742      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};743      const breached = Object.values(data).filter((count) => count > 0).length;744      setBreachResult(745        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"746      );747      props.onBreachCheckDone();748    }749  }750751752  async function handleSetupDuress() {753    const mainPw = duressMainPassword().trim();754    const duressPw = duressPassword().trim();755    const confirmPw = duressConfirm().trim();756757    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }758    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }759    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }760    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }761762    setDuressSaving(true);763    setDuressError(null);764765    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };766    if (!verifyRes.ok || !verifyRes.data) {767      setDuressError("incorrect main password");768      setDuressSaving(false);769      return;770    }771772    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };773    setDuressSaving(false);774    if (res.ok) {775      setDuressEnabled(true);776      setShowDuressSetup(false);777      setDuressPassword("");778      setDuressConfirm("");779      setDuressMainPassword("");780      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");781      setTimeout(() => setDuressSuccess(null), 5000);782    } else {783      setDuressError(784        res.error === "session_expired"785          ? "Vault session expired — lock and unlock Peach, then try again"786          : res.error ?? "failed to set duress password"787      );788    }789  }790791  async function handleClearDeclinedCards() {792    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });793    setDeclinedCardCount(0);794  }795796  async function handleCountDuplicates() {797    setDedupRunning(true);798    setDedupDone(null);799    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };800    setDedupRunning(false);801    if (res.ok) setDedupCount(res.data ?? 0);802  }803804  async function handleDeduplicate() {805    setDedupRunning(true);806    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };807    setDedupRunning(false);808    if (res.ok) {809      setDedupDone(res.data ?? 0);810      setDedupCount(0);811    }812  }813814  async function handleDisableDuress() {815    const mainPw = duressMainPassword().trim();816    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }817818    setDuressSaving(true);819    setDuressError(null);820821    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };822    if (!verifyRes.ok || !verifyRes.data) {823      setDuressError("incorrect main password");824      setDuressSaving(false);825      return;826    }827828    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };829    setDuressSaving(false);830    if (res.ok) {831      setDuressEnabled(false);832      setShowDuressSetup(false);833      setDuressMainPassword("");834      setDuressSuccess("Duress password disabled.");835      setTimeout(() => setDuressSuccess(null), 4000);836    } else {837      setDuressError(res.error ?? "failed to disable duress password");838    }839  }840841  async function handleSubmitFeedback() {842    const message = sanitizeFeedbackMessage(fbMessage());843    if (!message) return;844845    setFeedbackState('sending');846847    try {848      const hasDataPermission = await requestFeedbackDataPermission();849      if (!hasDataPermission) {850        setFeedbackState('idle');851        return;852      }853854      const category = FEEDBACK_CATEGORIES.includes(fbCategory())855        ? fbCategory()856        : FEEDBACK_CATEGORIES[0];857      const res = await fetch('https://api.web3forms.com/submit', {858        method: 'POST',859        headers: { 'Content-Type': 'application/json' },860        body: JSON.stringify({861          access_key: WEB3FORMS_KEY,862          subject: `Peach Feedback - ${category}`,863          message,864          from_name: 'Peach User',865          // Honeypot — leave blank866          botcheck: '',867        }),868      });869870      if (!res.ok) throw new Error(`HTTP ${res.status}`);871      const data = await res.json() as { success: boolean; message?: string };872      if (!data.success) throw new Error(data.message ?? 'unknown error');873874      setFeedbackState('sent');875      setFbMessage('');876      setFbCategory(FEEDBACK_CATEGORIES[0]);877    } catch (err) {878      console.error('[feedback]', err);879      setFeedbackState('error');880      setTimeout(() => setFeedbackState('idle'), 4000);881    }882  }883884  function renderSecurity() {885    return (886      <div class="peach-settings-body">887        <div class="peach-settings-group">888          <p class="peach-settings-sub-label">LOCKING</p>889          <div class="peach-settings-row">890            <span>Lock on close</span>891            <button892              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}893              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}894              aria-label="Toggle lock on close"895            >896              <span class="peach-toggle-knob"></span>897            </button>898          </div>899          <div class="peach-settings-row">900            <span>Auto-lock after</span>901            <select902              class="peach-settings-select"903              value={String(props.autoLockMinutes)}904              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}905            >906              <option value="1">1 minute</option>907              <option value="5">5 minutes</option>908              <option value="15">15 minutes</option>909              <option value="30">30 minutes</option>910              <option value="0">Never</option>911            </select>912          </div>913914          <Show when={isNativePlatform() && biometricSupported()}>915            <p class="peach-settings-sub-label">BIOMETRICS</p>916            <div class="peach-settings-row">917              <span>Unlock with biometrics</span>918              <button919                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}920                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}921                disabled={biometricBusy()}922                aria-label="Toggle biometric unlock"923              >924                <span class="peach-toggle-knob"></span>925              </button>926            </div>927            <Show when={biometricError()}>928              <div class="peach-settings-row peach-settings-row--meta">929                <span class="peach-hint peach-hint-error">{biometricError()}</span>930              </div>931            </Show>932          </Show>933934          <p class="peach-settings-sub-label">BREACH PROTECTION</p>935          <div class="peach-settings-row">936            <span>Breach monitoring</span>937            <button938              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}939              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}940              aria-label="Toggle breach monitoring"941            >942              <span class="peach-toggle-knob"></span>943            </button>944          </div>945          <div class="peach-settings-row peach-settings-row--meta">946            <span>Check all passwords</span>947            <button948              class="peach-btn peach-btn--subtle peach-btn--sm"949              onClick={() => void handleCheckAllBreaches()}950              disabled={checkingBreaches()}951            >952              {checkingBreaches() ? "checking..." : "run"}953            </button>954          </div>955          <Show when={breachResult()}>956            <div class="peach-settings-row peach-settings-row--meta">957              <span>Last check</span>958              <span class="peach-settings-value">{breachResult()}</span>959            </div>960          </Show>961962          <p class="peach-settings-sub-label">MASTER PASSWORD</p>963          <div class="peach-settings-row peach-settings-row--meta">964            <span>Master password</span>965            <button966              class="peach-btn peach-btn--subtle peach-btn--sm"967              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}968            >969              {showChangePw() ? "cancel" : "change"}970            </button>971          </div>972          <Show when={showChangePw()}>973            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">974              <input975                class="peach-settings-input"976                type="password"977                placeholder="current password"978                value={changePwCurrent()}979                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}980                autocomplete="current-password"981              />982              <input983                class="peach-settings-input"984                type="password"985                placeholder="new password (min 12 chars)"986                value={changePwNew()}987                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}988                autocomplete="new-password"989              />990              <input991                class="peach-settings-input"992                type="password"993                placeholder="confirm new password"994                value={changePwConfirm()}995                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}996                autocomplete="new-password"997                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}998              />999              <Show when={changePwError()}>1000                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1001              </Show>1002              <Show when={changePwSuccess()}>1003                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1004              </Show>1005              <button1006                class="peach-btn peach-btn--accent peach-btn--sm"1007                style="align-self:flex-start"1008                onClick={() => void handleChangePassword()}1009                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1010              >1011                {changePwSaving() ? "saving…" : "save new password"}1012              </button>1013            </div>1014          </Show>10151016          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1017          <div class="peach-settings-row peach-settings-row--meta">1018            <span>Recovery words</span>1019            <button1020              class="peach-btn peach-btn--subtle peach-btn--sm"1021              onClick={() => {1022                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1023              }}1024            >1025              {showViewWords() ? "cancel" : "view"}1026            </button>1027          </div>1028          <Show when={showViewWords()}>1029            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1030              <Show1031                when={viewedWords()}1032                fallback={1033                  <>1034                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1035                      Enter your master password to reveal your recovery words.1036                    </p>1037                    <input1038                      class="peach-settings-input"1039                      type="password"1040                      placeholder="master password"1041                      value={viewWordsPassword()}1042                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1043                      autocomplete="current-password"1044                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1045                    />1046                    <Show when={viewWordsError()}>1047                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1048                    </Show>1049                    <button1050                      class="peach-btn peach-btn--accent peach-btn--sm"1051                      style="align-self:flex-start"1052                      onClick={() => void handleViewRecoveryWords()}1053                      disabled={viewWordsBusy() || !viewWordsPassword()}1054                    >1055                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1056                    </button>1057                  </>1058                }1059              >1060                <div class="peach-recovery-words-grid">1061                  <For each={viewedWords()!}>1062                    {(word, i) => (1063                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1064                    )}1065                  </For>1066                </div>1067                <button1068                  class="peach-btn peach-btn--subtle peach-btn--sm"1069                  style="align-self:flex-start"1070                  onClick={closeViewWords}1071                >done</button>1072              </Show>1073            </div>1074          </Show>10751076          <p class="peach-settings-sub-label">1077            DURESS PASSWORD1078            <Show when={!isPro()}>1079              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1080            </Show>1081          </p>1082          <div class="peach-settings-row peach-settings-row--top">1083            <div class="peach-settings-row-main">1084              <span>1085                Duress password1086                <Show when={!isPro()}>1087                  <span class="peach-pro-badge">Pro</span>1088                </Show>1089              </span>1090              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1091                If entered under coercion, shows a decoy empty vault instead of your real one.1092              </span>1093            </div>1094            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1095              {duressEnabled() ? "on" : "off"}1096            </div>1097          </div>10981099          <Show when={duressEnabled() && !showDuressSetup()}>1100            <div class="peach-settings-row peach-settings-row--meta">1101              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1102                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.1103              </span>1104            </div>1105          </Show>11061107          <Show when={duressSuccess()}>1108            <div class="peach-settings-row peach-settings-row--meta">1109              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1110            </div>1111          </Show>11121113          <Show when={!showDuressSetup()}>1114            <div class="peach-settings-row peach-settings-row--meta">1115              <button1116                class="peach-btn peach-btn--subtle peach-btn--sm"1117                onClick={() => {1118                  // Pro gate entry-point: free users are redirected to the1119                  // upgrade screen before the setup form opens. The server-1120                  // side SETUP_DURESS handler is the single enforcement1121                  // point; this is UI redirection so the form never appears1122                  // for a user who can't complete it.1123                  if (!isPro() && !duressEnabled()) {1124                    props.onProGate?.("duress_vault");1125                    setActiveCard("pro");1126                    return;1127                  }1128                  setShowDuressSetup(true);1129                  setDuressError(null);1130                  setDuressSuccess(null);1131                  setDuressPassword("");1132                  setDuressConfirm("");1133                  setDuressMainPassword("");1134                }}1135              >1136                {duressEnabled() ? "Change / disable" : "Set up"}1137              </button>1138            </div>1139          </Show>11401141          <Show when={showDuressSetup()}>1142            <div class="peach-duress-form">1143              <label class="peach-label">main password (to confirm)</label>1144              <input1145                class="peach-settings-input"1146                type="password"1147                placeholder="your current master password"1148                autocomplete="current-password"1149                value={duressMainPassword()}1150                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1151              />1152              <Show when={!duressEnabled()}>1153                <label class="peach-label">new duress password</label>1154                <input1155                  class="peach-settings-input"1156                  type="password"1157                  placeholder="duress password (min 8 chars)"1158                  autocomplete="new-password"1159                  value={duressPassword()}1160                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1161                />1162                <label class="peach-label">confirm duress password</label>1163                <input1164                  class="peach-settings-input"1165                  type="password"1166                  placeholder="confirm duress password"1167                  autocomplete="new-password"1168                  value={duressConfirm()}1169                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1170                />1171              </Show>11721173              <Show when={duressError()}>1174                <p class="peach-hint peach-hint-error">{duressError()}</p>1175              </Show>11761177              <div class="peach-duress-actions">1178                <button1179                  class="peach-btn peach-btn--subtle peach-btn--sm"1180                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1181                >1182                  cancel1183                </button>1184                <Show when={duressEnabled()}>1185                  <button1186                    class="peach-btn peach-btn--ghost peach-btn--sm"1187                    style="color:var(--peach-breach)"1188                    onClick={() => void handleDisableDuress()}1189                    disabled={duressSaving()}1190                  >1191                    {duressSaving() ? "disabling..." : "disable duress"}1192                  </button>1193                </Show>1194                <Show when={!duressEnabled()}>1195                  <button1196                    class="peach-btn peach-btn--accent peach-btn--sm"1197                    onClick={() => void handleSetupDuress()}1198                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1199                  >1200                    {duressSaving() ? "saving..." : "save"}1201                  </button>1202                </Show>1203              </div>1204            </div>1205          </Show>12061207          <p class="peach-settings-sub-label">1208            CANARY ENTRIES1209            <Show when={!isPro()}>1210              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1211            </Show>1212          </p>1213          <div class="peach-settings-row peach-settings-row--top">1214            <div class="peach-settings-row-main">1215              <span>1216                Canary entries1217                <Show when={!isPro()}>1218                  <span class="peach-pro-badge">Pro</span>1219                </Show>1220              </span>1221              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1222                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1223              </span>1224            </div>1225            <span class="peach-settings-value" style="flex-shrink:0">1226              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1227            </span>1228          </div>1229          <Show when={canaryEntries().length > 0}>1230            <For each={canaryEntries()}>1231              {(entry) => (1232                <div class="peach-settings-row peach-settings-row--meta">1233                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1234                  <button1235                    class="peach-btn peach-btn--subtle peach-btn--sm"1236                    onClick={() => void handleTestCanary(entry)}1237                    disabled={canaryTestingId() === entry.id}1238                    style="flex-shrink:0"1239                  >1240                    {canaryTestingId() === entry.id1241                      ? "sending…"1242                      : canaryTestResult()?.id === entry.id1243                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1244                        : "test"}1245                  </button>1246                </div>1247              )}1248            </For>1249          </Show>12501251          <p class="peach-settings-sub-label">PASSKEYS</p>1252          <div class="peach-settings-row peach-settings-row--top">1253            <div class="peach-settings-row-main">1254              <span>Intercept passkey requests</span>1255              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1256                Peach can handle passkey sign-in and creation on supported Chromium browsers.1257              </span>1258            </div>1259            <button1260              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1261              onClick={() => void handleTogglePasskeys()}1262              aria-label="Toggle passkey interception"1263              disabled={!passkeyState()?.supported || passkeySaving()}1264            >1265              <span class="peach-toggle-knob"></span>1266            </button>1267          </div>1268          <div class="peach-settings-row peach-settings-row--meta">1269            <span>Status</span>1270            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1271          </div>1272          <div class="peach-settings-row peach-settings-row--meta">1273            <span>Browser</span>1274            <span class="peach-settings-value">1275              {passkeyState()?.browserLabel ?? "Unknown"}1276              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1277            </span>1278          </div>1279          <div class="peach-info-notice" style="margin-top:8px">1280            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1281          </div>1282          <Show when={passkeyState()?.conflict}>1283            <div class="peach-settings-row peach-settings-row--meta">1284              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1285            </div>1286          </Show>1287        </div>1288      </div>1289    );1290  }12911292  function renderVault() {1293    return (1294      <div class="peach-settings-body">1295        <div class="peach-settings-group">1296          <p class="peach-settings-sub-label">ENTRIES</p>1297          <div class="peach-settings-row peach-settings-row--meta">1298            <span>Entries</span>1299            <span class="peach-settings-value">{props.entriesCount}</span>1300          </div>1301          <div class="peach-settings-row peach-settings-row--meta">1302            <span>Duplicates</span>1303            <Show when={dedupDone() !== null} fallback={1304              <button1305                class="peach-btn peach-btn--subtle peach-btn--sm"1306                onClick={() => void handleCountDuplicates()}1307                disabled={dedupRunning()}1308              >1309                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1310              </button>1311            }>1312              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1313                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1314              </span>1315            </Show>1316          </div>1317          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1318            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1319              <div class="peach-restore-warning">1320                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1321              </div>1322              <div style="display:flex;gap:6px">1323                <button1324                  class="peach-btn peach-btn--subtle peach-btn--sm"1325                  onClick={() => setDedupCount(null)}1326                >1327                  cancel1328                </button>1329                <button1330                  class="peach-btn peach-btn--accent peach-btn--sm"1331                  onClick={() => void handleDeduplicate()}1332                  disabled={dedupRunning()}1333                >1334                  {dedupRunning() ? "removing..." : "remove duplicates"}1335                </button>1336              </div>1337            </div>1338          </Show>13391340          <p class="peach-settings-sub-label">MAINTENANCE</p>1341          <div class="peach-settings-row peach-settings-row--meta">1342            <span>Trash auto-delete</span>1343            <select1344              class="peach-settings-select"1345              value={String(props.trashRetentionDays)}1346              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1347            >1348              <option value="7">7 days</option>1349              <option value="30">30 days</option>1350              <option value="90">90 days</option>1351              <option value="0">Never</option>1352            </select>1353          </div>1354          <div class="peach-settings-row peach-settings-row--meta">1355            <span>Advanced Vault Health</span>1356            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1357              open1358            </button>1359          </div>1360          <Show when={declinedCardCount() > 0}>1361            <div class="peach-settings-row peach-settings-row--meta">1362              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1363              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1364                Clear1365              </button>1366            </div>1367          </Show>13681369          <p class="peach-settings-sub-label">DATA</p>1370          <div class="peach-settings-row peach-settings-row--meta">1371            <span>Import passwords</span>1372            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1373              open1374            </button>1375          </div>1376          <div class="peach-settings-row peach-settings-row--meta">1377            <span>Export backup</span>1378            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1379              open1380            </button>1381          </div>1382          <div class="peach-settings-row peach-settings-row--meta">1383            <span>1384              Peach Codex1385              <Show when={!isPro()}>1386                <span class="peach-pro-badge">Pro</span>1387              </Show>1388            </span>1389            <button1390              class="peach-btn peach-btn--subtle peach-btn--sm"1391              onClick={() => props.onOpenCodexGenerate?.()}1392            >1393              open1394            </button>1395          </div>1396          {/* Restore from Codex: intentionally never gated — a printed1397              Codex must remain restorable regardless of license state. */}1398          <div class="peach-settings-row peach-settings-row--meta">1399            <span>Restore from Codex</span>1400            <button1401              class="peach-btn peach-btn--subtle peach-btn--sm"1402              onClick={() => props.onOpenCodexRestore?.()}1403            >1404              open1405            </button>1406          </div>14071408          <span class="peach-settings-sub-label">VAULT HISTORY</span>1409          <Show when={historyLoading()}>1410            <p class="peach-settings-hint">Loading…</p>1411          </Show>1412          <Show when={!historyLoading() && historySnapshots().length === 0}>1413            <p class="peach-settings-hint">1414              No snapshots yet. Peach saves automatically before every change —1415              saves, syncs, and imports.1416            </p>1417          </Show>1418          <Show when={!historyLoading() && historySnapshots().length > 0}>1419            <p class="peach-settings-hint">1420              Restoring locks your vault. Unlock with the password that1421              encrypted that snapshot to confirm.1422            </p>1423            <div class="peach-history-list">1424              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1425                {(snap) => (1426                  <div class="peach-history-row">1427                    <div class="peach-history-meta">1428                      <span class="peach-history-time">1429                        {formatSnapshotTime(snap.savedAt)}1430                      </span>1431                      <span class="peach-history-sub">1432                        {[1433                          snap.entryCount !== null1434                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1435                            : null,1436                          reasonLabel(snap.reason),1437                        ]1438                          .filter(Boolean)1439                          .join(" · ")}1440                      </span>1441                    </div>1442                    <button1443                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1444                        historyRestoring() === snap.id ? " is-busy" : ""1445                      }`}1446                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1447                      disabled={historyRestoring() !== null}1448                    >1449                      <Show when={historyRestoring() === snap.id} fallback="Restore">1450                        …1451                      </Show>1452                    </button>1453                  </div>1454                )}1455              </For>1456            </div>1457            <Show when={historySnapshots().length > 3}>1458              <button1459                class="peach-settings-advanced-toggle"1460                onClick={() => setHistoryShowAll((v) => !v)}1461                aria-expanded={historyShowAll()}1462              >1463                <span class="peach-settings-hint" style="margin:0">1464                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1465                </span>1466                <span class="peach-settings-advanced-chevron">1467                  {historyShowAll() ? "▲" : "▼"}1468                </span>1469              </button>1470            </Show>1471            <p class="peach-history-footer">1472              {historySnapshots().length} of 10 snapshots1473            </p>1474          </Show>14751476          <button1477            class="peach-settings-advanced-toggle"1478            onClick={() => setShowVaultAdvanced((v) => !v)}1479            aria-expanded={showVaultAdvanced()}1480          >1481            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1482            <span class="peach-settings-advanced-chevron">1483              {showVaultAdvanced() ? "▲" : "▼"}1484            </span>1485          </button>14861487          <Show when={showVaultAdvanced()}>1488            <div class="peach-danger-card">1489              <div class="peach-danger-card-head">1490                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1491                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1492              </div>1493              <p class="peach-danger-card-copy">1494                Wipes the current vault and restores from a backup file.1495                Recoverable only if your backup is good.1496              </p>1497              <button1498                class="peach-danger-card-button"1499                onClick={() => props.onOpenFileImport?.()}1500              >1501                Choose backup file1502              </button>1503            </div>15041505            <Show when={props.onOpenPanic}>1506              <div class="peach-danger-card peach-danger-card--hazard">1507                <div class="peach-danger-card-head">1508                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1509                  <h3 class="peach-danger-card-title">Destroy vault</h3>1510                </div>1511                <p class="peach-danger-card-copy">1512                  Permanently wipes every entry, setting, and license on this1513                  device. There is no recovery.1514                </p>1515                <button1516                  class="peach-danger-card-button"1517                  onClick={() => props.onOpenPanic?.()}1518                >1519                  Begin destruction1520                </button>1521              </div>1522            </Show>1523          </Show>1524        </div>1525      </div>1526    );1527  }15281529  function renderSync() {1530    return (1531      <div class="peach-settings-body">1532        <div class="peach-settings-group">1533          <p class="peach-settings-sub-label">PAIRING</p>1534          <div class="peach-settings-row peach-settings-row--top">1535            <div class="peach-settings-row-main">1536              <span>1537                Peach Sync1538                <Show when={!isPro()}>1539                  <span class="peach-pro-badge">Pro</span>1540                </Show>1541              </span>1542              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1543                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1544              </span>1545            </div>1546            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1547          </div>1548          <div class="peach-settings-row peach-settings-row--meta">1549            <span>Status</span>1550            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1551          </div>15521553          {/* Pick a pairing method and begin — no separate hub screen. */}1554          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1555          <button1556            class="peach-pair-method"1557            disabled={!isPro()}1558            onClick={() => props.onOpenSyncPairing?.("chirp")}1559          >1560            <span class="peach-pair-method-icon" aria-hidden="true">1561              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1562                <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />1563                <path d="M14 7.5 Q15.6 10 14 12.5" />1564                <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />1565              </svg>1566            </span>1567            <span class="peach-pair-method-text">1568              <span class="peach-pair-method-title">Same room</span>1569              <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>1570            </span>1571          </button>1572          <button1573            class="peach-pair-method"1574            style="margin-top:8px"1575            disabled={!isPro()}1576            onClick={() => props.onOpenSyncPairing?.("desktop")}1577          >1578            <span class="peach-pair-method-icon" aria-hidden="true">1579              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1580                <rect x="2" y="4" width="16" height="10" rx="1.4" />1581                <path d="M7 17 L13 17" />1582                <path d="M9 14 L9 17 M11 14 L11 17" />1583              </svg>1584            </span>1585            <span class="peach-pair-method-text">1586              <span class="peach-pair-method-title">Enter a code</span>1587              <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>1588            </span>1589          </button>15901591          {/* Paired devices + manual sync — Settings is the hub. */}1592          <Show when={pairedDevices().length > 0}>1593            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1594            <For each={pairedDevices()}>1595              {(d) => (1596                <div class="peach-settings-row peach-settings-row--top">1597                  <div class="peach-settings-row-main">1598                    <span>{d.deviceName ?? "Unnamed device"}</span>1599                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1600                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1601                    </span>1602                  </div>1603                  <Show1604                    when={confirmUnpair() === d.deviceId}1605                    fallback={1606                      <button1607                        class="peach-btn peach-btn--subtle peach-btn--sm"1608                        onClick={() => setConfirmUnpair(d.deviceId)}1609                      >remove</button>1610                    }1611                  >1612                    <div style="display:flex;gap:6px;flex-shrink:0">1613                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1614                      <button1615                        class="peach-btn peach-btn--sm"1616                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1617                        onClick={() => void unpairDevice(d.deviceId)}1618                      >remove</button>1619                    </div>1620                  </Show>1621                </div>1622              )}1623            </For>1624            <div class="peach-settings-row peach-settings-row--meta">1625              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1626              <button1627                class="peach-btn peach-btn--subtle peach-btn--sm"1628                disabled={syncStatus() === "syncing"}1629                onClick={() => void syncNow()}1630              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1631            </div>1632          </Show>1633          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1634          <Show when={pendingVouched().length > 0}>1635            <p class="peach-settings-sub-label">1636              PENDING DEVICES1637              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1638            </p>1639            <For each={pendingVouched()}>1640              {(dev) => (1641                <div class="peach-settings-row peach-settings-row--top">1642                  <div class="peach-settings-row-main">1643                    <span>{dev.deviceName}</span>1644                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1645                      Was added by another of your devices. Allow it to sync with this vault?1646                    </span>1647                  </div>1648                  <div style="display:flex;gap:6px;flex-shrink:0">1649                    <button1650                      class="peach-btn peach-btn--sm"1651                      disabled={vouchedBusy() === dev.deviceId}1652                      onClick={() => void confirmVouched(dev.deviceId)}1653                    >Allow</button>1654                    <button1655                      class="peach-btn peach-btn--subtle peach-btn--sm"1656                      disabled={vouchedBusy() === dev.deviceId}1657                      onClick={() => void rejectVouched(dev.deviceId)}1658                    >Reject</button>1659                  </div>1660                </div>1661              )}1662            </For>1663          </Show>16641665          <div class="peach-info-notice peach-info-notice--note" style="margin-top:8px">1666            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1667          </div>16681669          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1670          <p class="peach-settings-sub-label">RECOVERY</p>1671          <Show1672            when={rotateConfirmOpen()}1673            fallback={1674              <div class="peach-settings-row peach-settings-row--top">1675                <div class="peach-settings-row-main">1676                  <span>Rotate sync identity</span>1677                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1678                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1679                  </span>1680                </div>1681                <button1682                  class="peach-btn peach-btn--subtle peach-btn--sm"1683                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1684                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1685                >rotate</button>1686              </div>1687            }1688          >1689            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1690              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.1691              <Show when={rotateError()}>1692                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1693              </Show>1694              <div style="display:flex;gap:6px;margin-top:10px">1695                <button1696                  class="peach-btn peach-btn--subtle peach-btn--sm"1697                  disabled={rotating()}1698                  onClick={() => setRotateConfirmOpen(false)}1699                >Cancel</button>1700                <button1701                  class="peach-btn peach-btn--sm"1702                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1703                  disabled={rotating()}1704                  onClick={() => void doRotateSyncIdentity()}1705                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1706              </div>1707            </div>1708          </Show>1709        </div>1710      </div>1711    );1712  }17131714  function renderAppearance() {1715    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1716      { id: "medium", label: "Default" },1717      { id: "large", label: "Large" },1718    ];1719    return (1720      <div class="peach-settings-body">1721        <div class="peach-settings-group">1722          <p class="peach-settings-sub-label">THEME</p>1723          <div class="peach-settings-row peach-settings-row--meta">1724            <span>Theme</span>1725            <button1726              class="peach-btn peach-btn--subtle peach-btn--sm"1727              onClick={() =>1728                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1729              }1730            >1731              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1732            </button>1733          </div>17341735          <p class="peach-settings-sub-label">ACCENT</p>1736          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1737            <span>Accent</span>1738            <div class="peach-accent-picker">1739              <For each={ACCENT_COLORS}>1740                {(color) => (1741                  <button1742                    class="peach-accent-swatch peach-swatch"1743                    classList={{ "is-active": props.accentColor === color.name }}1744                    style={{ background: color.hex }}1745                    onClick={() => void props.onAccentChange(color.name)}1746                    aria-label={color.name}1747                    title={color.name}1748                  />1749                )}1750              </For>1751            </div>1752          </div>17531754          <p class="peach-settings-sub-label">TEXT SIZE</p>1755          <div class="peach-settings-row peach-settings-row--meta">1756            <span>Size</span>1757            <div class="peach-pill-group">1758              <For each={sizeOptions}>1759                {(opt) => (1760                  <button1761                    class="peach-btn peach-btn--ghost peach-btn--sm"1762                    classList={{ "is-active": props.textSize === opt.id }}1763                    onClick={() => void props.onTextSizeChange(opt.id)}1764                  >1765                    {opt.label}1766                  </button>1767                )}1768              </For>1769            </div>1770          </div>17711772          <p class="peach-settings-sub-label">MOTION</p>1773          <div class="peach-settings-row peach-settings-row--meta">1774            <span>Reduce motion</span>1775            <button1776              class="peach-btn peach-btn--subtle peach-btn--sm"1777              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1778            >1779              {props.reduceMotion ? "On" : "Off"}1780            </button>1781          </div>1782        </div>1783      </div>1784    );1785  }17861787  function renderShortcuts() {1788    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1789    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17901791    function startCapture(action: ShortcutAction) {1792      setConflictMsg(null);1793      setCapturingAction(action);1794    }1795    function cancelCapture() {1796      setCapturingAction(null);1797      setConflictMsg(null);1798    }1799    function commitBinding(action: ShortcutAction, binding: Keybinding) {1800      const conflict = findConflict(props.shortcuts, binding, action);1801      if (conflict) {1802        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1803        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1804        return;1805      }1806      const next = { ...props.shortcuts, [action]: binding };1807      setCapturingAction(null);1808      setConflictMsg(null);1809      void props.onShortcutsChange(next);1810    }1811    function resetAction(action: ShortcutAction) {1812      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1813      void props.onShortcutsChange(next);1814    }1815    function resetAll() {1816      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1817    }18181819    // Capture-mode key handler — wired via ref onto the chip element so it1820    // catches the next non-modifier keypress and ignores other bindings.1821    function handleCaptureKey(e: KeyboardEvent) {1822      e.preventDefault();1823      e.stopPropagation();1824      if (e.key === "Escape") { cancelCapture(); return; }1825      const binding = keybindingFromEvent(e);1826      if (!binding) return; // bare modifier — keep waiting1827      const action = capturingAction();1828      if (action) commitBinding(action, binding);1829    }18301831    return (1832      <div class="peach-settings-body">1833        <div class="peach-settings-group peach-shortcuts-group">1834          <p class="peach-settings-sub-label">ENTRY VIEW</p>1835          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1836            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1837          </For>1838        </div>18391840        <div class="peach-settings-group peach-shortcuts-group">1841          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1842          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1843            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1844          </For>1845        </div>18461847        <Show when={conflictMsg()}>1848          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1849        </Show>18501851        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1852          <div class="peach-settings-row peach-settings-row--meta">1853            <span>Inline autofill on page</span>1854            <a1855              class="peach-settings-link"1856              href="chrome://extensions/shortcuts"1857              onClick={(e) => {1858                e.preventDefault();1859                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1860              }}1861            >1862              ⌘⇧P — customize ↗1863            </a>1864          </div>1865          <button1866            class="peach-btn peach-btn--subtle peach-btn--sm"1867            style="align-self:flex-start"1868            onClick={resetAll}1869          >1870            Reset all to defaults1871          </button>1872        </div>1873      </div>1874    );18751876    function renderRow(1877      desc: typeof SHORTCUT_DESCRIPTORS[number],1878      capturing: () => ShortcutAction | null,1879      onStart: (a: ShortcutAction) => void,1880      onCancel: () => void,1881      onReset: (a: ShortcutAction) => void,1882      onKey: (e: KeyboardEvent) => void,1883    ) {1884      const isActive = () => capturing() === desc.action;1885      const binding = () => props.shortcuts[desc.action];1886      const isCustom = () => {1887        const b = binding();1888        const d = DEFAULT_BINDINGS[desc.action];1889        return b.key !== d.key1890          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1891          || Boolean(b.meta)  !== Boolean(d.meta)1892          || Boolean(b.shift) !== Boolean(d.shift)1893          || Boolean(b.alt)   !== Boolean(d.alt);1894      };1895      return (1896        <div class="peach-shortcut-row">1897          <span class="peach-shortcut-row-label">{desc.label}</span>1898          <button1899            class="peach-shortcut-chip"1900            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1901            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1902            onKeyDown={(e) => { if (isActive()) onKey(e); }}1903            ref={(el) => {1904              // Auto-focus when entering capture mode so the keydown listener catches the next press.1905              if (el) {1906                queueMicrotask(() => { if (isActive()) el.focus(); });1907              }1908            }}1909          >1910            {isActive() ? "Press a key…" : formatKeybinding(binding())}1911          </button>1912          <Show when={isCustom() && !isActive()}>1913            <button1914              class="peach-shortcut-reset"1915              title="Reset to default"1916              onClick={() => onReset(desc.action)}1917            >1918              ↺1919            </button>1920          </Show>1921        </div>1922      );1923    }1924  }19251926  const PRO_FEATURES: Array<{1927    feature: ProFeature;1928    name: string;1929    desc: string;1930  }> = [1931    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1932    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1933    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1934    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1935  ];19361937  const PRO_SECURITY_FEATURES: Array<{1938    feature: ProFeature;1939    name: string;1940    desc: string;1941  }> = [1942    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1943    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1944  ];19451946  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1947  const [showKey, setShowKey] = createSignal(false);19481949  const [deactivating, setDeactivating] = createSignal(false);19501951  async function handleDeactivateLicense() {1952    setDeactivating(true);1953    await sendMessage({ type: "DEACTIVATE_LICENSE" });1954    setLicenseInfo(null);1955    await props.onLicenseChange?.();1956    setDeactivating(false);1957  }19581959  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1960    setCanaryTestingId(entry.id);1961    setCanaryTestResult(null);1962    const res = await sendMessage({1963      type: "FIRE_CANARY_PING",1964      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1965    }) as { ok: boolean };1966    setCanaryTestResult({ id: entry.id, ok: res.ok });1967    setCanaryTestingId(null);1968    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1969  }19701971  function renderLicense() {1972    async function onActivate() {1973      await handleActivateLicense();1974      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1975        await props.onLicenseChange?.();1976      }1977    }19781979    return (1980      <div class="peach-settings-body">1981        <Show1982          when={isPro()}1983          fallback={1984                <div class="peach-pro-upgrade">1985                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19861987                  <h2 class="peach-pro-headline">1988                    Every<em>thing,</em><br />every<em>where.</em>1989                  </h2>19901991                  <p class="peach-pro-subtitle">1992                    One key unlocks every Pro feature — for the lifetime of the app.1993                  </p>19941995                  <div class="peach-pro-grid">1996                    <For each={PRO_FEATURES}>1997                      {(item, i) => (1998                        <div1999                          class="peach-pro-grid-item"2000                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2001                        >2002                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2003                          <span class="peach-pro-grid-name">{item.name}</span>2004                          <span class="peach-pro-grid-desc">{item.desc}</span>2005                        </div>2006                      )}2007                    </For>2008                  </div>20092010                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2011                  <div class="peach-pro-grid">2012                    <For each={PRO_SECURITY_FEATURES}>2013                      {(item, i) => (2014                        <div2015                          class="peach-pro-grid-item"2016                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2017                        >2018                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2019                          <span class="peach-pro-grid-name">{item.name}</span>2020                          <span class="peach-pro-grid-desc">{item.desc}</span>2021                        </div>2022                      )}2023                    </For>2024                  </div>20252026                  <div class="peach-pro-upgrade-actions">2027                    <p class="peach-pro-pricing">2028                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2029                    </p>2030                    <a2031                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2032                      target="_blank"2033                      rel="noopener noreferrer"2034                      class="peach-pro-upgrade-cta"2035                    >2036                      Get Peach Pro2037                    </a>2038                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2039                    <div class="peach-pro-key-inline">2040                      <input2041                        class="peach-pro-key-inline-input"2042                        type="text"2043                        placeholder="PEACH-XXXX-XXXX-XXXX"2044                        value={licenseKeyInput()}2045                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2046                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2047                        autocomplete="off"2048                        spellcheck={false}2049                      />2050                      <button2051                        class="peach-pro-key-apply"2052                        onClick={() => void onActivate()}2053                        disabled={activatingLicense() || !licenseKeyInput().trim()}2054                      >2055                        {activatingLicense() ? "…" : "APPLY"}2056                      </button>2057                    </div>2058                    <Show when={licenseError()}>2059                      <p class="peach-pro-key-error">{licenseError()}</p>2060                    </Show>2061                  </div>2062                </div>2063          }2064        >2065          <div class="peach-pro-active">2066            <div class="peach-pro-active-hero">2067              <button2068                class="peach-pro-active-mark-wrap"2069                type="button"2070                aria-label="Celebrate"2071                onClick={handleProCelebrationClick}2072              >2073                <PeachMark class="peach-pro-active-mark" />2074                <Show when={justActivated()}>2075                  <div class="peach-pro-sparks" aria-hidden="true">2076                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2077                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2078                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2079                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2080                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2081                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2082                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2083                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2084                  </div>2085                </Show>2086              </button>2087              <h2 class="peach-pro-active-title">Peach Pro</h2>2088              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2089              <Show when={justActivated()}>2090                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2091              </Show>2092            </div>20932094            <Show when={licenseKey()}>2095              <div class="peach-settings-license">2096                <span class="peach-settings-label">LICENSE KEY</span>2097                <div class="peach-settings-license-row">2098                  <Show2099                    when={showKey()}2100                    fallback={2101                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2102                        ••••••••••••••••••••••••••••2103                      </span>2104                    }2105                  >2106                    <span class="peach-settings-license-key">{licenseKey()!}</span>2107                  </Show>2108                  <div class="peach-settings-license-actions">2109                    <button2110                      class="peach-settings-license-copy"2111                      title="Copy license key"2112                      onClick={() => copyLicenseKey(licenseKey()!)}2113                    >2114                      {copied() ? "✓" : "copy"}2115                    </button>2116                    <button2117                      class="peach-settings-license-toggle"2118                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2119                      title={showKey() ? "Hide" : "Reveal"}2120                      onClick={() => setShowKey(v => !v)}2121                    >2122                      <Show2123                        when={showKey()}2124                        fallback={2125                          <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">2126                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2127                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2128                          </svg>2129                        }2130                      >2131                        <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">2132                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2133                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2134                          <line x1="2" y1="2" x2="12" y2="12" />2135                        </svg>2136                      </Show>2137                    </button>2138                  </div>2139                </div>2140              </div>2141            </Show>21422143            <div class="peach-pro-active-features">2144              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2145                {(item) => (2146                  <div class="peach-pro-active-feature">2147                    <span class="peach-pro-active-feature-mark">✦</span>2148                    <span class="peach-pro-active-feature-name">{item.name}</span>2149                  </div>2150                )}2151              </For>2152            </div>21532154            <button2155              class="peach-pro-deactivate"2156              onClick={() => void handleDeactivateLicense()}2157              disabled={deactivating()}2158            >2159              {deactivating() ? "Deactivating…" : "Deactivate"}2160            </button>2161          </div>2162        </Show>2163      </div>2164    );2165  }21662167  function renderFeedback() {2168    return (2169      <div class="peach-settings-body">2170        <div class="peach-settings-group peach-feedback-group">2171          <Show2172            when={feedbackState() === 'sent'}2173            fallback={2174              <>2175                <p class="peach-feedback-intro">2176                  Help us improve Peach. Every report goes directly to our inbox.2177                </p>21782179                <p class="peach-settings-sub-label">TYPE</p>2180                <div class="peach-feedback-chips">2181                  <For each={FEEDBACK_CATEGORIES}>2182                    {(cat) => {2183                      const selected = () => fbCategory() === cat;2184                      return (2185                        <button2186                          type="button"2187                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2188                          aria-pressed={selected()}2189                          onClick={() => setFbCategory(cat)}2190                        >2191                          {cat}2192                        </button>2193                      );2194                    }}2195                  </For>2196                </div>21972198                <p class="peach-settings-sub-label">MESSAGE</p>2199                <textarea2200                  class="peach-feedback-textarea"2201                  placeholder="Describe the issue or idea..."2202                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2203                  value={fbMessage()}2204                  onInput={(e) => setFbMessage(e.currentTarget.value)}2205                />22062207                <p class="peach-feedback-meta">2208                  Version {version} · will be included automatically2209                </p>22102211                <button2212                  type="button"2213                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2214                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2215                  onClick={() => void handleSubmitFeedback()}2216                >2217                  <Switch>2218                    <Match when={feedbackState() === 'idle'}>Send</Match>2219                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2220                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2221                  </Switch>2222                </button>22232224                <p class="peach-feedback-anon">2225                  Sent anonymously · no account required2226                </p>2227              </>2228            }2229          >2230            <div class="peach-feedback-thanks" role="status" aria-live="polite">2231              <p class="peach-feedback-thanks-title">Thank you</p>2232              <p>2233                Your note is in the queue. Someone will process it soon.2234              </p>2235            </div>2236          </Show>2237        </div>2238      </div>2239    );2240  }22412242  const bodies: StackBodies = {2243    protection: renderSecurity(),2244    vault: renderVault(),2245    sync: renderSync(),2246    appearance: renderAppearance(),2247    shortcuts: renderShortcuts(),2248    pro: renderLicense(),2249    feedback: renderFeedback(),2250  };22512252  return (2253    <Show2254      when={showNewRecoveryWords()}2255      fallback={2256        <div2257          class="peach-overlay-view peach-settings-overlay"2258          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2259          style="display:flex;flex-direction:column"2260        >2261          <StackDeck2262            active={activeCard()}2263            onActiveChange={setActiveCard}2264            onBack={props.onBack}2265            cards={cards()}2266            bodies={bodies}2267            version={version}2268            isFanning={isFanning()}2269            celebratingCardId={justActivated() ? "pro" : null}2270            isPro={isPro()}2271          />2272        </div>2273      }2274    >2275      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2276    </Show>2277  );2278}