Settings.tsx

93.6 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 [lanOnly, setLanOnly] = createSignal(false);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_RELAY_SETTINGS" })) as {346      ok: boolean;347      data?: { relayUrl: string; lanOnly: boolean };348    };349    if (result.ok && result.data) {350      setRelayUrl(result.data.relayUrl);351      setLanOnly(result.data.lanOnly);352    }353  }354  async function saveRelaySettings(nextLanOnly = lanOnly()) {355    setRelaySaving(true);356    setRelayStatus(null);357    const result = (await sendMessage({358      type: "SET_RELAY_SETTINGS",359      payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly },360    })) as {361      ok: boolean;362      error?: string;363      data?: { relayUrl: string; lanOnly: boolean };364    };365    if (result.ok && result.data) {366      setRelayUrl(result.data.relayUrl);367      setLanOnly(result.data.lanOnly);368      setRelayStatus("Saved");369    } else {370      setRelayStatus("Use a valid wss:// relay URL");371    }372    setRelaySaving(false);373  }374  // UI-4 / MV-8: rotate sync identity confirmation dialog.375  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);376  const [rotating, setRotating] = createSignal(false);377  const [rotateError, setRotateError] = createSignal<string | null>(null);378379  async function loadPendingVouched() {380    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {381      ok: boolean;382      data?: PendingVouched[];383    };384    if (r.ok) setPendingVouched(r.data ?? []);385  }386  async function confirmVouched(deviceId: string) {387    setVouchedBusy(deviceId);388    try {389      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });390      await loadPendingVouched();391    } finally {392      setVouchedBusy(null);393    }394  }395  async function rejectVouched(deviceId: string) {396    setVouchedBusy(deviceId);397    try {398      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });399      await loadPendingVouched();400    } finally {401      setVouchedBusy(null);402    }403  }404  async function doRotateSyncIdentity() {405    setRotateError(null);406    setRotating(true);407    try {408      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {409        ok: boolean;410        error?: string;411      };412      if (!r.ok) {413        setRotateError(r.error ?? "Rotation failed.");414        return;415      }416      setRotateConfirmOpen(false);417      // Backend set peach_recovery_pending; reuse the existing recovery-words418      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).419      setShowNewRecoveryWords(true);420    } finally {421      setRotating(false);422    }423  }424425  const [dedupCount, setDedupCount] = createSignal<number | null>(null);426  const [dedupRunning, setDedupRunning] = createSignal(false);427  const [dedupDone, setDedupDone] = createSignal<number | null>(null);428429  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);430431  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted432  // BEFORE every real-mode vault write so the user can roll back if a sync433  // or import overwrites the wrong state.434  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);435  const [historyLoading, setHistoryLoading] = createSignal(false);436  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);437  const [historyShowAll, setHistoryShowAll] = createSignal(false);438439  async function loadVaultHistory() {440    setHistoryLoading(true);441    try {442      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });443      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];444      setHistorySnapshots(list);445    } finally {446      setHistoryLoading(false);447    }448  }449450  async function handleRestoreVaultSnapshot(id: string) {451    if (historyRestoring()) return;452    setHistoryRestoring(id);453    const res = await sendMessage({454      type: "RESTORE_VAULT_SNAPSHOT",455      payload: { snapshotId: id },456    });457    setHistoryRestoring(null);458    if ((res as { ok: boolean }).ok) {459      // Vault is now locked — let the parent close Settings; App.tsx's460      // VAULT_LOCKED listener will route back to the lock screen.461      props.onBack();462    }463  }464465  function formatSnapshotTime(savedAt: number): string {466    const diff = Date.now() - savedAt;467    if (diff < 60_000) return "Just now";468    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;469    const d = new Date(savedAt);470    const isToday = new Date().toDateString() === d.toDateString();471    const isYest =472      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();473    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });474    if (isToday) return `Today ${t}`;475    if (isYest) return `Yesterday ${t}`;476    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;477  }478479  function reasonLabel(reason: VaultSnapshot["reason"]): string {480    return (481      {482        local_save: "saved",483        sync_received: "synced",484        import: "imported",485        restore: "restored",486      } as const487    )[reason] ?? reason;488  }489490  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);491492  const [showChangePw, setShowChangePw] = createSignal(false);493  const [changePwCurrent, setChangePwCurrent] = createSignal("");494  const [changePwNew, setChangePwNew] = createSignal("");495  const [changePwConfirm, setChangePwConfirm] = createSignal("");496  const [changePwSaving, setChangePwSaving] = createSignal(false);497  const [changePwError, setChangePwError] = createSignal<string | null>(null);498  const [changePwSuccess] = createSignal(false);499  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);500501  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.502  const [showViewWords, setShowViewWords] = createSignal(false);503  const [viewWordsPassword, setViewWordsPassword] = createSignal("");504  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);505  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);506  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);507508  async function handleViewRecoveryWords() {509    setViewWordsError(null);510    if (!viewWordsPassword()) {511      setViewWordsError("Enter your master password.");512      return;513    }514    setViewWordsBusy(true);515    try {516      const r = (await sendMessage({517        type: "GET_RECOVERY_WORDS",518        payload: { password: viewWordsPassword() },519      })) as { ok: boolean; data?: string[]; error?: string };520      if (r.ok) {521        setViewedWords(r.data ?? []);522        setViewWordsPassword("");523        return;524      }525      switch (r.error) {526        case "wrong_password":527          setViewWordsError("Incorrect password");528          break;529        case "password_required":530          setViewWordsError("Enter your master password.");531          break;532        case "recovery_not_setup":533          setViewWordsError("Recovery words aren't set up for this vault yet.");534          break;535        default:536          setViewWordsError("Couldn't retrieve recovery words.");537      }538    } finally {539      setViewWordsBusy(false);540    }541  }542  function closeViewWords() {543    setShowViewWords(false);544    setViewWordsPassword("");545    setViewWordsError(null);546    setViewedWords(null);547  }548549  const [duressEnabled, setDuressEnabled] = createSignal(false);550  const [showDuressSetup, setShowDuressSetup] = createSignal(false);551  const [duressPassword, setDuressPassword] = createSignal("");552  const [duressConfirm, setDuressConfirm] = createSignal("");553  const [duressMainPassword, setDuressMainPassword] = createSignal("");554  const [duressSaving, setDuressSaving] = createSignal(false);555  const [duressError, setDuressError] = createSignal<string | null>(null);556  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);557558  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);559560  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);561  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);562563  const [fbCategory, setFbCategory] = createSignal("Bug report");564  const [fbMessage, setFbMessage] = createSignal("");565  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';566  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');567568  createEffect(() => {569    if (props.upgradeHighlight) {570      setActiveCard("pro");571    }572  });573574  onMount(async () => {575    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([576      sendMessage({ type: "GET_LICENSE" }),577      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),578      sendMessage({ type: "IS_DURESS_ENABLED" }),579      sendMessage({ type: "GET_SETTINGS" }),580      sendMessage({ type: "GET_LICENSE_KEY" }),581    ]);582583    // Vault history loads in the background — the Vault sub-view picks it up584    // when the user navigates in. Loading it lazily keeps Settings boot fast.585    void loadVaultHistory();586587    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the588    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.589    void loadPendingVouched();590    void loadPairedDevices();591    void loadRelaySettings();592    const onBroadcast = (msg: unknown) => {593      if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {594        void loadPendingVouched();595      }596    };597    chrome.runtime?.onMessage?.addListener(onBroadcast);598    onCleanup(() => {599      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }600    });601602    if ((licenseRes as { ok: boolean }).ok) {603      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);604    }605606    if ((passkeyRes as { ok: boolean }).ok) {607      setPasskeyState(608        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null609      );610    }611612    if ((duressRes as { ok: boolean }).ok) {613      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);614    }615616    if ((settingsRes as { ok: boolean }).ok) {617      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;618      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);619    }620621    if ((keyRes as { ok: boolean }).ok) {622      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);623    }624625    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });626    if ((entriesRes as { ok: boolean }).ok) {627      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];628      setCanaryEntries(629        entries630          .filter((e) => e.canary?.webhookUrl)631          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))632      );633    }634635636    if (isNativePlatform()) {637      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");638      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");639      try {640        if (biometric) {641          const a = await biometric.isAvailable();642          setBiometricSupported(a.available);643        }644        if (keystore) {645          const k = await keystore.hasMasterKey();646          setBiometricStored(k.exists);647        }648      } catch { /* missing plugins — leave defaults */ }649    }650  });651652  async function handleEnableBiometrics() {653    setBiometricBusy(true);654    setBiometricError(null);655    try {656      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {657        ok: boolean;658        data?: { masterKeyHex: string };659        error?: string;660      };661      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {662        setBiometricError(keyRes.error === "vault_locked"663          ? "unlock the vault first"664          : "couldn't read derived key");665        return;666      }667      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");668      if (!keystore) {669        setBiometricError("biometric plugin unavailable");670        return;671      }672      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });673      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });674      setBiometricStored(true);675    } catch (e) {676      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");677    } finally {678      setBiometricBusy(false);679    }680  }681682  async function handleDisableBiometrics() {683    setBiometricBusy(true);684    setBiometricError(null);685    try {686      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");687      if (keystore) await keystore.clearMasterKey();688      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });689      setBiometricStored(false);690    } catch (e) {691      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");692    } finally {693      setBiometricBusy(false);694    }695  }696697  async function handleChangePassword() {698    setChangePwError(null);699    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }700    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }701    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }702    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }703    setChangePwSaving(true);704    const res = await sendMessage({705      type: "CHANGE_PASSWORD",706      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },707    }) as { ok: boolean; error?: string };708    if (res.ok) {709      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");710      setShowChangePw(false);711      // LV-4: the user just authenticated (current password), so the recovery712      // words reveal that follows is exempt from the re-auth gate. Set the713      // pending flag the same way the create/recovery flow does; the display714      // clears it on confirm.715      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });716      setShowNewRecoveryWords(true);717    } else {718      setChangePwError(res.error ?? "failed to change password");719    }720    setChangePwSaving(false);721  }722723  const [copied, setCopied] = createSignal(false);724725  async function copyLicenseKey(key: string) {726    await navigator.clipboard.writeText(key);727    setCopied(true);728    setTimeout(() => setCopied(false), 2000);729  }730731  async function handleActivateLicense() {732    const key = licenseKeyInput().trim();733    if (!key) return;734    setActivatingLicense(true);735    setLicenseError(null);736    setLicenseSuccess(null);737    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });738    setActivatingLicense(false);739    if ((res as { ok: boolean }).ok) {740      const data = (res as { ok: boolean; data?: LicenseInfo }).data;741      if (data?.valid) {742        setLicenseInfo(data);743        setLicenseKey(key);744        setLicenseSuccess("License activated!");745        setLicenseKeyInput("");746      } else {747        setLicenseError("Invalid license key");748      }749    } else {750      setLicenseError("Activation failed");751    }752  }753754  async function handleTogglePasskeys() {755    const current = passkeyState();756    if (!current || !current.supported) return;757    setPasskeySaving(true);758    const res = await sendMessage({759      type: "SET_PASSKEY_PROVIDER_ENABLED",760      payload: { enabled: !current.enabled },761    });762    setPasskeySaving(false);763    if ((res as { ok: boolean }).ok) {764      setPasskeyState(765        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null766      );767    }768  }769770  async function handleCheckAllBreaches() {771    setCheckingBreaches(true);772    setBreachResult(null);773    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });774    setCheckingBreaches(false);775    if ((res as { ok: boolean }).ok) {776      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};777      const breached = Object.values(data).filter((count) => count > 0).length;778      setBreachResult(779        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"780      );781      props.onBreachCheckDone();782    }783  }784785786  async function handleSetupDuress() {787    const mainPw = duressMainPassword().trim();788    const duressPw = duressPassword().trim();789    const confirmPw = duressConfirm().trim();790791    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }792    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }793    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }794    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }795796    setDuressSaving(true);797    setDuressError(null);798799    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };800    if (!verifyRes.ok || !verifyRes.data) {801      setDuressError("incorrect main password");802      setDuressSaving(false);803      return;804    }805806    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };807    setDuressSaving(false);808    if (res.ok) {809      setDuressEnabled(true);810      setShowDuressSetup(false);811      setDuressPassword("");812      setDuressConfirm("");813      setDuressMainPassword("");814      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");815      setTimeout(() => setDuressSuccess(null), 5000);816    } else {817      setDuressError(818        res.error === "session_expired"819          ? "Vault session expired — lock and unlock Peach, then try again"820          : res.error ?? "failed to set duress password"821      );822    }823  }824825  async function handleClearDeclinedCards() {826    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });827    setDeclinedCardCount(0);828  }829830  async function handleCountDuplicates() {831    setDedupRunning(true);832    setDedupDone(null);833    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };834    setDedupRunning(false);835    if (res.ok) setDedupCount(res.data ?? 0);836  }837838  async function handleDeduplicate() {839    setDedupRunning(true);840    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };841    setDedupRunning(false);842    if (res.ok) {843      setDedupDone(res.data ?? 0);844      setDedupCount(0);845    }846  }847848  async function handleDisableDuress() {849    const mainPw = duressMainPassword().trim();850    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }851852    setDuressSaving(true);853    setDuressError(null);854855    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };856    if (!verifyRes.ok || !verifyRes.data) {857      setDuressError("incorrect main password");858      setDuressSaving(false);859      return;860    }861862    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };863    setDuressSaving(false);864    if (res.ok) {865      setDuressEnabled(false);866      setShowDuressSetup(false);867      setDuressMainPassword("");868      setDuressSuccess("Duress password disabled.");869      setTimeout(() => setDuressSuccess(null), 4000);870    } else {871      setDuressError(res.error ?? "failed to disable duress password");872    }873  }874875  async function handleSubmitFeedback() {876    const message = sanitizeFeedbackMessage(fbMessage());877    if (!message) return;878879    setFeedbackState('sending');880881    try {882      const hasDataPermission = await requestFeedbackDataPermission();883      if (!hasDataPermission) {884        setFeedbackState('idle');885        return;886      }887888      const category = FEEDBACK_CATEGORIES.includes(fbCategory())889        ? fbCategory()890        : FEEDBACK_CATEGORIES[0];891      const res = await fetch('https://api.web3forms.com/submit', {892        method: 'POST',893        headers: { 'Content-Type': 'application/json' },894        body: JSON.stringify({895          access_key: WEB3FORMS_KEY,896          subject: `Peach Feedback - ${category}`,897          message,898          from_name: 'Peach User',899          // Honeypot — leave blank900          botcheck: '',901        }),902      });903904      if (!res.ok) throw new Error(`HTTP ${res.status}`);905      const data = await res.json() as { success: boolean; message?: string };906      if (!data.success) throw new Error(data.message ?? 'unknown error');907908      setFeedbackState('sent');909      setFbMessage('');910      setFbCategory(FEEDBACK_CATEGORIES[0]);911    } catch (err) {912      console.error('[feedback]', err);913      setFeedbackState('error');914      setTimeout(() => setFeedbackState('idle'), 4000);915    }916  }917918  function renderSecurity() {919    return (920      <div class="peach-settings-body">921        <div class="peach-settings-group">922          <p class="peach-settings-sub-label">LOCKING</p>923          <div class="peach-settings-row">924            <span>Lock on close</span>925            <button926              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}927              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}928              aria-label="Toggle lock on close"929            >930              <span class="peach-toggle-knob"></span>931            </button>932          </div>933          <div class="peach-settings-row">934            <span>Auto-lock after</span>935            <select936              class="peach-settings-select"937              value={String(props.autoLockMinutes)}938              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}939            >940              <option value="1">1 minute</option>941              <option value="5">5 minutes</option>942              <option value="15">15 minutes</option>943              <option value="30">30 minutes</option>944              <option value="0">Never</option>945            </select>946          </div>947948          <Show when={isNativePlatform() && biometricSupported()}>949            <p class="peach-settings-sub-label">BIOMETRICS</p>950            <div class="peach-settings-row">951              <span>Unlock with biometrics</span>952              <button953                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}954                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}955                disabled={biometricBusy()}956                aria-label="Toggle biometric unlock"957              >958                <span class="peach-toggle-knob"></span>959              </button>960            </div>961            <Show when={biometricError()}>962              <div class="peach-settings-row peach-settings-row--meta">963                <span class="peach-hint peach-hint-error">{biometricError()}</span>964              </div>965            </Show>966          </Show>967968          <p class="peach-settings-sub-label">BREACH PROTECTION</p>969          <div class="peach-settings-row">970            <span>Breach monitoring</span>971            <button972              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}973              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}974              aria-label="Toggle breach monitoring"975            >976              <span class="peach-toggle-knob"></span>977            </button>978          </div>979          <div class="peach-settings-row peach-settings-row--meta">980            <span>Check all passwords</span>981            <button982              class="peach-btn peach-btn--subtle peach-btn--sm"983              onClick={() => void handleCheckAllBreaches()}984              disabled={checkingBreaches()}985            >986              {checkingBreaches() ? "checking..." : "run"}987            </button>988          </div>989          <Show when={breachResult()}>990            <div class="peach-settings-row peach-settings-row--meta">991              <span>Last check</span>992              <span class="peach-settings-value">{breachResult()}</span>993            </div>994          </Show>995996          <p class="peach-settings-sub-label">MASTER PASSWORD</p>997          <div class="peach-settings-row peach-settings-row--meta">998            <span>Master password</span>999            <button1000              class="peach-btn peach-btn--subtle peach-btn--sm"1001              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1002            >1003              {showChangePw() ? "cancel" : "change"}1004            </button>1005          </div>1006          <Show when={showChangePw()}>1007            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1008              <input1009                class="peach-settings-input"1010                type="password"1011                placeholder="current password"1012                value={changePwCurrent()}1013                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1014                autocomplete="current-password"1015              />1016              <input1017                class="peach-settings-input"1018                type="password"1019                placeholder="new password (min 12 chars)"1020                value={changePwNew()}1021                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1022                autocomplete="new-password"1023              />1024              <input1025                class="peach-settings-input"1026                type="password"1027                placeholder="confirm new password"1028                value={changePwConfirm()}1029                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1030                autocomplete="new-password"1031                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1032              />1033              <Show when={changePwError()}>1034                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1035              </Show>1036              <Show when={changePwSuccess()}>1037                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1038              </Show>1039              <button1040                class="peach-btn peach-btn--accent peach-btn--sm"1041                style="align-self:flex-start"1042                onClick={() => void handleChangePassword()}1043                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1044              >1045                {changePwSaving() ? "saving…" : "save new password"}1046              </button>1047            </div>1048          </Show>10491050          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1051          <div class="peach-settings-row peach-settings-row--meta">1052            <span>Recovery words</span>1053            <button1054              class="peach-btn peach-btn--subtle peach-btn--sm"1055              onClick={() => {1056                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1057              }}1058            >1059              {showViewWords() ? "cancel" : "view"}1060            </button>1061          </div>1062          <Show when={showViewWords()}>1063            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1064              <Show1065                when={viewedWords()}1066                fallback={1067                  <>1068                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1069                      Enter your master password to reveal your recovery words.1070                    </p>1071                    <input1072                      class="peach-settings-input"1073                      type="password"1074                      placeholder="master password"1075                      value={viewWordsPassword()}1076                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1077                      autocomplete="current-password"1078                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1079                    />1080                    <Show when={viewWordsError()}>1081                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1082                    </Show>1083                    <button1084                      class="peach-btn peach-btn--accent peach-btn--sm"1085                      style="align-self:flex-start"1086                      onClick={() => void handleViewRecoveryWords()}1087                      disabled={viewWordsBusy() || !viewWordsPassword()}1088                    >1089                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1090                    </button>1091                  </>1092                }1093              >1094                <div class="peach-recovery-words-grid">1095                  <For each={viewedWords()!}>1096                    {(word, i) => (1097                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1098                    )}1099                  </For>1100                </div>1101                <button1102                  class="peach-btn peach-btn--subtle peach-btn--sm"1103                  style="align-self:flex-start"1104                  onClick={closeViewWords}1105                >done</button>1106              </Show>1107            </div>1108          </Show>11091110          <p class="peach-settings-sub-label">1111            DURESS PASSWORD1112            <Show when={!isPro()}>1113              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1114            </Show>1115          </p>1116          <div class="peach-settings-row peach-settings-row--top">1117            <div class="peach-settings-row-main">1118              <span>1119                Duress password1120                <Show when={!isPro()}>1121                  <span class="peach-pro-badge">Pro</span>1122                </Show>1123              </span>1124              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1125                If entered under coercion, shows a decoy empty vault instead of your real one.1126              </span>1127            </div>1128            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1129              {duressEnabled() ? "on" : "off"}1130            </div>1131          </div>11321133          <Show when={duressEnabled() && !showDuressSetup()}>1134            <div class="peach-settings-row peach-settings-row--meta">1135              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1136                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.1137              </span>1138            </div>1139          </Show>11401141          <Show when={duressSuccess()}>1142            <div class="peach-settings-row peach-settings-row--meta">1143              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1144            </div>1145          </Show>11461147          <Show when={!showDuressSetup()}>1148            <div class="peach-settings-row peach-settings-row--meta">1149              <button1150                class="peach-btn peach-btn--subtle peach-btn--sm"1151                onClick={() => {1152                  // Pro gate entry-point: free users are redirected to the1153                  // upgrade screen before the setup form opens. The server-1154                  // side SETUP_DURESS handler is the single enforcement1155                  // point; this is UI redirection so the form never appears1156                  // for a user who can't complete it.1157                  if (!isPro() && !duressEnabled()) {1158                    props.onProGate?.("duress_vault");1159                    setActiveCard("pro");1160                    return;1161                  }1162                  setShowDuressSetup(true);1163                  setDuressError(null);1164                  setDuressSuccess(null);1165                  setDuressPassword("");1166                  setDuressConfirm("");1167                  setDuressMainPassword("");1168                }}1169              >1170                {duressEnabled() ? "Change / disable" : "Set up"}1171              </button>1172            </div>1173          </Show>11741175          <Show when={showDuressSetup()}>1176            <div class="peach-duress-form">1177              <label class="peach-label">main password (to confirm)</label>1178              <input1179                class="peach-settings-input"1180                type="password"1181                placeholder="your current master password"1182                autocomplete="current-password"1183                value={duressMainPassword()}1184                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1185              />1186              <Show when={!duressEnabled()}>1187                <label class="peach-label">new duress password</label>1188                <input1189                  class="peach-settings-input"1190                  type="password"1191                  placeholder="duress password (min 8 chars)"1192                  autocomplete="new-password"1193                  value={duressPassword()}1194                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1195                />1196                <label class="peach-label">confirm duress password</label>1197                <input1198                  class="peach-settings-input"1199                  type="password"1200                  placeholder="confirm duress password"1201                  autocomplete="new-password"1202                  value={duressConfirm()}1203                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1204                />1205              </Show>12061207              <Show when={duressError()}>1208                <p class="peach-hint peach-hint-error">{duressError()}</p>1209              </Show>12101211              <div class="peach-duress-actions">1212                <button1213                  class="peach-btn peach-btn--subtle peach-btn--sm"1214                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1215                >1216                  cancel1217                </button>1218                <Show when={duressEnabled()}>1219                  <button1220                    class="peach-btn peach-btn--ghost peach-btn--sm"1221                    style="color:var(--peach-breach)"1222                    onClick={() => void handleDisableDuress()}1223                    disabled={duressSaving()}1224                  >1225                    {duressSaving() ? "disabling..." : "disable duress"}1226                  </button>1227                </Show>1228                <Show when={!duressEnabled()}>1229                  <button1230                    class="peach-btn peach-btn--accent peach-btn--sm"1231                    onClick={() => void handleSetupDuress()}1232                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1233                  >1234                    {duressSaving() ? "saving..." : "save"}1235                  </button>1236                </Show>1237              </div>1238            </div>1239          </Show>12401241          <p class="peach-settings-sub-label">1242            CANARY ENTRIES1243            <Show when={!isPro()}>1244              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1245            </Show>1246          </p>1247          <div class="peach-settings-row peach-settings-row--top">1248            <div class="peach-settings-row-main">1249              <span>1250                Canary entries1251                <Show when={!isPro()}>1252                  <span class="peach-pro-badge">Pro</span>1253                </Show>1254              </span>1255              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1256                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1257              </span>1258            </div>1259            <span class="peach-settings-value" style="flex-shrink:0">1260              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1261            </span>1262          </div>1263          <Show when={canaryEntries().length > 0}>1264            <For each={canaryEntries()}>1265              {(entry) => (1266                <div class="peach-settings-row peach-settings-row--meta">1267                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1268                  <button1269                    class="peach-btn peach-btn--subtle peach-btn--sm"1270                    onClick={() => void handleTestCanary(entry)}1271                    disabled={canaryTestingId() === entry.id}1272                    style="flex-shrink:0"1273                  >1274                    {canaryTestingId() === entry.id1275                      ? "sending…"1276                      : canaryTestResult()?.id === entry.id1277                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1278                        : "test"}1279                  </button>1280                </div>1281              )}1282            </For>1283          </Show>12841285          <p class="peach-settings-sub-label">PASSKEYS</p>1286          <div class="peach-settings-row peach-settings-row--top">1287            <div class="peach-settings-row-main">1288              <span>Intercept passkey requests</span>1289              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1290                Peach can handle passkey sign-in and creation on supported Chromium browsers.1291              </span>1292            </div>1293            <button1294              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1295              onClick={() => void handleTogglePasskeys()}1296              aria-label="Toggle passkey interception"1297              disabled={!passkeyState()?.supported || passkeySaving()}1298            >1299              <span class="peach-toggle-knob"></span>1300            </button>1301          </div>1302          <div class="peach-settings-row peach-settings-row--meta">1303            <span>Status</span>1304            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1305          </div>1306          <div class="peach-settings-row peach-settings-row--meta">1307            <span>Browser</span>1308            <span class="peach-settings-value">1309              {passkeyState()?.browserLabel ?? "Unknown"}1310              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1311            </span>1312          </div>1313          <div class="peach-info-notice" style="margin-top:8px">1314            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1315          </div>1316          <Show when={passkeyState()?.conflict}>1317            <div class="peach-settings-row peach-settings-row--meta">1318              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1319            </div>1320          </Show>1321        </div>1322      </div>1323    );1324  }13251326  function renderVault() {1327    return (1328      <div class="peach-settings-body">1329        <div class="peach-settings-group">1330          <p class="peach-settings-sub-label">ENTRIES</p>1331          <div class="peach-settings-row peach-settings-row--meta">1332            <span>Entries</span>1333            <span class="peach-settings-value">{props.entriesCount}</span>1334          </div>1335          <div class="peach-settings-row peach-settings-row--meta">1336            <span>Duplicates</span>1337            <Show when={dedupDone() !== null} fallback={1338              <button1339                class="peach-btn peach-btn--subtle peach-btn--sm"1340                onClick={() => void handleCountDuplicates()}1341                disabled={dedupRunning()}1342              >1343                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1344              </button>1345            }>1346              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1347                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1348              </span>1349            </Show>1350          </div>1351          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1352            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1353              <div class="peach-restore-warning">1354                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1355              </div>1356              <div style="display:flex;gap:6px">1357                <button1358                  class="peach-btn peach-btn--subtle peach-btn--sm"1359                  onClick={() => setDedupCount(null)}1360                >1361                  cancel1362                </button>1363                <button1364                  class="peach-btn peach-btn--accent peach-btn--sm"1365                  onClick={() => void handleDeduplicate()}1366                  disabled={dedupRunning()}1367                >1368                  {dedupRunning() ? "removing..." : "remove duplicates"}1369                </button>1370              </div>1371            </div>1372          </Show>13731374          <p class="peach-settings-sub-label">MAINTENANCE</p>1375          <div class="peach-settings-row peach-settings-row--meta">1376            <span>Trash auto-delete</span>1377            <select1378              class="peach-settings-select"1379              value={String(props.trashRetentionDays)}1380              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1381            >1382              <option value="7">7 days</option>1383              <option value="30">30 days</option>1384              <option value="90">90 days</option>1385              <option value="0">Never</option>1386            </select>1387          </div>1388          <div class="peach-settings-row peach-settings-row--meta">1389            <span>Advanced Vault Health</span>1390            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1391              open1392            </button>1393          </div>1394          <Show when={declinedCardCount() > 0}>1395            <div class="peach-settings-row peach-settings-row--meta">1396              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1397              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1398                Clear1399              </button>1400            </div>1401          </Show>14021403          <p class="peach-settings-sub-label">DATA</p>1404          <div class="peach-settings-row peach-settings-row--meta">1405            <span>Import passwords</span>1406            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1407              open1408            </button>1409          </div>1410          <div class="peach-settings-row peach-settings-row--meta">1411            <span>Export backup</span>1412            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1413              open1414            </button>1415          </div>1416          <div class="peach-settings-row peach-settings-row--meta">1417            <span>1418              Peach Codex1419              <Show when={!isPro()}>1420                <span class="peach-pro-badge">Pro</span>1421              </Show>1422            </span>1423            <button1424              class="peach-btn peach-btn--subtle peach-btn--sm"1425              onClick={() => props.onOpenCodexGenerate?.()}1426            >1427              open1428            </button>1429          </div>1430          {/* Restore from Codex: intentionally never gated — a printed1431              Codex must remain restorable regardless of license state. */}1432          <div class="peach-settings-row peach-settings-row--meta">1433            <span>Restore from Codex</span>1434            <button1435              class="peach-btn peach-btn--subtle peach-btn--sm"1436              onClick={() => props.onOpenCodexRestore?.()}1437            >1438              open1439            </button>1440          </div>14411442          <span class="peach-settings-sub-label">VAULT HISTORY</span>1443          <Show when={historyLoading()}>1444            <p class="peach-settings-hint">Loading…</p>1445          </Show>1446          <Show when={!historyLoading() && historySnapshots().length === 0}>1447            <p class="peach-settings-hint">1448              No snapshots yet. Peach saves automatically before every change —1449              saves, syncs, and imports.1450            </p>1451          </Show>1452          <Show when={!historyLoading() && historySnapshots().length > 0}>1453            <p class="peach-settings-hint">1454              Restoring locks your vault. Unlock with the password that1455              encrypted that snapshot to confirm.1456            </p>1457            <div class="peach-history-list">1458              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1459                {(snap) => (1460                  <div class="peach-history-row">1461                    <div class="peach-history-meta">1462                      <span class="peach-history-time">1463                        {formatSnapshotTime(snap.savedAt)}1464                      </span>1465                      <span class="peach-history-sub">1466                        {[1467                          snap.entryCount !== null1468                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1469                            : null,1470                          reasonLabel(snap.reason),1471                        ]1472                          .filter(Boolean)1473                          .join(" · ")}1474                      </span>1475                    </div>1476                    <button1477                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1478                        historyRestoring() === snap.id ? " is-busy" : ""1479                      }`}1480                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1481                      disabled={historyRestoring() !== null}1482                    >1483                      <Show when={historyRestoring() === snap.id} fallback="Restore">1484                        …1485                      </Show>1486                    </button>1487                  </div>1488                )}1489              </For>1490            </div>1491            <Show when={historySnapshots().length > 3}>1492              <button1493                class="peach-settings-advanced-toggle"1494                onClick={() => setHistoryShowAll((v) => !v)}1495                aria-expanded={historyShowAll()}1496              >1497                <span class="peach-settings-hint" style="margin:0">1498                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1499                </span>1500                <span class="peach-settings-advanced-chevron">1501                  {historyShowAll() ? "▲" : "▼"}1502                </span>1503              </button>1504            </Show>1505            <p class="peach-history-footer">1506              {historySnapshots().length} of 10 snapshots1507            </p>1508          </Show>15091510          <button1511            class="peach-settings-advanced-toggle"1512            onClick={() => setShowVaultAdvanced((v) => !v)}1513            aria-expanded={showVaultAdvanced()}1514          >1515            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1516            <span class="peach-settings-advanced-chevron">1517              {showVaultAdvanced() ? "▲" : "▼"}1518            </span>1519          </button>15201521          <Show when={showVaultAdvanced()}>1522            <div class="peach-danger-card">1523              <div class="peach-danger-card-head">1524                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1525                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1526              </div>1527              <p class="peach-danger-card-copy">1528                Wipes the current vault and restores from a backup file.1529                Recoverable only if your backup is good.1530              </p>1531              <button1532                class="peach-danger-card-button"1533                onClick={() => props.onOpenFileImport?.()}1534              >1535                Choose backup file1536              </button>1537            </div>15381539            <Show when={props.onOpenPanic}>1540              <div class="peach-danger-card peach-danger-card--hazard">1541                <div class="peach-danger-card-head">1542                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1543                  <h3 class="peach-danger-card-title">Destroy vault</h3>1544                </div>1545                <p class="peach-danger-card-copy">1546                  Permanently wipes every entry, setting, and license on this1547                  device. There is no recovery.1548                </p>1549                <button1550                  class="peach-danger-card-button"1551                  onClick={() => props.onOpenPanic?.()}1552                >1553                  Begin destruction1554                </button>1555              </div>1556            </Show>1557          </Show>1558        </div>1559      </div>1560    );1561  }15621563  function renderSync() {1564    return (1565      <div class="peach-settings-body">1566        <div class="peach-settings-group">1567          <div class="peach-settings-row peach-settings-row--meta">1568            <span>Status</span>1569            <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1570              {isPro() ? "Pro" : "Free"}1571              <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1572            </span>1573          </div>15741575          {/* Pick a pairing method and begin — no separate hub screen. */}1576          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1577          {/* One path in: the phone listens and shows a code, the browser dials1578              it. A browser extension can never accept a connection, so there is1579              no flow where this device is the one being joined. */}1580          <button1581            class="peach-sync-pair-option"1582            onClick={() => props.onOpenSyncPairing?.("code")}1583          >1584            <span class="peach-sync-pair-option-icon" aria-hidden="true">1585              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1586                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1587                <path d="M9 15 L11 15" />1588              </svg>1589            </span>1590            <span class="peach-sync-pair-option-main">1591              <span class="peach-sync-pair-option-title">Phone or tablet</span>1592              <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1593            </span>1594            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1595          </button>1596          <Show when={!isPro()}>1597            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1598              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1599            </p>1600          </Show>16011602          <p class="peach-settings-sub-label" style="margin-top:12px">CONNECTION</p>1603          <div class="peach-settings-row peach-settings-row--top">1604            <div class="peach-settings-row-main">1605              <span>Use local network only (no relay)</span>1606              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607                Sync will only work when your devices share the same local network.1608                It won't work over VPN or when your devices are in different locations.1609              </span>1610            </div>1611            <label class="peach-switch">1612              <input1613                type="checkbox"1614                checked={lanOnly()}1615                disabled={relaySaving()}1616                onChange={(event) => {1617                  const enabled = (event.target as HTMLInputElement).checked;1618                  setLanOnly(enabled);1619                  void saveRelaySettings(enabled);1620                }}1621              />1622              <span class="peach-switch-track" />1623            </label>1624          </div>1625          <label class="peach-label" for="sync-relay-url">mesh relay URL</label>1626          <input1627            id="sync-relay-url"1628            class="peach-underline-input"1629            type="url"1630            inputmode="url"1631            autocomplete="off"1632            spellcheck={false}1633            value={relayUrl()}1634            disabled={relaySaving()}1635            onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1636            onKeyDown={(event) => {1637              if (event.key === "Enter") void saveRelaySettings();1638            }}1639          />1640          <div class="peach-settings-row peach-settings-row--meta">1641            <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1642              {relayStatus() ?? "Only secure wss:// relays are accepted."}1643            </span>1644            <button1645              class="peach-btn peach-btn--subtle peach-btn--sm"1646              disabled={relaySaving()}1647              onClick={() => void saveRelaySettings()}1648            >{relaySaving() ? "saving…" : "save"}</button>1649          </div>16501651          {/* Paired devices + manual sync — Settings is the hub. */}1652          <Show when={pairedDevices().length > 0}>1653            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1654            <For each={pairedDevices()}>1655              {(d) => (1656                <div class="peach-settings-row peach-settings-row--top">1657                  <div class="peach-settings-row-main">1658                    <span>{d.deviceName ?? "Unnamed device"}</span>1659                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1660                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1661                    </span>1662                  </div>1663                  <Show1664                    when={confirmUnpair() === d.deviceId}1665                    fallback={1666                      <button1667                        class="peach-btn peach-btn--subtle peach-btn--sm"1668                        onClick={() => setConfirmUnpair(d.deviceId)}1669                      >remove</button>1670                    }1671                  >1672                    <div style="display:flex;gap:6px;flex-shrink:0">1673                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1674                      <button1675                        class="peach-btn peach-btn--sm"1676                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1677                        onClick={() => void unpairDevice(d.deviceId)}1678                      >remove</button>1679                    </div>1680                  </Show>1681                </div>1682              )}1683            </For>1684            <div class="peach-settings-row peach-settings-row--meta">1685              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1686              <button1687                class="peach-btn peach-btn--subtle peach-btn--sm"1688                disabled={syncStatus() === "syncing"}1689                onClick={() => void syncNow()}1690              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1691            </div>1692          </Show>1693          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1694          <Show when={pendingVouched().length > 0}>1695            <p class="peach-settings-sub-label">1696              PENDING DEVICES1697              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1698            </p>1699            <For each={pendingVouched()}>1700              {(dev) => (1701                <div class="peach-settings-row peach-settings-row--top">1702                  <div class="peach-settings-row-main">1703                    <span>{dev.deviceName}</span>1704                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1705                      Was added by another of your devices. Allow it to sync with this vault?1706                    </span>1707                  </div>1708                  <div style="display:flex;gap:6px;flex-shrink:0">1709                    <button1710                      class="peach-btn peach-btn--sm"1711                      disabled={vouchedBusy() === dev.deviceId}1712                      onClick={() => void confirmVouched(dev.deviceId)}1713                    >Allow</button>1714                    <button1715                      class="peach-btn peach-btn--subtle peach-btn--sm"1716                      disabled={vouchedBusy() === dev.deviceId}1717                      onClick={() => void rejectVouched(dev.deviceId)}1718                    >Reject</button>1719                  </div>1720                </div>1721              )}1722            </For>1723          </Show>17241725          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1726          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1727          <Show1728            when={rotateConfirmOpen()}1729            fallback={1730              <div class="peach-settings-row peach-settings-row--top">1731                <div class="peach-settings-row-main">1732                  <span>Rotate sync identity</span>1733                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1734                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1735                  </span>1736                </div>1737                <button1738                  class="peach-btn peach-btn--subtle peach-btn--sm"1739                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1740                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1741                >rotate</button>1742              </div>1743            }1744          >1745            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1746              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.1747              <Show when={rotateError()}>1748                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1749              </Show>1750              <div style="display:flex;gap:6px;margin-top:10px">1751                <button1752                  class="peach-btn peach-btn--subtle peach-btn--sm"1753                  disabled={rotating()}1754                  onClick={() => setRotateConfirmOpen(false)}1755                >Cancel</button>1756                <button1757                  class="peach-btn peach-btn--sm"1758                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1759                  disabled={rotating()}1760                  onClick={() => void doRotateSyncIdentity()}1761                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1762              </div>1763            </div>1764          </Show>1765        </div>1766      </div>1767    );1768  }17691770  function renderAppearance() {1771    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1772      { id: "medium", label: "Default" },1773      { id: "large", label: "Large" },1774    ];1775    return (1776      <div class="peach-settings-body">1777        <div class="peach-settings-group">1778          <p class="peach-settings-sub-label">THEME</p>1779          <div class="peach-settings-row peach-settings-row--meta">1780            <span>Theme</span>1781            <button1782              class="peach-btn peach-btn--subtle peach-btn--sm"1783              onClick={() =>1784                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1785              }1786            >1787              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1788            </button>1789          </div>17901791          <p class="peach-settings-sub-label">ACCENT</p>1792          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1793            <span>Accent</span>1794            <div class="peach-accent-picker">1795              <For each={ACCENT_COLORS}>1796                {(color) => (1797                  <button1798                    class="peach-accent-swatch peach-swatch"1799                    classList={{ "is-active": props.accentColor === color.name }}1800                    style={{ background: color.hex }}1801                    onClick={() => void props.onAccentChange(color.name)}1802                    aria-label={color.name}1803                    title={color.name}1804                  />1805                )}1806              </For>1807            </div>1808          </div>18091810          <p class="peach-settings-sub-label">TEXT SIZE</p>1811          <div class="peach-settings-row peach-settings-row--meta">1812            <span>Size</span>1813            <div class="peach-pill-group">1814              <For each={sizeOptions}>1815                {(opt) => (1816                  <button1817                    class="peach-btn peach-btn--ghost peach-btn--sm"1818                    classList={{ "is-active": props.textSize === opt.id }}1819                    onClick={() => void props.onTextSizeChange(opt.id)}1820                  >1821                    {opt.label}1822                  </button>1823                )}1824              </For>1825            </div>1826          </div>18271828          <p class="peach-settings-sub-label">MOTION</p>1829          <div class="peach-settings-row peach-settings-row--meta">1830            <span>Reduce motion</span>1831            <button1832              class="peach-btn peach-btn--subtle peach-btn--sm"1833              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1834            >1835              {props.reduceMotion ? "On" : "Off"}1836            </button>1837          </div>1838        </div>1839      </div>1840    );1841  }18421843  function renderShortcuts() {1844    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1845    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18461847    function startCapture(action: ShortcutAction) {1848      setConflictMsg(null);1849      setCapturingAction(action);1850    }1851    function cancelCapture() {1852      setCapturingAction(null);1853      setConflictMsg(null);1854    }1855    function commitBinding(action: ShortcutAction, binding: Keybinding) {1856      const conflict = findConflict(props.shortcuts, binding, action);1857      if (conflict) {1858        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1859        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1860        return;1861      }1862      const next = { ...props.shortcuts, [action]: binding };1863      setCapturingAction(null);1864      setConflictMsg(null);1865      void props.onShortcutsChange(next);1866    }1867    function resetAction(action: ShortcutAction) {1868      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1869      void props.onShortcutsChange(next);1870    }1871    function resetAll() {1872      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1873    }18741875    // Capture-mode key handler — wired via ref onto the chip element so it1876    // catches the next non-modifier keypress and ignores other bindings.1877    function handleCaptureKey(e: KeyboardEvent) {1878      e.preventDefault();1879      e.stopPropagation();1880      if (e.key === "Escape") { cancelCapture(); return; }1881      const binding = keybindingFromEvent(e);1882      if (!binding) return; // bare modifier — keep waiting1883      const action = capturingAction();1884      if (action) commitBinding(action, binding);1885    }18861887    return (1888      <div class="peach-settings-body">1889        <div class="peach-settings-group peach-shortcuts-group">1890          <p class="peach-settings-sub-label">ENTRY VIEW</p>1891          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1892            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1893          </For>1894        </div>18951896        <div class="peach-settings-group peach-shortcuts-group">1897          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1898          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1899            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1900          </For>1901        </div>19021903        <Show when={conflictMsg()}>1904          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1905        </Show>19061907        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1908          <div class="peach-settings-row peach-settings-row--meta">1909            <span>Inline autofill on page</span>1910            <a1911              class="peach-settings-link"1912              href="chrome://extensions/shortcuts"1913              onClick={(e) => {1914                e.preventDefault();1915                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1916              }}1917            >1918              ⌘⇧P — customize ↗1919            </a>1920          </div>1921          <button1922            class="peach-btn peach-btn--subtle peach-btn--sm"1923            style="align-self:flex-start"1924            onClick={resetAll}1925          >1926            Reset all to defaults1927          </button>1928        </div>1929      </div>1930    );19311932    function renderRow(1933      desc: typeof SHORTCUT_DESCRIPTORS[number],1934      capturing: () => ShortcutAction | null,1935      onStart: (a: ShortcutAction) => void,1936      onCancel: () => void,1937      onReset: (a: ShortcutAction) => void,1938      onKey: (e: KeyboardEvent) => void,1939    ) {1940      const isActive = () => capturing() === desc.action;1941      const binding = () => props.shortcuts[desc.action];1942      const isCustom = () => {1943        const b = binding();1944        const d = DEFAULT_BINDINGS[desc.action];1945        return b.key !== d.key1946          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1947          || Boolean(b.meta)  !== Boolean(d.meta)1948          || Boolean(b.shift) !== Boolean(d.shift)1949          || Boolean(b.alt)   !== Boolean(d.alt);1950      };1951      return (1952        <div class="peach-shortcut-row">1953          <span class="peach-shortcut-row-label">{desc.label}</span>1954          <button1955            class="peach-shortcut-chip"1956            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1957            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1958            onKeyDown={(e) => { if (isActive()) onKey(e); }}1959            ref={(el) => {1960              // Auto-focus when entering capture mode so the keydown listener catches the next press.1961              if (el) {1962                queueMicrotask(() => { if (isActive()) el.focus(); });1963              }1964            }}1965          >1966            {isActive() ? "Press a key…" : formatKeybinding(binding())}1967          </button>1968          <Show when={isCustom() && !isActive()}>1969            <button1970              class="peach-shortcut-reset"1971              title="Reset to default"1972              onClick={() => onReset(desc.action)}1973            >1974              ↺1975            </button>1976          </Show>1977        </div>1978      );1979    }1980  }19811982  const PRO_FEATURES: Array<{1983    feature: ProFeature;1984    name: string;1985    desc: string;1986  }> = [1987    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1988    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1989    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1990    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1991  ];19921993  const PRO_SECURITY_FEATURES: Array<{1994    feature: ProFeature;1995    name: string;1996    desc: string;1997  }> = [1998    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1999    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2000  ];20012002  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2003  const [showKey, setShowKey] = createSignal(false);20042005  const [deactivating, setDeactivating] = createSignal(false);20062007  async function handleDeactivateLicense() {2008    setDeactivating(true);2009    await sendMessage({ type: "DEACTIVATE_LICENSE" });2010    setLicenseInfo(null);2011    await props.onLicenseChange?.();2012    setDeactivating(false);2013  }20142015  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2016    setCanaryTestingId(entry.id);2017    setCanaryTestResult(null);2018    const res = await sendMessage({2019      type: "FIRE_CANARY_PING",2020      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2021    }) as { ok: boolean };2022    setCanaryTestResult({ id: entry.id, ok: res.ok });2023    setCanaryTestingId(null);2024    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2025  }20262027  function renderLicense() {2028    async function onActivate() {2029      await handleActivateLicense();2030      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2031        await props.onLicenseChange?.();2032      }2033    }20342035    return (2036      <div class="peach-settings-body">2037        <Show2038          when={isPro()}2039          fallback={2040                <div class="peach-pro-upgrade">2041                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20422043                  <h2 class="peach-pro-headline">2044                    Every<em>thing,</em><br />every<em>where.</em>2045                  </h2>20462047                  <p class="peach-pro-subtitle">2048                    One key unlocks every Pro feature — for the lifetime of the app.2049                  </p>20502051                  <div class="peach-pro-grid">2052                    <For each={PRO_FEATURES}>2053                      {(item, i) => (2054                        <div2055                          class="peach-pro-grid-item"2056                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2057                        >2058                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2059                          <span class="peach-pro-grid-name">{item.name}</span>2060                          <span class="peach-pro-grid-desc">{item.desc}</span>2061                        </div>2062                      )}2063                    </For>2064                  </div>20652066                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2067                  <div class="peach-pro-grid">2068                    <For each={PRO_SECURITY_FEATURES}>2069                      {(item, i) => (2070                        <div2071                          class="peach-pro-grid-item"2072                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2073                        >2074                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2075                          <span class="peach-pro-grid-name">{item.name}</span>2076                          <span class="peach-pro-grid-desc">{item.desc}</span>2077                        </div>2078                      )}2079                    </For>2080                  </div>20812082                  <div class="peach-pro-upgrade-actions">2083                    <p class="peach-pro-pricing">2084                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2085                    </p>2086                    <a2087                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2088                      target="_blank"2089                      rel="noopener noreferrer"2090                      class="peach-pro-upgrade-cta"2091                    >2092                      Get Peach Pro2093                    </a>2094                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2095                    <div class="peach-pro-key-inline">2096                      <input2097                        class="peach-pro-key-inline-input"2098                        type="text"2099                        placeholder="PEACH-XXXX-XXXX-XXXX"2100                        value={licenseKeyInput()}2101                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2102                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2103                        autocomplete="off"2104                        spellcheck={false}2105                      />2106                      <button2107                        class="peach-pro-key-apply"2108                        onClick={() => void onActivate()}2109                        disabled={activatingLicense() || !licenseKeyInput().trim()}2110                      >2111                        {activatingLicense() ? "…" : "APPLY"}2112                      </button>2113                    </div>2114                    <Show when={licenseError()}>2115                      <p class="peach-pro-key-error">{licenseError()}</p>2116                    </Show>2117                  </div>2118                </div>2119          }2120        >2121          <div class="peach-pro-active">2122            <div class="peach-pro-active-hero">2123              <button2124                class="peach-pro-active-mark-wrap"2125                type="button"2126                aria-label="Celebrate"2127                onClick={handleProCelebrationClick}2128              >2129                <PeachMark class="peach-pro-active-mark" />2130                <Show when={justActivated()}>2131                  <div class="peach-pro-sparks" aria-hidden="true">2132                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2133                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2134                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2135                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2136                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2137                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2138                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2139                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2140                  </div>2141                </Show>2142              </button>2143              <h2 class="peach-pro-active-title">Peach Pro</h2>2144              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2145              <Show when={justActivated()}>2146                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2147              </Show>2148            </div>21492150            <Show when={licenseKey()}>2151              <div class="peach-settings-license">2152                <span class="peach-settings-label">LICENSE KEY</span>2153                <div class="peach-settings-license-row">2154                  <Show2155                    when={showKey()}2156                    fallback={2157                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2158                        ••••••••••••••••••••••••••••2159                      </span>2160                    }2161                  >2162                    <span class="peach-settings-license-key">{licenseKey()!}</span>2163                  </Show>2164                  <div class="peach-settings-license-actions">2165                    <button2166                      class="peach-settings-license-copy"2167                      title="Copy license key"2168                      onClick={() => copyLicenseKey(licenseKey()!)}2169                    >2170                      {copied() ? "✓" : "copy"}2171                    </button>2172                    <button2173                      class="peach-settings-license-toggle"2174                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2175                      title={showKey() ? "Hide" : "Reveal"}2176                      onClick={() => setShowKey(v => !v)}2177                    >2178                      <Show2179                        when={showKey()}2180                        fallback={2181                          <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">2182                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2183                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2184                          </svg>2185                        }2186                      >2187                        <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">2188                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2189                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2190                          <line x1="2" y1="2" x2="12" y2="12" />2191                        </svg>2192                      </Show>2193                    </button>2194                  </div>2195                </div>2196              </div>2197            </Show>21982199            <div class="peach-pro-active-features">2200              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2201                {(item) => (2202                  <div class="peach-pro-active-feature">2203                    <span class="peach-pro-active-feature-mark">✦</span>2204                    <span class="peach-pro-active-feature-name">{item.name}</span>2205                  </div>2206                )}2207              </For>2208            </div>22092210            <button2211              class="peach-pro-deactivate"2212              onClick={() => void handleDeactivateLicense()}2213              disabled={deactivating()}2214            >2215              {deactivating() ? "Deactivating…" : "Deactivate"}2216            </button>2217          </div>2218        </Show>2219      </div>2220    );2221  }22222223  function renderFeedback() {2224    return (2225      <div class="peach-settings-body">2226        <div class="peach-settings-group peach-feedback-group">2227          <Show2228            when={feedbackState() === 'sent'}2229            fallback={2230              <>2231                <p class="peach-feedback-intro">2232                  Help us improve Peach. Every report goes directly to our inbox.2233                </p>22342235                <p class="peach-settings-sub-label">TYPE</p>2236                <div class="peach-feedback-chips">2237                  <For each={FEEDBACK_CATEGORIES}>2238                    {(cat) => {2239                      const selected = () => fbCategory() === cat;2240                      return (2241                        <button2242                          type="button"2243                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2244                          aria-pressed={selected()}2245                          onClick={() => setFbCategory(cat)}2246                        >2247                          {cat}2248                        </button>2249                      );2250                    }}2251                  </For>2252                </div>22532254                <p class="peach-settings-sub-label">MESSAGE</p>2255                <textarea2256                  class="peach-feedback-textarea"2257                  placeholder="Describe the issue or idea..."2258                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2259                  value={fbMessage()}2260                  onInput={(e) => setFbMessage(e.currentTarget.value)}2261                />22622263                <p class="peach-feedback-meta">2264                  Version {version} · will be included automatically2265                </p>22662267                <button2268                  type="button"2269                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2270                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2271                  onClick={() => void handleSubmitFeedback()}2272                >2273                  <Switch>2274                    <Match when={feedbackState() === 'idle'}>Send</Match>2275                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2276                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2277                  </Switch>2278                </button>22792280                <p class="peach-feedback-anon">2281                  Sent anonymously · no account required2282                </p>2283              </>2284            }2285          >2286            <div class="peach-feedback-thanks" role="status" aria-live="polite">2287              <p class="peach-feedback-thanks-title">Thank you</p>2288              <p>2289                Your note is in the queue. Someone will process it soon.2290              </p>2291            </div>2292          </Show>2293        </div>2294      </div>2295    );2296  }22972298  const bodies: StackBodies = {2299    protection: renderSecurity(),2300    vault: renderVault(),2301    sync: renderSync(),2302    appearance: renderAppearance(),2303    shortcuts: renderShortcuts(),2304    pro: renderLicense(),2305    feedback: renderFeedback(),2306  };23072308  return (2309    <Show2310      when={showNewRecoveryWords()}2311      fallback={2312        <div2313          class="peach-overlay-view peach-settings-overlay"2314          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2315          style="display:flex;flex-direction:column"2316        >2317          <StackDeck2318            active={activeCard()}2319            onActiveChange={setActiveCard}2320            onBack={props.onBack}2321            cards={cards()}2322            bodies={bodies}2323            version={version}2324            isFanning={isFanning()}2325            celebratingCardId={justActivated() ? "pro" : null}2326            isPro={isPro()}2327          />2328        </div>2329      }2330    >2331      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2332    </Show>2333  );2334}