Settings.tsx

95.9 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10  AccentColorName,11  PasskeyProviderState,12  ProFeature,13  VaultSnapshot,14} from "../../shared/types";15import type { PairedDevice } from "../../shared/sync-types";16import {17  DEFAULT_BINDINGS,18  findConflict,19  formatKeybinding,20  keybindingFromEvent,21  SHORTCUT_DESCRIPTORS,22  type Keybinding,23  type ShortcutAction,24} from "../../shared/shortcuts";25import { isNativePlatform, nativePlugin } from "../../shared/native";26import { WEB3FORMS_KEY } from "../../shared/config";2728interface LicenseInfo {29  valid: boolean;30  plan: "free" | "pro";31  issued?: string;32}3334interface SettingsProps {35  entriesCount: number;36  breachMonitoring: boolean;37  lockOnClose: boolean;38  autoLockMinutes: number;39  trashRetentionDays: number;40  theme: "dark" | "light";41  accentColor: AccentColorName;42  textSize: "small" | "medium" | "large";43  reduceMotion: boolean;44  isPro?: boolean;45  initialSection?: string | null;46  upgradeHighlight?: ProFeature | null;47  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;48  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;49  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;50  onTrashRetentionChange: (days: number) => void | Promise<void>;51  onThemeChange: (theme: "dark" | "light") => void;52  onAccentChange: (name: AccentColorName) => void | Promise<void>;53  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;54  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;55  shortcuts: Record<ShortcutAction, Keybinding>;56  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;57  onLicenseChange?: () => void | Promise<void>;58  isExiting?: boolean;59  onBack: () => void;60  onImport: () => void;61  onExport: () => void;62  onBreachCheckDone: () => void;63  onOpenHealth?: () => void;64  onOpenSyncPairing?: (method?: "code") => void;65  onOpenFileImport?: () => void;66  onOpenPanic?: () => void;67  onOpenCodexGenerate?: () => void;68  onOpenCodexRestore?: () => void;69  /** Invoked from within Settings when a Pro-gated control is tapped on70   *  free tier. App owns the upgrade navigation + highlight state, so this71   *  callback is the single way for Settings to request that navigation. */72  onProGate?: (feature: ProFeature) => void;73  onCardChange?: (id: CardId) => void;74}7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];77const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";78const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;79const PRO_CELEBRATION_CARD_STEP_MS = 150;80const PRO_CELEBRATION_THANK_YOU_MS = 3200;8182function sanitizeFeedbackMessage(value: string): string {83  return value84    .trim()85    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)86    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")87    .replace(/&/g, "&amp;")88    .replace(/</g, "&lt;")89    .replace(/>/g, "&gt;")90    .replace(/"/g, "&quot;")91    .replace(/'/g, "&#39;");92}9394type DataCollectionPermissionApi = {95  permissions?: {96    getAll?: () => Promise<{ data_collection?: string[] }>;97    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;98  };99};100101async function requestFeedbackDataPermission(): Promise<boolean> {102  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??103    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;104  if (!api?.permissions?.getAll) return true;105106  try {107    const permissions = await api.permissions.getAll();108    if (!("data_collection" in permissions)) return true;109    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;110    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));111  } catch {112    return false;113  }114}115116function formatActivatedSince(issued: string | undefined): string {117  if (!issued) return "Active";118  try {119    const d = new Date(issued);120    if (Number.isNaN(d.getTime())) return "Active";121    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;122  } catch {123    return "Active";124  }125}126127function initialSectionToCard(value: string | null | undefined): CardId | null {128  if (!value) return null;129  if (value === "security") return "protection";130  if (value === "license") return "pro";131  if (132    value === "protection" ||133    value === "vault" ||134    value === "sync" ||135    value === "appearance" ||136    value === "shortcuts" ||137    value === "pro" ||138    value === "feedback"139  ) {140    return value;141  }142  return null;143}144145export function SettingsView(props: SettingsProps) {146  const manifest = chrome.runtime.getManifest();147  const version = manifest.version;148  const initial = props.initialSection;149  const [activeCard, setActiveCard] = createSignal<CardId>(150    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"151  );152153  createEffect(() => { props.onCardChange?.(activeCard()); });154155  const [justActivated, setJustActivated] = createSignal(false);156  const [isFanning, setIsFanning] = createSignal(false);157158  function triggerCelebration() {159    if (isFanning()) return;160    setJustActivated(false);161    setIsFanning(true);162    setActiveCard("pro");163    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];164    fanOrder.forEach((id, i) => {165      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);166    });167    window.setTimeout(() => {168      setActiveCard("pro");169      setIsFanning(false);170      setJustActivated(true);171      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);172    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);173  }174175  function handleProCelebrationClick(e: MouseEvent) {176    e.stopPropagation();177    triggerCelebration();178  }179180  const [checkingBreaches, setCheckingBreaches] = createSignal(false);181  const [breachResult, setBreachResult] = createSignal<string | null>(null);182  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);183  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");184  const [licenseError, setLicenseError] = createSignal<string | null>(null);185  const [, setLicenseSuccess] = createSignal<string | null>(null);186  const [activatingLicense, setActivatingLicense] = createSignal(false);187188  const isPro = () => props.isPro ?? false;189  const cards = createMemo<StackCardSpec[]>(() => [190    {191      id: "pro",192      num: "I · FOREVER",193      title: "Peach Pro",194      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",195      bgVar: "--stack-pro-bg",196      fgVar: "--stack-pro-fg",197      icon: "spark",198      onIconClick: isPro() ? triggerCelebration : undefined,199    },200    {201      id: "protection",202      num: "II · PROTECT",203      title: "Protection",204      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,205      bgVar: "--stack-protect-bg",206      fgVar: "--stack-protect-fg",207      icon: "shield",208    },209    {210      id: "vault",211      num: "III · KEEP",212      title: "Vault",213      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,214      bgVar: "--stack-vault-bg",215      fgVar: "--stack-vault-fg",216      icon: "lock",217    },218    {219      id: "sync",220      num: "IV · ECHO",221      title: "Sync",222      lede: isPro() ? "pair with a code, keep in step" : "Pro · encrypted device pairing",223      bgVar: "--stack-sync-bg",224      fgVar: "--stack-sync-fg",225      icon: "sync",226    },227    {228      id: "appearance",229      num: "V · DRESS",230      title: "Appearance",231      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,232      bgVar: "--stack-appear-bg",233      fgVar: "--stack-appear-fg",234      icon: "sun",235    },236    {237      id: "shortcuts",238      num: "VI · KEYS",239      title: "Shortcuts",240      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,241      bgVar: "--stack-shortcut-bg",242      fgVar: "--stack-shortcut-fg",243      icon: "keys",244    },245    {246      id: "feedback",247      num: "VII · ECHO",248      title: "Feedback",249      lede: `version ${version} · tell us what would make it better`,250      bgVar: "--stack-feedback-bg",251      fgVar: "--stack-feedback-fg",252      icon: "bubble",253    },254  ]);255  const [biometricSupported, setBiometricSupported] = createSignal(false);256  const [biometricStored, setBiometricStored] = createSignal(false);257  const [biometricBusy, setBiometricBusy] = createSignal(false);258  const [biometricError, setBiometricError] = createSignal<string | null>(null);259  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);260  const [passkeySaving, setPasskeySaving] = createSignal(false);261262  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting263  // explicit confirmation before they become sync targets.264  interface PendingVouched { deviceId: string; deviceName: string }265  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);266  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);267  // Paired-device management lives here — Settings is the sync hub.268  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);269  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272  const [relayUrl, setRelayUrl] = createSignal("");273  const [syncMode, setSyncMode] = createSignal<"public" | "private" | "off">("public");274  const [relaySaving, setRelaySaving] = createSignal(false);275  const [relayStatus, setRelayStatus] = createSignal<string | null>(null);276277  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;278  function syncRelativeTime(when: number | undefined): string {279    if (!when) return "Not synced yet";280    const elapsed = Date.now() - when;281    if (elapsed < 60_000) return "Just now";282    const minutes = Math.floor(elapsed / 60_000);283    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;284    const hours = Math.floor(minutes / 60);285    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;286    const days = Math.floor(hours / 24);287    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";288  }289  function isSyncStale(d: PairedDevice): boolean {290    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;291  }292  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {293    switch (t) {294      case "android": return "Android";295      case "ios": return "iPhone";296      case "extension": return "Browser";297      default: return "Device";298    }299  }300301  async function loadPairedDevices() {302    if (!isPro()) return;303    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {304      ok: boolean;305      data?: { devices: PairedDevice[] };306    };307    if (r.ok) setPairedDevices(r.data?.devices ?? []);308  }309  async function unpairDevice(deviceId: string) {310    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });311    setConfirmUnpair(null);312    await loadPairedDevices();313  }314  async function syncNow() {315    setSyncStatus("syncing");316    setSyncFlash(null);317    const res = (await sendMessage({318      type: "TRIGGER_SYNC",319      payload: { reason: "manual", force: true },320    })) as { ok: boolean; error?: string };321    setTimeout(async () => {322      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {323        ok: boolean;324        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };325      };326      const s = state.data;327      if (!s) return;328      setSyncStatus(s.status);329      if (s.status === "idle") {330        setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");331        setTimeout(() => setSyncFlash(null), 3000);332        await loadPairedDevices();333      } else if (s.status === "error") {334        setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);335        setTimeout(() => setSyncFlash(null), 4000);336      }337    }, 250);338    if (!res.ok) {339      setSyncStatus("error");340      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);341      setTimeout(() => setSyncFlash(null), 4000);342    }343  }344  async function loadRelaySettings() {345    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {346      ok: boolean;347      data?: { relayUrl: string; mode: "public" | "private" | "off" };348    };349    if (result.ok && result.data) {350      setRelayUrl(result.data.relayUrl);351      setSyncMode(result.data.mode);352    }353  }354  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */355  async function saveSyncSettings(mode?: "public" | "private" | "off") {356    setRelaySaving(true);357    setRelayStatus(null);358    const result = (await sendMessage({359      type: "SET_SYNC_SETTINGS",360      payload: mode ? { mode } : { relayUrl: relayUrl() },361    })) as {362      ok: boolean;363      error?: string;364      data?: { relayUrl: string; mode: "public" | "private" | "off" };365    };366    if (result.ok && result.data) {367      setRelayUrl(result.data.relayUrl);368      setSyncMode(result.data.mode);369      setRelayStatus(mode ? null : "Saved");370    } else {371      setRelayStatus("Use a valid wss:// relay URL");372    }373    setRelaySaving(false);374  }375  // UI-4 / MV-8: rotate sync identity confirmation dialog.376  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);377  const [rotating, setRotating] = createSignal(false);378  const [rotateError, setRotateError] = createSignal<string | null>(null);379380  async function loadPendingVouched() {381    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {382      ok: boolean;383      data?: PendingVouched[];384    };385    if (r.ok) setPendingVouched(r.data ?? []);386  }387  async function confirmVouched(deviceId: string) {388    setVouchedBusy(deviceId);389    try {390      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });391      await loadPendingVouched();392    } finally {393      setVouchedBusy(null);394    }395  }396  async function rejectVouched(deviceId: string) {397    setVouchedBusy(deviceId);398    try {399      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });400      await loadPendingVouched();401    } finally {402      setVouchedBusy(null);403    }404  }405  async function doRotateSyncIdentity() {406    setRotateError(null);407    setRotating(true);408    try {409      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {410        ok: boolean;411        error?: string;412      };413      if (!r.ok) {414        setRotateError(r.error ?? "Rotation failed.");415        return;416      }417      setRotateConfirmOpen(false);418      // Backend set peach_recovery_pending; reuse the existing recovery-words419      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).420      setShowNewRecoveryWords(true);421    } finally {422      setRotating(false);423    }424  }425426  const [dedupCount, setDedupCount] = createSignal<number | null>(null);427  const [dedupRunning, setDedupRunning] = createSignal(false);428  const [dedupDone, setDedupDone] = createSignal<number | null>(null);429430  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);431432  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted433  // BEFORE every real-mode vault write so the user can roll back if a sync434  // or import overwrites the wrong state.435  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);436  const [historyLoading, setHistoryLoading] = createSignal(false);437  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);438  const [historyShowAll, setHistoryShowAll] = createSignal(false);439440  async function loadVaultHistory() {441    setHistoryLoading(true);442    try {443      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });444      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];445      setHistorySnapshots(list);446    } finally {447      setHistoryLoading(false);448    }449  }450451  async function handleRestoreVaultSnapshot(id: string) {452    if (historyRestoring()) return;453    setHistoryRestoring(id);454    const res = await sendMessage({455      type: "RESTORE_VAULT_SNAPSHOT",456      payload: { snapshotId: id },457    });458    setHistoryRestoring(null);459    if ((res as { ok: boolean }).ok) {460      // Vault is now locked — let the parent close Settings; App.tsx's461      // VAULT_LOCKED listener will route back to the lock screen.462      props.onBack();463    }464  }465466  function formatSnapshotTime(savedAt: number): string {467    const diff = Date.now() - savedAt;468    if (diff < 60_000) return "Just now";469    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;470    const d = new Date(savedAt);471    const isToday = new Date().toDateString() === d.toDateString();472    const isYest =473      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();474    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });475    if (isToday) return `Today ${t}`;476    if (isYest) return `Yesterday ${t}`;477    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;478  }479480  function reasonLabel(reason: VaultSnapshot["reason"]): string {481    return (482      {483        local_save: "saved",484        sync_received: "synced",485        import: "imported",486        restore: "restored",487      } as const488    )[reason] ?? reason;489  }490491  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);492493  const [showChangePw, setShowChangePw] = createSignal(false);494  const [changePwCurrent, setChangePwCurrent] = createSignal("");495  const [changePwNew, setChangePwNew] = createSignal("");496  const [changePwConfirm, setChangePwConfirm] = createSignal("");497  const [changePwSaving, setChangePwSaving] = createSignal(false);498  const [changePwError, setChangePwError] = createSignal<string | null>(null);499  const [changePwSuccess] = createSignal(false);500  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);501502  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.503  const [showViewWords, setShowViewWords] = createSignal(false);504  const [viewWordsPassword, setViewWordsPassword] = createSignal("");505  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);506  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);507  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);508509  async function handleViewRecoveryWords() {510    setViewWordsError(null);511    if (!viewWordsPassword()) {512      setViewWordsError("Enter your master password.");513      return;514    }515    setViewWordsBusy(true);516    try {517      const r = (await sendMessage({518        type: "GET_RECOVERY_WORDS",519        payload: { password: viewWordsPassword() },520      })) as { ok: boolean; data?: string[]; error?: string };521      if (r.ok) {522        setViewedWords(r.data ?? []);523        setViewWordsPassword("");524        return;525      }526      switch (r.error) {527        case "wrong_password":528          setViewWordsError("Incorrect password");529          break;530        case "password_required":531          setViewWordsError("Enter your master password.");532          break;533        case "recovery_not_setup":534          setViewWordsError("Recovery words aren't set up for this vault yet.");535          break;536        default:537          setViewWordsError("Couldn't retrieve recovery words.");538      }539    } finally {540      setViewWordsBusy(false);541    }542  }543  function closeViewWords() {544    setShowViewWords(false);545    setViewWordsPassword("");546    setViewWordsError(null);547    setViewedWords(null);548  }549550  const [duressEnabled, setDuressEnabled] = createSignal(false);551  const [showDuressSetup, setShowDuressSetup] = createSignal(false);552  const [duressPassword, setDuressPassword] = createSignal("");553  const [duressConfirm, setDuressConfirm] = createSignal("");554  const [duressMainPassword, setDuressMainPassword] = createSignal("");555  const [duressSaving, setDuressSaving] = createSignal(false);556  const [duressError, setDuressError] = createSignal<string | null>(null);557  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);558559  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);560561  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);562  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);563564  const [fbCategory, setFbCategory] = createSignal("Bug report");565  const [fbMessage, setFbMessage] = createSignal("");566  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';567  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');568569  createEffect(() => {570    if (props.upgradeHighlight) {571      setActiveCard("pro");572    }573  });574575  onMount(async () => {576    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([577      sendMessage({ type: "GET_LICENSE" }),578      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),579      sendMessage({ type: "IS_DURESS_ENABLED" }),580      sendMessage({ type: "GET_SETTINGS" }),581      sendMessage({ type: "GET_LICENSE_KEY" }),582    ]);583584    // Vault history loads in the background — the Vault sub-view picks it up585    // when the user navigates in. Loading it lazily keeps Settings boot fast.586    void loadVaultHistory();587588    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the589    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.590    void loadPendingVouched();591    void loadPairedDevices();592    void loadRelaySettings();593    const onBroadcast = (msg: unknown) => {594      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {595        void loadPendingVouched();596      }597    };598    chrome.runtime?.onMessage?.addListener(onBroadcast);599    onCleanup(() => {600      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }601    });602603    if ((licenseRes as { ok: boolean }).ok) {604      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);605    }606607    if ((passkeyRes as { ok: boolean }).ok) {608      setPasskeyState(609        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null610      );611    }612613    if ((duressRes as { ok: boolean }).ok) {614      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);615    }616617    if ((settingsRes as { ok: boolean }).ok) {618      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;619      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);620    }621622    if ((keyRes as { ok: boolean }).ok) {623      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);624    }625626    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });627    if ((entriesRes as { ok: boolean }).ok) {628      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];629      setCanaryEntries(630        entries631          .filter((e) => e.canary?.webhookUrl)632          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))633      );634    }635636637    if (isNativePlatform()) {638      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");639      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");640      try {641        if (biometric) {642          const a = await biometric.isAvailable();643          setBiometricSupported(a.available);644        }645        if (keystore) {646          const k = await keystore.hasMasterKey();647          setBiometricStored(k.exists);648        }649      } catch { /* missing plugins — leave defaults */ }650    }651  });652653  async function handleEnableBiometrics() {654    setBiometricBusy(true);655    setBiometricError(null);656    try {657      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {658        ok: boolean;659        data?: { masterKeyHex: string };660        error?: string;661      };662      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {663        setBiometricError(keyRes.error === "vault_locked"664          ? "unlock the vault first"665          : "couldn't read derived key");666        return;667      }668      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");669      if (!keystore) {670        setBiometricError("biometric plugin unavailable");671        return;672      }673      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });674      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });675      setBiometricStored(true);676    } catch (e) {677      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");678    } finally {679      setBiometricBusy(false);680    }681  }682683  async function handleDisableBiometrics() {684    setBiometricBusy(true);685    setBiometricError(null);686    try {687      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");688      if (keystore) await keystore.clearMasterKey();689      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });690      setBiometricStored(false);691    } catch (e) {692      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");693    } finally {694      setBiometricBusy(false);695    }696  }697698  async function handleChangePassword() {699    setChangePwError(null);700    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }701    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }702    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }703    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }704    setChangePwSaving(true);705    const res = await sendMessage({706      type: "CHANGE_PASSWORD",707      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },708    }) as { ok: boolean; error?: string };709    if (res.ok) {710      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");711      setShowChangePw(false);712      // LV-4: the user just authenticated (current password), so the recovery713      // words reveal that follows is exempt from the re-auth gate. Set the714      // pending flag the same way the create/recovery flow does; the display715      // clears it on confirm.716      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });717      setShowNewRecoveryWords(true);718    } else {719      setChangePwError(res.error ?? "failed to change password");720    }721    setChangePwSaving(false);722  }723724  const [copied, setCopied] = createSignal(false);725726  async function copyLicenseKey(key: string) {727    await navigator.clipboard.writeText(key);728    setCopied(true);729    setTimeout(() => setCopied(false), 2000);730  }731732  async function handleActivateLicense() {733    const key = licenseKeyInput().trim();734    if (!key) return;735    setActivatingLicense(true);736    setLicenseError(null);737    setLicenseSuccess(null);738    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });739    setActivatingLicense(false);740    if ((res as { ok: boolean }).ok) {741      const data = (res as { ok: boolean; data?: LicenseInfo }).data;742      if (data?.valid) {743        setLicenseInfo(data);744        setLicenseKey(key);745        setLicenseSuccess("License activated!");746        setLicenseKeyInput("");747      } else {748        setLicenseError("Invalid license key");749      }750    } else {751      setLicenseError("Activation failed");752    }753  }754755  async function handleTogglePasskeys() {756    const current = passkeyState();757    if (!current || !current.supported) return;758    setPasskeySaving(true);759    const res = await sendMessage({760      type: "SET_PASSKEY_PROVIDER_ENABLED",761      payload: { enabled: !current.enabled },762    });763    setPasskeySaving(false);764    if ((res as { ok: boolean }).ok) {765      setPasskeyState(766        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null767      );768    }769  }770771  async function handleCheckAllBreaches() {772    setCheckingBreaches(true);773    setBreachResult(null);774    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });775    setCheckingBreaches(false);776    if ((res as { ok: boolean }).ok) {777      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};778      const breached = Object.values(data).filter((count) => count > 0).length;779      setBreachResult(780        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"781      );782      props.onBreachCheckDone();783    }784  }785786787  async function handleSetupDuress() {788    const mainPw = duressMainPassword().trim();789    const duressPw = duressPassword().trim();790    const confirmPw = duressConfirm().trim();791792    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }793    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }794    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }795    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }796797    setDuressSaving(true);798    setDuressError(null);799800    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };801    if (!verifyRes.ok || !verifyRes.data) {802      setDuressError("incorrect main password");803      setDuressSaving(false);804      return;805    }806807    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };808    setDuressSaving(false);809    if (res.ok) {810      setDuressEnabled(true);811      setShowDuressSetup(false);812      setDuressPassword("");813      setDuressConfirm("");814      setDuressMainPassword("");815      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");816      setTimeout(() => setDuressSuccess(null), 5000);817    } else {818      setDuressError(819        res.error === "session_expired"820          ? "Vault session expired — lock and unlock Peach, then try again"821          : res.error ?? "failed to set duress password"822      );823    }824  }825826  async function handleClearDeclinedCards() {827    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });828    setDeclinedCardCount(0);829  }830831  async function handleCountDuplicates() {832    setDedupRunning(true);833    setDedupDone(null);834    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };835    setDedupRunning(false);836    if (res.ok) setDedupCount(res.data ?? 0);837  }838839  async function handleDeduplicate() {840    setDedupRunning(true);841    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };842    setDedupRunning(false);843    if (res.ok) {844      setDedupDone(res.data ?? 0);845      setDedupCount(0);846    }847  }848849  async function handleDisableDuress() {850    const mainPw = duressMainPassword().trim();851    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }852853    setDuressSaving(true);854    setDuressError(null);855856    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };857    if (!verifyRes.ok || !verifyRes.data) {858      setDuressError("incorrect main password");859      setDuressSaving(false);860      return;861    }862863    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };864    setDuressSaving(false);865    if (res.ok) {866      setDuressEnabled(false);867      setShowDuressSetup(false);868      setDuressMainPassword("");869      setDuressSuccess("Duress password disabled.");870      setTimeout(() => setDuressSuccess(null), 4000);871    } else {872      setDuressError(res.error ?? "failed to disable duress password");873    }874  }875876  async function handleSubmitFeedback() {877    const message = sanitizeFeedbackMessage(fbMessage());878    if (!message) return;879880    setFeedbackState('sending');881882    try {883      const hasDataPermission = await requestFeedbackDataPermission();884      if (!hasDataPermission) {885        setFeedbackState('idle');886        return;887      }888889      const category = FEEDBACK_CATEGORIES.includes(fbCategory())890        ? fbCategory()891        : FEEDBACK_CATEGORIES[0];892      const res = await fetch('https://api.web3forms.com/submit', {893        method: 'POST',894        headers: { 'Content-Type': 'application/json' },895        body: JSON.stringify({896          access_key: WEB3FORMS_KEY,897          subject: `Peach Feedback - ${category}`,898          message,899          from_name: 'Peach User',900          // Honeypot — leave blank901          botcheck: '',902        }),903      });904905      if (!res.ok) throw new Error(`HTTP ${res.status}`);906      const data = await res.json() as { success: boolean; message?: string };907      if (!data.success) throw new Error(data.message ?? 'unknown error');908909      setFeedbackState('sent');910      setFbMessage('');911      setFbCategory(FEEDBACK_CATEGORIES[0]);912    } catch (err) {913      console.error('[feedback]', err);914      setFeedbackState('error');915      setTimeout(() => setFeedbackState('idle'), 4000);916    }917  }918919  function renderSecurity() {920    return (921      <div class="peach-settings-body">922        <div class="peach-settings-group">923          <p class="peach-settings-sub-label">LOCKING</p>924          <div class="peach-settings-row">925            <span>Lock on close</span>926            <button927              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}928              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}929              aria-label="Toggle lock on close"930            >931              <span class="peach-toggle-knob"></span>932            </button>933          </div>934          <div class="peach-settings-row">935            <span>Auto-lock after</span>936            <select937              class="peach-settings-select"938              value={String(props.autoLockMinutes)}939              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}940            >941              <option value="1">1 minute</option>942              <option value="5">5 minutes</option>943              <option value="15">15 minutes</option>944              <option value="30">30 minutes</option>945              <option value="0">Never</option>946            </select>947          </div>948949          <Show when={isNativePlatform() && biometricSupported()}>950            <p class="peach-settings-sub-label">BIOMETRICS</p>951            <div class="peach-settings-row">952              <span>Unlock with biometrics</span>953              <button954                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}955                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}956                disabled={biometricBusy()}957                aria-label="Toggle biometric unlock"958              >959                <span class="peach-toggle-knob"></span>960              </button>961            </div>962            <Show when={biometricError()}>963              <div class="peach-settings-row peach-settings-row--meta">964                <span class="peach-hint peach-hint-error">{biometricError()}</span>965              </div>966            </Show>967          </Show>968969          <p class="peach-settings-sub-label">BREACH PROTECTION</p>970          <div class="peach-settings-row">971            <span>Breach monitoring</span>972            <button973              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}974              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}975              aria-label="Toggle breach monitoring"976            >977              <span class="peach-toggle-knob"></span>978            </button>979          </div>980          <div class="peach-settings-row peach-settings-row--meta">981            <span>Check all passwords</span>982            <button983              class="peach-btn peach-btn--subtle peach-btn--sm"984              onClick={() => void handleCheckAllBreaches()}985              disabled={checkingBreaches()}986            >987              {checkingBreaches() ? "checking..." : "run"}988            </button>989          </div>990          <Show when={breachResult()}>991            <div class="peach-settings-row peach-settings-row--meta">992              <span>Last check</span>993              <span class="peach-settings-value">{breachResult()}</span>994            </div>995          </Show>996997          <p class="peach-settings-sub-label">MASTER PASSWORD</p>998          <div class="peach-settings-row peach-settings-row--meta">999            <span>Master password</span>1000            <button1001              class="peach-btn peach-btn--subtle peach-btn--sm"1002              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1003            >1004              {showChangePw() ? "cancel" : "change"}1005            </button>1006          </div>1007          <Show when={showChangePw()}>1008            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1009              <input1010                class="peach-settings-input"1011                type="password"1012                placeholder="current password"1013                value={changePwCurrent()}1014                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1015                autocomplete="current-password"1016              />1017              <input1018                class="peach-settings-input"1019                type="password"1020                placeholder="new password (min 12 chars)"1021                value={changePwNew()}1022                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1023                autocomplete="new-password"1024              />1025              <input1026                class="peach-settings-input"1027                type="password"1028                placeholder="confirm new password"1029                value={changePwConfirm()}1030                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1031                autocomplete="new-password"1032                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1033              />1034              <Show when={changePwError()}>1035                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1036              </Show>1037              <Show when={changePwSuccess()}>1038                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1039              </Show>1040              <button1041                class="peach-btn peach-btn--accent peach-btn--sm"1042                style="align-self:flex-start"1043                onClick={() => void handleChangePassword()}1044                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1045              >1046                {changePwSaving() ? "saving…" : "save new password"}1047              </button>1048            </div>1049          </Show>10501051          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1052          <div class="peach-settings-row peach-settings-row--meta">1053            <span>Recovery words</span>1054            <button1055              class="peach-btn peach-btn--subtle peach-btn--sm"1056              onClick={() => {1057                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1058              }}1059            >1060              {showViewWords() ? "cancel" : "view"}1061            </button>1062          </div>1063          <Show when={showViewWords()}>1064            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1065              <Show1066                when={viewedWords()}1067                fallback={1068                  <>1069                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1070                      Enter your master password to reveal your recovery words.1071                    </p>1072                    <input1073                      class="peach-settings-input"1074                      type="password"1075                      placeholder="master password"1076                      value={viewWordsPassword()}1077                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1078                      autocomplete="current-password"1079                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1080                    />1081                    <Show when={viewWordsError()}>1082                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1083                    </Show>1084                    <button1085                      class="peach-btn peach-btn--accent peach-btn--sm"1086                      style="align-self:flex-start"1087                      onClick={() => void handleViewRecoveryWords()}1088                      disabled={viewWordsBusy() || !viewWordsPassword()}1089                    >1090                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1091                    </button>1092                  </>1093                }1094              >1095                <div class="peach-recovery-words-grid">1096                  <For each={viewedWords()!}>1097                    {(word, i) => (1098                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1099                    )}1100                  </For>1101                </div>1102                <button1103                  class="peach-btn peach-btn--subtle peach-btn--sm"1104                  style="align-self:flex-start"1105                  onClick={closeViewWords}1106                >done</button>1107              </Show>1108            </div>1109          </Show>11101111          <p class="peach-settings-sub-label">1112            DURESS PASSWORD1113            <Show when={!isPro()}>1114              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1115            </Show>1116          </p>1117          <div class="peach-settings-row peach-settings-row--top">1118            <div class="peach-settings-row-main">1119              <span>1120                Duress password1121                <Show when={!isPro()}>1122                  <span class="peach-pro-badge">Pro</span>1123                </Show>1124              </span>1125              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1126                If entered under coercion, shows a decoy empty vault instead of your real one.1127              </span>1128            </div>1129            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1130              {duressEnabled() ? "on" : "off"}1131            </div>1132          </div>11331134          <Show when={duressEnabled() && !showDuressSetup()}>1135            <div class="peach-settings-row peach-settings-row--meta">1136              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1137                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.1138              </span>1139            </div>1140          </Show>11411142          <Show when={duressSuccess()}>1143            <div class="peach-settings-row peach-settings-row--meta">1144              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1145            </div>1146          </Show>11471148          <Show when={!showDuressSetup()}>1149            <div class="peach-settings-row peach-settings-row--meta">1150              <button1151                class="peach-btn peach-btn--subtle peach-btn--sm"1152                onClick={() => {1153                  // Pro gate entry-point: free users are redirected to the1154                  // upgrade screen before the setup form opens. The server-1155                  // side SETUP_DURESS handler is the single enforcement1156                  // point; this is UI redirection so the form never appears1157                  // for a user who can't complete it.1158                  if (!isPro() && !duressEnabled()) {1159                    props.onProGate?.("duress_vault");1160                    setActiveCard("pro");1161                    return;1162                  }1163                  setShowDuressSetup(true);1164                  setDuressError(null);1165                  setDuressSuccess(null);1166                  setDuressPassword("");1167                  setDuressConfirm("");1168                  setDuressMainPassword("");1169                }}1170              >1171                {duressEnabled() ? "Change / disable" : "Set up"}1172              </button>1173            </div>1174          </Show>11751176          <Show when={showDuressSetup()}>1177            <div class="peach-duress-form">1178              <label class="peach-label">main password (to confirm)</label>1179              <input1180                class="peach-settings-input"1181                type="password"1182                placeholder="your current master password"1183                autocomplete="current-password"1184                value={duressMainPassword()}1185                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1186              />1187              <Show when={!duressEnabled()}>1188                <label class="peach-label">new duress password</label>1189                <input1190                  class="peach-settings-input"1191                  type="password"1192                  placeholder="duress password (min 8 chars)"1193                  autocomplete="new-password"1194                  value={duressPassword()}1195                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1196                />1197                <label class="peach-label">confirm duress password</label>1198                <input1199                  class="peach-settings-input"1200                  type="password"1201                  placeholder="confirm duress password"1202                  autocomplete="new-password"1203                  value={duressConfirm()}1204                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1205                />1206              </Show>12071208              <Show when={duressError()}>1209                <p class="peach-hint peach-hint-error">{duressError()}</p>1210              </Show>12111212              <div class="peach-duress-actions">1213                <button1214                  class="peach-btn peach-btn--subtle peach-btn--sm"1215                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1216                >1217                  cancel1218                </button>1219                <Show when={duressEnabled()}>1220                  <button1221                    class="peach-btn peach-btn--ghost peach-btn--sm"1222                    style="color:var(--peach-breach)"1223                    onClick={() => void handleDisableDuress()}1224                    disabled={duressSaving()}1225                  >1226                    {duressSaving() ? "disabling..." : "disable duress"}1227                  </button>1228                </Show>1229                <Show when={!duressEnabled()}>1230                  <button1231                    class="peach-btn peach-btn--accent peach-btn--sm"1232                    onClick={() => void handleSetupDuress()}1233                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1234                  >1235                    {duressSaving() ? "saving..." : "save"}1236                  </button>1237                </Show>1238              </div>1239            </div>1240          </Show>12411242          <p class="peach-settings-sub-label">1243            CANARY ENTRIES1244            <Show when={!isPro()}>1245              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1246            </Show>1247          </p>1248          <div class="peach-settings-row peach-settings-row--top">1249            <div class="peach-settings-row-main">1250              <span>1251                Canary entries1252                <Show when={!isPro()}>1253                  <span class="peach-pro-badge">Pro</span>1254                </Show>1255              </span>1256              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1257                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1258              </span>1259            </div>1260            <span class="peach-settings-value" style="flex-shrink:0">1261              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1262            </span>1263          </div>1264          <Show when={canaryEntries().length > 0}>1265            <For each={canaryEntries()}>1266              {(entry) => (1267                <div class="peach-settings-row peach-settings-row--meta">1268                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1269                  <button1270                    class="peach-btn peach-btn--subtle peach-btn--sm"1271                    onClick={() => void handleTestCanary(entry)}1272                    disabled={canaryTestingId() === entry.id}1273                    style="flex-shrink:0"1274                  >1275                    {canaryTestingId() === entry.id1276                      ? "sending…"1277                      : canaryTestResult()?.id === entry.id1278                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1279                        : "test"}1280                  </button>1281                </div>1282              )}1283            </For>1284          </Show>12851286          <p class="peach-settings-sub-label">PASSKEYS</p>1287          <div class="peach-settings-row peach-settings-row--top">1288            <div class="peach-settings-row-main">1289              <span>Intercept passkey requests</span>1290              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1291                Peach can handle passkey sign-in and creation on supported Chromium browsers.1292              </span>1293            </div>1294            <button1295              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1296              onClick={() => void handleTogglePasskeys()}1297              aria-label="Toggle passkey interception"1298              disabled={!passkeyState()?.supported || passkeySaving()}1299            >1300              <span class="peach-toggle-knob"></span>1301            </button>1302          </div>1303          <div class="peach-settings-row peach-settings-row--meta">1304            <span>Status</span>1305            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1306          </div>1307          <div class="peach-settings-row peach-settings-row--meta">1308            <span>Browser</span>1309            <span class="peach-settings-value">1310              {passkeyState()?.browserLabel ?? "Unknown"}1311              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1312            </span>1313          </div>1314          <div class="peach-info-notice" style="margin-top:8px">1315            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1316          </div>1317          <Show when={passkeyState()?.conflict}>1318            <div class="peach-settings-row peach-settings-row--meta">1319              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1320            </div>1321          </Show>1322        </div>1323      </div>1324    );1325  }13261327  function renderVault() {1328    return (1329      <div class="peach-settings-body">1330        <div class="peach-settings-group">1331          <p class="peach-settings-sub-label">ENTRIES</p>1332          <div class="peach-settings-row peach-settings-row--meta">1333            <span>Entries</span>1334            <span class="peach-settings-value">{props.entriesCount}</span>1335          </div>1336          <div class="peach-settings-row peach-settings-row--meta">1337            <span>Duplicates</span>1338            <Show when={dedupDone() !== null} fallback={1339              <button1340                class="peach-btn peach-btn--subtle peach-btn--sm"1341                onClick={() => void handleCountDuplicates()}1342                disabled={dedupRunning()}1343              >1344                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1345              </button>1346            }>1347              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1348                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1349              </span>1350            </Show>1351          </div>1352          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1353            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1354              <div class="peach-restore-warning">1355                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1356              </div>1357              <div style="display:flex;gap:6px">1358                <button1359                  class="peach-btn peach-btn--subtle peach-btn--sm"1360                  onClick={() => setDedupCount(null)}1361                >1362                  cancel1363                </button>1364                <button1365                  class="peach-btn peach-btn--accent peach-btn--sm"1366                  onClick={() => void handleDeduplicate()}1367                  disabled={dedupRunning()}1368                >1369                  {dedupRunning() ? "removing..." : "remove duplicates"}1370                </button>1371              </div>1372            </div>1373          </Show>13741375          <p class="peach-settings-sub-label">MAINTENANCE</p>1376          <div class="peach-settings-row peach-settings-row--meta">1377            <span>Trash auto-delete</span>1378            <select1379              class="peach-settings-select"1380              value={String(props.trashRetentionDays)}1381              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1382            >1383              <option value="7">7 days</option>1384              <option value="30">30 days</option>1385              <option value="90">90 days</option>1386              <option value="0">Never</option>1387            </select>1388          </div>1389          <div class="peach-settings-row peach-settings-row--meta">1390            <span>Advanced Vault Health</span>1391            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1392              open1393            </button>1394          </div>1395          <Show when={declinedCardCount() > 0}>1396            <div class="peach-settings-row peach-settings-row--meta">1397              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1398              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1399                Clear1400              </button>1401            </div>1402          </Show>14031404          <p class="peach-settings-sub-label">DATA</p>1405          <div class="peach-settings-row peach-settings-row--meta">1406            <span>Import passwords</span>1407            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1408              open1409            </button>1410          </div>1411          <div class="peach-settings-row peach-settings-row--meta">1412            <span>Export backup</span>1413            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1414              open1415            </button>1416          </div>1417          <div class="peach-settings-row peach-settings-row--meta">1418            <span>1419              Peach Codex1420              <Show when={!isPro()}>1421                <span class="peach-pro-badge">Pro</span>1422              </Show>1423            </span>1424            <button1425              class="peach-btn peach-btn--subtle peach-btn--sm"1426              onClick={() => props.onOpenCodexGenerate?.()}1427            >1428              open1429            </button>1430          </div>1431          {/* Restore from Codex: intentionally never gated — a printed1432              Codex must remain restorable regardless of license state. */}1433          <div class="peach-settings-row peach-settings-row--meta">1434            <span>Restore from Codex</span>1435            <button1436              class="peach-btn peach-btn--subtle peach-btn--sm"1437              onClick={() => props.onOpenCodexRestore?.()}1438            >1439              open1440            </button>1441          </div>14421443          <span class="peach-settings-sub-label">VAULT HISTORY</span>1444          <Show when={historyLoading()}>1445            <p class="peach-settings-hint">Loading…</p>1446          </Show>1447          <Show when={!historyLoading() && historySnapshots().length === 0}>1448            <p class="peach-settings-hint">1449              No snapshots yet. Peach saves automatically before every change —1450              saves, syncs, and imports.1451            </p>1452          </Show>1453          <Show when={!historyLoading() && historySnapshots().length > 0}>1454            <p class="peach-settings-hint">1455              Restoring locks your vault. Unlock with the password that1456              encrypted that snapshot to confirm.1457            </p>1458            <div class="peach-history-list">1459              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1460                {(snap) => (1461                  <div class="peach-history-row">1462                    <div class="peach-history-meta">1463                      <span class="peach-history-time">1464                        {formatSnapshotTime(snap.savedAt)}1465                      </span>1466                      <span class="peach-history-sub">1467                        {[1468                          snap.entryCount !== null1469                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1470                            : null,1471                          reasonLabel(snap.reason),1472                        ]1473                          .filter(Boolean)1474                          .join(" · ")}1475                      </span>1476                    </div>1477                    <button1478                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1479                        historyRestoring() === snap.id ? " is-busy" : ""1480                      }`}1481                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1482                      disabled={historyRestoring() !== null}1483                    >1484                      <Show when={historyRestoring() === snap.id} fallback="Restore">1485                        …1486                      </Show>1487                    </button>1488                  </div>1489                )}1490              </For>1491            </div>1492            <Show when={historySnapshots().length > 3}>1493              <button1494                class="peach-settings-advanced-toggle"1495                onClick={() => setHistoryShowAll((v) => !v)}1496                aria-expanded={historyShowAll()}1497              >1498                <span class="peach-settings-hint" style="margin:0">1499                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1500                </span>1501                <span class="peach-settings-advanced-chevron">1502                  {historyShowAll() ? "▲" : "▼"}1503                </span>1504              </button>1505            </Show>1506            <p class="peach-history-footer">1507              {historySnapshots().length} of 10 snapshots1508            </p>1509          </Show>15101511          <button1512            class="peach-settings-advanced-toggle"1513            onClick={() => setShowVaultAdvanced((v) => !v)}1514            aria-expanded={showVaultAdvanced()}1515          >1516            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1517            <span class="peach-settings-advanced-chevron">1518              {showVaultAdvanced() ? "▲" : "▼"}1519            </span>1520          </button>15211522          <Show when={showVaultAdvanced()}>1523            <div class="peach-danger-card">1524              <div class="peach-danger-card-head">1525                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1526                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1527              </div>1528              <p class="peach-danger-card-copy">1529                Wipes the current vault and restores from a backup file.1530                Recoverable only if your backup is good.1531              </p>1532              <button1533                class="peach-danger-card-button"1534                onClick={() => props.onOpenFileImport?.()}1535              >1536                Choose backup file1537              </button>1538            </div>15391540            <Show when={props.onOpenPanic}>1541              <div class="peach-danger-card peach-danger-card--hazard">1542                <div class="peach-danger-card-head">1543                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1544                  <h3 class="peach-danger-card-title">Destroy vault</h3>1545                </div>1546                <p class="peach-danger-card-copy">1547                  Permanently wipes every entry, setting, and license on this1548                  device. There is no recovery.1549                </p>1550                <button1551                  class="peach-danger-card-button"1552                  onClick={() => props.onOpenPanic?.()}1553                >1554                  Begin destruction1555                </button>1556              </div>1557            </Show>1558          </Show>1559        </div>1560      </div>1561    );1562  }15631564  function renderSync() {1565    return (1566      <div class="peach-settings-body">1567        <div class="peach-settings-group">1568          <div class="peach-settings-row peach-settings-row--meta">1569            <span>Status</span>1570            <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1571              {isPro() ? "Pro" : "Free"}1572              <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1573            </span>1574          </div>15751576          {/* Pick a pairing method and begin — no separate hub screen. */}1577          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1578          {/* One path in: this browser displays an invitation and the other1579              device scans it. An extension can neither listen nor use a camera,1580              so displaying is the only role it can take. */}1581          <button1582            class="peach-sync-pair-option"1583            onClick={() => props.onOpenSyncPairing?.("code")}1584          >1585            <span class="peach-sync-pair-option-icon" aria-hidden="true">1586              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1587                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1588                <path d="M9 15 L11 15" />1589              </svg>1590            </span>1591            <span class="peach-sync-pair-option-main">1592              <span class="peach-sync-pair-option-title">Show a code</span>1593              <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1594            </span>1595            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1596          </button>1597          <Show when={!isPro()}>1598            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1599              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1600            </p>1601          </Show>16021603          {/* Three named choices, not a toggle with an implied default.1604              LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"1605              now means no sync rather than a peer-to-peer mode that never1606              reliably worked. */}1607          <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>16081609          <button1610            class="peach-sync-mode"1611            classList={{ "is-active": syncMode() === "public" }}1612            disabled={relaySaving()}1613            onClick={() => void saveSyncSettings("public")}1614          >1615            <span class="peach-sync-mode-main">1616              <span class="peach-sync-mode-title">Public relay</span>1617              <span class="peach-sync-mode-sub">1618                Peach's own relay. Works anywhere, nothing to set up. It never sees1619                your vault — everything is encrypted before it leaves the device.1620              </span>1621            </span>1622            <span class="peach-sync-mode-pip" aria-hidden="true" />1623          </button>16241625          <button1626            class="peach-sync-mode"1627            classList={{ "is-active": syncMode() === "private" }}1628            disabled={relaySaving()}1629            onClick={() => void saveSyncSettings("private")}1630          >1631            <span class="peach-sync-mode-main">1632              <span class="peach-sync-mode-title">Private relay</span>1633              <span class="peach-sync-mode-sub">1634                Your own peach-relay, on your own domain. Same open-source code.1635              </span>1636            </span>1637            <span class="peach-sync-mode-pip" aria-hidden="true" />1638          </button>16391640          <Show when={syncMode() === "private"}>1641            <label class="peach-label" for="sync-relay-url">relay URL</label>1642            <input1643              id="sync-relay-url"1644              class="peach-underline-input"1645              type="url"1646              inputmode="url"1647              autocomplete="off"1648              spellcheck={false}1649              value={relayUrl()}1650              disabled={relaySaving()}1651              onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1652              onKeyDown={(event) => {1653                if (event.key === "Enter") void saveSyncSettings();1654              }}1655            />1656            <div class="peach-settings-row peach-settings-row--meta">1657              <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1658                {relayStatus() ?? "Only secure wss:// relays are accepted."}1659              </span>1660              <button1661                class="peach-btn peach-btn--subtle peach-btn--sm"1662                disabled={relaySaving()}1663                onClick={() => void saveSyncSettings()}1664              >{relaySaving() ? "saving…" : "save"}</button>1665            </div>1666          </Show>16671668          <button1669            class="peach-sync-mode"1670            classList={{ "is-active": syncMode() === "off" }}1671            disabled={relaySaving()}1672            onClick={() => void saveSyncSettings("off")}1673          >1674            <span class="peach-sync-mode-main">1675              <span class="peach-sync-mode-title">No sync</span>1676              <span class="peach-sync-mode-sub">1677                This device keeps to itself. Move your vault by hand with Codex or1678                CSV export and import.1679              </span>1680            </span>1681            <span class="peach-sync-mode-pip" aria-hidden="true" />1682          </button>16831684          {/* Paired devices + manual sync — Settings is the hub. */}1685          <Show when={pairedDevices().length > 0}>1686            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1687            <For each={pairedDevices()}>1688              {(d) => (1689                <div class="peach-settings-row peach-settings-row--top">1690                  <div class="peach-settings-row-main">1691                    <span>{d.deviceName ?? "Unnamed device"}</span>1692                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1693                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1694                    </span>1695                  </div>1696                  <Show1697                    when={confirmUnpair() === d.deviceId}1698                    fallback={1699                      <button1700                        class="peach-btn peach-btn--subtle peach-btn--sm"1701                        onClick={() => setConfirmUnpair(d.deviceId)}1702                      >remove</button>1703                    }1704                  >1705                    {/* HOLE 4: revocation stops this device receiving future1706                        changes. It does NOT secure the vault against it — that1707                        device may already hold the recovery words, which are1708                        master-password-equivalent and restore the whole vault1709                        anywhere, forever, with no sync involved. Say so here,1710                        rather than leaving the user falsely reassured. */}1711                    <div class="peach-unpair-confirm">1712                      <p class="peach-unpair-confirm-note">1713                        Stops this device receiving future changes. It doesn't1714                        lock it out of what it already has — if it's lost or1715                        stolen, also change your master password and generate1716                        new recovery words.1717                      </p>1718                      <div style="display:flex;gap:6px;justify-content:flex-end">1719                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1720                        <button1721                          class="peach-btn peach-btn--sm"1722                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1723                          onClick={() => void unpairDevice(d.deviceId)}1724                        >remove</button>1725                      </div>1726                    </div>1727                  </Show>1728                </div>1729              )}1730            </For>1731            <div class="peach-settings-row peach-settings-row--meta">1732              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1733              <button1734                class="peach-btn peach-btn--subtle peach-btn--sm"1735                disabled={syncStatus() === "syncing"}1736                onClick={() => void syncNow()}1737              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1738            </div>1739          </Show>1740          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1741          <Show when={pendingVouched().length > 0}>1742            <p class="peach-settings-sub-label">1743              PENDING DEVICES1744              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1745            </p>1746            <For each={pendingVouched()}>1747              {(dev) => (1748                <div class="peach-settings-row peach-settings-row--top">1749                  <div class="peach-settings-row-main">1750                    <span>{dev.deviceName}</span>1751                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1752                      Was added by another of your devices. Allow it to sync with this vault?1753                    </span>1754                  </div>1755                  <div style="display:flex;gap:6px;flex-shrink:0">1756                    <button1757                      class="peach-btn peach-btn--sm"1758                      disabled={vouchedBusy() === dev.deviceId}1759                      onClick={() => void confirmVouched(dev.deviceId)}1760                    >Allow</button>1761                    <button1762                      class="peach-btn peach-btn--subtle peach-btn--sm"1763                      disabled={vouchedBusy() === dev.deviceId}1764                      onClick={() => void rejectVouched(dev.deviceId)}1765                    >Reject</button>1766                  </div>1767                </div>1768              )}1769            </For>1770          </Show>17711772          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1773          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1774          <Show1775            when={rotateConfirmOpen()}1776            fallback={1777              <div class="peach-settings-row peach-settings-row--top">1778                <div class="peach-settings-row-main">1779                  <span>Rotate sync identity</span>1780                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1781                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1782                  </span>1783                </div>1784                <button1785                  class="peach-btn peach-btn--subtle peach-btn--sm"1786                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1787                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1788                >rotate</button>1789              </div>1790            }1791          >1792            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1793              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.1794              <Show when={rotateError()}>1795                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1796              </Show>1797              <div style="display:flex;gap:6px;margin-top:10px">1798                <button1799                  class="peach-btn peach-btn--subtle peach-btn--sm"1800                  disabled={rotating()}1801                  onClick={() => setRotateConfirmOpen(false)}1802                >Cancel</button>1803                <button1804                  class="peach-btn peach-btn--sm"1805                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1806                  disabled={rotating()}1807                  onClick={() => void doRotateSyncIdentity()}1808                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1809              </div>1810            </div>1811          </Show>1812        </div>1813      </div>1814    );1815  }18161817  function renderAppearance() {1818    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1819      { id: "medium", label: "Default" },1820      { id: "large", label: "Large" },1821    ];1822    return (1823      <div class="peach-settings-body">1824        <div class="peach-settings-group">1825          <p class="peach-settings-sub-label">THEME</p>1826          <div class="peach-settings-row peach-settings-row--meta">1827            <span>Theme</span>1828            <button1829              class="peach-btn peach-btn--subtle peach-btn--sm"1830              onClick={() =>1831                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1832              }1833            >1834              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1835            </button>1836          </div>18371838          <p class="peach-settings-sub-label">ACCENT</p>1839          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1840            <span>Accent</span>1841            <div class="peach-accent-picker">1842              <For each={ACCENT_COLORS}>1843                {(color) => (1844                  <button1845                    class="peach-accent-swatch peach-swatch"1846                    classList={{ "is-active": props.accentColor === color.name }}1847                    style={{ background: color.hex }}1848                    onClick={() => void props.onAccentChange(color.name)}1849                    aria-label={color.name}1850                    title={color.name}1851                  />1852                )}1853              </For>1854            </div>1855          </div>18561857          <p class="peach-settings-sub-label">TEXT SIZE</p>1858          <div class="peach-settings-row peach-settings-row--meta">1859            <span>Size</span>1860            <div class="peach-pill-group">1861              <For each={sizeOptions}>1862                {(opt) => (1863                  <button1864                    class="peach-btn peach-btn--ghost peach-btn--sm"1865                    classList={{ "is-active": props.textSize === opt.id }}1866                    onClick={() => void props.onTextSizeChange(opt.id)}1867                  >1868                    {opt.label}1869                  </button>1870                )}1871              </For>1872            </div>1873          </div>18741875          <p class="peach-settings-sub-label">MOTION</p>1876          <div class="peach-settings-row peach-settings-row--meta">1877            <span>Reduce motion</span>1878            <button1879              class="peach-btn peach-btn--subtle peach-btn--sm"1880              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1881            >1882              {props.reduceMotion ? "On" : "Off"}1883            </button>1884          </div>1885        </div>1886      </div>1887    );1888  }18891890  function renderShortcuts() {1891    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1892    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18931894    function startCapture(action: ShortcutAction) {1895      setConflictMsg(null);1896      setCapturingAction(action);1897    }1898    function cancelCapture() {1899      setCapturingAction(null);1900      setConflictMsg(null);1901    }1902    function commitBinding(action: ShortcutAction, binding: Keybinding) {1903      const conflict = findConflict(props.shortcuts, binding, action);1904      if (conflict) {1905        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1906        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1907        return;1908      }1909      const next = { ...props.shortcuts, [action]: binding };1910      setCapturingAction(null);1911      setConflictMsg(null);1912      void props.onShortcutsChange(next);1913    }1914    function resetAction(action: ShortcutAction) {1915      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1916      void props.onShortcutsChange(next);1917    }1918    function resetAll() {1919      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1920    }19211922    // Capture-mode key handler — wired via ref onto the chip element so it1923    // catches the next non-modifier keypress and ignores other bindings.1924    function handleCaptureKey(e: KeyboardEvent) {1925      e.preventDefault();1926      e.stopPropagation();1927      if (e.key === "Escape") { cancelCapture(); return; }1928      const binding = keybindingFromEvent(e);1929      if (!binding) return; // bare modifier — keep waiting1930      const action = capturingAction();1931      if (action) commitBinding(action, binding);1932    }19331934    return (1935      <div class="peach-settings-body">1936        <div class="peach-settings-group peach-shortcuts-group">1937          <p class="peach-settings-sub-label">ENTRY VIEW</p>1938          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1939            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1940          </For>1941        </div>19421943        <div class="peach-settings-group peach-shortcuts-group">1944          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1945          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1946            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1947          </For>1948        </div>19491950        <Show when={conflictMsg()}>1951          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1952        </Show>19531954        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1955          <div class="peach-settings-row peach-settings-row--meta">1956            <span>Inline autofill on page</span>1957            <a1958              class="peach-settings-link"1959              href="chrome://extensions/shortcuts"1960              onClick={(e) => {1961                e.preventDefault();1962                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1963              }}1964            >1965              ⌘⇧P — customize ↗1966            </a>1967          </div>1968          <button1969            class="peach-btn peach-btn--subtle peach-btn--sm"1970            style="align-self:flex-start"1971            onClick={resetAll}1972          >1973            Reset all to defaults1974          </button>1975        </div>1976      </div>1977    );19781979    function renderRow(1980      desc: typeof SHORTCUT_DESCRIPTORS[number],1981      capturing: () => ShortcutAction | null,1982      onStart: (a: ShortcutAction) => void,1983      onCancel: () => void,1984      onReset: (a: ShortcutAction) => void,1985      onKey: (e: KeyboardEvent) => void,1986    ) {1987      const isActive = () => capturing() === desc.action;1988      const binding = () => props.shortcuts[desc.action];1989      const isCustom = () => {1990        const b = binding();1991        const d = DEFAULT_BINDINGS[desc.action];1992        return b.key !== d.key1993          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1994          || Boolean(b.meta)  !== Boolean(d.meta)1995          || Boolean(b.shift) !== Boolean(d.shift)1996          || Boolean(b.alt)   !== Boolean(d.alt);1997      };1998      return (1999        <div class="peach-shortcut-row">2000          <span class="peach-shortcut-row-label">{desc.label}</span>2001          <button2002            class="peach-shortcut-chip"2003            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2004            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2005            onKeyDown={(e) => { if (isActive()) onKey(e); }}2006            ref={(el) => {2007              // Auto-focus when entering capture mode so the keydown listener catches the next press.2008              if (el) {2009                queueMicrotask(() => { if (isActive()) el.focus(); });2010              }2011            }}2012          >2013            {isActive() ? "Press a key…" : formatKeybinding(binding())}2014          </button>2015          <Show when={isCustom() && !isActive()}>2016            <button2017              class="peach-shortcut-reset"2018              title="Reset to default"2019              onClick={() => onReset(desc.action)}2020            >2021              ↺2022            </button>2023          </Show>2024        </div>2025      );2026    }2027  }20282029  const PRO_FEATURES: Array<{2030    feature: ProFeature;2031    name: string;2032    desc: string;2033  }> = [2034    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2035    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2036    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2037    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2038  ];20392040  const PRO_SECURITY_FEATURES: Array<{2041    feature: ProFeature;2042    name: string;2043    desc: string;2044  }> = [2045    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2046    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2047  ];20482049  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2050  const [showKey, setShowKey] = createSignal(false);20512052  const [deactivating, setDeactivating] = createSignal(false);20532054  async function handleDeactivateLicense() {2055    setDeactivating(true);2056    await sendMessage({ type: "DEACTIVATE_LICENSE" });2057    setLicenseInfo(null);2058    await props.onLicenseChange?.();2059    setDeactivating(false);2060  }20612062  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2063    setCanaryTestingId(entry.id);2064    setCanaryTestResult(null);2065    const res = await sendMessage({2066      type: "FIRE_CANARY_PING",2067      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2068    }) as { ok: boolean };2069    setCanaryTestResult({ id: entry.id, ok: res.ok });2070    setCanaryTestingId(null);2071    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2072  }20732074  function renderLicense() {2075    async function onActivate() {2076      await handleActivateLicense();2077      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2078        await props.onLicenseChange?.();2079      }2080    }20812082    return (2083      <div class="peach-settings-body">2084        <Show2085          when={isPro()}2086          fallback={2087                <div class="peach-pro-upgrade">2088                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20892090                  <h2 class="peach-pro-headline">2091                    Every<em>thing,</em><br />every<em>where.</em>2092                  </h2>20932094                  <p class="peach-pro-subtitle">2095                    One key unlocks every Pro feature — for the lifetime of the app.2096                  </p>20972098                  <div class="peach-pro-grid">2099                    <For each={PRO_FEATURES}>2100                      {(item, i) => (2101                        <div2102                          class="peach-pro-grid-item"2103                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2104                        >2105                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2106                          <span class="peach-pro-grid-name">{item.name}</span>2107                          <span class="peach-pro-grid-desc">{item.desc}</span>2108                        </div>2109                      )}2110                    </For>2111                  </div>21122113                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2114                  <div class="peach-pro-grid">2115                    <For each={PRO_SECURITY_FEATURES}>2116                      {(item, i) => (2117                        <div2118                          class="peach-pro-grid-item"2119                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2120                        >2121                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2122                          <span class="peach-pro-grid-name">{item.name}</span>2123                          <span class="peach-pro-grid-desc">{item.desc}</span>2124                        </div>2125                      )}2126                    </For>2127                  </div>21282129                  <div class="peach-pro-upgrade-actions">2130                    <p class="peach-pro-pricing">2131                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2132                    </p>2133                    <a2134                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2135                      target="_blank"2136                      rel="noopener noreferrer"2137                      class="peach-pro-upgrade-cta"2138                    >2139                      Get Peach Pro2140                    </a>2141                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2142                    <div class="peach-pro-key-inline">2143                      <input2144                        class="peach-pro-key-inline-input"2145                        type="text"2146                        placeholder="PEACH-XXXX-XXXX-XXXX"2147                        value={licenseKeyInput()}2148                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2149                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2150                        autocomplete="off"2151                        spellcheck={false}2152                      />2153                      <button2154                        class="peach-pro-key-apply"2155                        onClick={() => void onActivate()}2156                        disabled={activatingLicense() || !licenseKeyInput().trim()}2157                      >2158                        {activatingLicense() ? "…" : "APPLY"}2159                      </button>2160                    </div>2161                    <Show when={licenseError()}>2162                      <p class="peach-pro-key-error">{licenseError()}</p>2163                    </Show>2164                  </div>2165                </div>2166          }2167        >2168          <div class="peach-pro-active">2169            <div class="peach-pro-active-hero">2170              <button2171                class="peach-pro-active-mark-wrap"2172                type="button"2173                aria-label="Celebrate"2174                onClick={handleProCelebrationClick}2175              >2176                <PeachMark class="peach-pro-active-mark" />2177                <Show when={justActivated()}>2178                  <div class="peach-pro-sparks" aria-hidden="true">2179                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2180                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2181                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2182                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2183                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2184                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2185                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2186                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2187                  </div>2188                </Show>2189              </button>2190              <h2 class="peach-pro-active-title">Peach Pro</h2>2191              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2192              <Show when={justActivated()}>2193                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2194              </Show>2195            </div>21962197            <Show when={licenseKey()}>2198              <div class="peach-settings-license">2199                <span class="peach-settings-label">LICENSE KEY</span>2200                <div class="peach-settings-license-row">2201                  <Show2202                    when={showKey()}2203                    fallback={2204                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2205                        ••••••••••••••••••••••••••••2206                      </span>2207                    }2208                  >2209                    <span class="peach-settings-license-key">{licenseKey()!}</span>2210                  </Show>2211                  <div class="peach-settings-license-actions">2212                    <button2213                      class="peach-settings-license-copy"2214                      title="Copy license key"2215                      onClick={() => copyLicenseKey(licenseKey()!)}2216                    >2217                      {copied() ? "✓" : "copy"}2218                    </button>2219                    <button2220                      class="peach-settings-license-toggle"2221                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2222                      title={showKey() ? "Hide" : "Reveal"}2223                      onClick={() => setShowKey(v => !v)}2224                    >2225                      <Show2226                        when={showKey()}2227                        fallback={2228                          <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">2229                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2230                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2231                          </svg>2232                        }2233                      >2234                        <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">2235                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2236                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2237                          <line x1="2" y1="2" x2="12" y2="12" />2238                        </svg>2239                      </Show>2240                    </button>2241                  </div>2242                </div>2243              </div>2244            </Show>22452246            <div class="peach-pro-active-features">2247              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2248                {(item) => (2249                  <div class="peach-pro-active-feature">2250                    <span class="peach-pro-active-feature-mark">✦</span>2251                    <span class="peach-pro-active-feature-name">{item.name}</span>2252                  </div>2253                )}2254              </For>2255            </div>22562257            <button2258              class="peach-pro-deactivate"2259              onClick={() => void handleDeactivateLicense()}2260              disabled={deactivating()}2261            >2262              {deactivating() ? "Deactivating…" : "Deactivate"}2263            </button>2264          </div>2265        </Show>2266      </div>2267    );2268  }22692270  function renderFeedback() {2271    return (2272      <div class="peach-settings-body">2273        <div class="peach-settings-group peach-feedback-group">2274          <Show2275            when={feedbackState() === 'sent'}2276            fallback={2277              <>2278                <p class="peach-feedback-intro">2279                  Help us improve Peach. Every report goes directly to our inbox.2280                </p>22812282                <p class="peach-settings-sub-label">TYPE</p>2283                <div class="peach-feedback-chips">2284                  <For each={FEEDBACK_CATEGORIES}>2285                    {(cat) => {2286                      const selected = () => fbCategory() === cat;2287                      return (2288                        <button2289                          type="button"2290                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2291                          aria-pressed={selected()}2292                          onClick={() => setFbCategory(cat)}2293                        >2294                          {cat}2295                        </button>2296                      );2297                    }}2298                  </For>2299                </div>23002301                <p class="peach-settings-sub-label">MESSAGE</p>2302                <textarea2303                  class="peach-feedback-textarea"2304                  placeholder="Describe the issue or idea..."2305                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2306                  value={fbMessage()}2307                  onInput={(e) => setFbMessage(e.currentTarget.value)}2308                />23092310                <p class="peach-feedback-meta">2311                  Version {version} · will be included automatically2312                </p>23132314                <button2315                  type="button"2316                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2317                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2318                  onClick={() => void handleSubmitFeedback()}2319                >2320                  <Switch>2321                    <Match when={feedbackState() === 'idle'}>Send</Match>2322                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2323                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2324                  </Switch>2325                </button>23262327                <p class="peach-feedback-anon">2328                  Sent anonymously · no account required2329                </p>2330              </>2331            }2332          >2333            <div class="peach-feedback-thanks" role="status" aria-live="polite">2334              <p class="peach-feedback-thanks-title">Thank you</p>2335              <p>2336                Your note is in the queue. Someone will process it soon.2337              </p>2338            </div>2339          </Show>2340        </div>2341      </div>2342    );2343  }23442345  const bodies: StackBodies = {2346    protection: renderSecurity(),2347    vault: renderVault(),2348    sync: renderSync(),2349    appearance: renderAppearance(),2350    shortcuts: renderShortcuts(),2351    pro: renderLicense(),2352    feedback: renderFeedback(),2353  };23542355  return (2356    <Show2357      when={showNewRecoveryWords()}2358      fallback={2359        <div2360          class="peach-overlay-view peach-settings-overlay"2361          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2362          style="display:flex;flex-direction:column"2363        >2364          <StackDeck2365            active={activeCard()}2366            onActiveChange={setActiveCard}2367            onBack={props.onBack}2368            cards={cards()}2369            bodies={bodies}2370            version={version}2371            isFanning={isFanning()}2372            celebratingCardId={justActivated() ? "pro" : null}2373            isPro={isPro()}2374          />2375        </div>2376      }2377    >2378      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2379    </Show>2380  );2381}