Settings.tsx

90.2 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import 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?: "code") => 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 with a code, 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 "extension": return "Browser";293      default: return "Device";294    }295  }296297  async function loadPairedDevices() {298    if (!isPro()) return;299    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {300      ok: boolean;301      data?: { devices: PairedDevice[] };302    };303    if (r.ok) setPairedDevices(r.data?.devices ?? []);304  }305  async function unpairDevice(deviceId: string) {306    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });307    setConfirmUnpair(null);308    await loadPairedDevices();309  }310  async function syncNow() {311    setSyncStatus("syncing");312    setSyncFlash(null);313    const res = (await sendMessage({314      type: "TRIGGER_SYNC",315      payload: { reason: "manual", force: true },316    })) as { ok: boolean; error?: string };317    setTimeout(async () => {318      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {319        ok: boolean;320        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };321      };322      const s = state.data;323      if (!s) return;324      setSyncStatus(s.status);325      if (s.status === "idle") {326        setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");327        setTimeout(() => setSyncFlash(null), 3000);328        await loadPairedDevices();329      } else if (s.status === "error") {330        setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);331        setTimeout(() => setSyncFlash(null), 4000);332      }333    }, 250);334    if (!res.ok) {335      setSyncStatus("error");336      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);337      setTimeout(() => setSyncFlash(null), 4000);338    }339  }340  // UI-4 / MV-8: rotate sync identity confirmation dialog.341  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);342  const [rotating, setRotating] = createSignal(false);343  const [rotateError, setRotateError] = createSignal<string | null>(null);344345  async function loadPendingVouched() {346    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {347      ok: boolean;348      data?: PendingVouched[];349    };350    if (r.ok) setPendingVouched(r.data ?? []);351  }352  async function confirmVouched(deviceId: string) {353    setVouchedBusy(deviceId);354    try {355      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });356      await loadPendingVouched();357    } finally {358      setVouchedBusy(null);359    }360  }361  async function rejectVouched(deviceId: string) {362    setVouchedBusy(deviceId);363    try {364      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });365      await loadPendingVouched();366    } finally {367      setVouchedBusy(null);368    }369  }370  async function doRotateSyncIdentity() {371    setRotateError(null);372    setRotating(true);373    try {374      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {375        ok: boolean;376        error?: string;377      };378      if (!r.ok) {379        setRotateError(r.error ?? "Rotation failed.");380        return;381      }382      setRotateConfirmOpen(false);383      // Backend set peach_recovery_pending; reuse the existing recovery-words384      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).385      setShowNewRecoveryWords(true);386    } finally {387      setRotating(false);388    }389  }390391  const [dedupCount, setDedupCount] = createSignal<number | null>(null);392  const [dedupRunning, setDedupRunning] = createSignal(false);393  const [dedupDone, setDedupDone] = createSignal<number | null>(null);394395  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);396397  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted398  // BEFORE every real-mode vault write so the user can roll back if a sync399  // or import overwrites the wrong state.400  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);401  const [historyLoading, setHistoryLoading] = createSignal(false);402  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);403  const [historyShowAll, setHistoryShowAll] = createSignal(false);404405  async function loadVaultHistory() {406    setHistoryLoading(true);407    try {408      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });409      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];410      setHistorySnapshots(list);411    } finally {412      setHistoryLoading(false);413    }414  }415416  async function handleRestoreVaultSnapshot(id: string) {417    if (historyRestoring()) return;418    setHistoryRestoring(id);419    const res = await sendMessage({420      type: "RESTORE_VAULT_SNAPSHOT",421      payload: { snapshotId: id },422    });423    setHistoryRestoring(null);424    if ((res as { ok: boolean }).ok) {425      // Vault is now locked — let the parent close Settings; App.tsx's426      // VAULT_LOCKED listener will route back to the lock screen.427      props.onBack();428    }429  }430431  function formatSnapshotTime(savedAt: number): string {432    const diff = Date.now() - savedAt;433    if (diff < 60_000) return "Just now";434    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;435    const d = new Date(savedAt);436    const isToday = new Date().toDateString() === d.toDateString();437    const isYest =438      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();439    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });440    if (isToday) return `Today ${t}`;441    if (isYest) return `Yesterday ${t}`;442    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;443  }444445  function reasonLabel(reason: VaultSnapshot["reason"]): string {446    return (447      {448        local_save: "saved",449        sync_received: "synced",450        import: "imported",451        restore: "restored",452      } as const453    )[reason] ?? reason;454  }455456  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);457458  const [showChangePw, setShowChangePw] = createSignal(false);459  const [changePwCurrent, setChangePwCurrent] = createSignal("");460  const [changePwNew, setChangePwNew] = createSignal("");461  const [changePwConfirm, setChangePwConfirm] = createSignal("");462  const [changePwSaving, setChangePwSaving] = createSignal(false);463  const [changePwError, setChangePwError] = createSignal<string | null>(null);464  const [changePwSuccess] = createSignal(false);465  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);466467  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.468  const [showViewWords, setShowViewWords] = createSignal(false);469  const [viewWordsPassword, setViewWordsPassword] = createSignal("");470  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);471  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);472  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);473474  async function handleViewRecoveryWords() {475    setViewWordsError(null);476    if (!viewWordsPassword()) {477      setViewWordsError("Enter your master password.");478      return;479    }480    setViewWordsBusy(true);481    try {482      const r = (await sendMessage({483        type: "GET_RECOVERY_WORDS",484        payload: { password: viewWordsPassword() },485      })) as { ok: boolean; data?: string[]; error?: string };486      if (r.ok) {487        setViewedWords(r.data ?? []);488        setViewWordsPassword("");489        return;490      }491      switch (r.error) {492        case "wrong_password":493          setViewWordsError("Incorrect password");494          break;495        case "password_required":496          setViewWordsError("Enter your master password.");497          break;498        case "recovery_not_setup":499          setViewWordsError("Recovery words aren't set up for this vault yet.");500          break;501        default:502          setViewWordsError("Couldn't retrieve recovery words.");503      }504    } finally {505      setViewWordsBusy(false);506    }507  }508  function closeViewWords() {509    setShowViewWords(false);510    setViewWordsPassword("");511    setViewWordsError(null);512    setViewedWords(null);513  }514515  const [duressEnabled, setDuressEnabled] = createSignal(false);516  const [showDuressSetup, setShowDuressSetup] = createSignal(false);517  const [duressPassword, setDuressPassword] = createSignal("");518  const [duressConfirm, setDuressConfirm] = createSignal("");519  const [duressMainPassword, setDuressMainPassword] = createSignal("");520  const [duressSaving, setDuressSaving] = createSignal(false);521  const [duressError, setDuressError] = createSignal<string | null>(null);522  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);523524  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);525526  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);527  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);528529  const [fbCategory, setFbCategory] = createSignal("Bug report");530  const [fbMessage, setFbMessage] = createSignal("");531  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';532  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');533534  createEffect(() => {535    if (props.upgradeHighlight) {536      setActiveCard("pro");537    }538  });539540  onMount(async () => {541    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([542      sendMessage({ type: "GET_LICENSE" }),543      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),544      sendMessage({ type: "IS_DURESS_ENABLED" }),545      sendMessage({ type: "GET_SETTINGS" }),546      sendMessage({ type: "GET_LICENSE_KEY" }),547    ]);548549    // Vault history loads in the background — the Vault sub-view picks it up550    // when the user navigates in. Loading it lazily keeps Settings boot fast.551    void loadVaultHistory();552553    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the554    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.555    void loadPendingVouched();556    void loadPairedDevices();557    const onBroadcast = (msg: unknown) => {558      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {559        void loadPendingVouched();560      }561    };562    chrome.runtime?.onMessage?.addListener(onBroadcast);563    onCleanup(() => {564      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }565    });566567    if ((licenseRes as { ok: boolean }).ok) {568      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);569    }570571    if ((passkeyRes as { ok: boolean }).ok) {572      setPasskeyState(573        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null574      );575    }576577    if ((duressRes as { ok: boolean }).ok) {578      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);579    }580581    if ((settingsRes as { ok: boolean }).ok) {582      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;583      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);584    }585586    if ((keyRes as { ok: boolean }).ok) {587      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);588    }589590    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });591    if ((entriesRes as { ok: boolean }).ok) {592      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];593      setCanaryEntries(594        entries595          .filter((e) => e.canary?.webhookUrl)596          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))597      );598    }599600601    if (isNativePlatform()) {602      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");603      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");604      try {605        if (biometric) {606          const a = await biometric.isAvailable();607          setBiometricSupported(a.available);608        }609        if (keystore) {610          const k = await keystore.hasMasterKey();611          setBiometricStored(k.exists);612        }613      } catch { /* missing plugins — leave defaults */ }614    }615  });616617  async function handleEnableBiometrics() {618    setBiometricBusy(true);619    setBiometricError(null);620    try {621      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {622        ok: boolean;623        data?: { masterKeyHex: string };624        error?: string;625      };626      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {627        setBiometricError(keyRes.error === "vault_locked"628          ? "unlock the vault first"629          : "couldn't read derived key");630        return;631      }632      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");633      if (!keystore) {634        setBiometricError("biometric plugin unavailable");635        return;636      }637      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });638      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });639      setBiometricStored(true);640    } catch (e) {641      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");642    } finally {643      setBiometricBusy(false);644    }645  }646647  async function handleDisableBiometrics() {648    setBiometricBusy(true);649    setBiometricError(null);650    try {651      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");652      if (keystore) await keystore.clearMasterKey();653      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });654      setBiometricStored(false);655    } catch (e) {656      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");657    } finally {658      setBiometricBusy(false);659    }660  }661662  async function handleChangePassword() {663    setChangePwError(null);664    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }665    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }666    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }667    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }668    setChangePwSaving(true);669    const res = await sendMessage({670      type: "CHANGE_PASSWORD",671      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },672    }) as { ok: boolean; error?: string };673    if (res.ok) {674      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");675      setShowChangePw(false);676      // LV-4: the user just authenticated (current password), so the recovery677      // words reveal that follows is exempt from the re-auth gate. Set the678      // pending flag the same way the create/recovery flow does; the display679      // clears it on confirm.680      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });681      setShowNewRecoveryWords(true);682    } else {683      setChangePwError(res.error ?? "failed to change password");684    }685    setChangePwSaving(false);686  }687688  const [copied, setCopied] = createSignal(false);689690  async function copyLicenseKey(key: string) {691    await navigator.clipboard.writeText(key);692    setCopied(true);693    setTimeout(() => setCopied(false), 2000);694  }695696  async function handleActivateLicense() {697    const key = licenseKeyInput().trim();698    if (!key) return;699    setActivatingLicense(true);700    setLicenseError(null);701    setLicenseSuccess(null);702    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });703    setActivatingLicense(false);704    if ((res as { ok: boolean }).ok) {705      const data = (res as { ok: boolean; data?: LicenseInfo }).data;706      if (data?.valid) {707        setLicenseInfo(data);708        setLicenseKey(key);709        setLicenseSuccess("License activated!");710        setLicenseKeyInput("");711      } else {712        setLicenseError("Invalid license key");713      }714    } else {715      setLicenseError("Activation failed");716    }717  }718719  async function handleTogglePasskeys() {720    const current = passkeyState();721    if (!current || !current.supported) return;722    setPasskeySaving(true);723    const res = await sendMessage({724      type: "SET_PASSKEY_PROVIDER_ENABLED",725      payload: { enabled: !current.enabled },726    });727    setPasskeySaving(false);728    if ((res as { ok: boolean }).ok) {729      setPasskeyState(730        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null731      );732    }733  }734735  async function handleCheckAllBreaches() {736    setCheckingBreaches(true);737    setBreachResult(null);738    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });739    setCheckingBreaches(false);740    if ((res as { ok: boolean }).ok) {741      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};742      const breached = Object.values(data).filter((count) => count > 0).length;743      setBreachResult(744        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"745      );746      props.onBreachCheckDone();747    }748  }749750751  async function handleSetupDuress() {752    const mainPw = duressMainPassword().trim();753    const duressPw = duressPassword().trim();754    const confirmPw = duressConfirm().trim();755756    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }757    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }758    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }759    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }760761    setDuressSaving(true);762    setDuressError(null);763764    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };765    if (!verifyRes.ok || !verifyRes.data) {766      setDuressError("incorrect main password");767      setDuressSaving(false);768      return;769    }770771    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };772    setDuressSaving(false);773    if (res.ok) {774      setDuressEnabled(true);775      setShowDuressSetup(false);776      setDuressPassword("");777      setDuressConfirm("");778      setDuressMainPassword("");779      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");780      setTimeout(() => setDuressSuccess(null), 5000);781    } else {782      setDuressError(783        res.error === "session_expired"784          ? "Vault session expired — lock and unlock Peach, then try again"785          : res.error ?? "failed to set duress password"786      );787    }788  }789790  async function handleClearDeclinedCards() {791    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });792    setDeclinedCardCount(0);793  }794795  async function handleCountDuplicates() {796    setDedupRunning(true);797    setDedupDone(null);798    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };799    setDedupRunning(false);800    if (res.ok) setDedupCount(res.data ?? 0);801  }802803  async function handleDeduplicate() {804    setDedupRunning(true);805    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };806    setDedupRunning(false);807    if (res.ok) {808      setDedupDone(res.data ?? 0);809      setDedupCount(0);810    }811  }812813  async function handleDisableDuress() {814    const mainPw = duressMainPassword().trim();815    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }816817    setDuressSaving(true);818    setDuressError(null);819820    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };821    if (!verifyRes.ok || !verifyRes.data) {822      setDuressError("incorrect main password");823      setDuressSaving(false);824      return;825    }826827    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };828    setDuressSaving(false);829    if (res.ok) {830      setDuressEnabled(false);831      setShowDuressSetup(false);832      setDuressMainPassword("");833      setDuressSuccess("Duress password disabled.");834      setTimeout(() => setDuressSuccess(null), 4000);835    } else {836      setDuressError(res.error ?? "failed to disable duress password");837    }838  }839840  async function handleSubmitFeedback() {841    const message = sanitizeFeedbackMessage(fbMessage());842    if (!message) return;843844    setFeedbackState('sending');845846    try {847      const hasDataPermission = await requestFeedbackDataPermission();848      if (!hasDataPermission) {849        setFeedbackState('idle');850        return;851      }852853      const category = FEEDBACK_CATEGORIES.includes(fbCategory())854        ? fbCategory()855        : FEEDBACK_CATEGORIES[0];856      const res = await fetch('https://api.web3forms.com/submit', {857        method: 'POST',858        headers: { 'Content-Type': 'application/json' },859        body: JSON.stringify({860          access_key: WEB3FORMS_KEY,861          subject: `Peach Feedback - ${category}`,862          message,863          from_name: 'Peach User',864          // Honeypot — leave blank865          botcheck: '',866        }),867      });868869      if (!res.ok) throw new Error(`HTTP ${res.status}`);870      const data = await res.json() as { success: boolean; message?: string };871      if (!data.success) throw new Error(data.message ?? 'unknown error');872873      setFeedbackState('sent');874      setFbMessage('');875      setFbCategory(FEEDBACK_CATEGORIES[0]);876    } catch (err) {877      console.error('[feedback]', err);878      setFeedbackState('error');879      setTimeout(() => setFeedbackState('idle'), 4000);880    }881  }882883  function renderSecurity() {884    return (885      <div class="peach-settings-body">886        <div class="peach-settings-group">887          <p class="peach-settings-sub-label">LOCKING</p>888          <div class="peach-settings-row">889            <span>Lock on close</span>890            <button891              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}892              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}893              aria-label="Toggle lock on close"894            >895              <span class="peach-toggle-knob"></span>896            </button>897          </div>898          <div class="peach-settings-row">899            <span>Auto-lock after</span>900            <select901              class="peach-settings-select"902              value={String(props.autoLockMinutes)}903              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}904            >905              <option value="1">1 minute</option>906              <option value="5">5 minutes</option>907              <option value="15">15 minutes</option>908              <option value="30">30 minutes</option>909              <option value="0">Never</option>910            </select>911          </div>912913          <Show when={isNativePlatform() && biometricSupported()}>914            <p class="peach-settings-sub-label">BIOMETRICS</p>915            <div class="peach-settings-row">916              <span>Unlock with biometrics</span>917              <button918                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}919                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}920                disabled={biometricBusy()}921                aria-label="Toggle biometric unlock"922              >923                <span class="peach-toggle-knob"></span>924              </button>925            </div>926            <Show when={biometricError()}>927              <div class="peach-settings-row peach-settings-row--meta">928                <span class="peach-hint peach-hint-error">{biometricError()}</span>929              </div>930            </Show>931          </Show>932933          <p class="peach-settings-sub-label">BREACH PROTECTION</p>934          <div class="peach-settings-row">935            <span>Breach monitoring</span>936            <button937              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}938              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}939              aria-label="Toggle breach monitoring"940            >941              <span class="peach-toggle-knob"></span>942            </button>943          </div>944          <div class="peach-settings-row peach-settings-row--meta">945            <span>Check all passwords</span>946            <button947              class="peach-btn peach-btn--subtle peach-btn--sm"948              onClick={() => void handleCheckAllBreaches()}949              disabled={checkingBreaches()}950            >951              {checkingBreaches() ? "checking..." : "run"}952            </button>953          </div>954          <Show when={breachResult()}>955            <div class="peach-settings-row peach-settings-row--meta">956              <span>Last check</span>957              <span class="peach-settings-value">{breachResult()}</span>958            </div>959          </Show>960961          <p class="peach-settings-sub-label">MASTER PASSWORD</p>962          <div class="peach-settings-row peach-settings-row--meta">963            <span>Master password</span>964            <button965              class="peach-btn peach-btn--subtle peach-btn--sm"966              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}967            >968              {showChangePw() ? "cancel" : "change"}969            </button>970          </div>971          <Show when={showChangePw()}>972            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">973              <input974                class="peach-settings-input"975                type="password"976                placeholder="current password"977                value={changePwCurrent()}978                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}979                autocomplete="current-password"980              />981              <input982                class="peach-settings-input"983                type="password"984                placeholder="new password (min 12 chars)"985                value={changePwNew()}986                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}987                autocomplete="new-password"988              />989              <input990                class="peach-settings-input"991                type="password"992                placeholder="confirm new password"993                value={changePwConfirm()}994                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}995                autocomplete="new-password"996                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}997              />998              <Show when={changePwError()}>999                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1000              </Show>1001              <Show when={changePwSuccess()}>1002                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1003              </Show>1004              <button1005                class="peach-btn peach-btn--accent peach-btn--sm"1006                style="align-self:flex-start"1007                onClick={() => void handleChangePassword()}1008                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1009              >1010                {changePwSaving() ? "saving…" : "save new password"}1011              </button>1012            </div>1013          </Show>10141015          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1016          <div class="peach-settings-row peach-settings-row--meta">1017            <span>Recovery words</span>1018            <button1019              class="peach-btn peach-btn--subtle peach-btn--sm"1020              onClick={() => {1021                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1022              }}1023            >1024              {showViewWords() ? "cancel" : "view"}1025            </button>1026          </div>1027          <Show when={showViewWords()}>1028            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1029              <Show1030                when={viewedWords()}1031                fallback={1032                  <>1033                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1034                      Enter your master password to reveal your recovery words.1035                    </p>1036                    <input1037                      class="peach-settings-input"1038                      type="password"1039                      placeholder="master password"1040                      value={viewWordsPassword()}1041                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1042                      autocomplete="current-password"1043                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1044                    />1045                    <Show when={viewWordsError()}>1046                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1047                    </Show>1048                    <button1049                      class="peach-btn peach-btn--accent peach-btn--sm"1050                      style="align-self:flex-start"1051                      onClick={() => void handleViewRecoveryWords()}1052                      disabled={viewWordsBusy() || !viewWordsPassword()}1053                    >1054                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1055                    </button>1056                  </>1057                }1058              >1059                <div class="peach-recovery-words-grid">1060                  <For each={viewedWords()!}>1061                    {(word, i) => (1062                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1063                    )}1064                  </For>1065                </div>1066                <button1067                  class="peach-btn peach-btn--subtle peach-btn--sm"1068                  style="align-self:flex-start"1069                  onClick={closeViewWords}1070                >done</button>1071              </Show>1072            </div>1073          </Show>10741075          <p class="peach-settings-sub-label">1076            DURESS PASSWORD1077            <Show when={!isPro()}>1078              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1079            </Show>1080          </p>1081          <div class="peach-settings-row peach-settings-row--top">1082            <div class="peach-settings-row-main">1083              <span>1084                Duress password1085                <Show when={!isPro()}>1086                  <span class="peach-pro-badge">Pro</span>1087                </Show>1088              </span>1089              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1090                If entered under coercion, shows a decoy empty vault instead of your real one.1091              </span>1092            </div>1093            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1094              {duressEnabled() ? "on" : "off"}1095            </div>1096          </div>10971098          <Show when={duressEnabled() && !showDuressSetup()}>1099            <div class="peach-settings-row peach-settings-row--meta">1100              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1101                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.1102              </span>1103            </div>1104          </Show>11051106          <Show when={duressSuccess()}>1107            <div class="peach-settings-row peach-settings-row--meta">1108              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1109            </div>1110          </Show>11111112          <Show when={!showDuressSetup()}>1113            <div class="peach-settings-row peach-settings-row--meta">1114              <button1115                class="peach-btn peach-btn--subtle peach-btn--sm"1116                onClick={() => {1117                  // Pro gate entry-point: free users are redirected to the1118                  // upgrade screen before the setup form opens. The server-1119                  // side SETUP_DURESS handler is the single enforcement1120                  // point; this is UI redirection so the form never appears1121                  // for a user who can't complete it.1122                  if (!isPro() && !duressEnabled()) {1123                    props.onProGate?.("duress_vault");1124                    setActiveCard("pro");1125                    return;1126                  }1127                  setShowDuressSetup(true);1128                  setDuressError(null);1129                  setDuressSuccess(null);1130                  setDuressPassword("");1131                  setDuressConfirm("");1132                  setDuressMainPassword("");1133                }}1134              >1135                {duressEnabled() ? "Change / disable" : "Set up"}1136              </button>1137            </div>1138          </Show>11391140          <Show when={showDuressSetup()}>1141            <div class="peach-duress-form">1142              <label class="peach-label">main password (to confirm)</label>1143              <input1144                class="peach-settings-input"1145                type="password"1146                placeholder="your current master password"1147                autocomplete="current-password"1148                value={duressMainPassword()}1149                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1150              />1151              <Show when={!duressEnabled()}>1152                <label class="peach-label">new duress password</label>1153                <input1154                  class="peach-settings-input"1155                  type="password"1156                  placeholder="duress password (min 8 chars)"1157                  autocomplete="new-password"1158                  value={duressPassword()}1159                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1160                />1161                <label class="peach-label">confirm duress password</label>1162                <input1163                  class="peach-settings-input"1164                  type="password"1165                  placeholder="confirm duress password"1166                  autocomplete="new-password"1167                  value={duressConfirm()}1168                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1169                />1170              </Show>11711172              <Show when={duressError()}>1173                <p class="peach-hint peach-hint-error">{duressError()}</p>1174              </Show>11751176              <div class="peach-duress-actions">1177                <button1178                  class="peach-btn peach-btn--subtle peach-btn--sm"1179                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1180                >1181                  cancel1182                </button>1183                <Show when={duressEnabled()}>1184                  <button1185                    class="peach-btn peach-btn--ghost peach-btn--sm"1186                    style="color:var(--peach-breach)"1187                    onClick={() => void handleDisableDuress()}1188                    disabled={duressSaving()}1189                  >1190                    {duressSaving() ? "disabling..." : "disable duress"}1191                  </button>1192                </Show>1193                <Show when={!duressEnabled()}>1194                  <button1195                    class="peach-btn peach-btn--accent peach-btn--sm"1196                    onClick={() => void handleSetupDuress()}1197                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1198                  >1199                    {duressSaving() ? "saving..." : "save"}1200                  </button>1201                </Show>1202              </div>1203            </div>1204          </Show>12051206          <p class="peach-settings-sub-label">1207            CANARY ENTRIES1208            <Show when={!isPro()}>1209              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1210            </Show>1211          </p>1212          <div class="peach-settings-row peach-settings-row--top">1213            <div class="peach-settings-row-main">1214              <span>1215                Canary entries1216                <Show when={!isPro()}>1217                  <span class="peach-pro-badge">Pro</span>1218                </Show>1219              </span>1220              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1221                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1222              </span>1223            </div>1224            <span class="peach-settings-value" style="flex-shrink:0">1225              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1226            </span>1227          </div>1228          <Show when={canaryEntries().length > 0}>1229            <For each={canaryEntries()}>1230              {(entry) => (1231                <div class="peach-settings-row peach-settings-row--meta">1232                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1233                  <button1234                    class="peach-btn peach-btn--subtle peach-btn--sm"1235                    onClick={() => void handleTestCanary(entry)}1236                    disabled={canaryTestingId() === entry.id}1237                    style="flex-shrink:0"1238                  >1239                    {canaryTestingId() === entry.id1240                      ? "sending…"1241                      : canaryTestResult()?.id === entry.id1242                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1243                        : "test"}1244                  </button>1245                </div>1246              )}1247            </For>1248          </Show>12491250          <p class="peach-settings-sub-label">PASSKEYS</p>1251          <div class="peach-settings-row peach-settings-row--top">1252            <div class="peach-settings-row-main">1253              <span>Intercept passkey requests</span>1254              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1255                Peach can handle passkey sign-in and creation on supported Chromium browsers.1256              </span>1257            </div>1258            <button1259              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1260              onClick={() => void handleTogglePasskeys()}1261              aria-label="Toggle passkey interception"1262              disabled={!passkeyState()?.supported || passkeySaving()}1263            >1264              <span class="peach-toggle-knob"></span>1265            </button>1266          </div>1267          <div class="peach-settings-row peach-settings-row--meta">1268            <span>Status</span>1269            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1270          </div>1271          <div class="peach-settings-row peach-settings-row--meta">1272            <span>Browser</span>1273            <span class="peach-settings-value">1274              {passkeyState()?.browserLabel ?? "Unknown"}1275              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1276            </span>1277          </div>1278          <div class="peach-info-notice" style="margin-top:8px">1279            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1280          </div>1281          <Show when={passkeyState()?.conflict}>1282            <div class="peach-settings-row peach-settings-row--meta">1283              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1284            </div>1285          </Show>1286        </div>1287      </div>1288    );1289  }12901291  function renderVault() {1292    return (1293      <div class="peach-settings-body">1294        <div class="peach-settings-group">1295          <p class="peach-settings-sub-label">ENTRIES</p>1296          <div class="peach-settings-row peach-settings-row--meta">1297            <span>Entries</span>1298            <span class="peach-settings-value">{props.entriesCount}</span>1299          </div>1300          <div class="peach-settings-row peach-settings-row--meta">1301            <span>Duplicates</span>1302            <Show when={dedupDone() !== null} fallback={1303              <button1304                class="peach-btn peach-btn--subtle peach-btn--sm"1305                onClick={() => void handleCountDuplicates()}1306                disabled={dedupRunning()}1307              >1308                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1309              </button>1310            }>1311              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1312                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1313              </span>1314            </Show>1315          </div>1316          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1317            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1318              <div class="peach-restore-warning">1319                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1320              </div>1321              <div style="display:flex;gap:6px">1322                <button1323                  class="peach-btn peach-btn--subtle peach-btn--sm"1324                  onClick={() => setDedupCount(null)}1325                >1326                  cancel1327                </button>1328                <button1329                  class="peach-btn peach-btn--accent peach-btn--sm"1330                  onClick={() => void handleDeduplicate()}1331                  disabled={dedupRunning()}1332                >1333                  {dedupRunning() ? "removing..." : "remove duplicates"}1334                </button>1335              </div>1336            </div>1337          </Show>13381339          <p class="peach-settings-sub-label">MAINTENANCE</p>1340          <div class="peach-settings-row peach-settings-row--meta">1341            <span>Trash auto-delete</span>1342            <select1343              class="peach-settings-select"1344              value={String(props.trashRetentionDays)}1345              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1346            >1347              <option value="7">7 days</option>1348              <option value="30">30 days</option>1349              <option value="90">90 days</option>1350              <option value="0">Never</option>1351            </select>1352          </div>1353          <div class="peach-settings-row peach-settings-row--meta">1354            <span>Advanced Vault Health</span>1355            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1356              open1357            </button>1358          </div>1359          <Show when={declinedCardCount() > 0}>1360            <div class="peach-settings-row peach-settings-row--meta">1361              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1362              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1363                Clear1364              </button>1365            </div>1366          </Show>13671368          <p class="peach-settings-sub-label">DATA</p>1369          <div class="peach-settings-row peach-settings-row--meta">1370            <span>Import passwords</span>1371            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1372              open1373            </button>1374          </div>1375          <div class="peach-settings-row peach-settings-row--meta">1376            <span>Export backup</span>1377            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1378              open1379            </button>1380          </div>1381          <div class="peach-settings-row peach-settings-row--meta">1382            <span>1383              Peach Codex1384              <Show when={!isPro()}>1385                <span class="peach-pro-badge">Pro</span>1386              </Show>1387            </span>1388            <button1389              class="peach-btn peach-btn--subtle peach-btn--sm"1390              onClick={() => props.onOpenCodexGenerate?.()}1391            >1392              open1393            </button>1394          </div>1395          {/* Restore from Codex: intentionally never gated — a printed1396              Codex must remain restorable regardless of license state. */}1397          <div class="peach-settings-row peach-settings-row--meta">1398            <span>Restore from Codex</span>1399            <button1400              class="peach-btn peach-btn--subtle peach-btn--sm"1401              onClick={() => props.onOpenCodexRestore?.()}1402            >1403              open1404            </button>1405          </div>14061407          <span class="peach-settings-sub-label">VAULT HISTORY</span>1408          <Show when={historyLoading()}>1409            <p class="peach-settings-hint">Loading…</p>1410          </Show>1411          <Show when={!historyLoading() && historySnapshots().length === 0}>1412            <p class="peach-settings-hint">1413              No snapshots yet. Peach saves automatically before every change —1414              saves, syncs, and imports.1415            </p>1416          </Show>1417          <Show when={!historyLoading() && historySnapshots().length > 0}>1418            <p class="peach-settings-hint">1419              Restoring locks your vault. Unlock with the password that1420              encrypted that snapshot to confirm.1421            </p>1422            <div class="peach-history-list">1423              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1424                {(snap) => (1425                  <div class="peach-history-row">1426                    <div class="peach-history-meta">1427                      <span class="peach-history-time">1428                        {formatSnapshotTime(snap.savedAt)}1429                      </span>1430                      <span class="peach-history-sub">1431                        {[1432                          snap.entryCount !== null1433                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1434                            : null,1435                          reasonLabel(snap.reason),1436                        ]1437                          .filter(Boolean)1438                          .join(" · ")}1439                      </span>1440                    </div>1441                    <button1442                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1443                        historyRestoring() === snap.id ? " is-busy" : ""1444                      }`}1445                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1446                      disabled={historyRestoring() !== null}1447                    >1448                      <Show when={historyRestoring() === snap.id} fallback="Restore">1449                        …1450                      </Show>1451                    </button>1452                  </div>1453                )}1454              </For>1455            </div>1456            <Show when={historySnapshots().length > 3}>1457              <button1458                class="peach-settings-advanced-toggle"1459                onClick={() => setHistoryShowAll((v) => !v)}1460                aria-expanded={historyShowAll()}1461              >1462                <span class="peach-settings-hint" style="margin:0">1463                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1464                </span>1465                <span class="peach-settings-advanced-chevron">1466                  {historyShowAll() ? "▲" : "▼"}1467                </span>1468              </button>1469            </Show>1470            <p class="peach-history-footer">1471              {historySnapshots().length} of 10 snapshots1472            </p>1473          </Show>14741475          <button1476            class="peach-settings-advanced-toggle"1477            onClick={() => setShowVaultAdvanced((v) => !v)}1478            aria-expanded={showVaultAdvanced()}1479          >1480            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1481            <span class="peach-settings-advanced-chevron">1482              {showVaultAdvanced() ? "▲" : "▼"}1483            </span>1484          </button>14851486          <Show when={showVaultAdvanced()}>1487            <div class="peach-danger-card">1488              <div class="peach-danger-card-head">1489                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1490                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1491              </div>1492              <p class="peach-danger-card-copy">1493                Wipes the current vault and restores from a backup file.1494                Recoverable only if your backup is good.1495              </p>1496              <button1497                class="peach-danger-card-button"1498                onClick={() => props.onOpenFileImport?.()}1499              >1500                Choose backup file1501              </button>1502            </div>15031504            <Show when={props.onOpenPanic}>1505              <div class="peach-danger-card peach-danger-card--hazard">1506                <div class="peach-danger-card-head">1507                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1508                  <h3 class="peach-danger-card-title">Destroy vault</h3>1509                </div>1510                <p class="peach-danger-card-copy">1511                  Permanently wipes every entry, setting, and license on this1512                  device. There is no recovery.1513                </p>1514                <button1515                  class="peach-danger-card-button"1516                  onClick={() => props.onOpenPanic?.()}1517                >1518                  Begin destruction1519                </button>1520              </div>1521            </Show>1522          </Show>1523        </div>1524      </div>1525    );1526  }15271528  function renderSync() {1529    return (1530      <div class="peach-settings-body">1531        <div class="peach-settings-group">1532          <div class="peach-settings-row peach-settings-row--meta">1533            <span>Status</span>1534            <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1535              {isPro() ? "Pro" : "Free"}1536              <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1537            </span>1538          </div>15391540          {/* Pick a pairing method and begin — no separate hub screen. */}1541          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1542          {/* One path in: the phone listens and shows a code, the browser dials1543              it. A browser extension can never accept a connection, so there is1544              no flow where this device is the one being joined. */}1545          <button1546            class="peach-sync-pair-option"1547            onClick={() => props.onOpenSyncPairing?.("code")}1548          >1549            <span class="peach-sync-pair-option-icon" aria-hidden="true">1550              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1551                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1552                <path d="M9 15 L11 15" />1553              </svg>1554            </span>1555            <span class="peach-sync-pair-option-main">1556              <span class="peach-sync-pair-option-title">Phone or tablet</span>1557              <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1558            </span>1559            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1560          </button>1561          <Show when={!isPro()}>1562            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1563              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1564            </p>1565          </Show>15661567          {/* Paired devices + manual sync — Settings is the hub. */}1568          <Show when={pairedDevices().length > 0}>1569            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1570            <For each={pairedDevices()}>1571              {(d) => (1572                <div class="peach-settings-row peach-settings-row--top">1573                  <div class="peach-settings-row-main">1574                    <span>{d.deviceName ?? "Unnamed device"}</span>1575                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1576                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1577                    </span>1578                  </div>1579                  <Show1580                    when={confirmUnpair() === d.deviceId}1581                    fallback={1582                      <button1583                        class="peach-btn peach-btn--subtle peach-btn--sm"1584                        onClick={() => setConfirmUnpair(d.deviceId)}1585                      >remove</button>1586                    }1587                  >1588                    <div style="display:flex;gap:6px;flex-shrink:0">1589                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1590                      <button1591                        class="peach-btn peach-btn--sm"1592                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1593                        onClick={() => void unpairDevice(d.deviceId)}1594                      >remove</button>1595                    </div>1596                  </Show>1597                </div>1598              )}1599            </For>1600            <div class="peach-settings-row peach-settings-row--meta">1601              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1602              <button1603                class="peach-btn peach-btn--subtle peach-btn--sm"1604                disabled={syncStatus() === "syncing"}1605                onClick={() => void syncNow()}1606              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1607            </div>1608          </Show>1609          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1610          <Show when={pendingVouched().length > 0}>1611            <p class="peach-settings-sub-label">1612              PENDING DEVICES1613              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1614            </p>1615            <For each={pendingVouched()}>1616              {(dev) => (1617                <div class="peach-settings-row peach-settings-row--top">1618                  <div class="peach-settings-row-main">1619                    <span>{dev.deviceName}</span>1620                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1621                      Was added by another of your devices. Allow it to sync with this vault?1622                    </span>1623                  </div>1624                  <div style="display:flex;gap:6px;flex-shrink:0">1625                    <button1626                      class="peach-btn peach-btn--sm"1627                      disabled={vouchedBusy() === dev.deviceId}1628                      onClick={() => void confirmVouched(dev.deviceId)}1629                    >Allow</button>1630                    <button1631                      class="peach-btn peach-btn--subtle peach-btn--sm"1632                      disabled={vouchedBusy() === dev.deviceId}1633                      onClick={() => void rejectVouched(dev.deviceId)}1634                    >Reject</button>1635                  </div>1636                </div>1637              )}1638            </For>1639          </Show>16401641          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1642          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1643          <Show1644            when={rotateConfirmOpen()}1645            fallback={1646              <div class="peach-settings-row peach-settings-row--top">1647                <div class="peach-settings-row-main">1648                  <span>Rotate sync identity</span>1649                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1650                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1651                  </span>1652                </div>1653                <button1654                  class="peach-btn peach-btn--subtle peach-btn--sm"1655                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1656                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1657                >rotate</button>1658              </div>1659            }1660          >1661            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1662              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.1663              <Show when={rotateError()}>1664                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1665              </Show>1666              <div style="display:flex;gap:6px;margin-top:10px">1667                <button1668                  class="peach-btn peach-btn--subtle peach-btn--sm"1669                  disabled={rotating()}1670                  onClick={() => setRotateConfirmOpen(false)}1671                >Cancel</button>1672                <button1673                  class="peach-btn peach-btn--sm"1674                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1675                  disabled={rotating()}1676                  onClick={() => void doRotateSyncIdentity()}1677                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1678              </div>1679            </div>1680          </Show>1681        </div>1682      </div>1683    );1684  }16851686  function renderAppearance() {1687    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1688      { id: "medium", label: "Default" },1689      { id: "large", label: "Large" },1690    ];1691    return (1692      <div class="peach-settings-body">1693        <div class="peach-settings-group">1694          <p class="peach-settings-sub-label">THEME</p>1695          <div class="peach-settings-row peach-settings-row--meta">1696            <span>Theme</span>1697            <button1698              class="peach-btn peach-btn--subtle peach-btn--sm"1699              onClick={() =>1700                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1701              }1702            >1703              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1704            </button>1705          </div>17061707          <p class="peach-settings-sub-label">ACCENT</p>1708          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1709            <span>Accent</span>1710            <div class="peach-accent-picker">1711              <For each={ACCENT_COLORS}>1712                {(color) => (1713                  <button1714                    class="peach-accent-swatch peach-swatch"1715                    classList={{ "is-active": props.accentColor === color.name }}1716                    style={{ background: color.hex }}1717                    onClick={() => void props.onAccentChange(color.name)}1718                    aria-label={color.name}1719                    title={color.name}1720                  />1721                )}1722              </For>1723            </div>1724          </div>17251726          <p class="peach-settings-sub-label">TEXT SIZE</p>1727          <div class="peach-settings-row peach-settings-row--meta">1728            <span>Size</span>1729            <div class="peach-pill-group">1730              <For each={sizeOptions}>1731                {(opt) => (1732                  <button1733                    class="peach-btn peach-btn--ghost peach-btn--sm"1734                    classList={{ "is-active": props.textSize === opt.id }}1735                    onClick={() => void props.onTextSizeChange(opt.id)}1736                  >1737                    {opt.label}1738                  </button>1739                )}1740              </For>1741            </div>1742          </div>17431744          <p class="peach-settings-sub-label">MOTION</p>1745          <div class="peach-settings-row peach-settings-row--meta">1746            <span>Reduce motion</span>1747            <button1748              class="peach-btn peach-btn--subtle peach-btn--sm"1749              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1750            >1751              {props.reduceMotion ? "On" : "Off"}1752            </button>1753          </div>1754        </div>1755      </div>1756    );1757  }17581759  function renderShortcuts() {1760    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1761    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17621763    function startCapture(action: ShortcutAction) {1764      setConflictMsg(null);1765      setCapturingAction(action);1766    }1767    function cancelCapture() {1768      setCapturingAction(null);1769      setConflictMsg(null);1770    }1771    function commitBinding(action: ShortcutAction, binding: Keybinding) {1772      const conflict = findConflict(props.shortcuts, binding, action);1773      if (conflict) {1774        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1775        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1776        return;1777      }1778      const next = { ...props.shortcuts, [action]: binding };1779      setCapturingAction(null);1780      setConflictMsg(null);1781      void props.onShortcutsChange(next);1782    }1783    function resetAction(action: ShortcutAction) {1784      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1785      void props.onShortcutsChange(next);1786    }1787    function resetAll() {1788      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1789    }17901791    // Capture-mode key handler — wired via ref onto the chip element so it1792    // catches the next non-modifier keypress and ignores other bindings.1793    function handleCaptureKey(e: KeyboardEvent) {1794      e.preventDefault();1795      e.stopPropagation();1796      if (e.key === "Escape") { cancelCapture(); return; }1797      const binding = keybindingFromEvent(e);1798      if (!binding) return; // bare modifier — keep waiting1799      const action = capturingAction();1800      if (action) commitBinding(action, binding);1801    }18021803    return (1804      <div class="peach-settings-body">1805        <div class="peach-settings-group peach-shortcuts-group">1806          <p class="peach-settings-sub-label">ENTRY VIEW</p>1807          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1808            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1809          </For>1810        </div>18111812        <div class="peach-settings-group peach-shortcuts-group">1813          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1814          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1815            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1816          </For>1817        </div>18181819        <Show when={conflictMsg()}>1820          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1821        </Show>18221823        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1824          <div class="peach-settings-row peach-settings-row--meta">1825            <span>Inline autofill on page</span>1826            <a1827              class="peach-settings-link"1828              href="chrome://extensions/shortcuts"1829              onClick={(e) => {1830                e.preventDefault();1831                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1832              }}1833            >1834              ⌘⇧P — customize ↗1835            </a>1836          </div>1837          <button1838            class="peach-btn peach-btn--subtle peach-btn--sm"1839            style="align-self:flex-start"1840            onClick={resetAll}1841          >1842            Reset all to defaults1843          </button>1844        </div>1845      </div>1846    );18471848    function renderRow(1849      desc: typeof SHORTCUT_DESCRIPTORS[number],1850      capturing: () => ShortcutAction | null,1851      onStart: (a: ShortcutAction) => void,1852      onCancel: () => void,1853      onReset: (a: ShortcutAction) => void,1854      onKey: (e: KeyboardEvent) => void,1855    ) {1856      const isActive = () => capturing() === desc.action;1857      const binding = () => props.shortcuts[desc.action];1858      const isCustom = () => {1859        const b = binding();1860        const d = DEFAULT_BINDINGS[desc.action];1861        return b.key !== d.key1862          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1863          || Boolean(b.meta)  !== Boolean(d.meta)1864          || Boolean(b.shift) !== Boolean(d.shift)1865          || Boolean(b.alt)   !== Boolean(d.alt);1866      };1867      return (1868        <div class="peach-shortcut-row">1869          <span class="peach-shortcut-row-label">{desc.label}</span>1870          <button1871            class="peach-shortcut-chip"1872            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1873            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1874            onKeyDown={(e) => { if (isActive()) onKey(e); }}1875            ref={(el) => {1876              // Auto-focus when entering capture mode so the keydown listener catches the next press.1877              if (el) {1878                queueMicrotask(() => { if (isActive()) el.focus(); });1879              }1880            }}1881          >1882            {isActive() ? "Press a key…" : formatKeybinding(binding())}1883          </button>1884          <Show when={isCustom() && !isActive()}>1885            <button1886              class="peach-shortcut-reset"1887              title="Reset to default"1888              onClick={() => onReset(desc.action)}1889            >1890              ↺1891            </button>1892          </Show>1893        </div>1894      );1895    }1896  }18971898  const PRO_FEATURES: Array<{1899    feature: ProFeature;1900    name: string;1901    desc: string;1902  }> = [1903    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1904    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1905    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1906    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1907  ];19081909  const PRO_SECURITY_FEATURES: Array<{1910    feature: ProFeature;1911    name: string;1912    desc: string;1913  }> = [1914    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1915    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1916  ];19171918  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1919  const [showKey, setShowKey] = createSignal(false);19201921  const [deactivating, setDeactivating] = createSignal(false);19221923  async function handleDeactivateLicense() {1924    setDeactivating(true);1925    await sendMessage({ type: "DEACTIVATE_LICENSE" });1926    setLicenseInfo(null);1927    await props.onLicenseChange?.();1928    setDeactivating(false);1929  }19301931  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1932    setCanaryTestingId(entry.id);1933    setCanaryTestResult(null);1934    const res = await sendMessage({1935      type: "FIRE_CANARY_PING",1936      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1937    }) as { ok: boolean };1938    setCanaryTestResult({ id: entry.id, ok: res.ok });1939    setCanaryTestingId(null);1940    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1941  }19421943  function renderLicense() {1944    async function onActivate() {1945      await handleActivateLicense();1946      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1947        await props.onLicenseChange?.();1948      }1949    }19501951    return (1952      <div class="peach-settings-body">1953        <Show1954          when={isPro()}1955          fallback={1956                <div class="peach-pro-upgrade">1957                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19581959                  <h2 class="peach-pro-headline">1960                    Every<em>thing,</em><br />every<em>where.</em>1961                  </h2>19621963                  <p class="peach-pro-subtitle">1964                    One key unlocks every Pro feature — for the lifetime of the app.1965                  </p>19661967                  <div class="peach-pro-grid">1968                    <For each={PRO_FEATURES}>1969                      {(item, i) => (1970                        <div1971                          class="peach-pro-grid-item"1972                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1973                        >1974                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1975                          <span class="peach-pro-grid-name">{item.name}</span>1976                          <span class="peach-pro-grid-desc">{item.desc}</span>1977                        </div>1978                      )}1979                    </For>1980                  </div>19811982                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1983                  <div class="peach-pro-grid">1984                    <For each={PRO_SECURITY_FEATURES}>1985                      {(item, i) => (1986                        <div1987                          class="peach-pro-grid-item"1988                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1989                        >1990                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1991                          <span class="peach-pro-grid-name">{item.name}</span>1992                          <span class="peach-pro-grid-desc">{item.desc}</span>1993                        </div>1994                      )}1995                    </For>1996                  </div>19971998                  <div class="peach-pro-upgrade-actions">1999                    <p class="peach-pro-pricing">2000                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2001                    </p>2002                    <a2003                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2004                      target="_blank"2005                      rel="noopener noreferrer"2006                      class="peach-pro-upgrade-cta"2007                    >2008                      Get Peach Pro2009                    </a>2010                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2011                    <div class="peach-pro-key-inline">2012                      <input2013                        class="peach-pro-key-inline-input"2014                        type="text"2015                        placeholder="PEACH-XXXX-XXXX-XXXX"2016                        value={licenseKeyInput()}2017                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2018                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2019                        autocomplete="off"2020                        spellcheck={false}2021                      />2022                      <button2023                        class="peach-pro-key-apply"2024                        onClick={() => void onActivate()}2025                        disabled={activatingLicense() || !licenseKeyInput().trim()}2026                      >2027                        {activatingLicense() ? "…" : "APPLY"}2028                      </button>2029                    </div>2030                    <Show when={licenseError()}>2031                      <p class="peach-pro-key-error">{licenseError()}</p>2032                    </Show>2033                  </div>2034                </div>2035          }2036        >2037          <div class="peach-pro-active">2038            <div class="peach-pro-active-hero">2039              <button2040                class="peach-pro-active-mark-wrap"2041                type="button"2042                aria-label="Celebrate"2043                onClick={handleProCelebrationClick}2044              >2045                <PeachMark class="peach-pro-active-mark" />2046                <Show when={justActivated()}>2047                  <div class="peach-pro-sparks" aria-hidden="true">2048                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2049                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2050                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2051                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2052                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2053                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2054                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2055                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2056                  </div>2057                </Show>2058              </button>2059              <h2 class="peach-pro-active-title">Peach Pro</h2>2060              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2061              <Show when={justActivated()}>2062                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2063              </Show>2064            </div>20652066            <Show when={licenseKey()}>2067              <div class="peach-settings-license">2068                <span class="peach-settings-label">LICENSE KEY</span>2069                <div class="peach-settings-license-row">2070                  <Show2071                    when={showKey()}2072                    fallback={2073                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2074                        ••••••••••••••••••••••••••••2075                      </span>2076                    }2077                  >2078                    <span class="peach-settings-license-key">{licenseKey()!}</span>2079                  </Show>2080                  <div class="peach-settings-license-actions">2081                    <button2082                      class="peach-settings-license-copy"2083                      title="Copy license key"2084                      onClick={() => copyLicenseKey(licenseKey()!)}2085                    >2086                      {copied() ? "✓" : "copy"}2087                    </button>2088                    <button2089                      class="peach-settings-license-toggle"2090                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2091                      title={showKey() ? "Hide" : "Reveal"}2092                      onClick={() => setShowKey(v => !v)}2093                    >2094                      <Show2095                        when={showKey()}2096                        fallback={2097                          <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">2098                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2099                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2100                          </svg>2101                        }2102                      >2103                        <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">2104                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2105                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2106                          <line x1="2" y1="2" x2="12" y2="12" />2107                        </svg>2108                      </Show>2109                    </button>2110                  </div>2111                </div>2112              </div>2113            </Show>21142115            <div class="peach-pro-active-features">2116              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2117                {(item) => (2118                  <div class="peach-pro-active-feature">2119                    <span class="peach-pro-active-feature-mark">✦</span>2120                    <span class="peach-pro-active-feature-name">{item.name}</span>2121                  </div>2122                )}2123              </For>2124            </div>21252126            <button2127              class="peach-pro-deactivate"2128              onClick={() => void handleDeactivateLicense()}2129              disabled={deactivating()}2130            >2131              {deactivating() ? "Deactivating…" : "Deactivate"}2132            </button>2133          </div>2134        </Show>2135      </div>2136    );2137  }21382139  function renderFeedback() {2140    return (2141      <div class="peach-settings-body">2142        <div class="peach-settings-group peach-feedback-group">2143          <Show2144            when={feedbackState() === 'sent'}2145            fallback={2146              <>2147                <p class="peach-feedback-intro">2148                  Help us improve Peach. Every report goes directly to our inbox.2149                </p>21502151                <p class="peach-settings-sub-label">TYPE</p>2152                <div class="peach-feedback-chips">2153                  <For each={FEEDBACK_CATEGORIES}>2154                    {(cat) => {2155                      const selected = () => fbCategory() === cat;2156                      return (2157                        <button2158                          type="button"2159                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2160                          aria-pressed={selected()}2161                          onClick={() => setFbCategory(cat)}2162                        >2163                          {cat}2164                        </button>2165                      );2166                    }}2167                  </For>2168                </div>21692170                <p class="peach-settings-sub-label">MESSAGE</p>2171                <textarea2172                  class="peach-feedback-textarea"2173                  placeholder="Describe the issue or idea..."2174                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2175                  value={fbMessage()}2176                  onInput={(e) => setFbMessage(e.currentTarget.value)}2177                />21782179                <p class="peach-feedback-meta">2180                  Version {version} · will be included automatically2181                </p>21822183                <button2184                  type="button"2185                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2186                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2187                  onClick={() => void handleSubmitFeedback()}2188                >2189                  <Switch>2190                    <Match when={feedbackState() === 'idle'}>Send</Match>2191                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2192                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2193                  </Switch>2194                </button>21952196                <p class="peach-feedback-anon">2197                  Sent anonymously · no account required2198                </p>2199              </>2200            }2201          >2202            <div class="peach-feedback-thanks" role="status" aria-live="polite">2203              <p class="peach-feedback-thanks-title">Thank you</p>2204              <p>2205                Your note is in the queue. Someone will process it soon.2206              </p>2207            </div>2208          </Show>2209        </div>2210      </div>2211    );2212  }22132214  const bodies: StackBodies = {2215    protection: renderSecurity(),2216    vault: renderVault(),2217    sync: renderSync(),2218    appearance: renderAppearance(),2219    shortcuts: renderShortcuts(),2220    pro: renderLicense(),2221    feedback: renderFeedback(),2222  };22232224  return (2225    <Show2226      when={showNewRecoveryWords()}2227      fallback={2228        <div2229          class="peach-overlay-view peach-settings-overlay"2230          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2231          style="display:flex;flex-direction:column"2232        >2233          <StackDeck2234            active={activeCard()}2235            onActiveChange={setActiveCard}2236            onBack={props.onBack}2237            cards={cards()}2238            bodies={bodies}2239            version={version}2240            isFanning={isFanning()}2241            celebratingCardId={justActivated() ? "pro" : null}2242            isPro={isPro()}2243          />2244        </div>2245      }2246    >2247      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2248    </Show>2249  );2250}