Settings.tsx

94.3 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);376  const [showSyncConnection, setShowSyncConnection] = createSignal(false);377  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);378379  async function loadPendingVouched() {380    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {381      ok: boolean;382      data?: PendingVouched[];383    };384    if (r.ok) setPendingVouched(r.data ?? []);385  }386  async function confirmVouched(deviceId: string) {387    setVouchedBusy(deviceId);388    try {389      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });390      await loadPendingVouched();391    } finally {392      setVouchedBusy(null);393    }394  }395  async function rejectVouched(deviceId: string) {396    setVouchedBusy(deviceId);397    try {398      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });399      await loadPendingVouched();400    } finally {401      setVouchedBusy(null);402    }403  }404  async function doRotateSyncIdentity() {405    setRotateError(null);406    setRotating(true);407    try {408      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {409        ok: boolean;410        error?: string;411      };412      if (!r.ok) {413        setRotateError(r.error ?? "Rotation failed.");414        return;415      }416      setRotateConfirmOpen(false);417      // Backend set peach_recovery_pending; reuse the existing recovery-words418      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).419      setShowNewRecoveryWords(true);420    } finally {421      setRotating(false);422    }423  }424425  const [dedupCount, setDedupCount] = createSignal<number | null>(null);426  const [dedupRunning, setDedupRunning] = createSignal(false);427  const [dedupDone, setDedupDone] = createSignal<number | null>(null);428429  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);430431  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted432  // BEFORE every real-mode vault write so the user can roll back if a sync433  // or import overwrites the wrong state.434  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);435  const [historyLoading, setHistoryLoading] = createSignal(false);436  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);437  const [historyShowAll, setHistoryShowAll] = createSignal(false);438439  async function loadVaultHistory() {440    setHistoryLoading(true);441    try {442      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });443      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];444      setHistorySnapshots(list);445    } finally {446      setHistoryLoading(false);447    }448  }449450  async function handleRestoreVaultSnapshot(id: string) {451    if (historyRestoring()) return;452    setHistoryRestoring(id);453    const res = await sendMessage({454      type: "RESTORE_VAULT_SNAPSHOT",455      payload: { snapshotId: id },456    });457    setHistoryRestoring(null);458    if ((res as { ok: boolean }).ok) {459      // Vault is now locked — let the parent close Settings; App.tsx's460      // VAULT_LOCKED listener will route back to the lock screen.461      props.onBack();462    }463  }464465  function formatSnapshotTime(savedAt: number): string {466    const diff = Date.now() - savedAt;467    if (diff < 60_000) return "Just now";468    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;469    const d = new Date(savedAt);470    const isToday = new Date().toDateString() === d.toDateString();471    const isYest =472      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();473    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });474    if (isToday) return `Today ${t}`;475    if (isYest) return `Yesterday ${t}`;476    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;477  }478479  function reasonLabel(reason: VaultSnapshot["reason"]): string {480    return (481      {482        local_save: "saved",483        sync_received: "synced",484        import: "imported",485        restore: "restored",486      } as const487    )[reason] ?? reason;488  }489490  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);491492  const [showChangePw, setShowChangePw] = createSignal(false);493  const [changePwCurrent, setChangePwCurrent] = createSignal("");494  const [changePwNew, setChangePwNew] = createSignal("");495  const [changePwConfirm, setChangePwConfirm] = createSignal("");496  const [changePwSaving, setChangePwSaving] = createSignal(false);497  const [changePwError, setChangePwError] = createSignal<string | null>(null);498  const [changePwSuccess] = createSignal(false);499  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);500501  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.502  const [showViewWords, setShowViewWords] = createSignal(false);503  const [viewWordsPassword, setViewWordsPassword] = createSignal("");504  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);505  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);506  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);507508  async function handleViewRecoveryWords() {509    setViewWordsError(null);510    if (!viewWordsPassword()) {511      setViewWordsError("Enter your master password.");512      return;513    }514    setViewWordsBusy(true);515    try {516      const r = (await sendMessage({517        type: "GET_RECOVERY_WORDS",518        payload: { password: viewWordsPassword() },519      })) as { ok: boolean; data?: string[]; error?: string };520      if (r.ok) {521        setViewedWords(r.data ?? []);522        setViewWordsPassword("");523        return;524      }525      switch (r.error) {526        case "wrong_password":527          setViewWordsError("Incorrect password");528          break;529        case "password_required":530          setViewWordsError("Enter your master password.");531          break;532        case "recovery_not_setup":533          setViewWordsError("Recovery words aren't set up for this vault yet.");534          break;535        default:536          setViewWordsError("Couldn't retrieve recovery words.");537      }538    } finally {539      setViewWordsBusy(false);540    }541  }542  function closeViewWords() {543    setShowViewWords(false);544    setViewWordsPassword("");545    setViewWordsError(null);546    setViewedWords(null);547  }548549  const [duressEnabled, setDuressEnabled] = createSignal(false);550  const [showDuressSetup, setShowDuressSetup] = createSignal(false);551  const [duressPassword, setDuressPassword] = createSignal("");552  const [duressConfirm, setDuressConfirm] = createSignal("");553  const [duressMainPassword, setDuressMainPassword] = createSignal("");554  const [duressSaving, setDuressSaving] = createSignal(false);555  const [duressError, setDuressError] = createSignal<string | null>(null);556  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);557558  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);559560  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);561  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);562563  const [fbCategory, setFbCategory] = createSignal("Bug report");564  const [fbMessage, setFbMessage] = createSignal("");565  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';566  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');567568  createEffect(() => {569    if (props.upgradeHighlight) {570      setActiveCard("pro");571    }572  });573574  onMount(async () => {575    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([576      sendMessage({ type: "GET_LICENSE" }),577      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),578      sendMessage({ type: "IS_DURESS_ENABLED" }),579      sendMessage({ type: "GET_SETTINGS" }),580      sendMessage({ type: "GET_LICENSE_KEY" }),581    ]);582583    // Vault history loads in the background — the Vault sub-view picks it up584    // when the user navigates in. Loading it lazily keeps Settings boot fast.585    void loadVaultHistory();586587    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the588    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.589    void loadPendingVouched();590    void loadPairedDevices();591    void loadSyncState();592    const onBroadcast = (msg: unknown) => {593      const m = msg as { type?: string; payload?: unknown } | null;594      if (m?.type === "VOUCHED_DEVICE_PENDING") {595        void loadPendingVouched();596      }597      if (m?.type === "SYNC_STATE_CHANGED") {598        const payload = (m.payload ?? {}) as SyncOutcomeSummary;599        setSyncSummary(payload);600        setSyncStatus(payload.status ?? "idle");601      }602    };603    chrome.runtime?.onMessage?.addListener(onBroadcast);604    onCleanup(() => {605      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }606    });607608    if ((licenseRes as { ok: boolean }).ok) {609      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);610    }611612    if ((passkeyRes as { ok: boolean }).ok) {613      setPasskeyState(614        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null615      );616    }617618    if ((duressRes as { ok: boolean }).ok) {619      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);620    }621622    if ((settingsRes as { ok: boolean }).ok) {623      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;624      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);625    }626627    if ((keyRes as { ok: boolean }).ok) {628      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);629    }630631    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });632    if ((entriesRes as { ok: boolean }).ok) {633      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];634      setCanaryEntries(635        entries636          .filter((e) => e.canary?.webhookUrl)637          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))638      );639    }640641642    if (isNativePlatform()) {643      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");644      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");645      try {646        if (biometric) {647          const a = await biometric.isAvailable();648          setBiometricSupported(a.available);649        }650        if (keystore) {651          const k = await keystore.hasMasterKey();652          setBiometricStored(k.exists);653        }654      } catch { /* missing plugins — leave defaults */ }655    }656  });657658  async function handleEnableBiometrics() {659    setBiometricBusy(true);660    setBiometricError(null);661    try {662      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {663        ok: boolean;664        data?: { masterKeyHex: string };665        error?: string;666      };667      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {668        setBiometricError(keyRes.error === "vault_locked"669          ? "unlock the vault first"670          : "couldn't read derived key");671        return;672      }673      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");674      if (!keystore) {675        setBiometricError("biometric plugin unavailable");676        return;677      }678      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });679      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });680      setBiometricStored(true);681    } catch (e) {682      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");683    } finally {684      setBiometricBusy(false);685    }686  }687688  async function handleDisableBiometrics() {689    setBiometricBusy(true);690    setBiometricError(null);691    try {692      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");693      if (keystore) await keystore.clearMasterKey();694      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });695      setBiometricStored(false);696    } catch (e) {697      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");698    } finally {699      setBiometricBusy(false);700    }701  }702703  async function handleChangePassword() {704    setChangePwError(null);705    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }706    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }707    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }708    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }709    setChangePwSaving(true);710    const res = await sendMessage({711      type: "CHANGE_PASSWORD",712      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },713    }) as { ok: boolean; error?: string };714    if (res.ok) {715      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");716      setShowChangePw(false);717      // LV-4: the user just authenticated (current password), so the recovery718      // words reveal that follows is exempt from the re-auth gate. Set the719      // pending flag the same way the create/recovery flow does; the display720      // clears it on confirm.721      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });722      setShowNewRecoveryWords(true);723    } else {724      setChangePwError(res.error ?? "failed to change password");725    }726    setChangePwSaving(false);727  }728729  const [copied, setCopied] = createSignal(false);730731  async function copyLicenseKey(key: string) {732    await navigator.clipboard.writeText(key);733    setCopied(true);734    setTimeout(() => setCopied(false), 2000);735  }736737  async function handleActivateLicense() {738    const key = licenseKeyInput().trim();739    if (!key) return;740    setActivatingLicense(true);741    setLicenseError(null);742    setLicenseSuccess(null);743    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });744    setActivatingLicense(false);745    if ((res as { ok: boolean }).ok) {746      const data = (res as { ok: boolean; data?: LicenseInfo }).data;747      if (data?.valid) {748        setLicenseInfo(data);749        setLicenseKey(key);750        setLicenseSuccess("License activated!");751        setLicenseKeyInput("");752      } else {753        setLicenseError("Invalid license key");754      }755    } else {756      setLicenseError("Activation failed");757    }758  }759760  async function handleTogglePasskeys() {761    const current = passkeyState();762    if (!current || !current.supported) return;763    setPasskeySaving(true);764    const res = await sendMessage({765      type: "SET_PASSKEY_PROVIDER_ENABLED",766      payload: { enabled: !current.enabled },767    });768    setPasskeySaving(false);769    if ((res as { ok: boolean }).ok) {770      setPasskeyState(771        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null772      );773    }774  }775776  async function handleCheckAllBreaches() {777    setCheckingBreaches(true);778    setBreachResult(null);779    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });780    setCheckingBreaches(false);781    if ((res as { ok: boolean }).ok) {782      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};783      const breached = Object.values(data).filter((count) => count > 0).length;784      setBreachResult(785        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"786      );787      props.onBreachCheckDone();788    }789  }790791792  async function handleSetupDuress() {793    const mainPw = duressMainPassword().trim();794    const duressPw = duressPassword().trim();795    const confirmPw = duressConfirm().trim();796797    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }798    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }799    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }800    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }801802    setDuressSaving(true);803    setDuressError(null);804805    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };806    if (!verifyRes.ok || !verifyRes.data) {807      setDuressError("incorrect main password");808      setDuressSaving(false);809      return;810    }811812    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };813    setDuressSaving(false);814    if (res.ok) {815      setDuressEnabled(true);816      setShowDuressSetup(false);817      setDuressPassword("");818      setDuressConfirm("");819      setDuressMainPassword("");820      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");821      setTimeout(() => setDuressSuccess(null), 5000);822    } else {823      setDuressError(824        res.error === "session_expired"825          ? "Vault session expired — lock and unlock Peach, then try again"826          : res.error ?? "failed to set duress password"827      );828    }829  }830831  async function handleClearDeclinedCards() {832    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });833    setDeclinedCardCount(0);834  }835836  async function handleCountDuplicates() {837    setDedupRunning(true);838    setDedupDone(null);839    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };840    setDedupRunning(false);841    if (res.ok) setDedupCount(res.data ?? 0);842  }843844  async function handleDeduplicate() {845    setDedupRunning(true);846    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };847    setDedupRunning(false);848    if (res.ok) {849      setDedupDone(res.data ?? 0);850      setDedupCount(0);851    }852  }853854  async function handleDisableDuress() {855    const mainPw = duressMainPassword().trim();856    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }857858    setDuressSaving(true);859    setDuressError(null);860861    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };862    if (!verifyRes.ok || !verifyRes.data) {863      setDuressError("incorrect main password");864      setDuressSaving(false);865      return;866    }867868    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };869    setDuressSaving(false);870    if (res.ok) {871      setDuressEnabled(false);872      setShowDuressSetup(false);873      setDuressMainPassword("");874      setDuressSuccess("Duress password disabled.");875      setTimeout(() => setDuressSuccess(null), 4000);876    } else {877      setDuressError(res.error ?? "failed to disable duress password");878    }879  }880881  async function handleSubmitFeedback() {882    const message = sanitizeFeedbackMessage(fbMessage());883    if (!message) return;884885    setFeedbackState('sending');886887    try {888      const hasDataPermission = await requestFeedbackDataPermission();889      if (!hasDataPermission) {890        setFeedbackState('idle');891        return;892      }893894      const category = FEEDBACK_CATEGORIES.includes(fbCategory())895        ? fbCategory()896        : FEEDBACK_CATEGORIES[0];897      const res = await fetch('https://api.web3forms.com/submit', {898        method: 'POST',899        headers: { 'Content-Type': 'application/json' },900        body: JSON.stringify({901          access_key: WEB3FORMS_KEY,902          subject: `Peach Feedback - ${category}`,903          message,904          from_name: 'Peach User',905          // Honeypot — leave blank906          botcheck: '',907        }),908      });909910      if (!res.ok) throw new Error(`HTTP ${res.status}`);911      const data = await res.json() as { success: boolean; message?: string };912      if (!data.success) throw new Error(data.message ?? 'unknown error');913914      setFeedbackState('sent');915      setFbMessage('');916      setFbCategory(FEEDBACK_CATEGORIES[0]);917    } catch (err) {918      console.error('[feedback]', err);919      setFeedbackState('error');920      setTimeout(() => setFeedbackState('idle'), 4000);921    }922  }923924  function renderSecurity() {925    return (926      <div class="peach-settings-body">927        <div class="peach-settings-group">928          <p class="peach-settings-sub-label">LOCKING</p>929          <div class="peach-settings-row">930            <span>Lock on close</span>931            <button932              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}933              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}934              aria-label="Toggle lock on close"935            >936              <span class="peach-toggle-knob"></span>937            </button>938          </div>939          <div class="peach-settings-row">940            <span>Auto-lock after</span>941            <select942              class="peach-settings-select"943              value={String(props.autoLockMinutes)}944              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}945            >946              <option value="1">1 minute</option>947              <option value="5">5 minutes</option>948              <option value="15">15 minutes</option>949              <option value="30">30 minutes</option>950              <option value="0">Never</option>951            </select>952          </div>953954          <Show when={isNativePlatform() && biometricSupported()}>955            <p class="peach-settings-sub-label">BIOMETRICS</p>956            <div class="peach-settings-row">957              <span>Unlock with biometrics</span>958              <button959                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}960                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}961                disabled={biometricBusy()}962                aria-label="Toggle biometric unlock"963              >964                <span class="peach-toggle-knob"></span>965              </button>966            </div>967            <Show when={biometricError()}>968              <div class="peach-settings-row peach-settings-row--meta">969                <span class="peach-hint peach-hint-error">{biometricError()}</span>970              </div>971            </Show>972          </Show>973974          <p class="peach-settings-sub-label">BREACH PROTECTION</p>975          <div class="peach-settings-row">976            <span>Breach monitoring</span>977            <button978              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}979              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}980              aria-label="Toggle breach monitoring"981            >982              <span class="peach-toggle-knob"></span>983            </button>984          </div>985          <div class="peach-settings-row peach-settings-row--meta">986            <span>Check all passwords</span>987            <button988              class="peach-btn peach-btn--subtle peach-btn--sm"989              onClick={() => void handleCheckAllBreaches()}990              disabled={checkingBreaches()}991            >992              {checkingBreaches() ? "checking..." : "run"}993            </button>994          </div>995          <Show when={breachResult()}>996            <div class="peach-settings-row peach-settings-row--meta">997              <span>Last check</span>998              <span class="peach-settings-value">{breachResult()}</span>999            </div>1000          </Show>10011002          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1003          <div class="peach-settings-row peach-settings-row--meta">1004            <span>Master password</span>1005            <button1006              class="peach-btn peach-btn--subtle peach-btn--sm"1007              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1008            >1009              {showChangePw() ? "cancel" : "change"}1010            </button>1011          </div>1012          <Show when={showChangePw()}>1013            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1014              <input1015                class="peach-settings-input"1016                type="password"1017                placeholder="current password"1018                value={changePwCurrent()}1019                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1020                autocomplete="current-password"1021              />1022              <input1023                class="peach-settings-input"1024                type="password"1025                placeholder="new password (min 12 chars)"1026                value={changePwNew()}1027                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1028                autocomplete="new-password"1029              />1030              <input1031                class="peach-settings-input"1032                type="password"1033                placeholder="confirm new password"1034                value={changePwConfirm()}1035                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1036                autocomplete="new-password"1037                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1038              />1039              <Show when={changePwError()}>1040                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1041              </Show>1042              <Show when={changePwSuccess()}>1043                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1044              </Show>1045              <button1046                class="peach-btn peach-btn--accent peach-btn--sm"1047                style="align-self:flex-start"1048                onClick={() => void handleChangePassword()}1049                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1050              >1051                {changePwSaving() ? "saving…" : "save new password"}1052              </button>1053            </div>1054          </Show>10551056          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1057          <div class="peach-settings-row peach-settings-row--meta">1058            <span>Recovery words</span>1059            <button1060              class="peach-btn peach-btn--subtle peach-btn--sm"1061              onClick={() => {1062                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1063              }}1064            >1065              {showViewWords() ? "cancel" : "view"}1066            </button>1067          </div>1068          <Show when={showViewWords()}>1069            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1070              <Show1071                when={viewedWords()}1072                fallback={1073                  <>1074                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1075                      Enter your master password to reveal your recovery words.1076                    </p>1077                    <input1078                      class="peach-settings-input"1079                      type="password"1080                      placeholder="master password"1081                      value={viewWordsPassword()}1082                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1083                      autocomplete="current-password"1084                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1085                    />1086                    <Show when={viewWordsError()}>1087                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1088                    </Show>1089                    <button1090                      class="peach-btn peach-btn--accent peach-btn--sm"1091                      style="align-self:flex-start"1092                      onClick={() => void handleViewRecoveryWords()}1093                      disabled={viewWordsBusy() || !viewWordsPassword()}1094                    >1095                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1096                    </button>1097                  </>1098                }1099              >1100                <div class="peach-recovery-words-grid">1101                  <For each={viewedWords()!}>1102                    {(word, i) => (1103                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1104                    )}1105                  </For>1106                </div>1107                <button1108                  class="peach-btn peach-btn--subtle peach-btn--sm"1109                  style="align-self:flex-start"1110                  onClick={closeViewWords}1111                >done</button>1112              </Show>1113            </div>1114          </Show>11151116          <p class="peach-settings-sub-label">1117            DURESS PASSWORD1118            <Show when={!isPro()}>1119              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1120            </Show>1121          </p>1122          <div class="peach-settings-row peach-settings-row--top">1123            <div class="peach-settings-row-main">1124              <span>1125                Duress password1126                <Show when={!isPro()}>1127                  <span class="peach-pro-badge">Pro</span>1128                </Show>1129              </span>1130              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1131                If entered under coercion, shows a decoy empty vault instead of your real one.1132              </span>1133            </div>1134            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1135              {duressEnabled() ? "on" : "off"}1136            </div>1137          </div>11381139          <Show when={duressEnabled() && !showDuressSetup()}>1140            <div class="peach-settings-row peach-settings-row--meta">1141              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1142                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.1143              </span>1144            </div>1145          </Show>11461147          <Show when={duressSuccess()}>1148            <div class="peach-settings-row peach-settings-row--meta">1149              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1150            </div>1151          </Show>11521153          <Show when={!showDuressSetup()}>1154            <div class="peach-settings-row peach-settings-row--meta">1155              <button1156                class="peach-btn peach-btn--subtle peach-btn--sm"1157                onClick={() => {1158                  // Pro gate entry-point: free users are redirected to the1159                  // upgrade screen before the setup form opens. The server-1160                  // side SETUP_DURESS handler is the single enforcement1161                  // point; this is UI redirection so the form never appears1162                  // for a user who can't complete it.1163                  if (!isPro() && !duressEnabled()) {1164                    props.onProGate?.("duress_vault");1165                    setActiveCard("pro");1166                    return;1167                  }1168                  setShowDuressSetup(true);1169                  setDuressError(null);1170                  setDuressSuccess(null);1171                  setDuressPassword("");1172                  setDuressConfirm("");1173                  setDuressMainPassword("");1174                }}1175              >1176                {duressEnabled() ? "Change / disable" : "Set up"}1177              </button>1178            </div>1179          </Show>11801181          <Show when={showDuressSetup()}>1182            <div class="peach-duress-form">1183              <label class="peach-label">main password (to confirm)</label>1184              <input1185                class="peach-settings-input"1186                type="password"1187                placeholder="your current master password"1188                autocomplete="current-password"1189                value={duressMainPassword()}1190                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1191              />1192              <Show when={!duressEnabled()}>1193                <label class="peach-label">new duress password</label>1194                <input1195                  class="peach-settings-input"1196                  type="password"1197                  placeholder="duress password (min 8 chars)"1198                  autocomplete="new-password"1199                  value={duressPassword()}1200                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1201                />1202                <label class="peach-label">confirm duress password</label>1203                <input1204                  class="peach-settings-input"1205                  type="password"1206                  placeholder="confirm duress password"1207                  autocomplete="new-password"1208                  value={duressConfirm()}1209                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1210                />1211              </Show>12121213              <Show when={duressError()}>1214                <p class="peach-hint peach-hint-error">{duressError()}</p>1215              </Show>12161217              <div class="peach-duress-actions">1218                <button1219                  class="peach-btn peach-btn--subtle peach-btn--sm"1220                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1221                >1222                  cancel1223                </button>1224                <Show when={duressEnabled()}>1225                  <button1226                    class="peach-btn peach-btn--ghost peach-btn--sm"1227                    style="color:var(--peach-breach)"1228                    onClick={() => void handleDisableDuress()}1229                    disabled={duressSaving()}1230                  >1231                    {duressSaving() ? "disabling..." : "disable duress"}1232                  </button>1233                </Show>1234                <Show when={!duressEnabled()}>1235                  <button1236                    class="peach-btn peach-btn--accent peach-btn--sm"1237                    onClick={() => void handleSetupDuress()}1238                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1239                  >1240                    {duressSaving() ? "saving..." : "save"}1241                  </button>1242                </Show>1243              </div>1244            </div>1245          </Show>12461247          <p class="peach-settings-sub-label">1248            CANARY ENTRIES1249            <Show when={!isPro()}>1250              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1251            </Show>1252          </p>1253          <div class="peach-settings-row peach-settings-row--top">1254            <div class="peach-settings-row-main">1255              <span>1256                Canary entries1257                <Show when={!isPro()}>1258                  <span class="peach-pro-badge">Pro</span>1259                </Show>1260              </span>1261              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1262                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1263              </span>1264            </div>1265            <span class="peach-settings-value" style="flex-shrink:0">1266              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1267            </span>1268          </div>1269          <Show when={canaryEntries().length > 0}>1270            <For each={canaryEntries()}>1271              {(entry) => (1272                <div class="peach-settings-row peach-settings-row--meta">1273                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1274                  <button1275                    class="peach-btn peach-btn--subtle peach-btn--sm"1276                    onClick={() => void handleTestCanary(entry)}1277                    disabled={canaryTestingId() === entry.id}1278                    style="flex-shrink:0"1279                  >1280                    {canaryTestingId() === entry.id1281                      ? "sending…"1282                      : canaryTestResult()?.id === entry.id1283                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1284                        : "test"}1285                  </button>1286                </div>1287              )}1288            </For>1289          </Show>12901291          <p class="peach-settings-sub-label">PASSKEYS</p>1292          <div class="peach-settings-row peach-settings-row--top">1293            <div class="peach-settings-row-main">1294              <span>Intercept passkey requests</span>1295              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1296                Peach can handle passkey sign-in and creation on supported Chromium browsers.1297              </span>1298            </div>1299            <button1300              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1301              onClick={() => void handleTogglePasskeys()}1302              aria-label="Toggle passkey interception"1303              disabled={!passkeyState()?.supported || passkeySaving()}1304            >1305              <span class="peach-toggle-knob"></span>1306            </button>1307          </div>1308          <div class="peach-settings-row peach-settings-row--meta">1309            <span>Status</span>1310            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1311          </div>1312          <div class="peach-settings-row peach-settings-row--meta">1313            <span>Browser</span>1314            <span class="peach-settings-value">1315              {passkeyState()?.browserLabel ?? "Unknown"}1316              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1317            </span>1318          </div>1319          <div class="peach-info-notice" style="margin-top:8px">1320            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1321          </div>1322          <Show when={passkeyState()?.conflict}>1323            <div class="peach-settings-row peach-settings-row--meta">1324              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1325            </div>1326          </Show>1327        </div>1328      </div>1329    );1330  }13311332  function renderVault() {1333    return (1334      <div class="peach-settings-body">1335        <div class="peach-settings-group">1336          <p class="peach-settings-sub-label">ENTRIES</p>1337          <div class="peach-settings-row peach-settings-row--meta">1338            <span>Entries</span>1339            <span class="peach-settings-value">{props.entriesCount}</span>1340          </div>1341          <div class="peach-settings-row peach-settings-row--meta">1342            <span>Duplicates</span>1343            <Show when={dedupDone() !== null} fallback={1344              <button1345                class="peach-btn peach-btn--subtle peach-btn--sm"1346                onClick={() => void handleCountDuplicates()}1347                disabled={dedupRunning()}1348              >1349                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1350              </button>1351            }>1352              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1353                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1354              </span>1355            </Show>1356          </div>1357          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1358            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1359              <div class="peach-restore-warning">1360                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1361              </div>1362              <div style="display:flex;gap:6px">1363                <button1364                  class="peach-btn peach-btn--subtle peach-btn--sm"1365                  onClick={() => setDedupCount(null)}1366                >1367                  cancel1368                </button>1369                <button1370                  class="peach-btn peach-btn--accent peach-btn--sm"1371                  onClick={() => void handleDeduplicate()}1372                  disabled={dedupRunning()}1373                >1374                  {dedupRunning() ? "removing..." : "remove duplicates"}1375                </button>1376              </div>1377            </div>1378          </Show>13791380          <p class="peach-settings-sub-label">MAINTENANCE</p>1381          <div class="peach-settings-row peach-settings-row--meta">1382            <span>Trash auto-delete</span>1383            <select1384              class="peach-settings-select"1385              value={String(props.trashRetentionDays)}1386              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1387            >1388              <option value="7">7 days</option>1389              <option value="30">30 days</option>1390              <option value="90">90 days</option>1391              <option value="0">Never</option>1392            </select>1393          </div>1394          <div class="peach-settings-row peach-settings-row--meta">1395            <span>Advanced Vault Health</span>1396            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1397              open1398            </button>1399          </div>1400          <Show when={declinedCardCount() > 0}>1401            <div class="peach-settings-row peach-settings-row--meta">1402              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1403              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1404                Clear1405              </button>1406            </div>1407          </Show>14081409          <p class="peach-settings-sub-label">DATA</p>1410          <div class="peach-settings-row peach-settings-row--meta">1411            <span>Import passwords</span>1412            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1413              open1414            </button>1415          </div>1416          <div class="peach-settings-row peach-settings-row--meta">1417            <span>Export backup</span>1418            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1419              open1420            </button>1421          </div>1422          <div class="peach-settings-row peach-settings-row--meta">1423            <span>1424              Peach Codex1425              <Show when={!isPro()}>1426                <span class="peach-pro-badge">Pro</span>1427              </Show>1428            </span>1429            <button1430              class="peach-btn peach-btn--subtle peach-btn--sm"1431              onClick={() => props.onOpenCodexGenerate?.()}1432            >1433              open1434            </button>1435          </div>1436          {/* Restore from Codex: intentionally never gated — a printed1437              Codex must remain restorable regardless of license state. */}1438          <div class="peach-settings-row peach-settings-row--meta">1439            <span>Restore from Codex</span>1440            <button1441              class="peach-btn peach-btn--subtle peach-btn--sm"1442              onClick={() => props.onOpenCodexRestore?.()}1443            >1444              open1445            </button>1446          </div>14471448          <span class="peach-settings-sub-label">VAULT HISTORY</span>1449          <Show when={historyLoading()}>1450            <p class="peach-settings-hint">Loading…</p>1451          </Show>1452          <Show when={!historyLoading() && historySnapshots().length === 0}>1453            <p class="peach-settings-hint">1454              No snapshots yet. Peach saves automatically before every change —1455              saves, syncs, and imports.1456            </p>1457          </Show>1458          <Show when={!historyLoading() && historySnapshots().length > 0}>1459            <p class="peach-settings-hint">1460              Restoring locks your vault. Unlock with the password that1461              encrypted that snapshot to confirm.1462            </p>1463            <div class="peach-history-list">1464              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1465                {(snap) => (1466                  <div class="peach-history-row">1467                    <div class="peach-history-meta">1468                      <span class="peach-history-time">1469                        {formatSnapshotTime(snap.savedAt)}1470                      </span>1471                      <span class="peach-history-sub">1472                        {[1473                          snap.entryCount !== null1474                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1475                            : null,1476                          reasonLabel(snap.reason),1477                        ]1478                          .filter(Boolean)1479                          .join(" · ")}1480                      </span>1481                    </div>1482                    <button1483                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1484                        historyRestoring() === snap.id ? " is-busy" : ""1485                      }`}1486                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1487                      disabled={historyRestoring() !== null}1488                    >1489                      <Show when={historyRestoring() === snap.id} fallback="Restore">1490                        …1491                      </Show>1492                    </button>1493                  </div>1494                )}1495              </For>1496            </div>1497            <Show when={historySnapshots().length > 3}>1498              <button1499                class="peach-settings-advanced-toggle"1500                onClick={() => setHistoryShowAll((v) => !v)}1501                aria-expanded={historyShowAll()}1502              >1503                <span class="peach-settings-hint" style="margin:0">1504                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1505                </span>1506                <span class="peach-settings-advanced-chevron">1507                  {historyShowAll() ? "▲" : "▼"}1508                </span>1509              </button>1510            </Show>1511            <p class="peach-history-footer">1512              {historySnapshots().length} of 10 snapshots1513            </p>1514          </Show>15151516          <button1517            class="peach-settings-advanced-toggle"1518            onClick={() => setShowVaultAdvanced((v) => !v)}1519            aria-expanded={showVaultAdvanced()}1520          >1521            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1522            <span class="peach-settings-advanced-chevron">1523              {showVaultAdvanced() ? "▲" : "▼"}1524            </span>1525          </button>15261527          <Show when={showVaultAdvanced()}>1528            <div class="peach-danger-card">1529              <div class="peach-danger-card-head">1530                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1531                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1532              </div>1533              <p class="peach-danger-card-copy">1534                Wipes the current vault and restores from a backup file.1535                Recoverable only if your backup is good.1536              </p>1537              <button1538                class="peach-danger-card-button"1539                onClick={() => props.onOpenFileImport?.()}1540              >1541                Choose backup file1542              </button>1543            </div>15441545            <Show when={props.onOpenPanic}>1546              <div class="peach-danger-card peach-danger-card--hazard">1547                <div class="peach-danger-card-head">1548                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1549                  <h3 class="peach-danger-card-title">Destroy vault</h3>1550                </div>1551                <p class="peach-danger-card-copy">1552                  Permanently wipes every entry, setting, and license on this1553                  device. There is no recovery.1554                </p>1555                <button1556                  class="peach-danger-card-button"1557                  onClick={() => props.onOpenPanic?.()}1558                >1559                  Begin destruction1560                </button>1561              </div>1562            </Show>1563          </Show>1564        </div>1565      </div>1566    );1567  }15681569  function renderSync() {1570    return (1571      <div class="peach-settings-body">1572        <div class="peach-settings-group">1573          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1574          <div1575            class="peach-settings-row peach-settings-row--meta"1576            style="align-items:center;gap:8px"1577            role="status"1578            aria-label={syncLightLabel()}1579            title={syncLightLabel()}1580          >1581            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1582            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1583              {syncFlash() ?? lastSyncLine()}1584            </span>1585          </div>15861587          <p class="peach-settings-sub-label">ADD A DEVICE</p>1588          <button1589            class="peach-sync-pair-option"1590            onClick={() => props.onOpenSyncPairing?.("code")}1591          >1592            <span class="peach-sync-pair-option-icon" aria-hidden="true">1593              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1594                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1595                <path d="M9 15 L11 15" />1596              </svg>1597            </span>1598            <span class="peach-sync-pair-option-main">1599              <span class="peach-sync-pair-option-title">Show a code</span>1600              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1601            </span>1602            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1603          </button>1604          <Show when={!isPro()}>1605            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1606              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1607            </p>1608          </Show>16091610          <Show when={pairedDevices().length > 0}>1611            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1612            <For each={pairedDevices()}>1613              {(d) => (1614                <div class="peach-settings-row peach-settings-row--top">1615                  <div class="peach-settings-row-main">1616                    <span>{d.deviceName ?? "Unnamed device"}</span>1617                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1618                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1619                    </span>1620                  </div>1621                  <Show1622                    when={confirmUnpair() === d.deviceId}1623                    fallback={1624                      <button1625                        class="peach-btn peach-btn--subtle peach-btn--sm"1626                        onClick={() => setConfirmUnpair(d.deviceId)}1627                      >remove</button>1628                    }1629                  >1630                    {/* HOLE 4: revocation stops this device receiving future1631                        changes. It does NOT secure the vault against it — that1632                        device may already hold the recovery words, which are1633                        master-password-equivalent and restore the whole vault1634                        anywhere, forever, with no sync involved. Say so here,1635                        rather than leaving the user falsely reassured. */}1636                    <div class="peach-unpair-confirm">1637                      <p class="peach-unpair-confirm-note">1638                        Stops this device receiving future changes. It doesn't1639                        lock it out of what it already has — if it's lost or1640                        stolen, also change your master password and generate1641                        new recovery words.1642                      </p>1643                      <div style="display:flex;gap:6px;justify-content:flex-end">1644                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1645                        <button1646                          class="peach-btn peach-btn--sm"1647                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1648                          onClick={() => void unpairDevice(d.deviceId)}1649                        >remove</button>1650                      </div>1651                    </div>1652                  </Show>1653                </div>1654              )}1655            </For>1656            <div class="peach-settings-row peach-settings-row--meta">1657              <span class="peach-settings-value" style="color:var(--peach-t2)">1658                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01659                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1660                  : ""}1661              </span>1662              <button1663                class="peach-btn peach-btn--subtle peach-btn--sm"1664                disabled={syncStatus() === "syncing"}1665                onClick={() => void syncNow()}1666              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1667            </div>1668          </Show>16691670          <Show when={pendingVouched().length > 0}>1671            <p class="peach-settings-sub-label">1672              PENDING DEVICES1673              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1674            </p>1675            <For each={pendingVouched()}>1676              {(dev) => (1677                <div class="peach-settings-row peach-settings-row--top">1678                  <div class="peach-settings-row-main">1679                    <span>{dev.deviceName}</span>1680                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1681                      Was added by another of your devices. Allow it to sync with this vault?1682                    </span>1683                  </div>1684                  <div style="display:flex;gap:6px;flex-shrink:0">1685                    <button1686                      class="peach-btn peach-btn--sm"1687                      disabled={vouchedBusy() === dev.deviceId}1688                      onClick={() => void confirmVouched(dev.deviceId)}1689                    >Allow</button>1690                    <button1691                      class="peach-btn peach-btn--subtle peach-btn--sm"1692                      disabled={vouchedBusy() === dev.deviceId}1693                      onClick={() => void rejectVouched(dev.deviceId)}1694                    >Reject</button>1695                  </div>1696                </div>1697              )}1698            </For>1699          </Show>17001701          <button1702            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1703            onClick={() => setShowSyncConnection((value) => !value)}1704            aria-expanded={showSyncConnection()}1705            aria-controls="peach-sync-connection"1706          >1707            <span>1708              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1709              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1710            </span>1711            <span class="peach-settings-advanced-chevron">1712              {showSyncConnection() ? "▲" : "▼"}1713            </span>1714          </button>1715          <Show when={showSyncConnection()}>1716            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1717              <SyncModeSelector />1718            </div>1719          </Show>17201721          <button1722            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1723            onClick={() => setShowSyncRecovery((value) => !value)}1724            aria-expanded={showSyncRecovery()}1725            aria-controls="peach-sync-recovery"1726          >1727            <span>1728              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1729              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1730            </span>1731            <span class="peach-settings-advanced-chevron">1732              {showSyncRecovery() ? "▲" : "▼"}1733            </span>1734          </button>1735          <Show when={showSyncRecovery()}>1736            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1737              <Show1738                when={rotateConfirmOpen()}1739                fallback={1740                  <div class="peach-settings-row peach-settings-row--top">1741                    <div class="peach-settings-row-main">1742                      <span>Rotate sync identity</span>1743                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1744                        Disconnects every device and replaces the keys used for sync.1745                      </span>1746                    </div>1747                    <button1748                      class="peach-btn peach-btn--subtle peach-btn--sm"1749                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1750                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1751                    >rotate</button>1752                  </div>1753                }1754              >1755                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1756                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1757                  <Show when={rotateError()}>1758                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1759                  </Show>1760                  <div style="display:flex;gap:6px;margin-top:10px">1761                    <button1762                      class="peach-btn peach-btn--subtle peach-btn--sm"1763                      disabled={rotating()}1764                      onClick={() => setRotateConfirmOpen(false)}1765                    >Cancel</button>1766                    <button1767                      class="peach-btn peach-btn--sm"1768                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1769                      disabled={rotating()}1770                      onClick={() => void doRotateSyncIdentity()}1771                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1772                  </div>1773                </div>1774              </Show>1775            </div>1776          </Show>1777        </div>1778      </div>1779    );1780  }17811782  function renderAppearance() {1783    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1784      { id: "medium", label: "Default" },1785      { id: "large", label: "Large" },1786    ];1787    return (1788      <div class="peach-settings-body">1789        <div class="peach-settings-group">1790          <p class="peach-settings-sub-label">THEME</p>1791          <div class="peach-settings-row peach-settings-row--meta">1792            <span>Theme</span>1793            <button1794              class="peach-btn peach-btn--subtle peach-btn--sm"1795              onClick={() =>1796                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1797              }1798            >1799              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1800            </button>1801          </div>18021803          <p class="peach-settings-sub-label">ACCENT</p>1804          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1805            <span>Accent</span>1806            <div class="peach-accent-picker">1807              <For each={ACCENT_COLORS}>1808                {(color) => (1809                  <button1810                    class="peach-accent-swatch peach-swatch"1811                    classList={{ "is-active": props.accentColor === color.name }}1812                    style={{ background: color.hex }}1813                    onClick={() => void props.onAccentChange(color.name)}1814                    aria-label={color.name}1815                    title={color.name}1816                  />1817                )}1818              </For>1819            </div>1820          </div>18211822          <p class="peach-settings-sub-label">TEXT SIZE</p>1823          <div class="peach-settings-row peach-settings-row--meta">1824            <span>Size</span>1825            <div class="peach-pill-group">1826              <For each={sizeOptions}>1827                {(opt) => (1828                  <button1829                    class="peach-btn peach-btn--ghost peach-btn--sm"1830                    classList={{ "is-active": props.textSize === opt.id }}1831                    onClick={() => void props.onTextSizeChange(opt.id)}1832                  >1833                    {opt.label}1834                  </button>1835                )}1836              </For>1837            </div>1838          </div>18391840          <p class="peach-settings-sub-label">MOTION</p>1841          <div class="peach-settings-row peach-settings-row--meta">1842            <span>Reduce motion</span>1843            <button1844              class="peach-btn peach-btn--subtle peach-btn--sm"1845              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1846            >1847              {props.reduceMotion ? "On" : "Off"}1848            </button>1849          </div>1850        </div>1851      </div>1852    );1853  }18541855  function renderShortcuts() {1856    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1857    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18581859    function startCapture(action: ShortcutAction) {1860      setConflictMsg(null);1861      setCapturingAction(action);1862    }1863    function cancelCapture() {1864      setCapturingAction(null);1865      setConflictMsg(null);1866    }1867    function commitBinding(action: ShortcutAction, binding: Keybinding) {1868      const conflict = findConflict(props.shortcuts, binding, action);1869      if (conflict) {1870        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1871        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1872        return;1873      }1874      const next = { ...props.shortcuts, [action]: binding };1875      setCapturingAction(null);1876      setConflictMsg(null);1877      void props.onShortcutsChange(next);1878    }1879    function resetAction(action: ShortcutAction) {1880      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1881      void props.onShortcutsChange(next);1882    }1883    function resetAll() {1884      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1885    }18861887    // Capture-mode key handler — wired via ref onto the chip element so it1888    // catches the next non-modifier keypress and ignores other bindings.1889    function handleCaptureKey(e: KeyboardEvent) {1890      e.preventDefault();1891      e.stopPropagation();1892      if (e.key === "Escape") { cancelCapture(); return; }1893      const binding = keybindingFromEvent(e);1894      if (!binding) return; // bare modifier — keep waiting1895      const action = capturingAction();1896      if (action) commitBinding(action, binding);1897    }18981899    return (1900      <div class="peach-settings-body">1901        <div class="peach-settings-group peach-shortcuts-group">1902          <p class="peach-settings-sub-label">ENTRY VIEW</p>1903          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1904            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1905          </For>1906        </div>19071908        <div class="peach-settings-group peach-shortcuts-group">1909          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1910          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1911            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1912          </For>1913        </div>19141915        <Show when={conflictMsg()}>1916          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1917        </Show>19181919        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1920          <div class="peach-settings-row peach-settings-row--meta">1921            <span>Inline autofill on page</span>1922            <a1923              class="peach-settings-link"1924              href="chrome://extensions/shortcuts"1925              onClick={(e) => {1926                e.preventDefault();1927                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1928              }}1929            >1930              ⌘⇧P — customize ↗1931            </a>1932          </div>1933          <button1934            class="peach-btn peach-btn--subtle peach-btn--sm"1935            style="align-self:flex-start"1936            onClick={resetAll}1937          >1938            Reset all to defaults1939          </button>1940        </div>1941      </div>1942    );19431944    function renderRow(1945      desc: typeof SHORTCUT_DESCRIPTORS[number],1946      capturing: () => ShortcutAction | null,1947      onStart: (a: ShortcutAction) => void,1948      onCancel: () => void,1949      onReset: (a: ShortcutAction) => void,1950      onKey: (e: KeyboardEvent) => void,1951    ) {1952      const isActive = () => capturing() === desc.action;1953      const binding = () => props.shortcuts[desc.action];1954      const isCustom = () => {1955        const b = binding();1956        const d = DEFAULT_BINDINGS[desc.action];1957        return b.key !== d.key1958          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1959          || Boolean(b.meta)  !== Boolean(d.meta)1960          || Boolean(b.shift) !== Boolean(d.shift)1961          || Boolean(b.alt)   !== Boolean(d.alt);1962      };1963      return (1964        <div class="peach-shortcut-row">1965          <span class="peach-shortcut-row-label">{desc.label}</span>1966          <button1967            class="peach-shortcut-chip"1968            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1969            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1970            onKeyDown={(e) => { if (isActive()) onKey(e); }}1971            ref={(el) => {1972              // Auto-focus when entering capture mode so the keydown listener catches the next press.1973              if (el) {1974                queueMicrotask(() => { if (isActive()) el.focus(); });1975              }1976            }}1977          >1978            {isActive() ? "Press a key…" : formatKeybinding(binding())}1979          </button>1980          <Show when={isCustom() && !isActive()}>1981            <button1982              class="peach-shortcut-reset"1983              title="Reset to default"1984              onClick={() => onReset(desc.action)}1985            >1986              ↺1987            </button>1988          </Show>1989        </div>1990      );1991    }1992  }19931994  const PRO_FEATURES: Array<{1995    feature: ProFeature;1996    name: string;1997    desc: string;1998  }> = [1999    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2000    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2001    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2002    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2003  ];20042005  const PRO_SECURITY_FEATURES: Array<{2006    feature: ProFeature;2007    name: string;2008    desc: string;2009  }> = [2010    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2011    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2012  ];20132014  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2015  const [showKey, setShowKey] = createSignal(false);20162017  const [deactivating, setDeactivating] = createSignal(false);20182019  async function handleDeactivateLicense() {2020    setDeactivating(true);2021    await sendMessage({ type: "DEACTIVATE_LICENSE" });2022    setLicenseInfo(null);2023    await props.onLicenseChange?.();2024    setDeactivating(false);2025  }20262027  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2028    setCanaryTestingId(entry.id);2029    setCanaryTestResult(null);2030    const res = await sendMessage({2031      type: "FIRE_CANARY_PING",2032      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2033    }) as { ok: boolean };2034    setCanaryTestResult({ id: entry.id, ok: res.ok });2035    setCanaryTestingId(null);2036    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2037  }20382039  function renderLicense() {2040    async function onActivate() {2041      await handleActivateLicense();2042      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2043        await props.onLicenseChange?.();2044      }2045    }20462047    return (2048      <div class="peach-settings-body">2049        <Show2050          when={isPro()}2051          fallback={2052                <div class="peach-pro-upgrade">2053                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20542055                  <h2 class="peach-pro-headline">2056                    Every<em>thing,</em><br />every<em>where.</em>2057                  </h2>20582059                  <p class="peach-pro-subtitle">2060                    One key unlocks every Pro feature — for the lifetime of the app.2061                  </p>20622063                  <div class="peach-pro-grid">2064                    <For each={PRO_FEATURES}>2065                      {(item, i) => (2066                        <div2067                          class="peach-pro-grid-item"2068                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2069                        >2070                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2071                          <span class="peach-pro-grid-name">{item.name}</span>2072                          <span class="peach-pro-grid-desc">{item.desc}</span>2073                        </div>2074                      )}2075                    </For>2076                  </div>20772078                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2079                  <div class="peach-pro-grid">2080                    <For each={PRO_SECURITY_FEATURES}>2081                      {(item, i) => (2082                        <div2083                          class="peach-pro-grid-item"2084                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2085                        >2086                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2087                          <span class="peach-pro-grid-name">{item.name}</span>2088                          <span class="peach-pro-grid-desc">{item.desc}</span>2089                        </div>2090                      )}2091                    </For>2092                  </div>20932094                  <div class="peach-pro-upgrade-actions">2095                    <p class="peach-pro-pricing">2096                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2097                    </p>2098                    <a2099                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2100                      target="_blank"2101                      rel="noopener noreferrer"2102                      class="peach-pro-upgrade-cta"2103                    >2104                      Get Peach Pro2105                    </a>2106                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2107                    <div class="peach-pro-key-inline">2108                      <input2109                        class="peach-pro-key-inline-input"2110                        type="text"2111                        placeholder="PEACH-XXXX-XXXX-XXXX"2112                        value={licenseKeyInput()}2113                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2114                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2115                        autocomplete="off"2116                        spellcheck={false}2117                      />2118                      <button2119                        class="peach-pro-key-apply"2120                        onClick={() => void onActivate()}2121                        disabled={activatingLicense() || !licenseKeyInput().trim()}2122                      >2123                        {activatingLicense() ? "…" : "APPLY"}2124                      </button>2125                    </div>2126                    <Show when={licenseError()}>2127                      <p class="peach-pro-key-error">{licenseError()}</p>2128                    </Show>2129                  </div>2130                </div>2131          }2132        >2133          <div class="peach-pro-active">2134            <div class="peach-pro-active-hero">2135              <button2136                class="peach-pro-active-mark-wrap"2137                type="button"2138                aria-label="Celebrate"2139                onClick={handleProCelebrationClick}2140              >2141                <PeachMark class="peach-pro-active-mark" />2142                <Show when={justActivated()}>2143                  <div class="peach-pro-sparks" aria-hidden="true">2144                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2145                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2146                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2147                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2148                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2149                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2150                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2151                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2152                  </div>2153                </Show>2154              </button>2155              <h2 class="peach-pro-active-title">Peach Pro</h2>2156              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2157              <Show when={justActivated()}>2158                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2159              </Show>2160            </div>21612162            <Show when={licenseKey()}>2163              <div class="peach-settings-license">2164                <span class="peach-settings-label">LICENSE KEY</span>2165                <div class="peach-settings-license-row">2166                  <Show2167                    when={showKey()}2168                    fallback={2169                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2170                        ••••••••••••••••••••••••••••2171                      </span>2172                    }2173                  >2174                    <span class="peach-settings-license-key">{licenseKey()!}</span>2175                  </Show>2176                  <div class="peach-settings-license-actions">2177                    <button2178                      class="peach-settings-license-copy"2179                      title="Copy license key"2180                      onClick={() => copyLicenseKey(licenseKey()!)}2181                    >2182                      {copied() ? "✓" : "copy"}2183                    </button>2184                    <button2185                      class="peach-settings-license-toggle"2186                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2187                      title={showKey() ? "Hide" : "Reveal"}2188                      onClick={() => setShowKey(v => !v)}2189                    >2190                      <Show2191                        when={showKey()}2192                        fallback={2193                          <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">2194                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2195                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2196                          </svg>2197                        }2198                      >2199                        <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">2200                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2201                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2202                          <line x1="2" y1="2" x2="12" y2="12" />2203                        </svg>2204                      </Show>2205                    </button>2206                  </div>2207                </div>2208              </div>2209            </Show>22102211            <div class="peach-pro-active-features">2212              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2213                {(item) => (2214                  <div class="peach-pro-active-feature">2215                    <span class="peach-pro-active-feature-mark">✦</span>2216                    <span class="peach-pro-active-feature-name">{item.name}</span>2217                  </div>2218                )}2219              </For>2220            </div>22212222            <button2223              class="peach-pro-deactivate"2224              onClick={() => void handleDeactivateLicense()}2225              disabled={deactivating()}2226            >2227              {deactivating() ? "Deactivating…" : "Deactivate"}2228            </button>2229          </div>2230        </Show>2231      </div>2232    );2233  }22342235  function renderFeedback() {2236    return (2237      <div class="peach-settings-body">2238        <div class="peach-settings-group peach-feedback-group">2239          <Show2240            when={feedbackState() === 'sent'}2241            fallback={2242              <>2243                <p class="peach-feedback-intro">2244                  Help us improve Peach. Every report goes directly to our inbox.2245                </p>22462247                <p class="peach-settings-sub-label">TYPE</p>2248                <div class="peach-feedback-chips">2249                  <For each={FEEDBACK_CATEGORIES}>2250                    {(cat) => {2251                      const selected = () => fbCategory() === cat;2252                      return (2253                        <button2254                          type="button"2255                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2256                          aria-pressed={selected()}2257                          onClick={() => setFbCategory(cat)}2258                        >2259                          {cat}2260                        </button>2261                      );2262                    }}2263                  </For>2264                </div>22652266                <p class="peach-settings-sub-label">MESSAGE</p>2267                <textarea2268                  class="peach-feedback-textarea"2269                  placeholder="Describe the issue or idea..."2270                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2271                  value={fbMessage()}2272                  onInput={(e) => setFbMessage(e.currentTarget.value)}2273                />22742275                <p class="peach-feedback-meta">2276                  Version {version} · will be included automatically2277                </p>22782279                <button2280                  type="button"2281                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2282                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2283                  onClick={() => void handleSubmitFeedback()}2284                >2285                  <Switch>2286                    <Match when={feedbackState() === 'idle'}>Send</Match>2287                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2288                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2289                  </Switch>2290                </button>22912292                <p class="peach-feedback-anon">2293                  Sent anonymously · no account required2294                </p>2295              </>2296            }2297          >2298            <div class="peach-feedback-thanks" role="status" aria-live="polite">2299              <p class="peach-feedback-thanks-title">Thank you</p>2300              <p>2301                Your note is in the queue. Someone will process it soon.2302              </p>2303            </div>2304          </Show>2305        </div>2306      </div>2307    );2308  }23092310  const bodies: StackBodies = {2311    protection: renderSecurity(),2312    vault: renderVault(),2313    sync: renderSync(),2314    appearance: renderAppearance(),2315    shortcuts: renderShortcuts(),2316    pro: renderLicense(),2317    feedback: renderFeedback(),2318  };23192320  return (2321    <Show2322      when={showNewRecoveryWords()}2323      fallback={2324        <div2325          class="peach-overlay-view peach-settings-overlay"2326          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2327          style="display:flex;flex-direction:column"2328        >2329          <StackDeck2330            active={activeCard()}2331            onActiveChange={setActiveCard}2332            onBack={props.onBack}2333            cards={cards()}2334            bodies={bodies}2335            version={version}2336            isFanning={isFanning()}2337            celebratingCardId={justActivated() ? "pro" : null}2338            isPro={isPro()}2339          />2340        </div>2341      }2342    >2343      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2344    </Show>2345  );2346}