Settings.tsx

91.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import type { PairedDevice } from "../../shared/sync-types";16import {17  DEFAULT_BINDINGS,18  findConflict,19  formatKeybinding,20  keybindingFromEvent,21  SHORTCUT_DESCRIPTORS,22  type Keybinding,23  type ShortcutAction,24} from "../../shared/shortcuts";25import { isNativePlatform, nativePlugin } from "../../shared/native";26import { WEB3FORMS_KEY } from "../../shared/config";2728interface LicenseInfo {29  valid: boolean;30  plan: "free" | "pro";31  issued?: string;32}3334interface SettingsProps {35  entriesCount: number;36  breachMonitoring: boolean;37  lockOnClose: boolean;38  autoLockMinutes: number;39  trashRetentionDays: number;40  theme: "dark" | "light";41  accentColor: AccentColorName;42  textSize: "small" | "medium" | "large";43  reduceMotion: boolean;44  isPro?: boolean;45  initialSection?: string | null;46  upgradeHighlight?: ProFeature | null;47  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;48  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;49  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;50  onTrashRetentionChange: (days: number) => void | Promise<void>;51  onThemeChange: (theme: "dark" | "light") => void;52  onAccentChange: (name: AccentColorName) => void | Promise<void>;53  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;54  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;55  shortcuts: Record<ShortcutAction, Keybinding>;56  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;57  onLicenseChange?: () => void | Promise<void>;58  isExiting?: boolean;59  onBack: () => void;60  onImport: () => void;61  onExport: () => void;62  onBreachCheckDone: () => void;63  onOpenHealth?: () => void;64  onOpenSyncPairing?: (method?: "chirp" | "desktop") => void;65  onOpenFileImport?: () => void;66  onOpenPanic?: () => void;67  onOpenCodexGenerate?: () => void;68  onOpenCodexRestore?: () => void;69  /** Invoked from within Settings when a Pro-gated control is tapped on70   *  free tier. App owns the upgrade navigation + highlight state, so this71   *  callback is the single way for Settings to request that navigation. */72  onProGate?: (feature: ProFeature) => void;73  onCardChange?: (id: CardId) => void;74}7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];77const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";78const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;79const PRO_CELEBRATION_CARD_STEP_MS = 150;80const PRO_CELEBRATION_THANK_YOU_MS = 3200;8182function sanitizeFeedbackMessage(value: string): string {83  return value84    .trim()85    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)86    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")87    .replace(/&/g, "&amp;")88    .replace(/</g, "&lt;")89    .replace(/>/g, "&gt;")90    .replace(/"/g, "&quot;")91    .replace(/'/g, "&#39;");92}9394type DataCollectionPermissionApi = {95  permissions?: {96    getAll?: () => Promise<{ data_collection?: string[] }>;97    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;98  };99};100101async function requestFeedbackDataPermission(): Promise<boolean> {102  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??103    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;104  if (!api?.permissions?.getAll) return true;105106  try {107    const permissions = await api.permissions.getAll();108    if (!("data_collection" in permissions)) return true;109    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;110    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));111  } catch {112    return false;113  }114}115116function formatActivatedSince(issued: string | undefined): string {117  if (!issued) return "Active";118  try {119    const d = new Date(issued);120    if (Number.isNaN(d.getTime())) return "Active";121    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;122  } catch {123    return "Active";124  }125}126127function initialSectionToCard(value: string | null | undefined): CardId | null {128  if (!value) return null;129  if (value === "security") return "protection";130  if (value === "license") return "pro";131  if (132    value === "protection" ||133    value === "vault" ||134    value === "sync" ||135    value === "appearance" ||136    value === "shortcuts" ||137    value === "pro" ||138    value === "feedback"139  ) {140    return value;141  }142  return null;143}144145export function SettingsView(props: SettingsProps) {146  const manifest = chrome.runtime.getManifest();147  const version = manifest.version;148  const initial = props.initialSection;149  const [activeCard, setActiveCard] = createSignal<CardId>(150    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"151  );152153  createEffect(() => { props.onCardChange?.(activeCard()); });154155  const [justActivated, setJustActivated] = createSignal(false);156  const [isFanning, setIsFanning] = createSignal(false);157158  function triggerCelebration() {159    if (isFanning()) return;160    setJustActivated(false);161    setIsFanning(true);162    setActiveCard("pro");163    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];164    fanOrder.forEach((id, i) => {165      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);166    });167    window.setTimeout(() => {168      setActiveCard("pro");169      setIsFanning(false);170      setJustActivated(true);171      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);172    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);173  }174175  function handleProCelebrationClick(e: MouseEvent) {176    e.stopPropagation();177    triggerCelebration();178  }179180  const [checkingBreaches, setCheckingBreaches] = createSignal(false);181  const [breachResult, setBreachResult] = createSignal<string | null>(null);182  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);183  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");184  const [licenseError, setLicenseError] = createSignal<string | null>(null);185  const [, setLicenseSuccess] = createSignal<string | null>(null);186  const [activatingLicense, setActivatingLicense] = createSignal(false);187188  const isPro = () => props.isPro ?? false;189  const cards = createMemo<StackCardSpec[]>(() => [190    {191      id: "pro",192      num: "I · FOREVER",193      title: "Peach Pro",194      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",195      bgVar: "--stack-pro-bg",196      fgVar: "--stack-pro-fg",197      icon: "spark",198      onIconClick: isPro() ? triggerCelebration : undefined,199    },200    {201      id: "protection",202      num: "II · PROTECT",203      title: "Protection",204      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,205      bgVar: "--stack-protect-bg",206      fgVar: "--stack-protect-fg",207      icon: "shield",208    },209    {210      id: "vault",211      num: "III · KEEP",212      title: "Vault",213      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,214      bgVar: "--stack-vault-bg",215      fgVar: "--stack-vault-fg",216      icon: "lock",217    },218    {219      id: "sync",220      num: "IV · ECHO",221      title: "Sync",222      lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",223      bgVar: "--stack-sync-bg",224      fgVar: "--stack-sync-fg",225      icon: "sync",226    },227    {228      id: "appearance",229      num: "V · DRESS",230      title: "Appearance",231      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,232      bgVar: "--stack-appear-bg",233      fgVar: "--stack-appear-fg",234      icon: "sun",235    },236    {237      id: "shortcuts",238      num: "VI · KEYS",239      title: "Shortcuts",240      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,241      bgVar: "--stack-shortcut-bg",242      fgVar: "--stack-shortcut-fg",243      icon: "keys",244    },245    {246      id: "feedback",247      num: "VII · ECHO",248      title: "Feedback",249      lede: `version ${version} · tell us what would make it better`,250      bgVar: "--stack-feedback-bg",251      fgVar: "--stack-feedback-fg",252      icon: "bubble",253    },254  ]);255  const [biometricSupported, setBiometricSupported] = createSignal(false);256  const [biometricStored, setBiometricStored] = createSignal(false);257  const [biometricBusy, setBiometricBusy] = createSignal(false);258  const [biometricError, setBiometricError] = createSignal<string | null>(null);259  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);260  const [passkeySaving, setPasskeySaving] = createSignal(false);261262  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting263  // explicit confirmation before they become sync targets.264  interface PendingVouched { deviceId: string; deviceName: string }265  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);266  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);267  // Paired-device management lives here — Settings is the sync hub.268  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);269  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272273  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;274  function syncRelativeTime(when: number | undefined): string {275    if (!when) return "Not synced yet";276    const elapsed = Date.now() - when;277    if (elapsed < 60_000) return "Just now";278    const minutes = Math.floor(elapsed / 60_000);279    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;280    const hours = Math.floor(minutes / 60);281    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;282    const days = Math.floor(hours / 24);283    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";284  }285  function isSyncStale(d: PairedDevice): boolean {286    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;287  }288289  async function loadPairedDevices() {290    if (!isPro()) return;291    const r = (await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" })) as {292      ok: boolean;293      data?: { devices: PairedDevice[] };294    };295    if (r.ok) setPairedDevices(r.data?.devices ?? []);296  }297  async function unpairDevice(deviceId: string) {298    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });299    setConfirmUnpair(null);300    await loadPairedDevices();301  }302  async function syncNow() {303    setSyncStatus("syncing");304    setSyncFlash(null);305    const res = (await sendMessage({306      type: "TRIGGER_SYNC",307      payload: { reason: "manual", force: true },308    })) as { ok: boolean; error?: string };309    setTimeout(async () => {310      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {311        ok: boolean;312        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };313      };314      const s = state.data;315      if (!s) return;316      setSyncStatus(s.status);317      if (s.status === "idle") {318        setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");319        setTimeout(() => setSyncFlash(null), 3000);320        await loadPairedDevices();321      } else if (s.status === "error") {322        setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);323        setTimeout(() => setSyncFlash(null), 4000);324      }325    }, 250);326    if (!res.ok) {327      setSyncStatus("error");328      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);329      setTimeout(() => setSyncFlash(null), 4000);330    }331  }332  // UI-4 / MV-8: rotate sync identity confirmation dialog.333  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);334  const [rotating, setRotating] = createSignal(false);335  const [rotateError, setRotateError] = createSignal<string | null>(null);336337  async function loadPendingVouched() {338    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {339      ok: boolean;340      data?: PendingVouched[];341    };342    if (r.ok) setPendingVouched(r.data ?? []);343  }344  async function confirmVouched(deviceId: string) {345    setVouchedBusy(deviceId);346    try {347      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });348      await loadPendingVouched();349    } finally {350      setVouchedBusy(null);351    }352  }353  async function rejectVouched(deviceId: string) {354    setVouchedBusy(deviceId);355    try {356      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });357      await loadPendingVouched();358    } finally {359      setVouchedBusy(null);360    }361  }362  async function doRotateSyncIdentity() {363    setRotateError(null);364    setRotating(true);365    try {366      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {367        ok: boolean;368        error?: string;369      };370      if (!r.ok) {371        setRotateError(r.error ?? "Rotation failed.");372        return;373      }374      setRotateConfirmOpen(false);375      // Backend set peach_recovery_pending; reuse the existing recovery-words376      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).377      setShowNewRecoveryWords(true);378    } finally {379      setRotating(false);380    }381  }382383  const [dedupCount, setDedupCount] = createSignal<number | null>(null);384  const [dedupRunning, setDedupRunning] = createSignal(false);385  const [dedupDone, setDedupDone] = createSignal<number | null>(null);386387  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);388389  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted390  // BEFORE every real-mode vault write so the user can roll back if a sync391  // or import overwrites the wrong state.392  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);393  const [historyLoading, setHistoryLoading] = createSignal(false);394  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);395  const [historyShowAll, setHistoryShowAll] = createSignal(false);396397  async function loadVaultHistory() {398    setHistoryLoading(true);399    try {400      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });401      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];402      setHistorySnapshots(list);403    } finally {404      setHistoryLoading(false);405    }406  }407408  async function handleRestoreVaultSnapshot(id: string) {409    if (historyRestoring()) return;410    setHistoryRestoring(id);411    const res = await sendMessage({412      type: "RESTORE_VAULT_SNAPSHOT",413      payload: { snapshotId: id },414    });415    setHistoryRestoring(null);416    if ((res as { ok: boolean }).ok) {417      // Vault is now locked — let the parent close Settings; App.tsx's418      // VAULT_LOCKED listener will route back to the lock screen.419      props.onBack();420    }421  }422423  function formatSnapshotTime(savedAt: number): string {424    const diff = Date.now() - savedAt;425    if (diff < 60_000) return "Just now";426    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;427    const d = new Date(savedAt);428    const isToday = new Date().toDateString() === d.toDateString();429    const isYest =430      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();431    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });432    if (isToday) return `Today ${t}`;433    if (isYest) return `Yesterday ${t}`;434    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;435  }436437  function reasonLabel(reason: VaultSnapshot["reason"]): string {438    return (439      {440        local_save: "saved",441        sync_received: "synced",442        import: "imported",443        restore: "restored",444      } as const445    )[reason] ?? reason;446  }447448  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);449450  const [showChangePw, setShowChangePw] = createSignal(false);451  const [changePwCurrent, setChangePwCurrent] = createSignal("");452  const [changePwNew, setChangePwNew] = createSignal("");453  const [changePwConfirm, setChangePwConfirm] = createSignal("");454  const [changePwSaving, setChangePwSaving] = createSignal(false);455  const [changePwError, setChangePwError] = createSignal<string | null>(null);456  const [changePwSuccess] = createSignal(false);457  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);458459  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.460  const [showViewWords, setShowViewWords] = createSignal(false);461  const [viewWordsPassword, setViewWordsPassword] = createSignal("");462  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);463  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);464  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);465466  async function handleViewRecoveryWords() {467    setViewWordsError(null);468    if (!viewWordsPassword()) {469      setViewWordsError("Enter your master password.");470      return;471    }472    setViewWordsBusy(true);473    try {474      const r = (await sendMessage({475        type: "GET_RECOVERY_WORDS",476        payload: { password: viewWordsPassword() },477      })) as { ok: boolean; data?: string[]; error?: string };478      if (r.ok) {479        setViewedWords(r.data ?? []);480        setViewWordsPassword("");481        return;482      }483      switch (r.error) {484        case "wrong_password":485          setViewWordsError("Incorrect password");486          break;487        case "password_required":488          setViewWordsError("Enter your master password.");489          break;490        case "recovery_not_setup":491          setViewWordsError("Recovery words aren't set up for this vault yet.");492          break;493        default:494          setViewWordsError("Couldn't retrieve recovery words.");495      }496    } finally {497      setViewWordsBusy(false);498    }499  }500  function closeViewWords() {501    setShowViewWords(false);502    setViewWordsPassword("");503    setViewWordsError(null);504    setViewedWords(null);505  }506507  const [duressEnabled, setDuressEnabled] = createSignal(false);508  const [showDuressSetup, setShowDuressSetup] = createSignal(false);509  const [duressPassword, setDuressPassword] = createSignal("");510  const [duressConfirm, setDuressConfirm] = createSignal("");511  const [duressMainPassword, setDuressMainPassword] = createSignal("");512  const [duressSaving, setDuressSaving] = createSignal(false);513  const [duressError, setDuressError] = createSignal<string | null>(null);514  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);515516  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);517518  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);519  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);520521  const [fbCategory, setFbCategory] = createSignal("Bug report");522  const [fbMessage, setFbMessage] = createSignal("");523  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';524  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');525526  createEffect(() => {527    if (props.upgradeHighlight) {528      setActiveCard("pro");529    }530  });531532  onMount(async () => {533    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([534      sendMessage({ type: "GET_LICENSE" }),535      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),536      sendMessage({ type: "IS_DURESS_ENABLED" }),537      sendMessage({ type: "GET_SETTINGS" }),538      sendMessage({ type: "GET_LICENSE_KEY" }),539    ]);540541    // Vault history loads in the background — the Vault sub-view picks it up542    // when the user navigates in. Loading it lazily keeps Settings boot fast.543    void loadVaultHistory();544545    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the546    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.547    void loadPendingVouched();548    void loadPairedDevices();549    const onBroadcast = (msg: unknown) => {550      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {551        void loadPendingVouched();552      }553    };554    chrome.runtime?.onMessage?.addListener(onBroadcast);555    onCleanup(() => {556      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }557    });558559    if ((licenseRes as { ok: boolean }).ok) {560      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);561    }562563    if ((passkeyRes as { ok: boolean }).ok) {564      setPasskeyState(565        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null566      );567    }568569    if ((duressRes as { ok: boolean }).ok) {570      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);571    }572573    if ((settingsRes as { ok: boolean }).ok) {574      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;575      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);576    }577578    if ((keyRes as { ok: boolean }).ok) {579      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);580    }581582    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });583    if ((entriesRes as { ok: boolean }).ok) {584      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];585      setCanaryEntries(586        entries587          .filter((e) => e.canary?.webhookUrl)588          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))589      );590    }591592593    if (isNativePlatform()) {594      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");595      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");596      try {597        if (biometric) {598          const a = await biometric.isAvailable();599          setBiometricSupported(a.available);600        }601        if (keystore) {602          const k = await keystore.hasMasterKey();603          setBiometricStored(k.exists);604        }605      } catch { /* missing plugins — leave defaults */ }606    }607  });608609  async function handleEnableBiometrics() {610    setBiometricBusy(true);611    setBiometricError(null);612    try {613      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {614        ok: boolean;615        data?: { masterKeyHex: string };616        error?: string;617      };618      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {619        setBiometricError(keyRes.error === "vault_locked"620          ? "unlock the vault first"621          : "couldn't read derived key");622        return;623      }624      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");625      if (!keystore) {626        setBiometricError("biometric plugin unavailable");627        return;628      }629      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });630      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });631      setBiometricStored(true);632    } catch (e) {633      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");634    } finally {635      setBiometricBusy(false);636    }637  }638639  async function handleDisableBiometrics() {640    setBiometricBusy(true);641    setBiometricError(null);642    try {643      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");644      if (keystore) await keystore.clearMasterKey();645      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });646      setBiometricStored(false);647    } catch (e) {648      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");649    } finally {650      setBiometricBusy(false);651    }652  }653654  async function handleChangePassword() {655    setChangePwError(null);656    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }657    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }658    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }659    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }660    setChangePwSaving(true);661    const res = await sendMessage({662      type: "CHANGE_PASSWORD",663      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },664    }) as { ok: boolean; error?: string };665    if (res.ok) {666      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");667      setShowChangePw(false);668      // LV-4: the user just authenticated (current password), so the recovery669      // words reveal that follows is exempt from the re-auth gate. Set the670      // pending flag the same way the create/recovery flow does; the display671      // clears it on confirm.672      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });673      setShowNewRecoveryWords(true);674    } else {675      setChangePwError(res.error ?? "failed to change password");676    }677    setChangePwSaving(false);678  }679680  const [copied, setCopied] = createSignal(false);681682  async function copyLicenseKey(key: string) {683    await navigator.clipboard.writeText(key);684    setCopied(true);685    setTimeout(() => setCopied(false), 2000);686  }687688  async function handleActivateLicense() {689    const key = licenseKeyInput().trim();690    if (!key) return;691    setActivatingLicense(true);692    setLicenseError(null);693    setLicenseSuccess(null);694    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });695    setActivatingLicense(false);696    if ((res as { ok: boolean }).ok) {697      const data = (res as { ok: boolean; data?: LicenseInfo }).data;698      if (data?.valid) {699        setLicenseInfo(data);700        setLicenseKey(key);701        setLicenseSuccess("License activated!");702        setLicenseKeyInput("");703      } else {704        setLicenseError("Invalid license key");705      }706    } else {707      setLicenseError("Activation failed");708    }709  }710711  async function handleTogglePasskeys() {712    const current = passkeyState();713    if (!current || !current.supported) return;714    setPasskeySaving(true);715    const res = await sendMessage({716      type: "SET_PASSKEY_PROVIDER_ENABLED",717      payload: { enabled: !current.enabled },718    });719    setPasskeySaving(false);720    if ((res as { ok: boolean }).ok) {721      setPasskeyState(722        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null723      );724    }725  }726727  async function handleCheckAllBreaches() {728    setCheckingBreaches(true);729    setBreachResult(null);730    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });731    setCheckingBreaches(false);732    if ((res as { ok: boolean }).ok) {733      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};734      const breached = Object.values(data).filter((count) => count > 0).length;735      setBreachResult(736        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"737      );738      props.onBreachCheckDone();739    }740  }741742743  async function handleSetupDuress() {744    const mainPw = duressMainPassword().trim();745    const duressPw = duressPassword().trim();746    const confirmPw = duressConfirm().trim();747748    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }749    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }750    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }751    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }752753    setDuressSaving(true);754    setDuressError(null);755756    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };757    if (!verifyRes.ok || !verifyRes.data) {758      setDuressError("incorrect main password");759      setDuressSaving(false);760      return;761    }762763    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };764    setDuressSaving(false);765    if (res.ok) {766      setDuressEnabled(true);767      setShowDuressSetup(false);768      setDuressPassword("");769      setDuressConfirm("");770      setDuressMainPassword("");771      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");772      setTimeout(() => setDuressSuccess(null), 5000);773    } else {774      setDuressError(775        res.error === "session_expired"776          ? "Vault session expired — lock and unlock Peach, then try again"777          : res.error ?? "failed to set duress password"778      );779    }780  }781782  async function handleClearDeclinedCards() {783    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });784    setDeclinedCardCount(0);785  }786787  async function handleCountDuplicates() {788    setDedupRunning(true);789    setDedupDone(null);790    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };791    setDedupRunning(false);792    if (res.ok) setDedupCount(res.data ?? 0);793  }794795  async function handleDeduplicate() {796    setDedupRunning(true);797    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };798    setDedupRunning(false);799    if (res.ok) {800      setDedupDone(res.data ?? 0);801      setDedupCount(0);802    }803  }804805  async function handleDisableDuress() {806    const mainPw = duressMainPassword().trim();807    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }808809    setDuressSaving(true);810    setDuressError(null);811812    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };813    if (!verifyRes.ok || !verifyRes.data) {814      setDuressError("incorrect main password");815      setDuressSaving(false);816      return;817    }818819    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };820    setDuressSaving(false);821    if (res.ok) {822      setDuressEnabled(false);823      setShowDuressSetup(false);824      setDuressMainPassword("");825      setDuressSuccess("Duress password disabled.");826      setTimeout(() => setDuressSuccess(null), 4000);827    } else {828      setDuressError(res.error ?? "failed to disable duress password");829    }830  }831832  async function handleSubmitFeedback() {833    const message = sanitizeFeedbackMessage(fbMessage());834    if (!message) return;835836    setFeedbackState('sending');837838    try {839      const hasDataPermission = await requestFeedbackDataPermission();840      if (!hasDataPermission) {841        setFeedbackState('idle');842        return;843      }844845      const category = FEEDBACK_CATEGORIES.includes(fbCategory())846        ? fbCategory()847        : FEEDBACK_CATEGORIES[0];848      const res = await fetch('https://api.web3forms.com/submit', {849        method: 'POST',850        headers: { 'Content-Type': 'application/json' },851        body: JSON.stringify({852          access_key: WEB3FORMS_KEY,853          subject: `Peach Feedback - ${category}`,854          message,855          from_name: 'Peach User',856          // Honeypot — leave blank857          botcheck: '',858        }),859      });860861      if (!res.ok) throw new Error(`HTTP ${res.status}`);862      const data = await res.json() as { success: boolean; message?: string };863      if (!data.success) throw new Error(data.message ?? 'unknown error');864865      setFeedbackState('sent');866      setFbMessage('');867      setFbCategory(FEEDBACK_CATEGORIES[0]);868    } catch (err) {869      console.error('[feedback]', err);870      setFeedbackState('error');871      setTimeout(() => setFeedbackState('idle'), 4000);872    }873  }874875  function renderSecurity() {876    return (877      <div class="peach-settings-body">878        <div class="peach-settings-group">879          <p class="peach-settings-sub-label">LOCKING</p>880          <div class="peach-settings-row">881            <span>Lock on close</span>882            <button883              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}884              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}885              aria-label="Toggle lock on close"886            >887              <span class="peach-toggle-knob"></span>888            </button>889          </div>890          <div class="peach-settings-row">891            <span>Auto-lock after</span>892            <select893              class="peach-settings-select"894              value={String(props.autoLockMinutes)}895              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}896            >897              <option value="1">1 minute</option>898              <option value="5">5 minutes</option>899              <option value="15">15 minutes</option>900              <option value="30">30 minutes</option>901              <option value="0">Never</option>902            </select>903          </div>904905          <Show when={isNativePlatform() && biometricSupported()}>906            <p class="peach-settings-sub-label">BIOMETRICS</p>907            <div class="peach-settings-row">908              <span>Unlock with biometrics</span>909              <button910                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}911                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}912                disabled={biometricBusy()}913                aria-label="Toggle biometric unlock"914              >915                <span class="peach-toggle-knob"></span>916              </button>917            </div>918            <Show when={biometricError()}>919              <div class="peach-settings-row peach-settings-row--meta">920                <span class="peach-hint peach-hint-error">{biometricError()}</span>921              </div>922            </Show>923          </Show>924925          <p class="peach-settings-sub-label">BREACH PROTECTION</p>926          <div class="peach-settings-row">927            <span>Breach monitoring</span>928            <button929              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}930              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}931              aria-label="Toggle breach monitoring"932            >933              <span class="peach-toggle-knob"></span>934            </button>935          </div>936          <div class="peach-settings-row peach-settings-row--meta">937            <span>Check all passwords</span>938            <button939              class="peach-btn peach-btn--subtle peach-btn--sm"940              onClick={() => void handleCheckAllBreaches()}941              disabled={checkingBreaches()}942            >943              {checkingBreaches() ? "checking..." : "run"}944            </button>945          </div>946          <Show when={breachResult()}>947            <div class="peach-settings-row peach-settings-row--meta">948              <span>Last check</span>949              <span class="peach-settings-value">{breachResult()}</span>950            </div>951          </Show>952953          <p class="peach-settings-sub-label">MASTER PASSWORD</p>954          <div class="peach-settings-row peach-settings-row--meta">955            <span>Master password</span>956            <button957              class="peach-btn peach-btn--subtle peach-btn--sm"958              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}959            >960              {showChangePw() ? "cancel" : "change"}961            </button>962          </div>963          <Show when={showChangePw()}>964            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">965              <input966                class="peach-settings-input"967                type="password"968                placeholder="current password"969                value={changePwCurrent()}970                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}971                autocomplete="current-password"972              />973              <input974                class="peach-settings-input"975                type="password"976                placeholder="new password (min 12 chars)"977                value={changePwNew()}978                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}979                autocomplete="new-password"980              />981              <input982                class="peach-settings-input"983                type="password"984                placeholder="confirm new password"985                value={changePwConfirm()}986                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}987                autocomplete="new-password"988                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}989              />990              <Show when={changePwError()}>991                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>992              </Show>993              <Show when={changePwSuccess()}>994                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>995              </Show>996              <button997                class="peach-btn peach-btn--accent peach-btn--sm"998                style="align-self:flex-start"999                onClick={() => void handleChangePassword()}1000                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1001              >1002                {changePwSaving() ? "saving…" : "save new password"}1003              </button>1004            </div>1005          </Show>10061007          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1008          <div class="peach-settings-row peach-settings-row--meta">1009            <span>Recovery words</span>1010            <button1011              class="peach-btn peach-btn--subtle peach-btn--sm"1012              onClick={() => {1013                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1014              }}1015            >1016              {showViewWords() ? "cancel" : "view"}1017            </button>1018          </div>1019          <Show when={showViewWords()}>1020            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1021              <Show1022                when={viewedWords()}1023                fallback={1024                  <>1025                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1026                      Enter your master password to reveal your recovery words.1027                    </p>1028                    <input1029                      class="peach-settings-input"1030                      type="password"1031                      placeholder="master password"1032                      value={viewWordsPassword()}1033                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1034                      autocomplete="current-password"1035                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1036                    />1037                    <Show when={viewWordsError()}>1038                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1039                    </Show>1040                    <button1041                      class="peach-btn peach-btn--accent peach-btn--sm"1042                      style="align-self:flex-start"1043                      onClick={() => void handleViewRecoveryWords()}1044                      disabled={viewWordsBusy() || !viewWordsPassword()}1045                    >1046                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1047                    </button>1048                  </>1049                }1050              >1051                <div class="peach-recovery-words-grid">1052                  <For each={viewedWords()!}>1053                    {(word, i) => (1054                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1055                    )}1056                  </For>1057                </div>1058                <button1059                  class="peach-btn peach-btn--subtle peach-btn--sm"1060                  style="align-self:flex-start"1061                  onClick={closeViewWords}1062                >done</button>1063              </Show>1064            </div>1065          </Show>10661067          <p class="peach-settings-sub-label">1068            DURESS PASSWORD1069            <Show when={!isPro()}>1070              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1071            </Show>1072          </p>1073          <div class="peach-settings-row peach-settings-row--top">1074            <div class="peach-settings-row-main">1075              <span>1076                Duress password1077                <Show when={!isPro()}>1078                  <span class="peach-pro-badge">Pro</span>1079                </Show>1080              </span>1081              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1082                If entered under coercion, shows a decoy empty vault instead of your real one.1083              </span>1084            </div>1085            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1086              {duressEnabled() ? "on" : "off"}1087            </div>1088          </div>10891090          <Show when={duressEnabled() && !showDuressSetup()}>1091            <div class="peach-settings-row peach-settings-row--meta">1092              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1093                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.1094              </span>1095            </div>1096          </Show>10971098          <Show when={duressSuccess()}>1099            <div class="peach-settings-row peach-settings-row--meta">1100              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1101            </div>1102          </Show>11031104          <Show when={!showDuressSetup()}>1105            <div class="peach-settings-row peach-settings-row--meta">1106              <button1107                class="peach-btn peach-btn--subtle peach-btn--sm"1108                onClick={() => {1109                  // Pro gate entry-point: free users are redirected to the1110                  // upgrade screen before the setup form opens. The server-1111                  // side SETUP_DURESS handler is the single enforcement1112                  // point; this is UI redirection so the form never appears1113                  // for a user who can't complete it.1114                  if (!isPro() && !duressEnabled()) {1115                    props.onProGate?.("duress_vault");1116                    setActiveCard("pro");1117                    return;1118                  }1119                  setShowDuressSetup(true);1120                  setDuressError(null);1121                  setDuressSuccess(null);1122                  setDuressPassword("");1123                  setDuressConfirm("");1124                  setDuressMainPassword("");1125                }}1126              >1127                {duressEnabled() ? "Change / disable" : "Set up"}1128              </button>1129            </div>1130          </Show>11311132          <Show when={showDuressSetup()}>1133            <div class="peach-duress-form">1134              <label class="peach-label">main password (to confirm)</label>1135              <input1136                class="peach-settings-input"1137                type="password"1138                placeholder="your current master password"1139                autocomplete="current-password"1140                value={duressMainPassword()}1141                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1142              />1143              <Show when={!duressEnabled()}>1144                <label class="peach-label">new duress password</label>1145                <input1146                  class="peach-settings-input"1147                  type="password"1148                  placeholder="duress password (min 8 chars)"1149                  autocomplete="new-password"1150                  value={duressPassword()}1151                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1152                />1153                <label class="peach-label">confirm duress password</label>1154                <input1155                  class="peach-settings-input"1156                  type="password"1157                  placeholder="confirm duress password"1158                  autocomplete="new-password"1159                  value={duressConfirm()}1160                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1161                />1162              </Show>11631164              <Show when={duressError()}>1165                <p class="peach-hint peach-hint-error">{duressError()}</p>1166              </Show>11671168              <div class="peach-duress-actions">1169                <button1170                  class="peach-btn peach-btn--subtle peach-btn--sm"1171                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1172                >1173                  cancel1174                </button>1175                <Show when={duressEnabled()}>1176                  <button1177                    class="peach-btn peach-btn--ghost peach-btn--sm"1178                    style="color:var(--peach-breach)"1179                    onClick={() => void handleDisableDuress()}1180                    disabled={duressSaving()}1181                  >1182                    {duressSaving() ? "disabling..." : "disable duress"}1183                  </button>1184                </Show>1185                <Show when={!duressEnabled()}>1186                  <button1187                    class="peach-btn peach-btn--accent peach-btn--sm"1188                    onClick={() => void handleSetupDuress()}1189                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1190                  >1191                    {duressSaving() ? "saving..." : "save"}1192                  </button>1193                </Show>1194              </div>1195            </div>1196          </Show>11971198          <p class="peach-settings-sub-label">1199            CANARY ENTRIES1200            <Show when={!isPro()}>1201              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1202            </Show>1203          </p>1204          <div class="peach-settings-row peach-settings-row--top">1205            <div class="peach-settings-row-main">1206              <span>1207                Canary entries1208                <Show when={!isPro()}>1209                  <span class="peach-pro-badge">Pro</span>1210                </Show>1211              </span>1212              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1213                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1214              </span>1215            </div>1216            <span class="peach-settings-value" style="flex-shrink:0">1217              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1218            </span>1219          </div>1220          <Show when={canaryEntries().length > 0}>1221            <For each={canaryEntries()}>1222              {(entry) => (1223                <div class="peach-settings-row peach-settings-row--meta">1224                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1225                  <button1226                    class="peach-btn peach-btn--subtle peach-btn--sm"1227                    onClick={() => void handleTestCanary(entry)}1228                    disabled={canaryTestingId() === entry.id}1229                    style="flex-shrink:0"1230                  >1231                    {canaryTestingId() === entry.id1232                      ? "sending…"1233                      : canaryTestResult()?.id === entry.id1234                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1235                        : "test"}1236                  </button>1237                </div>1238              )}1239            </For>1240          </Show>12411242          <p class="peach-settings-sub-label">PASSKEYS</p>1243          <div class="peach-settings-row peach-settings-row--top">1244            <div class="peach-settings-row-main">1245              <span>Intercept passkey requests</span>1246              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1247                Peach can handle passkey sign-in and creation on supported Chromium browsers.1248              </span>1249            </div>1250            <button1251              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1252              onClick={() => void handleTogglePasskeys()}1253              aria-label="Toggle passkey interception"1254              disabled={!passkeyState()?.supported || passkeySaving()}1255            >1256              <span class="peach-toggle-knob"></span>1257            </button>1258          </div>1259          <div class="peach-settings-row peach-settings-row--meta">1260            <span>Status</span>1261            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1262          </div>1263          <div class="peach-settings-row peach-settings-row--meta">1264            <span>Browser</span>1265            <span class="peach-settings-value">1266              {passkeyState()?.browserLabel ?? "Unknown"}1267              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1268            </span>1269          </div>1270          <div class="peach-info-notice" style="margin-top:8px">1271            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1272          </div>1273          <Show when={passkeyState()?.conflict}>1274            <div class="peach-settings-row peach-settings-row--meta">1275              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1276            </div>1277          </Show>1278        </div>1279      </div>1280    );1281  }12821283  function renderVault() {1284    return (1285      <div class="peach-settings-body">1286        <div class="peach-settings-group">1287          <p class="peach-settings-sub-label">ENTRIES</p>1288          <div class="peach-settings-row peach-settings-row--meta">1289            <span>Entries</span>1290            <span class="peach-settings-value">{props.entriesCount}</span>1291          </div>1292          <div class="peach-settings-row peach-settings-row--meta">1293            <span>Duplicates</span>1294            <Show when={dedupDone() !== null} fallback={1295              <button1296                class="peach-btn peach-btn--subtle peach-btn--sm"1297                onClick={() => void handleCountDuplicates()}1298                disabled={dedupRunning()}1299              >1300                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1301              </button>1302            }>1303              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1304                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1305              </span>1306            </Show>1307          </div>1308          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1309            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1310              <div class="peach-restore-warning">1311                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1312              </div>1313              <div style="display:flex;gap:6px">1314                <button1315                  class="peach-btn peach-btn--subtle peach-btn--sm"1316                  onClick={() => setDedupCount(null)}1317                >1318                  cancel1319                </button>1320                <button1321                  class="peach-btn peach-btn--accent peach-btn--sm"1322                  onClick={() => void handleDeduplicate()}1323                  disabled={dedupRunning()}1324                >1325                  {dedupRunning() ? "removing..." : "remove duplicates"}1326                </button>1327              </div>1328            </div>1329          </Show>13301331          <p class="peach-settings-sub-label">MAINTENANCE</p>1332          <div class="peach-settings-row peach-settings-row--meta">1333            <span>Trash auto-delete</span>1334            <select1335              class="peach-settings-select"1336              value={String(props.trashRetentionDays)}1337              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1338            >1339              <option value="7">7 days</option>1340              <option value="30">30 days</option>1341              <option value="90">90 days</option>1342              <option value="0">Never</option>1343            </select>1344          </div>1345          <div class="peach-settings-row peach-settings-row--meta">1346            <span>Advanced Vault Health</span>1347            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1348              open1349            </button>1350          </div>1351          <Show when={declinedCardCount() > 0}>1352            <div class="peach-settings-row peach-settings-row--meta">1353              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1354              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1355                Clear1356              </button>1357            </div>1358          </Show>13591360          <p class="peach-settings-sub-label">DATA</p>1361          <div class="peach-settings-row peach-settings-row--meta">1362            <span>Import passwords</span>1363            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1364              open1365            </button>1366          </div>1367          <div class="peach-settings-row peach-settings-row--meta">1368            <span>Export backup</span>1369            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1370              open1371            </button>1372          </div>1373          <div class="peach-settings-row peach-settings-row--meta">1374            <span>1375              Peach Codex1376              <Show when={!isPro()}>1377                <span class="peach-pro-badge">Pro</span>1378              </Show>1379            </span>1380            <button1381              class="peach-btn peach-btn--subtle peach-btn--sm"1382              onClick={() => props.onOpenCodexGenerate?.()}1383            >1384              open1385            </button>1386          </div>1387          {/* Restore from Codex: intentionally never gated — a printed1388              Codex must remain restorable regardless of license state. */}1389          <div class="peach-settings-row peach-settings-row--meta">1390            <span>Restore from Codex</span>1391            <button1392              class="peach-btn peach-btn--subtle peach-btn--sm"1393              onClick={() => props.onOpenCodexRestore?.()}1394            >1395              open1396            </button>1397          </div>13981399          <span class="peach-settings-sub-label">VAULT HISTORY</span>1400          <Show when={historyLoading()}>1401            <p class="peach-settings-hint">Loading…</p>1402          </Show>1403          <Show when={!historyLoading() && historySnapshots().length === 0}>1404            <p class="peach-settings-hint">1405              No snapshots yet. Peach saves automatically before every change —1406              saves, syncs, and imports.1407            </p>1408          </Show>1409          <Show when={!historyLoading() && historySnapshots().length > 0}>1410            <p class="peach-settings-hint">1411              Restoring locks your vault. Unlock with the password that1412              encrypted that snapshot to confirm.1413            </p>1414            <div class="peach-history-list">1415              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1416                {(snap) => (1417                  <div class="peach-history-row">1418                    <div class="peach-history-meta">1419                      <span class="peach-history-time">1420                        {formatSnapshotTime(snap.savedAt)}1421                      </span>1422                      <span class="peach-history-sub">1423                        {[1424                          snap.entryCount !== null1425                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1426                            : null,1427                          reasonLabel(snap.reason),1428                        ]1429                          .filter(Boolean)1430                          .join(" · ")}1431                      </span>1432                    </div>1433                    <button1434                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1435                        historyRestoring() === snap.id ? " is-busy" : ""1436                      }`}1437                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1438                      disabled={historyRestoring() !== null}1439                    >1440                      <Show when={historyRestoring() === snap.id} fallback="Restore">1441                        …1442                      </Show>1443                    </button>1444                  </div>1445                )}1446              </For>1447            </div>1448            <Show when={historySnapshots().length > 3}>1449              <button1450                class="peach-settings-advanced-toggle"1451                onClick={() => setHistoryShowAll((v) => !v)}1452                aria-expanded={historyShowAll()}1453              >1454                <span class="peach-settings-hint" style="margin:0">1455                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1456                </span>1457                <span class="peach-settings-advanced-chevron">1458                  {historyShowAll() ? "▲" : "▼"}1459                </span>1460              </button>1461            </Show>1462            <p class="peach-history-footer">1463              {historySnapshots().length} of 10 snapshots1464            </p>1465          </Show>14661467          <button1468            class="peach-settings-advanced-toggle"1469            onClick={() => setShowVaultAdvanced((v) => !v)}1470            aria-expanded={showVaultAdvanced()}1471          >1472            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1473            <span class="peach-settings-advanced-chevron">1474              {showVaultAdvanced() ? "▲" : "▼"}1475            </span>1476          </button>14771478          <Show when={showVaultAdvanced()}>1479            <div class="peach-danger-card">1480              <div class="peach-danger-card-head">1481                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1482                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1483              </div>1484              <p class="peach-danger-card-copy">1485                Wipes the current vault and restores from a backup file.1486                Recoverable only if your backup is good.1487              </p>1488              <button1489                class="peach-danger-card-button"1490                onClick={() => props.onOpenFileImport?.()}1491              >1492                Choose backup file1493              </button>1494            </div>14951496            <Show when={props.onOpenPanic}>1497              <div class="peach-danger-card peach-danger-card--hazard">1498                <div class="peach-danger-card-head">1499                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1500                  <h3 class="peach-danger-card-title">Destroy vault</h3>1501                </div>1502                <p class="peach-danger-card-copy">1503                  Permanently wipes every entry, setting, and license on this1504                  device. There is no recovery.1505                </p>1506                <button1507                  class="peach-danger-card-button"1508                  onClick={() => props.onOpenPanic?.()}1509                >1510                  Begin destruction1511                </button>1512              </div>1513            </Show>1514          </Show>1515        </div>1516      </div>1517    );1518  }15191520  function renderSync() {1521    return (1522      <div class="peach-settings-body">1523        <div class="peach-settings-group">1524          <p class="peach-settings-sub-label">PAIRING</p>1525          <div class="peach-settings-row peach-settings-row--top">1526            <div class="peach-settings-row-main">1527              <span>1528                Peach Sync1529                <Show when={!isPro()}>1530                  <span class="peach-pro-badge">Pro</span>1531                </Show>1532              </span>1533              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1534                Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1535              </span>1536            </div>1537            <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1538          </div>1539          <div class="peach-settings-row peach-settings-row--meta">1540            <span>Status</span>1541            <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1542          </div>15431544          {/* Pick a pairing method and begin — no separate hub screen. */}1545          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1546          <button1547            class="peach-pair-method"1548            disabled={!isPro()}1549            onClick={() => props.onOpenSyncPairing?.("chirp")}1550          >1551            <span class="peach-pair-method-icon" aria-hidden="true">1552              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1553                <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />1554                <path d="M14 7.5 Q15.6 10 14 12.5" />1555                <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />1556              </svg>1557            </span>1558            <span class="peach-pair-method-text">1559              <span class="peach-pair-method-title">Same room</span>1560              <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>1561            </span>1562          </button>1563          <button1564            class="peach-pair-method"1565            style="margin-top:8px"1566            disabled={!isPro()}1567            onClick={() => props.onOpenSyncPairing?.("desktop")}1568          >1569            <span class="peach-pair-method-icon" aria-hidden="true">1570              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1571                <rect x="2" y="4" width="16" height="10" rx="1.4" />1572                <path d="M7 17 L13 17" />1573                <path d="M9 14 L9 17 M11 14 L11 17" />1574              </svg>1575            </span>1576            <span class="peach-pair-method-text">1577              <span class="peach-pair-method-title">Enter a code</span>1578              <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>1579            </span>1580          </button>15811582          {/* Paired devices + manual sync — Settings is the hub. */}1583          <Show when={pairedDevices().length > 0}>1584            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1585            <For each={pairedDevices()}>1586              {(d) => (1587                <div class="peach-settings-row peach-settings-row--top">1588                  <div class="peach-settings-row-main">1589                    <span>{d.deviceName ?? "Unnamed device"}</span>1590                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1591                      {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1592                    </span>1593                  </div>1594                  <Show1595                    when={confirmUnpair() === d.deviceId}1596                    fallback={1597                      <button1598                        class="peach-btn peach-btn--subtle peach-btn--sm"1599                        onClick={() => setConfirmUnpair(d.deviceId)}1600                      >remove</button>1601                    }1602                  >1603                    <div style="display:flex;gap:6px;flex-shrink:0">1604                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1605                      <button1606                        class="peach-btn peach-btn--sm"1607                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1608                        onClick={() => void unpairDevice(d.deviceId)}1609                      >remove</button>1610                    </div>1611                  </Show>1612                </div>1613              )}1614            </For>1615            <div class="peach-settings-row peach-settings-row--meta">1616              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1617              <button1618                class="peach-btn peach-btn--subtle peach-btn--sm"1619                disabled={syncStatus() === "syncing"}1620                onClick={() => void syncNow()}1621              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1622            </div>1623          </Show>1624          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1625          <Show when={pendingVouched().length > 0}>1626            <p class="peach-settings-sub-label">1627              PENDING DEVICES1628              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1629            </p>1630            <For each={pendingVouched()}>1631              {(dev) => (1632                <div class="peach-settings-row peach-settings-row--top">1633                  <div class="peach-settings-row-main">1634                    <span>{dev.deviceName}</span>1635                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1636                      Was added by another of your devices. Allow it to sync with this vault?1637                    </span>1638                  </div>1639                  <div style="display:flex;gap:6px;flex-shrink:0">1640                    <button1641                      class="peach-btn peach-btn--sm"1642                      disabled={vouchedBusy() === dev.deviceId}1643                      onClick={() => void confirmVouched(dev.deviceId)}1644                    >Allow</button>1645                    <button1646                      class="peach-btn peach-btn--subtle peach-btn--sm"1647                      disabled={vouchedBusy() === dev.deviceId}1648                      onClick={() => void rejectVouched(dev.deviceId)}1649                    >Reject</button>1650                  </div>1651                </div>1652              )}1653            </For>1654          </Show>16551656          <div class="peach-info-notice peach-info-notice--note" style="margin-top:8px">1657            Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1658          </div>16591660          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1661          <p class="peach-settings-sub-label">RECOVERY</p>1662          <Show1663            when={rotateConfirmOpen()}1664            fallback={1665              <div class="peach-settings-row peach-settings-row--top">1666                <div class="peach-settings-row-main">1667                  <span>Rotate sync identity</span>1668                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1669                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1670                  </span>1671                </div>1672                <button1673                  class="peach-btn peach-btn--subtle peach-btn--sm"1674                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1675                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1676                >rotate</button>1677              </div>1678            }1679          >1680            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1681              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.1682              <Show when={rotateError()}>1683                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1684              </Show>1685              <div style="display:flex;gap:6px;margin-top:10px">1686                <button1687                  class="peach-btn peach-btn--subtle peach-btn--sm"1688                  disabled={rotating()}1689                  onClick={() => setRotateConfirmOpen(false)}1690                >Cancel</button>1691                <button1692                  class="peach-btn peach-btn--sm"1693                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1694                  disabled={rotating()}1695                  onClick={() => void doRotateSyncIdentity()}1696                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1697              </div>1698            </div>1699          </Show>1700        </div>1701      </div>1702    );1703  }17041705  function renderAppearance() {1706    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1707      { id: "medium", label: "Default" },1708      { id: "large", label: "Large" },1709    ];1710    return (1711      <div class="peach-settings-body">1712        <div class="peach-settings-group">1713          <p class="peach-settings-sub-label">THEME</p>1714          <div class="peach-settings-row peach-settings-row--meta">1715            <span>Theme</span>1716            <button1717              class="peach-btn peach-btn--subtle peach-btn--sm"1718              onClick={() =>1719                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1720              }1721            >1722              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1723            </button>1724          </div>17251726          <p class="peach-settings-sub-label">ACCENT</p>1727          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1728            <span>Accent</span>1729            <div class="peach-accent-picker">1730              <For each={ACCENT_COLORS}>1731                {(color) => (1732                  <button1733                    class="peach-accent-swatch peach-swatch"1734                    classList={{ "is-active": props.accentColor === color.name }}1735                    style={{ background: color.hex }}1736                    onClick={() => void props.onAccentChange(color.name)}1737                    aria-label={color.name}1738                    title={color.name}1739                  />1740                )}1741              </For>1742            </div>1743          </div>17441745          <p class="peach-settings-sub-label">TEXT SIZE</p>1746          <div class="peach-settings-row peach-settings-row--meta">1747            <span>Size</span>1748            <div class="peach-pill-group">1749              <For each={sizeOptions}>1750                {(opt) => (1751                  <button1752                    class="peach-btn peach-btn--ghost peach-btn--sm"1753                    classList={{ "is-active": props.textSize === opt.id }}1754                    onClick={() => void props.onTextSizeChange(opt.id)}1755                  >1756                    {opt.label}1757                  </button>1758                )}1759              </For>1760            </div>1761          </div>17621763          <p class="peach-settings-sub-label">MOTION</p>1764          <div class="peach-settings-row peach-settings-row--meta">1765            <span>Reduce motion</span>1766            <button1767              class="peach-btn peach-btn--subtle peach-btn--sm"1768              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1769            >1770              {props.reduceMotion ? "On" : "Off"}1771            </button>1772          </div>1773        </div>1774      </div>1775    );1776  }17771778  function renderShortcuts() {1779    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1780    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17811782    function startCapture(action: ShortcutAction) {1783      setConflictMsg(null);1784      setCapturingAction(action);1785    }1786    function cancelCapture() {1787      setCapturingAction(null);1788      setConflictMsg(null);1789    }1790    function commitBinding(action: ShortcutAction, binding: Keybinding) {1791      const conflict = findConflict(props.shortcuts, binding, action);1792      if (conflict) {1793        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1794        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1795        return;1796      }1797      const next = { ...props.shortcuts, [action]: binding };1798      setCapturingAction(null);1799      setConflictMsg(null);1800      void props.onShortcutsChange(next);1801    }1802    function resetAction(action: ShortcutAction) {1803      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1804      void props.onShortcutsChange(next);1805    }1806    function resetAll() {1807      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1808    }18091810    // Capture-mode key handler — wired via ref onto the chip element so it1811    // catches the next non-modifier keypress and ignores other bindings.1812    function handleCaptureKey(e: KeyboardEvent) {1813      e.preventDefault();1814      e.stopPropagation();1815      if (e.key === "Escape") { cancelCapture(); return; }1816      const binding = keybindingFromEvent(e);1817      if (!binding) return; // bare modifier — keep waiting1818      const action = capturingAction();1819      if (action) commitBinding(action, binding);1820    }18211822    return (1823      <div class="peach-settings-body">1824        <div class="peach-settings-group peach-shortcuts-group">1825          <p class="peach-settings-sub-label">ENTRY VIEW</p>1826          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1827            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1828          </For>1829        </div>18301831        <div class="peach-settings-group peach-shortcuts-group">1832          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1833          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1834            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1835          </For>1836        </div>18371838        <Show when={conflictMsg()}>1839          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1840        </Show>18411842        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1843          <div class="peach-settings-row peach-settings-row--meta">1844            <span>Inline autofill on page</span>1845            <a1846              class="peach-settings-link"1847              href="chrome://extensions/shortcuts"1848              onClick={(e) => {1849                e.preventDefault();1850                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1851              }}1852            >1853              ⌘⇧P — customize ↗1854            </a>1855          </div>1856          <button1857            class="peach-btn peach-btn--subtle peach-btn--sm"1858            style="align-self:flex-start"1859            onClick={resetAll}1860          >1861            Reset all to defaults1862          </button>1863        </div>1864      </div>1865    );18661867    function renderRow(1868      desc: typeof SHORTCUT_DESCRIPTORS[number],1869      capturing: () => ShortcutAction | null,1870      onStart: (a: ShortcutAction) => void,1871      onCancel: () => void,1872      onReset: (a: ShortcutAction) => void,1873      onKey: (e: KeyboardEvent) => void,1874    ) {1875      const isActive = () => capturing() === desc.action;1876      const binding = () => props.shortcuts[desc.action];1877      const isCustom = () => {1878        const b = binding();1879        const d = DEFAULT_BINDINGS[desc.action];1880        return b.key !== d.key1881          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1882          || Boolean(b.meta)  !== Boolean(d.meta)1883          || Boolean(b.shift) !== Boolean(d.shift)1884          || Boolean(b.alt)   !== Boolean(d.alt);1885      };1886      return (1887        <div class="peach-shortcut-row">1888          <span class="peach-shortcut-row-label">{desc.label}</span>1889          <button1890            class="peach-shortcut-chip"1891            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1892            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1893            onKeyDown={(e) => { if (isActive()) onKey(e); }}1894            ref={(el) => {1895              // Auto-focus when entering capture mode so the keydown listener catches the next press.1896              if (el) {1897                queueMicrotask(() => { if (isActive()) el.focus(); });1898              }1899            }}1900          >1901            {isActive() ? "Press a key…" : formatKeybinding(binding())}1902          </button>1903          <Show when={isCustom() && !isActive()}>1904            <button1905              class="peach-shortcut-reset"1906              title="Reset to default"1907              onClick={() => onReset(desc.action)}1908            >1909              ↺1910            </button>1911          </Show>1912        </div>1913      );1914    }1915  }19161917  const PRO_FEATURES: Array<{1918    feature: ProFeature;1919    name: string;1920    desc: string;1921  }> = [1922    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1923    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1924    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1925    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1926  ];19271928  const PRO_SECURITY_FEATURES: Array<{1929    feature: ProFeature;1930    name: string;1931    desc: string;1932  }> = [1933    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1934    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1935  ];19361937  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1938  const [showKey, setShowKey] = createSignal(false);19391940  const [deactivating, setDeactivating] = createSignal(false);19411942  async function handleDeactivateLicense() {1943    setDeactivating(true);1944    await sendMessage({ type: "DEACTIVATE_LICENSE" });1945    setLicenseInfo(null);1946    await props.onLicenseChange?.();1947    setDeactivating(false);1948  }19491950  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1951    setCanaryTestingId(entry.id);1952    setCanaryTestResult(null);1953    const res = await sendMessage({1954      type: "FIRE_CANARY_PING",1955      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1956    }) as { ok: boolean };1957    setCanaryTestResult({ id: entry.id, ok: res.ok });1958    setCanaryTestingId(null);1959    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1960  }19611962  function renderLicense() {1963    async function onActivate() {1964      await handleActivateLicense();1965      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1966        await props.onLicenseChange?.();1967      }1968    }19691970    return (1971      <div class="peach-settings-body">1972        <Show1973          when={isPro()}1974          fallback={1975                <div class="peach-pro-upgrade">1976                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19771978                  <h2 class="peach-pro-headline">1979                    Every<em>thing,</em><br />every<em>where.</em>1980                  </h2>19811982                  <p class="peach-pro-subtitle">1983                    One key unlocks every Pro feature — for the lifetime of the app.1984                  </p>19851986                  <div class="peach-pro-grid">1987                    <For each={PRO_FEATURES}>1988                      {(item, i) => (1989                        <div1990                          class="peach-pro-grid-item"1991                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1992                        >1993                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1994                          <span class="peach-pro-grid-name">{item.name}</span>1995                          <span class="peach-pro-grid-desc">{item.desc}</span>1996                        </div>1997                      )}1998                    </For>1999                  </div>20002001                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2002                  <div class="peach-pro-grid">2003                    <For each={PRO_SECURITY_FEATURES}>2004                      {(item, i) => (2005                        <div2006                          class="peach-pro-grid-item"2007                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2008                        >2009                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2010                          <span class="peach-pro-grid-name">{item.name}</span>2011                          <span class="peach-pro-grid-desc">{item.desc}</span>2012                        </div>2013                      )}2014                    </For>2015                  </div>20162017                  <div class="peach-pro-upgrade-actions">2018                    <p class="peach-pro-pricing">2019                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2020                    </p>2021                    <a2022                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2023                      target="_blank"2024                      rel="noopener noreferrer"2025                      class="peach-pro-upgrade-cta"2026                    >2027                      Get Peach Pro2028                    </a>2029                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2030                    <div class="peach-pro-key-inline">2031                      <input2032                        class="peach-pro-key-inline-input"2033                        type="text"2034                        placeholder="PEACH-XXXX-XXXX-XXXX"2035                        value={licenseKeyInput()}2036                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2037                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2038                        autocomplete="off"2039                        spellcheck={false}2040                      />2041                      <button2042                        class="peach-pro-key-apply"2043                        onClick={() => void onActivate()}2044                        disabled={activatingLicense() || !licenseKeyInput().trim()}2045                      >2046                        {activatingLicense() ? "…" : "APPLY"}2047                      </button>2048                    </div>2049                    <Show when={licenseError()}>2050                      <p class="peach-pro-key-error">{licenseError()}</p>2051                    </Show>2052                  </div>2053                </div>2054          }2055        >2056          <div class="peach-pro-active">2057            <div class="peach-pro-active-hero">2058              <button2059                class="peach-pro-active-mark-wrap"2060                type="button"2061                aria-label="Celebrate"2062                onClick={handleProCelebrationClick}2063              >2064                <PeachMark class="peach-pro-active-mark" />2065                <Show when={justActivated()}>2066                  <div class="peach-pro-sparks" aria-hidden="true">2067                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2068                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2069                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2070                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2071                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2072                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2073                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2074                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2075                  </div>2076                </Show>2077              </button>2078              <h2 class="peach-pro-active-title">Peach Pro</h2>2079              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2080              <Show when={justActivated()}>2081                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2082              </Show>2083            </div>20842085            <Show when={licenseKey()}>2086              <div class="peach-settings-license">2087                <span class="peach-settings-label">LICENSE KEY</span>2088                <div class="peach-settings-license-row">2089                  <Show2090                    when={showKey()}2091                    fallback={2092                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2093                        ••••••••••••••••••••••••••••2094                      </span>2095                    }2096                  >2097                    <span class="peach-settings-license-key">{licenseKey()!}</span>2098                  </Show>2099                  <div class="peach-settings-license-actions">2100                    <button2101                      class="peach-settings-license-copy"2102                      title="Copy license key"2103                      onClick={() => copyLicenseKey(licenseKey()!)}2104                    >2105                      {copied() ? "✓" : "copy"}2106                    </button>2107                    <button2108                      class="peach-settings-license-toggle"2109                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2110                      title={showKey() ? "Hide" : "Reveal"}2111                      onClick={() => setShowKey(v => !v)}2112                    >2113                      <Show2114                        when={showKey()}2115                        fallback={2116                          <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">2117                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2118                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2119                          </svg>2120                        }2121                      >2122                        <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">2123                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2124                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2125                          <line x1="2" y1="2" x2="12" y2="12" />2126                        </svg>2127                      </Show>2128                    </button>2129                  </div>2130                </div>2131              </div>2132            </Show>21332134            <div class="peach-pro-active-features">2135              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2136                {(item) => (2137                  <div class="peach-pro-active-feature">2138                    <span class="peach-pro-active-feature-mark">✦</span>2139                    <span class="peach-pro-active-feature-name">{item.name}</span>2140                  </div>2141                )}2142              </For>2143            </div>21442145            <button2146              class="peach-pro-deactivate"2147              onClick={() => void handleDeactivateLicense()}2148              disabled={deactivating()}2149            >2150              {deactivating() ? "Deactivating…" : "Deactivate"}2151            </button>2152          </div>2153        </Show>2154      </div>2155    );2156  }21572158  function renderFeedback() {2159    return (2160      <div class="peach-settings-body">2161        <div class="peach-settings-group peach-feedback-group">2162          <Show2163            when={feedbackState() === 'sent'}2164            fallback={2165              <>2166                <p class="peach-feedback-intro">2167                  Help us improve Peach. Every report goes directly to our inbox.2168                </p>21692170                <p class="peach-settings-sub-label">TYPE</p>2171                <div class="peach-feedback-chips">2172                  <For each={FEEDBACK_CATEGORIES}>2173                    {(cat) => {2174                      const selected = () => fbCategory() === cat;2175                      return (2176                        <button2177                          type="button"2178                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2179                          aria-pressed={selected()}2180                          onClick={() => setFbCategory(cat)}2181                        >2182                          {cat}2183                        </button>2184                      );2185                    }}2186                  </For>2187                </div>21882189                <p class="peach-settings-sub-label">MESSAGE</p>2190                <textarea2191                  class="peach-feedback-textarea"2192                  placeholder="Describe the issue or idea..."2193                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2194                  value={fbMessage()}2195                  onInput={(e) => setFbMessage(e.currentTarget.value)}2196                />21972198                <p class="peach-feedback-meta">2199                  Version {version} · will be included automatically2200                </p>22012202                <button2203                  type="button"2204                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2205                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2206                  onClick={() => void handleSubmitFeedback()}2207                >2208                  <Switch>2209                    <Match when={feedbackState() === 'idle'}>Send</Match>2210                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2211                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2212                  </Switch>2213                </button>22142215                <p class="peach-feedback-anon">2216                  Sent anonymously · no account required2217                </p>2218              </>2219            }2220          >2221            <div class="peach-feedback-thanks" role="status" aria-live="polite">2222              <p class="peach-feedback-thanks-title">Thank you</p>2223              <p>2224                Your note is in the queue. Someone will process it soon.2225              </p>2226            </div>2227          </Show>2228        </div>2229      </div>2230    );2231  }22322233  const bodies: StackBodies = {2234    protection: renderSecurity(),2235    vault: renderVault(),2236    sync: renderSync(),2237    appearance: renderAppearance(),2238    shortcuts: renderShortcuts(),2239    pro: renderLicense(),2240    feedback: renderFeedback(),2241  };22422243  return (2244    <Show2245      when={showNewRecoveryWords()}2246      fallback={2247        <div2248          class="peach-overlay-view peach-settings-overlay"2249          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2250          style="display:flex;flex-direction:column"2251        >2252          <StackDeck2253            active={activeCard()}2254            onActiveChange={setActiveCard}2255            onBack={props.onBack}2256            cards={cards()}2257            bodies={bodies}2258            version={version}2259            isFanning={isFanning()}2260            celebratingCardId={justActivated() ? "pro" : null}2261            isPro={isPro()}2262          />2263        </div>2264      }2265    >2266      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2267    </Show>2268  );2269}