Settings.tsx

94.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11  AccentColorName,12  PasskeyProviderState,13  ProFeature,14  VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18  describeSyncOutcome,19  syncIndicator,20  syncIndicatorLabel,21  type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24  DEFAULT_BINDINGS,25  findConflict,26  formatKeybinding,27  keybindingFromEvent,28  SHORTCUT_DESCRIPTORS,29  type Keybinding,30  type ShortcutAction,31} from "../../shared/shortcuts";32import { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36  valid: boolean;37  plan: "free" | "pro";38  issued?: string;39}4041interface SettingsProps {42  entriesCount: number;43  breachMonitoring: boolean;44  lockOnClose: boolean;45  autoLockMinutes: number;46  trashRetentionDays: number;47  theme: "dark" | "light";48  accentColor: AccentColorName;49  textSize: "small" | "medium" | "large";50  reduceMotion: boolean;51  isPro?: boolean;52  initialSection?: string | null;53  upgradeHighlight?: ProFeature | null;54  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57  onTrashRetentionChange: (days: number) => void | Promise<void>;58  onThemeChange: (theme: "dark" | "light") => void;59  onAccentChange: (name: AccentColorName) => void | Promise<void>;60  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62  shortcuts: Record<ShortcutAction, Keybinding>;63  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64  onLicenseChange?: () => void | Promise<void>;65  isExiting?: boolean;66  onBack: () => void;67  onImport: () => void;68  onExport: () => void;69  onBreachCheckDone: () => void;70  onOpenHealth?: () => void;71  onOpenSyncPairing?: (method?: "code") => void;72  onOpenFileImport?: () => void;73  onOpenPanic?: () => void;74  onOpenCodexGenerate?: () => void;75  onOpenCodexRestore?: () => void;76  /** Invoked from within Settings when a Pro-gated control is tapped on77   *  free tier. App owns the upgrade navigation + highlight state, so this78   *  callback is the single way for Settings to request that navigation. */79  onProGate?: (feature: ProFeature) => void;80  onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";85const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;86const PRO_CELEBRATION_CARD_STEP_MS = 150;87const PRO_CELEBRATION_THANK_YOU_MS = 3200;8889function sanitizeFeedbackMessage(value: string): string {90  return value91    .trim()92    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)93    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")94    .replace(/&/g, "&amp;")95    .replace(/</g, "&lt;")96    .replace(/>/g, "&gt;")97    .replace(/"/g, "&quot;")98    .replace(/'/g, "&#39;");99}100101type DataCollectionPermissionApi = {102  permissions?: {103    getAll?: () => Promise<{ data_collection?: string[] }>;104    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;105  };106};107108async function requestFeedbackDataPermission(): Promise<boolean> {109  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??110    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;111  if (!api?.permissions?.getAll) return true;112113  try {114    const permissions = await api.permissions.getAll();115    if (!("data_collection" in permissions)) return true;116    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;117    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));118  } catch {119    return false;120  }121}122123function formatActivatedSince(issued: string | undefined): string {124  if (!issued) return "Active";125  try {126    const d = new Date(issued);127    if (Number.isNaN(d.getTime())) return "Active";128    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;129  } catch {130    return "Active";131  }132}133134function initialSectionToCard(value: string | null | undefined): CardId | null {135  if (!value) return null;136  if (value === "security") return "protection";137  if (value === "license") return "pro";138  if (139    value === "protection" ||140    value === "vault" ||141    value === "sync" ||142    value === "appearance" ||143    value === "shortcuts" ||144    value === "pro" ||145    value === "feedback"146  ) {147    return value;148  }149  return null;150}151152export function SettingsView(props: SettingsProps) {153  const manifest = chrome.runtime.getManifest();154  const version = manifest.version;155  const initial = props.initialSection;156  const [activeCard, setActiveCard] = createSignal<CardId>(157    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"158  );159160  createEffect(() => { props.onCardChange?.(activeCard()); });161162  const [justActivated, setJustActivated] = createSignal(false);163  const [isFanning, setIsFanning] = createSignal(false);164165  function triggerCelebration() {166    if (isFanning()) return;167    setJustActivated(false);168    setIsFanning(true);169    setActiveCard("pro");170    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];171    fanOrder.forEach((id, i) => {172      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);173    });174    window.setTimeout(() => {175      setActiveCard("pro");176      setIsFanning(false);177      setJustActivated(true);178      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);179    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);180  }181182  function handleProCelebrationClick(e: MouseEvent) {183    e.stopPropagation();184    triggerCelebration();185  }186187  const [checkingBreaches, setCheckingBreaches] = createSignal(false);188  const [breachResult, setBreachResult] = createSignal<string | null>(null);189  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);190  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");191  const [licenseError, setLicenseError] = createSignal<string | null>(null);192  const [, setLicenseSuccess] = createSignal<string | null>(null);193  const [activatingLicense, setActivatingLicense] = createSignal(false);194195  const isPro = () => props.isPro ?? false;196  const cards = createMemo<StackCardSpec[]>(() => [197    {198      id: "pro",199      num: "I · FOREVER",200      title: "Peach Pro",201      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",202      bgVar: "--stack-pro-bg",203      fgVar: "--stack-pro-fg",204      icon: "spark",205      onIconClick: isPro() ? triggerCelebration : undefined,206    },207    {208      id: "protection",209      num: "II · PROTECT",210      title: "Protection",211      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,212      bgVar: "--stack-protect-bg",213      fgVar: "--stack-protect-fg",214      icon: "shield",215    },216    {217      id: "vault",218      num: "III · KEEP",219      title: "Vault",220      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,221      bgVar: "--stack-vault-bg",222      fgVar: "--stack-vault-fg",223      icon: "lock",224    },225    {226      id: "sync",227      num: "IV · ECHO",228      title: "Sync",229      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",230      bgVar: "--stack-sync-bg",231      fgVar: "--stack-sync-fg",232      icon: "sync",233    },234    {235      id: "appearance",236      num: "V · DRESS",237      title: "Appearance",238      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,239      bgVar: "--stack-appear-bg",240      fgVar: "--stack-appear-fg",241      icon: "sun",242    },243    {244      id: "shortcuts",245      num: "VI · KEYS",246      title: "Shortcuts",247      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,248      bgVar: "--stack-shortcut-bg",249      fgVar: "--stack-shortcut-fg",250      icon: "keys",251    },252    {253      id: "feedback",254      num: "VII · ECHO",255      title: "Feedback",256      lede: `version ${version} · tell us what would make it better`,257      bgVar: "--stack-feedback-bg",258      fgVar: "--stack-feedback-fg",259      icon: "bubble",260    },261  ]);262  const [biometricSupported, setBiometricSupported] = createSignal(false);263  const [biometricStored, setBiometricStored] = createSignal(false);264  const [biometricBusy, setBiometricBusy] = createSignal(false);265  const [biometricError, setBiometricError] = createSignal<string | null>(null);266  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);267  const [passkeySaving, setPasskeySaving] = createSignal(false);268269  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting270  // explicit confirmation before they become sync targets.271  interface PendingVouched { deviceId: string; deviceName: string }272  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);273  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);274  // Paired-device management lives here — Settings is the sync hub.275  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);276  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);277  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");278  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);279  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —280  // a transient toast is no use for diagnosing a phone that isn't syncing.281  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});282  const syncLight = () =>283    syncStatus() === "syncing"284      ? "busy"285      : syncIndicator(syncSummary(), pairedDevices().length);286  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());287  const lastSyncLine = () => {288    const s = syncSummary();289    if (!s.outcome) return "No sync attempted yet";290    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;291  };292  async function loadSyncState() {293    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {294      ok: boolean;295      data?: SyncOutcomeSummary;296    };297    if (r.ok && r.data) {298      setSyncSummary(r.data);299      setSyncStatus(r.data.status ?? "idle");300    }301  }302303  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;304  function syncRelativeTime(when: number | undefined): string {305    if (!when) return "Not synced yet";306    const elapsed = Date.now() - when;307    if (elapsed < 60_000) return "Just now";308    const minutes = Math.floor(elapsed / 60_000);309    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;310    const hours = Math.floor(minutes / 60);311    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;312    const days = Math.floor(hours / 24);313    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";314  }315  function isSyncStale(d: PairedDevice): boolean {316    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;317  }318  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {319    switch (t) {320      case "android": return "Android";321      case "ios": return "iPhone";322      case "extension": return "Browser";323      default: return "Device";324    }325  }326327  async function loadPairedDevices() {328    if (!isPro()) return;329    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {330      ok: boolean;331      data?: { devices: PairedDevice[] };332    };333    if (r.ok) setPairedDevices(r.data?.devices ?? []);334  }335  async function unpairDevice(deviceId: string) {336    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });337    setConfirmUnpair(null);338    await loadPairedDevices();339  }340  async function syncNow() {341    setSyncStatus("syncing");342    setSyncFlash(null);343    const res = (await sendMessage({344      type: "TRIGGER_SYNC",345      payload: { reason: "manual", force: true },346    })) as { ok: boolean; error?: string };347    if (!res.ok) {348      setSyncStatus("error");349      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });350      return;351    }352    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the353    // outcome. A single delayed read used to land while the run was still354    // "syncing" and leave the button disabled forever.355    let s: SyncOutcomeSummary = { status: "syncing" };356    for (let i = 0; i < 40 && s.status === "syncing"; i++) {357      await new Promise((r) => setTimeout(r, 250));358      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {359        ok: boolean;360        data?: SyncOutcomeSummary;361      };362      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };363    }364    setSyncSummary(s);365    setSyncStatus(s.status ?? "idle");366    // The persistent line below carries the outcome; the flash is only the367    // "that click did something" acknowledgement.368    setSyncFlash(describeSyncOutcome(s));369    setTimeout(() => setSyncFlash(null), 4000);370    await loadPairedDevices();371  }372  // UI-4 / MV-8: rotate sync identity confirmation dialog.373  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);374  const [rotating, setRotating] = createSignal(false);375  const [rotateError, setRotateError] = createSignal<string | null>(null);376377  async function loadPendingVouched() {378    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {379      ok: boolean;380      data?: PendingVouched[];381    };382    if (r.ok) setPendingVouched(r.data ?? []);383  }384  async function confirmVouched(deviceId: string) {385    setVouchedBusy(deviceId);386    try {387      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });388      await loadPendingVouched();389    } finally {390      setVouchedBusy(null);391    }392  }393  async function rejectVouched(deviceId: string) {394    setVouchedBusy(deviceId);395    try {396      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });397      await loadPendingVouched();398    } finally {399      setVouchedBusy(null);400    }401  }402  async function doRotateSyncIdentity() {403    setRotateError(null);404    setRotating(true);405    try {406      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {407        ok: boolean;408        error?: string;409      };410      if (!r.ok) {411        setRotateError(r.error ?? "Rotation failed.");412        return;413      }414      setRotateConfirmOpen(false);415      // Backend set peach_recovery_pending; reuse the existing recovery-words416      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).417      setShowNewRecoveryWords(true);418    } finally {419      setRotating(false);420    }421  }422423  const [dedupCount, setDedupCount] = createSignal<number | null>(null);424  const [dedupRunning, setDedupRunning] = createSignal(false);425  const [dedupDone, setDedupDone] = createSignal<number | null>(null);426427  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);428429  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted430  // BEFORE every real-mode vault write so the user can roll back if a sync431  // or import overwrites the wrong state.432  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);433  const [historyLoading, setHistoryLoading] = createSignal(false);434  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);435  const [historyShowAll, setHistoryShowAll] = createSignal(false);436437  async function loadVaultHistory() {438    setHistoryLoading(true);439    try {440      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });441      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];442      setHistorySnapshots(list);443    } finally {444      setHistoryLoading(false);445    }446  }447448  async function handleRestoreVaultSnapshot(id: string) {449    if (historyRestoring()) return;450    setHistoryRestoring(id);451    const res = await sendMessage({452      type: "RESTORE_VAULT_SNAPSHOT",453      payload: { snapshotId: id },454    });455    setHistoryRestoring(null);456    if ((res as { ok: boolean }).ok) {457      // Vault is now locked — let the parent close Settings; App.tsx's458      // VAULT_LOCKED listener will route back to the lock screen.459      props.onBack();460    }461  }462463  function formatSnapshotTime(savedAt: number): string {464    const diff = Date.now() - savedAt;465    if (diff < 60_000) return "Just now";466    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;467    const d = new Date(savedAt);468    const isToday = new Date().toDateString() === d.toDateString();469    const isYest =470      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();471    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });472    if (isToday) return `Today ${t}`;473    if (isYest) return `Yesterday ${t}`;474    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;475  }476477  function reasonLabel(reason: VaultSnapshot["reason"]): string {478    return (479      {480        local_save: "saved",481        sync_received: "synced",482        import: "imported",483        restore: "restored",484      } as const485    )[reason] ?? reason;486  }487488  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);489490  const [showChangePw, setShowChangePw] = createSignal(false);491  const [changePwCurrent, setChangePwCurrent] = createSignal("");492  const [changePwNew, setChangePwNew] = createSignal("");493  const [changePwConfirm, setChangePwConfirm] = createSignal("");494  const [changePwSaving, setChangePwSaving] = createSignal(false);495  const [changePwError, setChangePwError] = createSignal<string | null>(null);496  const [changePwSuccess] = createSignal(false);497  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);498499  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.500  const [showViewWords, setShowViewWords] = createSignal(false);501  const [viewWordsPassword, setViewWordsPassword] = createSignal("");502  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);503  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);504  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);505506  async function handleViewRecoveryWords() {507    setViewWordsError(null);508    if (!viewWordsPassword()) {509      setViewWordsError("Enter your master password.");510      return;511    }512    setViewWordsBusy(true);513    try {514      const r = (await sendMessage({515        type: "GET_RECOVERY_WORDS",516        payload: { password: viewWordsPassword() },517      })) as { ok: boolean; data?: string[]; error?: string };518      if (r.ok) {519        setViewedWords(r.data ?? []);520        setViewWordsPassword("");521        return;522      }523      switch (r.error) {524        case "wrong_password":525          setViewWordsError("Incorrect password");526          break;527        case "password_required":528          setViewWordsError("Enter your master password.");529          break;530        case "recovery_not_setup":531          setViewWordsError("Recovery words aren't set up for this vault yet.");532          break;533        default:534          setViewWordsError("Couldn't retrieve recovery words.");535      }536    } finally {537      setViewWordsBusy(false);538    }539  }540  function closeViewWords() {541    setShowViewWords(false);542    setViewWordsPassword("");543    setViewWordsError(null);544    setViewedWords(null);545  }546547  const [duressEnabled, setDuressEnabled] = createSignal(false);548  const [showDuressSetup, setShowDuressSetup] = createSignal(false);549  const [duressPassword, setDuressPassword] = createSignal("");550  const [duressConfirm, setDuressConfirm] = createSignal("");551  const [duressMainPassword, setDuressMainPassword] = createSignal("");552  const [duressSaving, setDuressSaving] = createSignal(false);553  const [duressError, setDuressError] = createSignal<string | null>(null);554  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);555556  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);557558  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);559  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);560561  const [fbCategory, setFbCategory] = createSignal("Bug report");562  const [fbMessage, setFbMessage] = createSignal("");563  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';564  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');565566  createEffect(() => {567    if (props.upgradeHighlight) {568      setActiveCard("pro");569    }570  });571572  onMount(async () => {573    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([574      sendMessage({ type: "GET_LICENSE" }),575      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),576      sendMessage({ type: "IS_DURESS_ENABLED" }),577      sendMessage({ type: "GET_SETTINGS" }),578      sendMessage({ type: "GET_LICENSE_KEY" }),579    ]);580581    // Vault history loads in the background — the Vault sub-view picks it up582    // when the user navigates in. Loading it lazily keeps Settings boot fast.583    void loadVaultHistory();584585    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the586    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.587    void loadPendingVouched();588    void loadPairedDevices();589    void loadSyncState();590    const onBroadcast = (msg: unknown) => {591      const m = msg as { type?: string; payload?: unknown } | null;592      if (m?.type === "VOUCHED_DEVICE_PENDING") {593        void loadPendingVouched();594      }595      if (m?.type === "SYNC_STATE_CHANGED") {596        const payload = (m.payload ?? {}) as SyncOutcomeSummary;597        setSyncSummary(payload);598        setSyncStatus(payload.status ?? "idle");599      }600    };601    chrome.runtime?.onMessage?.addListener(onBroadcast);602    onCleanup(() => {603      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }604    });605606    if ((licenseRes as { ok: boolean }).ok) {607      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);608    }609610    if ((passkeyRes as { ok: boolean }).ok) {611      setPasskeyState(612        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null613      );614    }615616    if ((duressRes as { ok: boolean }).ok) {617      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);618    }619620    if ((settingsRes as { ok: boolean }).ok) {621      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;622      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);623    }624625    if ((keyRes as { ok: boolean }).ok) {626      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);627    }628629    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });630    if ((entriesRes as { ok: boolean }).ok) {631      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];632      setCanaryEntries(633        entries634          .filter((e) => e.canary?.webhookUrl)635          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))636      );637    }638639640    if (isNativePlatform()) {641      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");642      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");643      try {644        if (biometric) {645          const a = await biometric.isAvailable();646          setBiometricSupported(a.available);647        }648        if (keystore) {649          const k = await keystore.hasMasterKey();650          setBiometricStored(k.exists);651        }652      } catch { /* missing plugins — leave defaults */ }653    }654  });655656  async function handleEnableBiometrics() {657    setBiometricBusy(true);658    setBiometricError(null);659    try {660      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {661        ok: boolean;662        data?: { masterKeyHex: string };663        error?: string;664      };665      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {666        setBiometricError(keyRes.error === "vault_locked"667          ? "unlock the vault first"668          : "couldn't read derived key");669        return;670      }671      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");672      if (!keystore) {673        setBiometricError("biometric plugin unavailable");674        return;675      }676      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });677      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });678      setBiometricStored(true);679    } catch (e) {680      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");681    } finally {682      setBiometricBusy(false);683    }684  }685686  async function handleDisableBiometrics() {687    setBiometricBusy(true);688    setBiometricError(null);689    try {690      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");691      if (keystore) await keystore.clearMasterKey();692      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });693      setBiometricStored(false);694    } catch (e) {695      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");696    } finally {697      setBiometricBusy(false);698    }699  }700701  async function handleChangePassword() {702    setChangePwError(null);703    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }704    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }705    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }706    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }707    setChangePwSaving(true);708    const res = await sendMessage({709      type: "CHANGE_PASSWORD",710      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },711    }) as { ok: boolean; error?: string };712    if (res.ok) {713      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");714      setShowChangePw(false);715      // LV-4: the user just authenticated (current password), so the recovery716      // words reveal that follows is exempt from the re-auth gate. Set the717      // pending flag the same way the create/recovery flow does; the display718      // clears it on confirm.719      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });720      setShowNewRecoveryWords(true);721    } else {722      setChangePwError(res.error ?? "failed to change password");723    }724    setChangePwSaving(false);725  }726727  const [copied, setCopied] = createSignal(false);728729  async function copyLicenseKey(key: string) {730    await navigator.clipboard.writeText(key);731    setCopied(true);732    setTimeout(() => setCopied(false), 2000);733  }734735  async function handleActivateLicense() {736    const key = licenseKeyInput().trim();737    if (!key) return;738    setActivatingLicense(true);739    setLicenseError(null);740    setLicenseSuccess(null);741    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });742    setActivatingLicense(false);743    if ((res as { ok: boolean }).ok) {744      const data = (res as { ok: boolean; data?: LicenseInfo }).data;745      if (data?.valid) {746        setLicenseInfo(data);747        setLicenseKey(key);748        setLicenseSuccess("License activated!");749        setLicenseKeyInput("");750      } else {751        setLicenseError("Invalid license key");752      }753    } else {754      setLicenseError("Activation failed");755    }756  }757758  async function handleTogglePasskeys() {759    const current = passkeyState();760    if (!current || !current.supported) return;761    setPasskeySaving(true);762    const res = await sendMessage({763      type: "SET_PASSKEY_PROVIDER_ENABLED",764      payload: { enabled: !current.enabled },765    });766    setPasskeySaving(false);767    if ((res as { ok: boolean }).ok) {768      setPasskeyState(769        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null770      );771    }772  }773774  async function handleCheckAllBreaches() {775    setCheckingBreaches(true);776    setBreachResult(null);777    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });778    setCheckingBreaches(false);779    if ((res as { ok: boolean }).ok) {780      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};781      const breached = Object.values(data).filter((count) => count > 0).length;782      setBreachResult(783        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"784      );785      props.onBreachCheckDone();786    }787  }788789790  async function handleSetupDuress() {791    const mainPw = duressMainPassword().trim();792    const duressPw = duressPassword().trim();793    const confirmPw = duressConfirm().trim();794795    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }796    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }797    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }798    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }799800    setDuressSaving(true);801    setDuressError(null);802803    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };804    if (!verifyRes.ok || !verifyRes.data) {805      setDuressError("incorrect main password");806      setDuressSaving(false);807      return;808    }809810    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };811    setDuressSaving(false);812    if (res.ok) {813      setDuressEnabled(true);814      setShowDuressSetup(false);815      setDuressPassword("");816      setDuressConfirm("");817      setDuressMainPassword("");818      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");819      setTimeout(() => setDuressSuccess(null), 5000);820    } else {821      setDuressError(822        res.error === "session_expired"823          ? "Vault session expired — lock and unlock Peach, then try again"824          : res.error ?? "failed to set duress password"825      );826    }827  }828829  async function handleClearDeclinedCards() {830    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });831    setDeclinedCardCount(0);832  }833834  async function handleCountDuplicates() {835    setDedupRunning(true);836    setDedupDone(null);837    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };838    setDedupRunning(false);839    if (res.ok) setDedupCount(res.data ?? 0);840  }841842  async function handleDeduplicate() {843    setDedupRunning(true);844    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };845    setDedupRunning(false);846    if (res.ok) {847      setDedupDone(res.data ?? 0);848      setDedupCount(0);849    }850  }851852  async function handleDisableDuress() {853    const mainPw = duressMainPassword().trim();854    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }855856    setDuressSaving(true);857    setDuressError(null);858859    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };860    if (!verifyRes.ok || !verifyRes.data) {861      setDuressError("incorrect main password");862      setDuressSaving(false);863      return;864    }865866    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };867    setDuressSaving(false);868    if (res.ok) {869      setDuressEnabled(false);870      setShowDuressSetup(false);871      setDuressMainPassword("");872      setDuressSuccess("Duress password disabled.");873      setTimeout(() => setDuressSuccess(null), 4000);874    } else {875      setDuressError(res.error ?? "failed to disable duress password");876    }877  }878879  async function handleSubmitFeedback() {880    const message = sanitizeFeedbackMessage(fbMessage());881    if (!message) return;882883    setFeedbackState('sending');884885    try {886      const hasDataPermission = await requestFeedbackDataPermission();887      if (!hasDataPermission) {888        setFeedbackState('idle');889        return;890      }891892      const category = FEEDBACK_CATEGORIES.includes(fbCategory())893        ? fbCategory()894        : FEEDBACK_CATEGORIES[0];895      const res = await fetch('https://api.web3forms.com/submit', {896        method: 'POST',897        headers: { 'Content-Type': 'application/json' },898        body: JSON.stringify({899          access_key: WEB3FORMS_KEY,900          subject: `Peach Feedback - ${category}`,901          message,902          from_name: 'Peach User',903          // Honeypot — leave blank904          botcheck: '',905        }),906      });907908      if (!res.ok) throw new Error(`HTTP ${res.status}`);909      const data = await res.json() as { success: boolean; message?: string };910      if (!data.success) throw new Error(data.message ?? 'unknown error');911912      setFeedbackState('sent');913      setFbMessage('');914      setFbCategory(FEEDBACK_CATEGORIES[0]);915    } catch (err) {916      console.error('[feedback]', err);917      setFeedbackState('error');918      setTimeout(() => setFeedbackState('idle'), 4000);919    }920  }921922  function renderSecurity() {923    return (924      <div class="peach-settings-body">925        <div class="peach-settings-group">926          <p class="peach-settings-sub-label">LOCKING</p>927          <div class="peach-settings-row">928            <span>Lock on close</span>929            <button930              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}931              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}932              aria-label="Toggle lock on close"933            >934              <span class="peach-toggle-knob"></span>935            </button>936          </div>937          <div class="peach-settings-row">938            <span>Auto-lock after</span>939            <select940              class="peach-settings-select"941              value={String(props.autoLockMinutes)}942              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}943            >944              <option value="1">1 minute</option>945              <option value="5">5 minutes</option>946              <option value="15">15 minutes</option>947              <option value="30">30 minutes</option>948              <option value="0">Never</option>949            </select>950          </div>951952          <Show when={isNativePlatform() && biometricSupported()}>953            <p class="peach-settings-sub-label">BIOMETRICS</p>954            <div class="peach-settings-row">955              <span>Unlock with biometrics</span>956              <button957                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}958                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}959                disabled={biometricBusy()}960                aria-label="Toggle biometric unlock"961              >962                <span class="peach-toggle-knob"></span>963              </button>964            </div>965            <Show when={biometricError()}>966              <div class="peach-settings-row peach-settings-row--meta">967                <span class="peach-hint peach-hint-error">{biometricError()}</span>968              </div>969            </Show>970          </Show>971972          <p class="peach-settings-sub-label">BREACH PROTECTION</p>973          <div class="peach-settings-row">974            <span>Breach monitoring</span>975            <button976              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}977              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}978              aria-label="Toggle breach monitoring"979            >980              <span class="peach-toggle-knob"></span>981            </button>982          </div>983          <div class="peach-settings-row peach-settings-row--meta">984            <span>Check all passwords</span>985            <button986              class="peach-btn peach-btn--subtle peach-btn--sm"987              onClick={() => void handleCheckAllBreaches()}988              disabled={checkingBreaches()}989            >990              {checkingBreaches() ? "checking..." : "run"}991            </button>992          </div>993          <Show when={breachResult()}>994            <div class="peach-settings-row peach-settings-row--meta">995              <span>Last check</span>996              <span class="peach-settings-value">{breachResult()}</span>997            </div>998          </Show>9991000          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1001          <div class="peach-settings-row peach-settings-row--meta">1002            <span>Master password</span>1003            <button1004              class="peach-btn peach-btn--subtle peach-btn--sm"1005              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1006            >1007              {showChangePw() ? "cancel" : "change"}1008            </button>1009          </div>1010          <Show when={showChangePw()}>1011            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1012              <input1013                class="peach-settings-input"1014                type="password"1015                placeholder="current password"1016                value={changePwCurrent()}1017                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1018                autocomplete="current-password"1019              />1020              <input1021                class="peach-settings-input"1022                type="password"1023                placeholder="new password (min 12 chars)"1024                value={changePwNew()}1025                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1026                autocomplete="new-password"1027              />1028              <input1029                class="peach-settings-input"1030                type="password"1031                placeholder="confirm new password"1032                value={changePwConfirm()}1033                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1034                autocomplete="new-password"1035                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1036              />1037              <Show when={changePwError()}>1038                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1039              </Show>1040              <Show when={changePwSuccess()}>1041                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1042              </Show>1043              <button1044                class="peach-btn peach-btn--accent peach-btn--sm"1045                style="align-self:flex-start"1046                onClick={() => void handleChangePassword()}1047                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1048              >1049                {changePwSaving() ? "saving…" : "save new password"}1050              </button>1051            </div>1052          </Show>10531054          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1055          <div class="peach-settings-row peach-settings-row--meta">1056            <span>Recovery words</span>1057            <button1058              class="peach-btn peach-btn--subtle peach-btn--sm"1059              onClick={() => {1060                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1061              }}1062            >1063              {showViewWords() ? "cancel" : "view"}1064            </button>1065          </div>1066          <Show when={showViewWords()}>1067            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1068              <Show1069                when={viewedWords()}1070                fallback={1071                  <>1072                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1073                      Enter your master password to reveal your recovery words.1074                    </p>1075                    <input1076                      class="peach-settings-input"1077                      type="password"1078                      placeholder="master password"1079                      value={viewWordsPassword()}1080                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1081                      autocomplete="current-password"1082                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1083                    />1084                    <Show when={viewWordsError()}>1085                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1086                    </Show>1087                    <button1088                      class="peach-btn peach-btn--accent peach-btn--sm"1089                      style="align-self:flex-start"1090                      onClick={() => void handleViewRecoveryWords()}1091                      disabled={viewWordsBusy() || !viewWordsPassword()}1092                    >1093                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1094                    </button>1095                  </>1096                }1097              >1098                <div class="peach-recovery-words-grid">1099                  <For each={viewedWords()!}>1100                    {(word, i) => (1101                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1102                    )}1103                  </For>1104                </div>1105                <button1106                  class="peach-btn peach-btn--subtle peach-btn--sm"1107                  style="align-self:flex-start"1108                  onClick={closeViewWords}1109                >done</button>1110              </Show>1111            </div>1112          </Show>11131114          <p class="peach-settings-sub-label">1115            DURESS PASSWORD1116            <Show when={!isPro()}>1117              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1118            </Show>1119          </p>1120          <div class="peach-settings-row peach-settings-row--top">1121            <div class="peach-settings-row-main">1122              <span>1123                Duress password1124                <Show when={!isPro()}>1125                  <span class="peach-pro-badge">Pro</span>1126                </Show>1127              </span>1128              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1129                If entered under coercion, shows a decoy empty vault instead of your real one.1130              </span>1131            </div>1132            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1133              {duressEnabled() ? "on" : "off"}1134            </div>1135          </div>11361137          <Show when={duressEnabled() && !showDuressSetup()}>1138            <div class="peach-settings-row peach-settings-row--meta">1139              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1140                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.1141              </span>1142            </div>1143          </Show>11441145          <Show when={duressSuccess()}>1146            <div class="peach-settings-row peach-settings-row--meta">1147              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1148            </div>1149          </Show>11501151          <Show when={!showDuressSetup()}>1152            <div class="peach-settings-row peach-settings-row--meta">1153              <button1154                class="peach-btn peach-btn--subtle peach-btn--sm"1155                onClick={() => {1156                  // Pro gate entry-point: free users are redirected to the1157                  // upgrade screen before the setup form opens. The server-1158                  // side SETUP_DURESS handler is the single enforcement1159                  // point; this is UI redirection so the form never appears1160                  // for a user who can't complete it.1161                  if (!isPro() && !duressEnabled()) {1162                    props.onProGate?.("duress_vault");1163                    setActiveCard("pro");1164                    return;1165                  }1166                  setShowDuressSetup(true);1167                  setDuressError(null);1168                  setDuressSuccess(null);1169                  setDuressPassword("");1170                  setDuressConfirm("");1171                  setDuressMainPassword("");1172                }}1173              >1174                {duressEnabled() ? "Change / disable" : "Set up"}1175              </button>1176            </div>1177          </Show>11781179          <Show when={showDuressSetup()}>1180            <div class="peach-duress-form">1181              <label class="peach-label">main password (to confirm)</label>1182              <input1183                class="peach-settings-input"1184                type="password"1185                placeholder="your current master password"1186                autocomplete="current-password"1187                value={duressMainPassword()}1188                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1189              />1190              <Show when={!duressEnabled()}>1191                <label class="peach-label">new duress password</label>1192                <input1193                  class="peach-settings-input"1194                  type="password"1195                  placeholder="duress password (min 8 chars)"1196                  autocomplete="new-password"1197                  value={duressPassword()}1198                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1199                />1200                <label class="peach-label">confirm duress password</label>1201                <input1202                  class="peach-settings-input"1203                  type="password"1204                  placeholder="confirm duress password"1205                  autocomplete="new-password"1206                  value={duressConfirm()}1207                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1208                />1209              </Show>12101211              <Show when={duressError()}>1212                <p class="peach-hint peach-hint-error">{duressError()}</p>1213              </Show>12141215              <div class="peach-duress-actions">1216                <button1217                  class="peach-btn peach-btn--subtle peach-btn--sm"1218                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1219                >1220                  cancel1221                </button>1222                <Show when={duressEnabled()}>1223                  <button1224                    class="peach-btn peach-btn--ghost peach-btn--sm"1225                    style="color:var(--peach-breach)"1226                    onClick={() => void handleDisableDuress()}1227                    disabled={duressSaving()}1228                  >1229                    {duressSaving() ? "disabling..." : "disable duress"}1230                  </button>1231                </Show>1232                <Show when={!duressEnabled()}>1233                  <button1234                    class="peach-btn peach-btn--accent peach-btn--sm"1235                    onClick={() => void handleSetupDuress()}1236                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1237                  >1238                    {duressSaving() ? "saving..." : "save"}1239                  </button>1240                </Show>1241              </div>1242            </div>1243          </Show>12441245          <p class="peach-settings-sub-label">1246            CANARY ENTRIES1247            <Show when={!isPro()}>1248              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1249            </Show>1250          </p>1251          <div class="peach-settings-row peach-settings-row--top">1252            <div class="peach-settings-row-main">1253              <span>1254                Canary entries1255                <Show when={!isPro()}>1256                  <span class="peach-pro-badge">Pro</span>1257                </Show>1258              </span>1259              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1260                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1261              </span>1262            </div>1263            <span class="peach-settings-value" style="flex-shrink:0">1264              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1265            </span>1266          </div>1267          <Show when={canaryEntries().length > 0}>1268            <For each={canaryEntries()}>1269              {(entry) => (1270                <div class="peach-settings-row peach-settings-row--meta">1271                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1272                  <button1273                    class="peach-btn peach-btn--subtle peach-btn--sm"1274                    onClick={() => void handleTestCanary(entry)}1275                    disabled={canaryTestingId() === entry.id}1276                    style="flex-shrink:0"1277                  >1278                    {canaryTestingId() === entry.id1279                      ? "sending…"1280                      : canaryTestResult()?.id === entry.id1281                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1282                        : "test"}1283                  </button>1284                </div>1285              )}1286            </For>1287          </Show>12881289          <p class="peach-settings-sub-label">PASSKEYS</p>1290          <div class="peach-settings-row peach-settings-row--top">1291            <div class="peach-settings-row-main">1292              <span>Intercept passkey requests</span>1293              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1294                Peach can handle passkey sign-in and creation on supported Chromium browsers.1295              </span>1296            </div>1297            <button1298              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1299              onClick={() => void handleTogglePasskeys()}1300              aria-label="Toggle passkey interception"1301              disabled={!passkeyState()?.supported || passkeySaving()}1302            >1303              <span class="peach-toggle-knob"></span>1304            </button>1305          </div>1306          <div class="peach-settings-row peach-settings-row--meta">1307            <span>Status</span>1308            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1309          </div>1310          <div class="peach-settings-row peach-settings-row--meta">1311            <span>Browser</span>1312            <span class="peach-settings-value">1313              {passkeyState()?.browserLabel ?? "Unknown"}1314              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1315            </span>1316          </div>1317          <div class="peach-info-notice" style="margin-top:8px">1318            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1319          </div>1320          <Show when={passkeyState()?.conflict}>1321            <div class="peach-settings-row peach-settings-row--meta">1322              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1323            </div>1324          </Show>1325        </div>1326      </div>1327    );1328  }13291330  function renderVault() {1331    return (1332      <div class="peach-settings-body">1333        <div class="peach-settings-group">1334          <p class="peach-settings-sub-label">ENTRIES</p>1335          <div class="peach-settings-row peach-settings-row--meta">1336            <span>Entries</span>1337            <span class="peach-settings-value">{props.entriesCount}</span>1338          </div>1339          <div class="peach-settings-row peach-settings-row--meta">1340            <span>Duplicates</span>1341            <Show when={dedupDone() !== null} fallback={1342              <button1343                class="peach-btn peach-btn--subtle peach-btn--sm"1344                onClick={() => void handleCountDuplicates()}1345                disabled={dedupRunning()}1346              >1347                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1348              </button>1349            }>1350              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1351                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1352              </span>1353            </Show>1354          </div>1355          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1356            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1357              <div class="peach-restore-warning">1358                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1359              </div>1360              <div style="display:flex;gap:6px">1361                <button1362                  class="peach-btn peach-btn--subtle peach-btn--sm"1363                  onClick={() => setDedupCount(null)}1364                >1365                  cancel1366                </button>1367                <button1368                  class="peach-btn peach-btn--accent peach-btn--sm"1369                  onClick={() => void handleDeduplicate()}1370                  disabled={dedupRunning()}1371                >1372                  {dedupRunning() ? "removing..." : "remove duplicates"}1373                </button>1374              </div>1375            </div>1376          </Show>13771378          <p class="peach-settings-sub-label">MAINTENANCE</p>1379          <div class="peach-settings-row peach-settings-row--meta">1380            <span>Trash auto-delete</span>1381            <select1382              class="peach-settings-select"1383              value={String(props.trashRetentionDays)}1384              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1385            >1386              <option value="7">7 days</option>1387              <option value="30">30 days</option>1388              <option value="90">90 days</option>1389              <option value="0">Never</option>1390            </select>1391          </div>1392          <div class="peach-settings-row peach-settings-row--meta">1393            <span>Advanced Vault Health</span>1394            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1395              open1396            </button>1397          </div>1398          <Show when={declinedCardCount() > 0}>1399            <div class="peach-settings-row peach-settings-row--meta">1400              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1401              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1402                Clear1403              </button>1404            </div>1405          </Show>14061407          <p class="peach-settings-sub-label">DATA</p>1408          <div class="peach-settings-row peach-settings-row--meta">1409            <span>Import passwords</span>1410            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1411              open1412            </button>1413          </div>1414          <div class="peach-settings-row peach-settings-row--meta">1415            <span>Export backup</span>1416            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1417              open1418            </button>1419          </div>1420          <div class="peach-settings-row peach-settings-row--meta">1421            <span>1422              Peach Codex1423              <Show when={!isPro()}>1424                <span class="peach-pro-badge">Pro</span>1425              </Show>1426            </span>1427            <button1428              class="peach-btn peach-btn--subtle peach-btn--sm"1429              onClick={() => props.onOpenCodexGenerate?.()}1430            >1431              open1432            </button>1433          </div>1434          {/* Restore from Codex: intentionally never gated — a printed1435              Codex must remain restorable regardless of license state. */}1436          <div class="peach-settings-row peach-settings-row--meta">1437            <span>Restore from Codex</span>1438            <button1439              class="peach-btn peach-btn--subtle peach-btn--sm"1440              onClick={() => props.onOpenCodexRestore?.()}1441            >1442              open1443            </button>1444          </div>14451446          <span class="peach-settings-sub-label">VAULT HISTORY</span>1447          <Show when={historyLoading()}>1448            <p class="peach-settings-hint">Loading…</p>1449          </Show>1450          <Show when={!historyLoading() && historySnapshots().length === 0}>1451            <p class="peach-settings-hint">1452              No snapshots yet. Peach saves automatically before every change —1453              saves, syncs, and imports.1454            </p>1455          </Show>1456          <Show when={!historyLoading() && historySnapshots().length > 0}>1457            <p class="peach-settings-hint">1458              Restoring locks your vault. Unlock with the password that1459              encrypted that snapshot to confirm.1460            </p>1461            <div class="peach-history-list">1462              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1463                {(snap) => (1464                  <div class="peach-history-row">1465                    <div class="peach-history-meta">1466                      <span class="peach-history-time">1467                        {formatSnapshotTime(snap.savedAt)}1468                      </span>1469                      <span class="peach-history-sub">1470                        {[1471                          snap.entryCount !== null1472                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1473                            : null,1474                          reasonLabel(snap.reason),1475                        ]1476                          .filter(Boolean)1477                          .join(" · ")}1478                      </span>1479                    </div>1480                    <button1481                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1482                        historyRestoring() === snap.id ? " is-busy" : ""1483                      }`}1484                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1485                      disabled={historyRestoring() !== null}1486                    >1487                      <Show when={historyRestoring() === snap.id} fallback="Restore">1488                        …1489                      </Show>1490                    </button>1491                  </div>1492                )}1493              </For>1494            </div>1495            <Show when={historySnapshots().length > 3}>1496              <button1497                class="peach-settings-advanced-toggle"1498                onClick={() => setHistoryShowAll((v) => !v)}1499                aria-expanded={historyShowAll()}1500              >1501                <span class="peach-settings-hint" style="margin:0">1502                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1503                </span>1504                <span class="peach-settings-advanced-chevron">1505                  {historyShowAll() ? "▲" : "▼"}1506                </span>1507              </button>1508            </Show>1509            <p class="peach-history-footer">1510              {historySnapshots().length} of 10 snapshots1511            </p>1512          </Show>15131514          <button1515            class="peach-settings-advanced-toggle"1516            onClick={() => setShowVaultAdvanced((v) => !v)}1517            aria-expanded={showVaultAdvanced()}1518          >1519            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1520            <span class="peach-settings-advanced-chevron">1521              {showVaultAdvanced() ? "▲" : "▼"}1522            </span>1523          </button>15241525          <Show when={showVaultAdvanced()}>1526            <div class="peach-danger-card">1527              <div class="peach-danger-card-head">1528                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1529                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1530              </div>1531              <p class="peach-danger-card-copy">1532                Wipes the current vault and restores from a backup file.1533                Recoverable only if your backup is good.1534              </p>1535              <button1536                class="peach-danger-card-button"1537                onClick={() => props.onOpenFileImport?.()}1538              >1539                Choose backup file1540              </button>1541            </div>15421543            <Show when={props.onOpenPanic}>1544              <div class="peach-danger-card peach-danger-card--hazard">1545                <div class="peach-danger-card-head">1546                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1547                  <h3 class="peach-danger-card-title">Destroy vault</h3>1548                </div>1549                <p class="peach-danger-card-copy">1550                  Permanently wipes every entry, setting, and license on this1551                  device. There is no recovery.1552                </p>1553                <button1554                  class="peach-danger-card-button"1555                  onClick={() => props.onOpenPanic?.()}1556                >1557                  Begin destruction1558                </button>1559              </div>1560            </Show>1561          </Show>1562        </div>1563      </div>1564    );1565  }15661567  function renderSync() {1568    return (1569      <div class="peach-settings-body">1570        <div class="peach-settings-group">1571          <div class="peach-settings-row peach-settings-row--meta">1572            <span>Status</span>1573            <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1574              {isPro() ? "Pro" : "Free"}1575              <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1576            </span>1577          </div>15781579          {/* Pick a pairing method and begin — no separate hub screen. */}1580          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1581          {/* One path in: this browser displays an invitation and the other1582              device scans it. An extension can neither listen nor use a camera,1583              so displaying is the only role it can take. */}1584          <button1585            class="peach-sync-pair-option"1586            onClick={() => props.onOpenSyncPairing?.("code")}1587          >1588            <span class="peach-sync-pair-option-icon" aria-hidden="true">1589              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1590                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1591                <path d="M9 15 L11 15" />1592              </svg>1593            </span>1594            <span class="peach-sync-pair-option-main">1595              <span class="peach-sync-pair-option-title">Show a code</span>1596              <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1597            </span>1598            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1599          </button>1600          <Show when={!isPro()}>1601            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1602              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1603            </p>1604          </Show>16051606          {/* Three named choices, not a toggle with an implied default.1607              LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"1608              now means no sync rather than a peer-to-peer mode that never1609              reliably worked. This screen is the source of truth; the pairing1610              surfaces render the same component compact. */}1611          <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1612          <SyncModeSelector />16131614          {/* Sync indicator light: pulsing green while syncing, solid green on1615              a healthy pair, red on any problem. The line beside it says the1616              same thing in words — colour is never the only signal. */}1617          <div1618            class="peach-settings-row peach-settings-row--meta"1619            style="align-items:center;gap:8px"1620            role="status"1621            aria-label={syncLightLabel()}1622            title={syncLightLabel()}1623          >1624            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1625            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1626              {syncFlash() ?? lastSyncLine()}1627            </span>1628          </div>16291630          {/* Paired devices + manual sync — Settings is the hub. */}1631          <Show when={pairedDevices().length > 0}>1632            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1633            <For each={pairedDevices()}>1634              {(d) => (1635                <div class="peach-settings-row peach-settings-row--top">1636                  <div class="peach-settings-row-main">1637                    <span>{d.deviceName ?? "Unnamed device"}</span>1638                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1639                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1640                    </span>1641                  </div>1642                  <Show1643                    when={confirmUnpair() === d.deviceId}1644                    fallback={1645                      <button1646                        class="peach-btn peach-btn--subtle peach-btn--sm"1647                        onClick={() => setConfirmUnpair(d.deviceId)}1648                      >remove</button>1649                    }1650                  >1651                    {/* HOLE 4: revocation stops this device receiving future1652                        changes. It does NOT secure the vault against it — that1653                        device may already hold the recovery words, which are1654                        master-password-equivalent and restore the whole vault1655                        anywhere, forever, with no sync involved. Say so here,1656                        rather than leaving the user falsely reassured. */}1657                    <div class="peach-unpair-confirm">1658                      <p class="peach-unpair-confirm-note">1659                        Stops this device receiving future changes. It doesn't1660                        lock it out of what it already has — if it's lost or1661                        stolen, also change your master password and generate1662                        new recovery words.1663                      </p>1664                      <div style="display:flex;gap:6px;justify-content:flex-end">1665                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1666                        <button1667                          class="peach-btn peach-btn--sm"1668                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1669                          onClick={() => void unpairDevice(d.deviceId)}1670                        >remove</button>1671                      </div>1672                    </div>1673                  </Show>1674                </div>1675              )}1676            </For>1677            <div class="peach-settings-row peach-settings-row--meta">1678              <span class="peach-settings-value" style="color:var(--peach-t2)">1679                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01680                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1681                  : ""}1682              </span>1683              <button1684                class="peach-btn peach-btn--subtle peach-btn--sm"1685                disabled={syncStatus() === "syncing"}1686                onClick={() => void syncNow()}1687              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1688            </div>1689          </Show>1690          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1691          <Show when={pendingVouched().length > 0}>1692            <p class="peach-settings-sub-label">1693              PENDING DEVICES1694              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1695            </p>1696            <For each={pendingVouched()}>1697              {(dev) => (1698                <div class="peach-settings-row peach-settings-row--top">1699                  <div class="peach-settings-row-main">1700                    <span>{dev.deviceName}</span>1701                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1702                      Was added by another of your devices. Allow it to sync with this vault?1703                    </span>1704                  </div>1705                  <div style="display:flex;gap:6px;flex-shrink:0">1706                    <button1707                      class="peach-btn peach-btn--sm"1708                      disabled={vouchedBusy() === dev.deviceId}1709                      onClick={() => void confirmVouched(dev.deviceId)}1710                    >Allow</button>1711                    <button1712                      class="peach-btn peach-btn--subtle peach-btn--sm"1713                      disabled={vouchedBusy() === dev.deviceId}1714                      onClick={() => void rejectVouched(dev.deviceId)}1715                    >Reject</button>1716                  </div>1717                </div>1718              )}1719            </For>1720          </Show>17211722          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1723          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1724          <Show1725            when={rotateConfirmOpen()}1726            fallback={1727              <div class="peach-settings-row peach-settings-row--top">1728                <div class="peach-settings-row-main">1729                  <span>Rotate sync identity</span>1730                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1731                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1732                  </span>1733                </div>1734                <button1735                  class="peach-btn peach-btn--subtle peach-btn--sm"1736                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1737                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1738                >rotate</button>1739              </div>1740            }1741          >1742            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1743              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.1744              <Show when={rotateError()}>1745                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1746              </Show>1747              <div style="display:flex;gap:6px;margin-top:10px">1748                <button1749                  class="peach-btn peach-btn--subtle peach-btn--sm"1750                  disabled={rotating()}1751                  onClick={() => setRotateConfirmOpen(false)}1752                >Cancel</button>1753                <button1754                  class="peach-btn peach-btn--sm"1755                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1756                  disabled={rotating()}1757                  onClick={() => void doRotateSyncIdentity()}1758                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1759              </div>1760            </div>1761          </Show>1762        </div>1763      </div>1764    );1765  }17661767  function renderAppearance() {1768    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1769      { id: "medium", label: "Default" },1770      { id: "large", label: "Large" },1771    ];1772    return (1773      <div class="peach-settings-body">1774        <div class="peach-settings-group">1775          <p class="peach-settings-sub-label">THEME</p>1776          <div class="peach-settings-row peach-settings-row--meta">1777            <span>Theme</span>1778            <button1779              class="peach-btn peach-btn--subtle peach-btn--sm"1780              onClick={() =>1781                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1782              }1783            >1784              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1785            </button>1786          </div>17871788          <p class="peach-settings-sub-label">ACCENT</p>1789          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1790            <span>Accent</span>1791            <div class="peach-accent-picker">1792              <For each={ACCENT_COLORS}>1793                {(color) => (1794                  <button1795                    class="peach-accent-swatch peach-swatch"1796                    classList={{ "is-active": props.accentColor === color.name }}1797                    style={{ background: color.hex }}1798                    onClick={() => void props.onAccentChange(color.name)}1799                    aria-label={color.name}1800                    title={color.name}1801                  />1802                )}1803              </For>1804            </div>1805          </div>18061807          <p class="peach-settings-sub-label">TEXT SIZE</p>1808          <div class="peach-settings-row peach-settings-row--meta">1809            <span>Size</span>1810            <div class="peach-pill-group">1811              <For each={sizeOptions}>1812                {(opt) => (1813                  <button1814                    class="peach-btn peach-btn--ghost peach-btn--sm"1815                    classList={{ "is-active": props.textSize === opt.id }}1816                    onClick={() => void props.onTextSizeChange(opt.id)}1817                  >1818                    {opt.label}1819                  </button>1820                )}1821              </For>1822            </div>1823          </div>18241825          <p class="peach-settings-sub-label">MOTION</p>1826          <div class="peach-settings-row peach-settings-row--meta">1827            <span>Reduce motion</span>1828            <button1829              class="peach-btn peach-btn--subtle peach-btn--sm"1830              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1831            >1832              {props.reduceMotion ? "On" : "Off"}1833            </button>1834          </div>1835        </div>1836      </div>1837    );1838  }18391840  function renderShortcuts() {1841    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1842    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18431844    function startCapture(action: ShortcutAction) {1845      setConflictMsg(null);1846      setCapturingAction(action);1847    }1848    function cancelCapture() {1849      setCapturingAction(null);1850      setConflictMsg(null);1851    }1852    function commitBinding(action: ShortcutAction, binding: Keybinding) {1853      const conflict = findConflict(props.shortcuts, binding, action);1854      if (conflict) {1855        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1856        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1857        return;1858      }1859      const next = { ...props.shortcuts, [action]: binding };1860      setCapturingAction(null);1861      setConflictMsg(null);1862      void props.onShortcutsChange(next);1863    }1864    function resetAction(action: ShortcutAction) {1865      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1866      void props.onShortcutsChange(next);1867    }1868    function resetAll() {1869      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1870    }18711872    // Capture-mode key handler — wired via ref onto the chip element so it1873    // catches the next non-modifier keypress and ignores other bindings.1874    function handleCaptureKey(e: KeyboardEvent) {1875      e.preventDefault();1876      e.stopPropagation();1877      if (e.key === "Escape") { cancelCapture(); return; }1878      const binding = keybindingFromEvent(e);1879      if (!binding) return; // bare modifier — keep waiting1880      const action = capturingAction();1881      if (action) commitBinding(action, binding);1882    }18831884    return (1885      <div class="peach-settings-body">1886        <div class="peach-settings-group peach-shortcuts-group">1887          <p class="peach-settings-sub-label">ENTRY VIEW</p>1888          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1889            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1890          </For>1891        </div>18921893        <div class="peach-settings-group peach-shortcuts-group">1894          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1895          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1896            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1897          </For>1898        </div>18991900        <Show when={conflictMsg()}>1901          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1902        </Show>19031904        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1905          <div class="peach-settings-row peach-settings-row--meta">1906            <span>Inline autofill on page</span>1907            <a1908              class="peach-settings-link"1909              href="chrome://extensions/shortcuts"1910              onClick={(e) => {1911                e.preventDefault();1912                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1913              }}1914            >1915              ⌘⇧P — customize ↗1916            </a>1917          </div>1918          <button1919            class="peach-btn peach-btn--subtle peach-btn--sm"1920            style="align-self:flex-start"1921            onClick={resetAll}1922          >1923            Reset all to defaults1924          </button>1925        </div>1926      </div>1927    );19281929    function renderRow(1930      desc: typeof SHORTCUT_DESCRIPTORS[number],1931      capturing: () => ShortcutAction | null,1932      onStart: (a: ShortcutAction) => void,1933      onCancel: () => void,1934      onReset: (a: ShortcutAction) => void,1935      onKey: (e: KeyboardEvent) => void,1936    ) {1937      const isActive = () => capturing() === desc.action;1938      const binding = () => props.shortcuts[desc.action];1939      const isCustom = () => {1940        const b = binding();1941        const d = DEFAULT_BINDINGS[desc.action];1942        return b.key !== d.key1943          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1944          || Boolean(b.meta)  !== Boolean(d.meta)1945          || Boolean(b.shift) !== Boolean(d.shift)1946          || Boolean(b.alt)   !== Boolean(d.alt);1947      };1948      return (1949        <div class="peach-shortcut-row">1950          <span class="peach-shortcut-row-label">{desc.label}</span>1951          <button1952            class="peach-shortcut-chip"1953            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1954            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1955            onKeyDown={(e) => { if (isActive()) onKey(e); }}1956            ref={(el) => {1957              // Auto-focus when entering capture mode so the keydown listener catches the next press.1958              if (el) {1959                queueMicrotask(() => { if (isActive()) el.focus(); });1960              }1961            }}1962          >1963            {isActive() ? "Press a key…" : formatKeybinding(binding())}1964          </button>1965          <Show when={isCustom() && !isActive()}>1966            <button1967              class="peach-shortcut-reset"1968              title="Reset to default"1969              onClick={() => onReset(desc.action)}1970            >1971              ↺1972            </button>1973          </Show>1974        </div>1975      );1976    }1977  }19781979  const PRO_FEATURES: Array<{1980    feature: ProFeature;1981    name: string;1982    desc: string;1983  }> = [1984    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1985    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1986    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1987    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1988  ];19891990  const PRO_SECURITY_FEATURES: Array<{1991    feature: ProFeature;1992    name: string;1993    desc: string;1994  }> = [1995    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1996    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1997  ];19981999  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2000  const [showKey, setShowKey] = createSignal(false);20012002  const [deactivating, setDeactivating] = createSignal(false);20032004  async function handleDeactivateLicense() {2005    setDeactivating(true);2006    await sendMessage({ type: "DEACTIVATE_LICENSE" });2007    setLicenseInfo(null);2008    await props.onLicenseChange?.();2009    setDeactivating(false);2010  }20112012  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2013    setCanaryTestingId(entry.id);2014    setCanaryTestResult(null);2015    const res = await sendMessage({2016      type: "FIRE_CANARY_PING",2017      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2018    }) as { ok: boolean };2019    setCanaryTestResult({ id: entry.id, ok: res.ok });2020    setCanaryTestingId(null);2021    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2022  }20232024  function renderLicense() {2025    async function onActivate() {2026      await handleActivateLicense();2027      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2028        await props.onLicenseChange?.();2029      }2030    }20312032    return (2033      <div class="peach-settings-body">2034        <Show2035          when={isPro()}2036          fallback={2037                <div class="peach-pro-upgrade">2038                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20392040                  <h2 class="peach-pro-headline">2041                    Every<em>thing,</em><br />every<em>where.</em>2042                  </h2>20432044                  <p class="peach-pro-subtitle">2045                    One key unlocks every Pro feature — for the lifetime of the app.2046                  </p>20472048                  <div class="peach-pro-grid">2049                    <For each={PRO_FEATURES}>2050                      {(item, i) => (2051                        <div2052                          class="peach-pro-grid-item"2053                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2054                        >2055                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2056                          <span class="peach-pro-grid-name">{item.name}</span>2057                          <span class="peach-pro-grid-desc">{item.desc}</span>2058                        </div>2059                      )}2060                    </For>2061                  </div>20622063                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2064                  <div class="peach-pro-grid">2065                    <For each={PRO_SECURITY_FEATURES}>2066                      {(item, i) => (2067                        <div2068                          class="peach-pro-grid-item"2069                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2070                        >2071                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2072                          <span class="peach-pro-grid-name">{item.name}</span>2073                          <span class="peach-pro-grid-desc">{item.desc}</span>2074                        </div>2075                      )}2076                    </For>2077                  </div>20782079                  <div class="peach-pro-upgrade-actions">2080                    <p class="peach-pro-pricing">2081                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2082                    </p>2083                    <a2084                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2085                      target="_blank"2086                      rel="noopener noreferrer"2087                      class="peach-pro-upgrade-cta"2088                    >2089                      Get Peach Pro2090                    </a>2091                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2092                    <div class="peach-pro-key-inline">2093                      <input2094                        class="peach-pro-key-inline-input"2095                        type="text"2096                        placeholder="PEACH-XXXX-XXXX-XXXX"2097                        value={licenseKeyInput()}2098                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2099                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2100                        autocomplete="off"2101                        spellcheck={false}2102                      />2103                      <button2104                        class="peach-pro-key-apply"2105                        onClick={() => void onActivate()}2106                        disabled={activatingLicense() || !licenseKeyInput().trim()}2107                      >2108                        {activatingLicense() ? "…" : "APPLY"}2109                      </button>2110                    </div>2111                    <Show when={licenseError()}>2112                      <p class="peach-pro-key-error">{licenseError()}</p>2113                    </Show>2114                  </div>2115                </div>2116          }2117        >2118          <div class="peach-pro-active">2119            <div class="peach-pro-active-hero">2120              <button2121                class="peach-pro-active-mark-wrap"2122                type="button"2123                aria-label="Celebrate"2124                onClick={handleProCelebrationClick}2125              >2126                <PeachMark class="peach-pro-active-mark" />2127                <Show when={justActivated()}>2128                  <div class="peach-pro-sparks" aria-hidden="true">2129                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2130                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2131                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2132                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2133                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2134                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2135                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2136                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2137                  </div>2138                </Show>2139              </button>2140              <h2 class="peach-pro-active-title">Peach Pro</h2>2141              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2142              <Show when={justActivated()}>2143                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2144              </Show>2145            </div>21462147            <Show when={licenseKey()}>2148              <div class="peach-settings-license">2149                <span class="peach-settings-label">LICENSE KEY</span>2150                <div class="peach-settings-license-row">2151                  <Show2152                    when={showKey()}2153                    fallback={2154                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2155                        ••••••••••••••••••••••••••••2156                      </span>2157                    }2158                  >2159                    <span class="peach-settings-license-key">{licenseKey()!}</span>2160                  </Show>2161                  <div class="peach-settings-license-actions">2162                    <button2163                      class="peach-settings-license-copy"2164                      title="Copy license key"2165                      onClick={() => copyLicenseKey(licenseKey()!)}2166                    >2167                      {copied() ? "✓" : "copy"}2168                    </button>2169                    <button2170                      class="peach-settings-license-toggle"2171                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2172                      title={showKey() ? "Hide" : "Reveal"}2173                      onClick={() => setShowKey(v => !v)}2174                    >2175                      <Show2176                        when={showKey()}2177                        fallback={2178                          <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">2179                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2180                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2181                          </svg>2182                        }2183                      >2184                        <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">2185                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2186                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2187                          <line x1="2" y1="2" x2="12" y2="12" />2188                        </svg>2189                      </Show>2190                    </button>2191                  </div>2192                </div>2193              </div>2194            </Show>21952196            <div class="peach-pro-active-features">2197              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2198                {(item) => (2199                  <div class="peach-pro-active-feature">2200                    <span class="peach-pro-active-feature-mark">✦</span>2201                    <span class="peach-pro-active-feature-name">{item.name}</span>2202                  </div>2203                )}2204              </For>2205            </div>22062207            <button2208              class="peach-pro-deactivate"2209              onClick={() => void handleDeactivateLicense()}2210              disabled={deactivating()}2211            >2212              {deactivating() ? "Deactivating…" : "Deactivate"}2213            </button>2214          </div>2215        </Show>2216      </div>2217    );2218  }22192220  function renderFeedback() {2221    return (2222      <div class="peach-settings-body">2223        <div class="peach-settings-group peach-feedback-group">2224          <Show2225            when={feedbackState() === 'sent'}2226            fallback={2227              <>2228                <p class="peach-feedback-intro">2229                  Help us improve Peach. Every report goes directly to our inbox.2230                </p>22312232                <p class="peach-settings-sub-label">TYPE</p>2233                <div class="peach-feedback-chips">2234                  <For each={FEEDBACK_CATEGORIES}>2235                    {(cat) => {2236                      const selected = () => fbCategory() === cat;2237                      return (2238                        <button2239                          type="button"2240                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2241                          aria-pressed={selected()}2242                          onClick={() => setFbCategory(cat)}2243                        >2244                          {cat}2245                        </button>2246                      );2247                    }}2248                  </For>2249                </div>22502251                <p class="peach-settings-sub-label">MESSAGE</p>2252                <textarea2253                  class="peach-feedback-textarea"2254                  placeholder="Describe the issue or idea..."2255                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2256                  value={fbMessage()}2257                  onInput={(e) => setFbMessage(e.currentTarget.value)}2258                />22592260                <p class="peach-feedback-meta">2261                  Version {version} · will be included automatically2262                </p>22632264                <button2265                  type="button"2266                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2267                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2268                  onClick={() => void handleSubmitFeedback()}2269                >2270                  <Switch>2271                    <Match when={feedbackState() === 'idle'}>Send</Match>2272                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2273                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2274                  </Switch>2275                </button>22762277                <p class="peach-feedback-anon">2278                  Sent anonymously · no account required2279                </p>2280              </>2281            }2282          >2283            <div class="peach-feedback-thanks" role="status" aria-live="polite">2284              <p class="peach-feedback-thanks-title">Thank you</p>2285              <p>2286                Your note is in the queue. Someone will process it soon.2287              </p>2288            </div>2289          </Show>2290        </div>2291      </div>2292    );2293  }22942295  const bodies: StackBodies = {2296    protection: renderSecurity(),2297    vault: renderVault(),2298    sync: renderSync(),2299    appearance: renderAppearance(),2300    shortcuts: renderShortcuts(),2301    pro: renderLicense(),2302    feedback: renderFeedback(),2303  };23042305  return (2306    <Show2307      when={showNewRecoveryWords()}2308      fallback={2309        <div2310          class="peach-overlay-view peach-settings-overlay"2311          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2312          style="display:flex;flex-direction:column"2313        >2314          <StackDeck2315            active={activeCard()}2316            onActiveChange={setActiveCard}2317            onBack={props.onBack}2318            cards={cards()}2319            bodies={bodies}2320            version={version}2321            isFanning={isFanning()}2322            celebratingCardId={justActivated() ? "pro" : null}2323            isPro={isPro()}2324          />2325        </div>2326      }2327    >2328      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2329    </Show>2330  );2331}