Settings.tsx

93.5 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>LAN only</span>1606              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607                Disable internet relay fallback. Nearby-device sync still works.1608              </span>1609            </div>1610            <label class="peach-switch">1611              <input1612                type="checkbox"1613                checked={lanOnly()}1614                disabled={relaySaving()}1615                onChange={(event) => {1616                  const enabled = (event.target as HTMLInputElement).checked;1617                  setLanOnly(enabled);1618                  void saveRelaySettings(enabled);1619                }}1620              />1621              <span class="peach-switch-track" />1622            </label>1623          </div>1624          <label class="peach-label" for="sync-relay-url">relay URL</label>1625          <input1626            id="sync-relay-url"1627            class="peach-underline-input"1628            type="url"1629            inputmode="url"1630            autocomplete="off"1631            spellcheck={false}1632            value={relayUrl()}1633            disabled={relaySaving()}1634            onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1635            onKeyDown={(event) => {1636              if (event.key === "Enter") void saveRelaySettings();1637            }}1638          />1639          <div class="peach-settings-row peach-settings-row--meta">1640            <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1641              {relayStatus() ?? "Only secure wss:// relays are accepted."}1642            </span>1643            <button1644              class="peach-btn peach-btn--subtle peach-btn--sm"1645              disabled={relaySaving()}1646              onClick={() => void saveRelaySettings()}1647            >{relaySaving() ? "saving…" : "save"}</button>1648          </div>16491650          {/* Paired devices + manual sync — Settings is the hub. */}1651          <Show when={pairedDevices().length > 0}>1652            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1653            <For each={pairedDevices()}>1654              {(d) => (1655                <div class="peach-settings-row peach-settings-row--top">1656                  <div class="peach-settings-row-main">1657                    <span>{d.deviceName ?? "Unnamed device"}</span>1658                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1659                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1660                    </span>1661                  </div>1662                  <Show1663                    when={confirmUnpair() === d.deviceId}1664                    fallback={1665                      <button1666                        class="peach-btn peach-btn--subtle peach-btn--sm"1667                        onClick={() => setConfirmUnpair(d.deviceId)}1668                      >remove</button>1669                    }1670                  >1671                    <div style="display:flex;gap:6px;flex-shrink:0">1672                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1673                      <button1674                        class="peach-btn peach-btn--sm"1675                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1676                        onClick={() => void unpairDevice(d.deviceId)}1677                      >remove</button>1678                    </div>1679                  </Show>1680                </div>1681              )}1682            </For>1683            <div class="peach-settings-row peach-settings-row--meta">1684              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1685              <button1686                class="peach-btn peach-btn--subtle peach-btn--sm"1687                disabled={syncStatus() === "syncing"}1688                onClick={() => void syncNow()}1689              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1690            </div>1691          </Show>1692          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1693          <Show when={pendingVouched().length > 0}>1694            <p class="peach-settings-sub-label">1695              PENDING DEVICES1696              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1697            </p>1698            <For each={pendingVouched()}>1699              {(dev) => (1700                <div class="peach-settings-row peach-settings-row--top">1701                  <div class="peach-settings-row-main">1702                    <span>{dev.deviceName}</span>1703                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1704                      Was added by another of your devices. Allow it to sync with this vault?1705                    </span>1706                  </div>1707                  <div style="display:flex;gap:6px;flex-shrink:0">1708                    <button1709                      class="peach-btn peach-btn--sm"1710                      disabled={vouchedBusy() === dev.deviceId}1711                      onClick={() => void confirmVouched(dev.deviceId)}1712                    >Allow</button>1713                    <button1714                      class="peach-btn peach-btn--subtle peach-btn--sm"1715                      disabled={vouchedBusy() === dev.deviceId}1716                      onClick={() => void rejectVouched(dev.deviceId)}1717                    >Reject</button>1718                  </div>1719                </div>1720              )}1721            </For>1722          </Show>17231724          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1725          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1726          <Show1727            when={rotateConfirmOpen()}1728            fallback={1729              <div class="peach-settings-row peach-settings-row--top">1730                <div class="peach-settings-row-main">1731                  <span>Rotate sync identity</span>1732                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1733                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1734                  </span>1735                </div>1736                <button1737                  class="peach-btn peach-btn--subtle peach-btn--sm"1738                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1739                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1740                >rotate</button>1741              </div>1742            }1743          >1744            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1745              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.1746              <Show when={rotateError()}>1747                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1748              </Show>1749              <div style="display:flex;gap:6px;margin-top:10px">1750                <button1751                  class="peach-btn peach-btn--subtle peach-btn--sm"1752                  disabled={rotating()}1753                  onClick={() => setRotateConfirmOpen(false)}1754                >Cancel</button>1755                <button1756                  class="peach-btn peach-btn--sm"1757                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1758                  disabled={rotating()}1759                  onClick={() => void doRotateSyncIdentity()}1760                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1761              </div>1762            </div>1763          </Show>1764        </div>1765      </div>1766    );1767  }17681769  function renderAppearance() {1770    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1771      { id: "medium", label: "Default" },1772      { id: "large", label: "Large" },1773    ];1774    return (1775      <div class="peach-settings-body">1776        <div class="peach-settings-group">1777          <p class="peach-settings-sub-label">THEME</p>1778          <div class="peach-settings-row peach-settings-row--meta">1779            <span>Theme</span>1780            <button1781              class="peach-btn peach-btn--subtle peach-btn--sm"1782              onClick={() =>1783                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1784              }1785            >1786              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1787            </button>1788          </div>17891790          <p class="peach-settings-sub-label">ACCENT</p>1791          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1792            <span>Accent</span>1793            <div class="peach-accent-picker">1794              <For each={ACCENT_COLORS}>1795                {(color) => (1796                  <button1797                    class="peach-accent-swatch peach-swatch"1798                    classList={{ "is-active": props.accentColor === color.name }}1799                    style={{ background: color.hex }}1800                    onClick={() => void props.onAccentChange(color.name)}1801                    aria-label={color.name}1802                    title={color.name}1803                  />1804                )}1805              </For>1806            </div>1807          </div>18081809          <p class="peach-settings-sub-label">TEXT SIZE</p>1810          <div class="peach-settings-row peach-settings-row--meta">1811            <span>Size</span>1812            <div class="peach-pill-group">1813              <For each={sizeOptions}>1814                {(opt) => (1815                  <button1816                    class="peach-btn peach-btn--ghost peach-btn--sm"1817                    classList={{ "is-active": props.textSize === opt.id }}1818                    onClick={() => void props.onTextSizeChange(opt.id)}1819                  >1820                    {opt.label}1821                  </button>1822                )}1823              </For>1824            </div>1825          </div>18261827          <p class="peach-settings-sub-label">MOTION</p>1828          <div class="peach-settings-row peach-settings-row--meta">1829            <span>Reduce motion</span>1830            <button1831              class="peach-btn peach-btn--subtle peach-btn--sm"1832              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1833            >1834              {props.reduceMotion ? "On" : "Off"}1835            </button>1836          </div>1837        </div>1838      </div>1839    );1840  }18411842  function renderShortcuts() {1843    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1844    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18451846    function startCapture(action: ShortcutAction) {1847      setConflictMsg(null);1848      setCapturingAction(action);1849    }1850    function cancelCapture() {1851      setCapturingAction(null);1852      setConflictMsg(null);1853    }1854    function commitBinding(action: ShortcutAction, binding: Keybinding) {1855      const conflict = findConflict(props.shortcuts, binding, action);1856      if (conflict) {1857        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1858        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1859        return;1860      }1861      const next = { ...props.shortcuts, [action]: binding };1862      setCapturingAction(null);1863      setConflictMsg(null);1864      void props.onShortcutsChange(next);1865    }1866    function resetAction(action: ShortcutAction) {1867      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1868      void props.onShortcutsChange(next);1869    }1870    function resetAll() {1871      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1872    }18731874    // Capture-mode key handler — wired via ref onto the chip element so it1875    // catches the next non-modifier keypress and ignores other bindings.1876    function handleCaptureKey(e: KeyboardEvent) {1877      e.preventDefault();1878      e.stopPropagation();1879      if (e.key === "Escape") { cancelCapture(); return; }1880      const binding = keybindingFromEvent(e);1881      if (!binding) return; // bare modifier — keep waiting1882      const action = capturingAction();1883      if (action) commitBinding(action, binding);1884    }18851886    return (1887      <div class="peach-settings-body">1888        <div class="peach-settings-group peach-shortcuts-group">1889          <p class="peach-settings-sub-label">ENTRY VIEW</p>1890          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1891            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1892          </For>1893        </div>18941895        <div class="peach-settings-group peach-shortcuts-group">1896          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1897          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1898            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1899          </For>1900        </div>19011902        <Show when={conflictMsg()}>1903          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1904        </Show>19051906        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1907          <div class="peach-settings-row peach-settings-row--meta">1908            <span>Inline autofill on page</span>1909            <a1910              class="peach-settings-link"1911              href="chrome://extensions/shortcuts"1912              onClick={(e) => {1913                e.preventDefault();1914                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1915              }}1916            >1917              ⌘⇧P — customize ↗1918            </a>1919          </div>1920          <button1921            class="peach-btn peach-btn--subtle peach-btn--sm"1922            style="align-self:flex-start"1923            onClick={resetAll}1924          >1925            Reset all to defaults1926          </button>1927        </div>1928      </div>1929    );19301931    function renderRow(1932      desc: typeof SHORTCUT_DESCRIPTORS[number],1933      capturing: () => ShortcutAction | null,1934      onStart: (a: ShortcutAction) => void,1935      onCancel: () => void,1936      onReset: (a: ShortcutAction) => void,1937      onKey: (e: KeyboardEvent) => void,1938    ) {1939      const isActive = () => capturing() === desc.action;1940      const binding = () => props.shortcuts[desc.action];1941      const isCustom = () => {1942        const b = binding();1943        const d = DEFAULT_BINDINGS[desc.action];1944        return b.key !== d.key1945          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1946          || Boolean(b.meta)  !== Boolean(d.meta)1947          || Boolean(b.shift) !== Boolean(d.shift)1948          || Boolean(b.alt)   !== Boolean(d.alt);1949      };1950      return (1951        <div class="peach-shortcut-row">1952          <span class="peach-shortcut-row-label">{desc.label}</span>1953          <button1954            class="peach-shortcut-chip"1955            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1956            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1957            onKeyDown={(e) => { if (isActive()) onKey(e); }}1958            ref={(el) => {1959              // Auto-focus when entering capture mode so the keydown listener catches the next press.1960              if (el) {1961                queueMicrotask(() => { if (isActive()) el.focus(); });1962              }1963            }}1964          >1965            {isActive() ? "Press a key…" : formatKeybinding(binding())}1966          </button>1967          <Show when={isCustom() && !isActive()}>1968            <button1969              class="peach-shortcut-reset"1970              title="Reset to default"1971              onClick={() => onReset(desc.action)}1972            >1973              ↺1974            </button>1975          </Show>1976        </div>1977      );1978    }1979  }19801981  const PRO_FEATURES: Array<{1982    feature: ProFeature;1983    name: string;1984    desc: string;1985  }> = [1986    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1987    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1988    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1989    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1990  ];19911992  const PRO_SECURITY_FEATURES: Array<{1993    feature: ProFeature;1994    name: string;1995    desc: string;1996  }> = [1997    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1998    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1999  ];20002001  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2002  const [showKey, setShowKey] = createSignal(false);20032004  const [deactivating, setDeactivating] = createSignal(false);20052006  async function handleDeactivateLicense() {2007    setDeactivating(true);2008    await sendMessage({ type: "DEACTIVATE_LICENSE" });2009    setLicenseInfo(null);2010    await props.onLicenseChange?.();2011    setDeactivating(false);2012  }20132014  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2015    setCanaryTestingId(entry.id);2016    setCanaryTestResult(null);2017    const res = await sendMessage({2018      type: "FIRE_CANARY_PING",2019      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2020    }) as { ok: boolean };2021    setCanaryTestResult({ id: entry.id, ok: res.ok });2022    setCanaryTestingId(null);2023    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2024  }20252026  function renderLicense() {2027    async function onActivate() {2028      await handleActivateLicense();2029      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2030        await props.onLicenseChange?.();2031      }2032    }20332034    return (2035      <div class="peach-settings-body">2036        <Show2037          when={isPro()}2038          fallback={2039                <div class="peach-pro-upgrade">2040                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20412042                  <h2 class="peach-pro-headline">2043                    Every<em>thing,</em><br />every<em>where.</em>2044                  </h2>20452046                  <p class="peach-pro-subtitle">2047                    One key unlocks every Pro feature — for the lifetime of the app.2048                  </p>20492050                  <div class="peach-pro-grid">2051                    <For each={PRO_FEATURES}>2052                      {(item, i) => (2053                        <div2054                          class="peach-pro-grid-item"2055                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2056                        >2057                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2058                          <span class="peach-pro-grid-name">{item.name}</span>2059                          <span class="peach-pro-grid-desc">{item.desc}</span>2060                        </div>2061                      )}2062                    </For>2063                  </div>20642065                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2066                  <div class="peach-pro-grid">2067                    <For each={PRO_SECURITY_FEATURES}>2068                      {(item, i) => (2069                        <div2070                          class="peach-pro-grid-item"2071                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2072                        >2073                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2074                          <span class="peach-pro-grid-name">{item.name}</span>2075                          <span class="peach-pro-grid-desc">{item.desc}</span>2076                        </div>2077                      )}2078                    </For>2079                  </div>20802081                  <div class="peach-pro-upgrade-actions">2082                    <p class="peach-pro-pricing">2083                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2084                    </p>2085                    <a2086                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2087                      target="_blank"2088                      rel="noopener noreferrer"2089                      class="peach-pro-upgrade-cta"2090                    >2091                      Get Peach Pro2092                    </a>2093                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2094                    <div class="peach-pro-key-inline">2095                      <input2096                        class="peach-pro-key-inline-input"2097                        type="text"2098                        placeholder="PEACH-XXXX-XXXX-XXXX"2099                        value={licenseKeyInput()}2100                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2101                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2102                        autocomplete="off"2103                        spellcheck={false}2104                      />2105                      <button2106                        class="peach-pro-key-apply"2107                        onClick={() => void onActivate()}2108                        disabled={activatingLicense() || !licenseKeyInput().trim()}2109                      >2110                        {activatingLicense() ? "…" : "APPLY"}2111                      </button>2112                    </div>2113                    <Show when={licenseError()}>2114                      <p class="peach-pro-key-error">{licenseError()}</p>2115                    </Show>2116                  </div>2117                </div>2118          }2119        >2120          <div class="peach-pro-active">2121            <div class="peach-pro-active-hero">2122              <button2123                class="peach-pro-active-mark-wrap"2124                type="button"2125                aria-label="Celebrate"2126                onClick={handleProCelebrationClick}2127              >2128                <PeachMark class="peach-pro-active-mark" />2129                <Show when={justActivated()}>2130                  <div class="peach-pro-sparks" aria-hidden="true">2131                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2132                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2133                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2134                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2135                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2136                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2137                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2138                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2139                  </div>2140                </Show>2141              </button>2142              <h2 class="peach-pro-active-title">Peach Pro</h2>2143              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2144              <Show when={justActivated()}>2145                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2146              </Show>2147            </div>21482149            <Show when={licenseKey()}>2150              <div class="peach-settings-license">2151                <span class="peach-settings-label">LICENSE KEY</span>2152                <div class="peach-settings-license-row">2153                  <Show2154                    when={showKey()}2155                    fallback={2156                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2157                        ••••••••••••••••••••••••••••2158                      </span>2159                    }2160                  >2161                    <span class="peach-settings-license-key">{licenseKey()!}</span>2162                  </Show>2163                  <div class="peach-settings-license-actions">2164                    <button2165                      class="peach-settings-license-copy"2166                      title="Copy license key"2167                      onClick={() => copyLicenseKey(licenseKey()!)}2168                    >2169                      {copied() ? "✓" : "copy"}2170                    </button>2171                    <button2172                      class="peach-settings-license-toggle"2173                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2174                      title={showKey() ? "Hide" : "Reveal"}2175                      onClick={() => setShowKey(v => !v)}2176                    >2177                      <Show2178                        when={showKey()}2179                        fallback={2180                          <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">2181                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2182                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2183                          </svg>2184                        }2185                      >2186                        <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">2187                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2188                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2189                          <line x1="2" y1="2" x2="12" y2="12" />2190                        </svg>2191                      </Show>2192                    </button>2193                  </div>2194                </div>2195              </div>2196            </Show>21972198            <div class="peach-pro-active-features">2199              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2200                {(item) => (2201                  <div class="peach-pro-active-feature">2202                    <span class="peach-pro-active-feature-mark">✦</span>2203                    <span class="peach-pro-active-feature-name">{item.name}</span>2204                  </div>2205                )}2206              </For>2207            </div>22082209            <button2210              class="peach-pro-deactivate"2211              onClick={() => void handleDeactivateLicense()}2212              disabled={deactivating()}2213            >2214              {deactivating() ? "Deactivating…" : "Deactivate"}2215            </button>2216          </div>2217        </Show>2218      </div>2219    );2220  }22212222  function renderFeedback() {2223    return (2224      <div class="peach-settings-body">2225        <div class="peach-settings-group peach-feedback-group">2226          <Show2227            when={feedbackState() === 'sent'}2228            fallback={2229              <>2230                <p class="peach-feedback-intro">2231                  Help us improve Peach. Every report goes directly to our inbox.2232                </p>22332234                <p class="peach-settings-sub-label">TYPE</p>2235                <div class="peach-feedback-chips">2236                  <For each={FEEDBACK_CATEGORIES}>2237                    {(cat) => {2238                      const selected = () => fbCategory() === cat;2239                      return (2240                        <button2241                          type="button"2242                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2243                          aria-pressed={selected()}2244                          onClick={() => setFbCategory(cat)}2245                        >2246                          {cat}2247                        </button>2248                      );2249                    }}2250                  </For>2251                </div>22522253                <p class="peach-settings-sub-label">MESSAGE</p>2254                <textarea2255                  class="peach-feedback-textarea"2256                  placeholder="Describe the issue or idea..."2257                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2258                  value={fbMessage()}2259                  onInput={(e) => setFbMessage(e.currentTarget.value)}2260                />22612262                <p class="peach-feedback-meta">2263                  Version {version} · will be included automatically2264                </p>22652266                <button2267                  type="button"2268                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2269                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2270                  onClick={() => void handleSubmitFeedback()}2271                >2272                  <Switch>2273                    <Match when={feedbackState() === 'idle'}>Send</Match>2274                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2275                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2276                  </Switch>2277                </button>22782279                <p class="peach-feedback-anon">2280                  Sent anonymously · no account required2281                </p>2282              </>2283            }2284          >2285            <div class="peach-feedback-thanks" role="status" aria-live="polite">2286              <p class="peach-feedback-thanks-title">Thank you</p>2287              <p>2288                Your note is in the queue. Someone will process it soon.2289              </p>2290            </div>2291          </Show>2292        </div>2293      </div>2294    );2295  }22962297  const bodies: StackBodies = {2298    protection: renderSecurity(),2299    vault: renderVault(),2300    sync: renderSync(),2301    appearance: renderAppearance(),2302    shortcuts: renderShortcuts(),2303    pro: renderLicense(),2304    feedback: renderFeedback(),2305  };23062307  return (2308    <Show2309      when={showNewRecoveryWords()}2310      fallback={2311        <div2312          class="peach-overlay-view peach-settings-overlay"2313          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2314          style="display:flex;flex-direction:column"2315        >2316          <StackDeck2317            active={activeCard()}2318            onActiveChange={setActiveCard}2319            onBack={props.onBack}2320            cards={cards()}2321            bodies={bodies}2322            version={version}2323            isFanning={isFanning()}2324            celebratingCardId={justActivated() ? "pro" : null}2325            isPro={isPro()}2326          />2327        </div>2328      }2329    >2330      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2331    </Show>2332  );2333}