Settings.tsx

94.5 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { 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    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text94    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")95    .replace(/&/g, "&amp;")96    .replace(/</g, "&lt;")97    .replace(/>/g, "&gt;")98    .replace(/"/g, "&quot;")99    .replace(/'/g, "&#39;");100}101102type DataCollectionPermissionApi = {103  permissions?: {104    getAll?: () => Promise<{ data_collection?: string[] }>;105    request?: (permissions: { data_collection: string[] }) => Promise<boolean>;106  };107};108109async function requestFeedbackDataPermission(): Promise<boolean> {110  const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??111    (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;112  if (!api?.permissions?.getAll) return true;113114  try {115    const permissions = await api.permissions.getAll();116    if (!("data_collection" in permissions)) return true;117    if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;118    return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));119  } catch {120    return false;121  }122}123124function formatActivatedSince(issued: string | undefined): string {125  if (!issued) return "Active";126  try {127    const d = new Date(issued);128    if (Number.isNaN(d.getTime())) return "Active";129    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;130  } catch {131    return "Active";132  }133}134135function initialSectionToCard(value: string | null | undefined): CardId | null {136  if (!value) return null;137  if (value === "security") return "protection";138  if (value === "license") return "pro";139  if (140    value === "protection" ||141    value === "vault" ||142    value === "sync" ||143    value === "appearance" ||144    value === "shortcuts" ||145    value === "pro" ||146    value === "feedback"147  ) {148    return value;149  }150  return null;151}152153export function SettingsView(props: SettingsProps) {154  const manifest = chrome.runtime.getManifest();155  const version = manifest.version;156  const initial = props.initialSection;157  const [activeCard, setActiveCard] = createSignal<CardId>(158    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"159  );160161  createEffect(() => { props.onCardChange?.(activeCard()); });162163  const [justActivated, setJustActivated] = createSignal(false);164  const [isFanning, setIsFanning] = createSignal(false);165166  function triggerCelebration() {167    if (isFanning()) return;168    setJustActivated(false);169    setIsFanning(true);170    setActiveCard("pro");171    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];172    fanOrder.forEach((id, i) => {173      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);174    });175    window.setTimeout(() => {176      setActiveCard("pro");177      setIsFanning(false);178      setJustActivated(true);179      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);180    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);181  }182183  function handleProCelebrationClick(e: MouseEvent) {184    e.stopPropagation();185    triggerCelebration();186  }187188  const [checkingBreaches, setCheckingBreaches] = createSignal(false);189  const [breachResult, setBreachResult] = createSignal<string | null>(null);190  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);191  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");192  const [licenseError, setLicenseError] = createSignal<string | null>(null);193  const [, setLicenseSuccess] = createSignal<string | null>(null);194  const [activatingLicense, setActivatingLicense] = createSignal(false);195196  const isPro = () => props.isPro ?? false;197  const cards = createMemo<StackCardSpec[]>(() => [198    {199      id: "pro",200      num: "I · FOREVER",201      title: "Peach Pro",202      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",203      bgVar: "--stack-pro-bg",204      fgVar: "--stack-pro-fg",205      icon: "spark",206      onIconClick: isPro() ? triggerCelebration : undefined,207    },208    {209      id: "protection",210      num: "II · PROTECT",211      title: "Protection",212      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,213      bgVar: "--stack-protect-bg",214      fgVar: "--stack-protect-fg",215      icon: "shield",216    },217    {218      id: "vault",219      num: "III · KEEP",220      title: "Vault",221      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,222      bgVar: "--stack-vault-bg",223      fgVar: "--stack-vault-fg",224      icon: "lock",225    },226    {227      id: "sync",228      num: "IV · ECHO",229      title: "Sync",230      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",231      bgVar: "--stack-sync-bg",232      fgVar: "--stack-sync-fg",233      icon: "sync",234    },235    {236      id: "appearance",237      num: "V · DRESS",238      title: "Appearance",239      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,240      bgVar: "--stack-appear-bg",241      fgVar: "--stack-appear-fg",242      icon: "sun",243    },244    {245      id: "shortcuts",246      num: "VI · KEYS",247      title: "Shortcuts",248      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,249      bgVar: "--stack-shortcut-bg",250      fgVar: "--stack-shortcut-fg",251      icon: "keys",252    },253    {254      id: "feedback",255      num: "VII · ECHO",256      title: "Feedback",257      lede: `version ${version} · tell us what would make it better`,258      bgVar: "--stack-feedback-bg",259      fgVar: "--stack-feedback-fg",260      icon: "bubble",261    },262  ]);263  const [biometricSupported, setBiometricSupported] = createSignal(false);264  const [biometricStored, setBiometricStored] = createSignal(false);265  const [biometricBusy, setBiometricBusy] = createSignal(false);266  const [biometricError, setBiometricError] = createSignal<string | null>(null);267  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);268  const [passkeySaving, setPasskeySaving] = createSignal(false);269270  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting271  // explicit confirmation before they become sync targets.272  interface PendingVouched { deviceId: string; deviceName: string }273  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);274  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);275  // Paired-device management lives here — Settings is the sync hub.276  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);277  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);278  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");279  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);280  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —281  // a transient toast is no use for diagnosing a phone that isn't syncing.282  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});283  const syncLight = () =>284    syncStatus() === "syncing"285      ? "busy"286      : syncIndicator(syncSummary(), pairedDevices().length);287  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());288  const lastSyncLine = () => {289    const s = syncSummary();290    if (!s.outcome) return "No sync attempted yet";291    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;292  };293  async function loadSyncState() {294    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {295      ok: boolean;296      data?: SyncOutcomeSummary;297    };298    if (r.ok && r.data) {299      setSyncSummary(r.data);300      setSyncStatus(r.data.status ?? "idle");301    }302  }303304  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;305  function syncRelativeTime(when: number | undefined): string {306    if (!when) return "Not synced yet";307    const elapsed = Date.now() - when;308    if (elapsed < 60_000) return "Just now";309    const minutes = Math.floor(elapsed / 60_000);310    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;311    const hours = Math.floor(minutes / 60);312    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;313    const days = Math.floor(hours / 24);314    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";315  }316  function isSyncStale(d: PairedDevice): boolean {317    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;318  }319  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {320    switch (t) {321      case "android": return "Android";322      case "ios": return "iPhone";323      case "extension": return "Browser";324      default: return "Device";325    }326  }327328  async function loadPairedDevices() {329    if (!isPro()) return;330    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {331      ok: boolean;332      data?: { devices: PairedDevice[] };333    };334    if (r.ok) setPairedDevices(r.data?.devices ?? []);335  }336  async function unpairDevice(deviceId: string) {337    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });338    setConfirmUnpair(null);339    await loadPairedDevices();340  }341  async function syncNow() {342    setSyncStatus("syncing");343    setSyncFlash(null);344    const res = (await sendMessage({345      type: "TRIGGER_SYNC",346      payload: { reason: "manual", force: true },347    })) as { ok: boolean; error?: string };348    if (!res.ok) {349      setSyncStatus("error");350      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });351      return;352    }353    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the354    // outcome. A single delayed read used to land while the run was still355    // "syncing" and leave the button disabled forever.356    let s: SyncOutcomeSummary = { status: "syncing" };357    for (let i = 0; i < 40 && s.status === "syncing"; i++) {358      await new Promise((r) => setTimeout(r, 250));359      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {360        ok: boolean;361        data?: SyncOutcomeSummary;362      };363      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };364    }365    setSyncSummary(s);366    setSyncStatus(s.status ?? "idle");367    // The persistent line below carries the outcome; the flash is only the368    // "that click did something" acknowledgement.369    setSyncFlash(describeSyncOutcome(s));370    setTimeout(() => setSyncFlash(null), 4000);371    await loadPairedDevices();372  }373  // UI-4 / MV-8: rotate sync identity confirmation dialog.374  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);375  const [rotating, setRotating] = createSignal(false);376  const [rotateError, setRotateError] = createSignal<string | null>(null);377  const [showSyncConnection, setShowSyncConnection] = createSignal(false);378  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);379380  async function loadPendingVouched() {381    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {382      ok: boolean;383      data?: PendingVouched[];384    };385    if (r.ok) setPendingVouched(r.data ?? []);386  }387  async function confirmVouched(deviceId: string) {388    setVouchedBusy(deviceId);389    try {390      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });391      await loadPendingVouched();392    } finally {393      setVouchedBusy(null);394    }395  }396  async function rejectVouched(deviceId: string) {397    setVouchedBusy(deviceId);398    try {399      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });400      await loadPendingVouched();401    } finally {402      setVouchedBusy(null);403    }404  }405  async function doRotateSyncIdentity() {406    setRotateError(null);407    setRotating(true);408    try {409      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {410        ok: boolean;411        error?: string;412      };413      if (!r.ok) {414        setRotateError(r.error ?? "Rotation failed.");415        return;416      }417      setRotateConfirmOpen(false);418      // Backend set peach_recovery_pending; reuse the existing recovery-words419      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).420      setShowNewRecoveryWords(true);421    } finally {422      setRotating(false);423    }424  }425426  const [dedupCount, setDedupCount] = createSignal<number | null>(null);427  const [dedupRunning, setDedupRunning] = createSignal(false);428  const [dedupDone, setDedupDone] = createSignal<number | null>(null);429430  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);431432  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted433  // BEFORE every real-mode vault write so the user can roll back if a sync434  // or import overwrites the wrong state.435  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);436  const [historyLoading, setHistoryLoading] = createSignal(false);437  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);438  const [historyShowAll, setHistoryShowAll] = createSignal(false);439440  async function loadVaultHistory() {441    setHistoryLoading(true);442    try {443      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });444      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];445      setHistorySnapshots(list);446    } finally {447      setHistoryLoading(false);448    }449  }450451  async function handleRestoreVaultSnapshot(id: string) {452    if (historyRestoring()) return;453    setHistoryRestoring(id);454    const res = await sendMessage({455      type: "RESTORE_VAULT_SNAPSHOT",456      payload: { snapshotId: id },457    });458    setHistoryRestoring(null);459    if ((res as { ok: boolean }).ok) {460      // Vault is now locked — let the parent close Settings; App.tsx's461      // VAULT_LOCKED listener will route back to the lock screen.462      props.onBack();463    }464  }465466  function formatSnapshotTime(savedAt: number): string {467    const diff = Date.now() - savedAt;468    if (diff < 60_000) return "Just now";469    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;470    const d = new Date(savedAt);471    const isToday = new Date().toDateString() === d.toDateString();472    const isYest =473      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();474    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });475    if (isToday) return `Today ${t}`;476    if (isYest) return `Yesterday ${t}`;477    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;478  }479480  function reasonLabel(reason: VaultSnapshot["reason"]): string {481    return (482      {483        local_save: "saved",484        sync_received: "synced",485        import: "imported",486        restore: "restored",487      } as const488    )[reason] ?? reason;489  }490491  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);492493  const [showChangePw, setShowChangePw] = createSignal(false);494  const [changePwCurrent, setChangePwCurrent] = createSignal("");495  const [changePwNew, setChangePwNew] = createSignal("");496  const [changePwConfirm, setChangePwConfirm] = createSignal("");497  const [changePwSaving, setChangePwSaving] = createSignal(false);498  const [changePwError, setChangePwError] = createSignal<string | null>(null);499  const [changePwSuccess] = createSignal(false);500  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);501502  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.503  const [showViewWords, setShowViewWords] = createSignal(false);504  const [viewWordsPassword, setViewWordsPassword] = createSignal("");505  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);506  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);507  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);508509  async function handleViewRecoveryWords() {510    setViewWordsError(null);511    if (!viewWordsPassword()) {512      setViewWordsError("Enter your master password.");513      return;514    }515    setViewWordsBusy(true);516    try {517      const r = (await sendMessage({518        type: "GET_RECOVERY_WORDS",519        payload: { password: viewWordsPassword() },520      })) as { ok: boolean; data?: string[]; error?: string };521      if (r.ok) {522        setViewedWords(r.data ?? []);523        setViewWordsPassword("");524        return;525      }526      switch (r.error) {527        case "wrong_password":528          setViewWordsError("Incorrect password");529          break;530        case "password_required":531          setViewWordsError("Enter your master password.");532          break;533        case "recovery_not_setup":534          setViewWordsError("Recovery words aren't set up for this vault yet.");535          break;536        default:537          setViewWordsError("Couldn't retrieve recovery words.");538      }539    } finally {540      setViewWordsBusy(false);541    }542  }543  function closeViewWords() {544    setShowViewWords(false);545    setViewWordsPassword("");546    setViewWordsError(null);547    setViewedWords(null);548  }549550  const [duressEnabled, setDuressEnabled] = createSignal(false);551  const [showDuressSetup, setShowDuressSetup] = createSignal(false);552  const [duressPassword, setDuressPassword] = createSignal("");553  const [duressConfirm, setDuressConfirm] = createSignal("");554  const [duressMainPassword, setDuressMainPassword] = createSignal("");555  const [duressSaving, setDuressSaving] = createSignal(false);556  const [duressError, setDuressError] = createSignal<string | null>(null);557  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);558559  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);560561  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);562  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);563564  const [fbCategory, setFbCategory] = createSignal("Bug report");565  const [fbMessage, setFbMessage] = createSignal("");566  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';567  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');568569  createEffect(() => {570    if (props.upgradeHighlight) {571      setActiveCard("pro");572    }573  });574575  onMount(async () => {576    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([577      sendMessage({ type: "GET_LICENSE" }),578      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),579      sendMessage({ type: "IS_DURESS_ENABLED" }),580      sendMessage({ type: "GET_SETTINGS" }),581      sendMessage({ type: "GET_LICENSE_KEY" }),582    ]);583584    // Vault history loads in the background — the Vault sub-view picks it up585    // when the user navigates in. Loading it lazily keeps Settings boot fast.586    void loadVaultHistory();587588    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the589    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.590    void loadPendingVouched();591    void loadPairedDevices();592    void loadSyncState();593    const onBroadcast = (msg: unknown) => {594      const m = msg as { type?: string; payload?: unknown } | null;595      if (m?.type === "VOUCHED_DEVICE_PENDING") {596        void loadPendingVouched();597      }598      if (m?.type === "SYNC_STATE_CHANGED") {599        const payload = (m.payload ?? {}) as SyncOutcomeSummary;600        setSyncSummary(payload);601        setSyncStatus(payload.status ?? "idle");602      }603    };604    chrome.runtime?.onMessage?.addListener(onBroadcast);605    onCleanup(() => {606      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }607    });608609    if ((licenseRes as { ok: boolean }).ok) {610      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);611    }612613    if ((passkeyRes as { ok: boolean }).ok) {614      setPasskeyState(615        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null616      );617    }618619    if ((duressRes as { ok: boolean }).ok) {620      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);621    }622623    if ((settingsRes as { ok: boolean }).ok) {624      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;625      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);626    }627628    if ((keyRes as { ok: boolean }).ok) {629      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);630    }631632    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });633    if ((entriesRes as { ok: boolean }).ok) {634      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];635      setCanaryEntries(636        entries637          .filter((e) => e.canary?.webhookUrl)638          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))639      );640    }641642643    if (isNativePlatform()) {644      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");645      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");646      try {647        if (biometric) {648          const a = await biometric.isAvailable();649          setBiometricSupported(a.available);650        }651        if (keystore) {652          const k = await keystore.hasMasterKey();653          setBiometricStored(k.exists);654        }655      } catch { /* missing plugins — leave defaults */ }656    }657  });658659  async function handleEnableBiometrics() {660    setBiometricBusy(true);661    setBiometricError(null);662    try {663      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {664        ok: boolean;665        data?: { masterKeyHex: string };666        error?: string;667      };668      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {669        setBiometricError(keyRes.error === "vault_locked"670          ? "unlock the vault first"671          : "couldn't read derived key");672        return;673      }674      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");675      if (!keystore) {676        setBiometricError("biometric plugin unavailable");677        return;678      }679      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });680      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });681      setBiometricStored(true);682    } catch (e) {683      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");684    } finally {685      setBiometricBusy(false);686    }687  }688689  async function handleDisableBiometrics() {690    setBiometricBusy(true);691    setBiometricError(null);692    try {693      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");694      if (keystore) await keystore.clearMasterKey();695      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });696      setBiometricStored(false);697    } catch (e) {698      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");699    } finally {700      setBiometricBusy(false);701    }702  }703704  async function handleChangePassword() {705    setChangePwError(null);706    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }707    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }708    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }709    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }710    setChangePwSaving(true);711    const res = await sendMessage({712      type: "CHANGE_PASSWORD",713      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },714    }) as { ok: boolean; error?: string };715    if (res.ok) {716      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");717      setShowChangePw(false);718      // LV-4: the user just authenticated (current password), so the recovery719      // words reveal that follows is exempt from the re-auth gate. Set the720      // pending flag the same way the create/recovery flow does; the display721      // clears it on confirm.722      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });723      setShowNewRecoveryWords(true);724    } else {725      setChangePwError(res.error ?? "failed to change password");726    }727    setChangePwSaving(false);728  }729730  const [copied, setCopied] = createSignal(false);731732  async function copyLicenseKey(key: string) {733    await navigator.clipboard.writeText(key);734    setCopied(true);735    setTimeout(() => setCopied(false), 2000);736  }737738  async function handleActivateLicense() {739    const key = licenseKeyInput().trim();740    if (!key) return;741    setActivatingLicense(true);742    setLicenseError(null);743    setLicenseSuccess(null);744    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });745    setActivatingLicense(false);746    if ((res as { ok: boolean }).ok) {747      const data = (res as { ok: boolean; data?: LicenseInfo }).data;748      if (data?.valid) {749        setLicenseInfo(data);750        setLicenseKey(key);751        setLicenseSuccess("License activated!");752        setLicenseKeyInput("");753      } else {754        setLicenseError("Invalid license key");755      }756    } else {757      setLicenseError("Activation failed");758    }759  }760761  async function handleTogglePasskeys() {762    const current = passkeyState();763    if (!current || !current.supported) return;764    setPasskeySaving(true);765    const res = await sendMessage({766      type: "SET_PASSKEY_PROVIDER_ENABLED",767      payload: { enabled: !current.enabled },768    });769    setPasskeySaving(false);770    if ((res as { ok: boolean }).ok) {771      setPasskeyState(772        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null773      );774    }775  }776777  async function handleCheckAllBreaches() {778    setCheckingBreaches(true);779    setBreachResult(null);780    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });781    setCheckingBreaches(false);782    if ((res as { ok: boolean }).ok) {783      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};784      const breached = Object.values(data).filter((count) => count > 0).length;785      setBreachResult(786        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"787      );788      props.onBreachCheckDone();789    }790  }791792793  async function handleSetupDuress() {794    const mainPw = duressMainPassword().trim();795    const duressPw = duressPassword().trim();796    const confirmPw = duressConfirm().trim();797798    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }799    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }800    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }801    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }802803    setDuressSaving(true);804    setDuressError(null);805806    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };807    if (!verifyRes.ok || !verifyRes.data) {808      setDuressError("incorrect main password");809      setDuressSaving(false);810      return;811    }812813    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };814    setDuressSaving(false);815    if (res.ok) {816      setDuressEnabled(true);817      setShowDuressSetup(false);818      setDuressPassword("");819      setDuressConfirm("");820      setDuressMainPassword("");821      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");822      setTimeout(() => setDuressSuccess(null), 5000);823    } else {824      setDuressError(825        res.error === "session_expired"826          ? "Vault session expired — lock and unlock Peach, then try again"827          : res.error ?? "failed to set duress password"828      );829    }830  }831832  async function handleClearDeclinedCards() {833    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });834    setDeclinedCardCount(0);835  }836837  async function handleCountDuplicates() {838    setDedupRunning(true);839    setDedupDone(null);840    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };841    setDedupRunning(false);842    if (res.ok) setDedupCount(res.data ?? 0);843  }844845  async function handleDeduplicate() {846    setDedupRunning(true);847    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };848    setDedupRunning(false);849    if (res.ok) {850      setDedupDone(res.data ?? 0);851      setDedupCount(0);852    }853  }854855  async function handleDisableDuress() {856    const mainPw = duressMainPassword().trim();857    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }858859    setDuressSaving(true);860    setDuressError(null);861862    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };863    if (!verifyRes.ok || !verifyRes.data) {864      setDuressError("incorrect main password");865      setDuressSaving(false);866      return;867    }868869    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };870    setDuressSaving(false);871    if (res.ok) {872      setDuressEnabled(false);873      setShowDuressSetup(false);874      setDuressMainPassword("");875      setDuressSuccess("Duress password disabled.");876      setTimeout(() => setDuressSuccess(null), 4000);877    } else {878      setDuressError(res.error ?? "failed to disable duress password");879    }880  }881882  async function handleSubmitFeedback() {883    const message = sanitizeFeedbackMessage(fbMessage());884    if (!message) return;885886    setFeedbackState('sending');887888    try {889      const hasDataPermission = await requestFeedbackDataPermission();890      if (!hasDataPermission) {891        setFeedbackState('idle');892        return;893      }894895      const category = FEEDBACK_CATEGORIES.includes(fbCategory())896        ? fbCategory()897        : FEEDBACK_CATEGORIES[0];898      const res = await fetch('https://api.web3forms.com/submit', {899        method: 'POST',900        headers: { 'Content-Type': 'application/json' },901        body: JSON.stringify({902          access_key: WEB3FORMS_KEY,903          subject: `Peach Feedback - ${category}`,904          message,905          from_name: 'Peach User',906          // Honeypot — leave blank907          botcheck: '',908        }),909      });910911      if (!res.ok) throw new Error(`HTTP ${res.status}`);912      const data = await res.json() as { success: boolean; message?: string };913      if (!data.success) throw new Error(data.message ?? 'unknown error');914915      setFeedbackState('sent');916      setFbMessage('');917      setFbCategory(FEEDBACK_CATEGORIES[0]);918    } catch (err) {919      console.error('[feedback]', err);920      setFeedbackState('error');921      setTimeout(() => setFeedbackState('idle'), 4000);922    }923  }924925  function renderSecurity() {926    return (927      <div class="peach-settings-body">928        <div class="peach-settings-group">929          <p class="peach-settings-sub-label">LOCKING</p>930          <div class="peach-settings-row">931            <span>Lock on close</span>932            <button933              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}934              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}935              aria-label="Toggle lock on close"936            >937              <span class="peach-toggle-knob"></span>938            </button>939          </div>940          <div class="peach-settings-row">941            <span>Auto-lock after</span>942            <select943              class="peach-settings-select"944              value={String(props.autoLockMinutes)}945              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}946            >947              <option value="1">1 minute</option>948              <option value="5">5 minutes</option>949              <option value="15">15 minutes</option>950              <option value="30">30 minutes</option>951              <option value="0">Never</option>952            </select>953          </div>954955          <Show when={isNativePlatform() && biometricSupported()}>956            <p class="peach-settings-sub-label">BIOMETRICS</p>957            <div class="peach-settings-row">958              <span>Unlock with biometrics</span>959              <button960                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}961                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}962                disabled={biometricBusy()}963                aria-label="Toggle biometric unlock"964              >965                <span class="peach-toggle-knob"></span>966              </button>967            </div>968            <Show when={biometricError()}>969              <div class="peach-settings-row peach-settings-row--meta">970                <span class="peach-hint peach-hint-error">{biometricError()}</span>971              </div>972            </Show>973          </Show>974975          <p class="peach-settings-sub-label">BREACH PROTECTION</p>976          <div class="peach-settings-row">977            <span>Breach monitoring</span>978            <button979              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}980              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}981              aria-label="Toggle breach monitoring"982            >983              <span class="peach-toggle-knob"></span>984            </button>985          </div>986          <div class="peach-settings-row peach-settings-row--meta">987            <span>Check all passwords</span>988            <button989              class="peach-btn peach-btn--subtle peach-btn--sm"990              onClick={() => void handleCheckAllBreaches()}991              disabled={checkingBreaches()}992            >993              {checkingBreaches() ? "checking..." : "run"}994            </button>995          </div>996          <Show when={breachResult()}>997            <div class="peach-settings-row peach-settings-row--meta">998              <span>Last check</span>999              <span class="peach-settings-value">{breachResult()}</span>1000            </div>1001          </Show>10021003          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1004          <div class="peach-settings-row peach-settings-row--meta">1005            <span>Master password</span>1006            <button1007              class="peach-btn peach-btn--subtle peach-btn--sm"1008              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1009            >1010              {showChangePw() ? "cancel" : "change"}1011            </button>1012          </div>1013          <Show when={showChangePw()}>1014            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1015              <input1016                class="peach-settings-input"1017                type="password"1018                placeholder="current password"1019                value={changePwCurrent()}1020                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1021                autocomplete="current-password"1022              />1023              <input1024                class="peach-settings-input"1025                type="password"1026                placeholder="new password (min 12 chars)"1027                value={changePwNew()}1028                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1029                autocomplete="new-password"1030              />1031              <input1032                class="peach-settings-input"1033                type="password"1034                placeholder="confirm new password"1035                value={changePwConfirm()}1036                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1037                autocomplete="new-password"1038                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1039              />1040              <Show when={changePwError()}>1041                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1042              </Show>1043              <Show when={changePwSuccess()}>1044                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1045              </Show>1046              <button1047                class="peach-btn peach-btn--accent peach-btn--sm"1048                style="align-self:flex-start"1049                onClick={() => void handleChangePassword()}1050                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1051              >1052                {changePwSaving() ? "saving…" : "save new password"}1053              </button>1054            </div>1055          </Show>10561057          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1058          <div class="peach-settings-row peach-settings-row--meta">1059            <span>Recovery words</span>1060            <button1061              class="peach-btn peach-btn--subtle peach-btn--sm"1062              onClick={() => {1063                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1064              }}1065            >1066              {showViewWords() ? "cancel" : "view"}1067            </button>1068          </div>1069          <Show when={showViewWords()}>1070            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1071              <Show1072                when={viewedWords()}1073                fallback={1074                  <>1075                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1076                      Enter your master password to reveal your recovery words.1077                    </p>1078                    <input1079                      class="peach-settings-input"1080                      type="password"1081                      placeholder="master password"1082                      value={viewWordsPassword()}1083                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1084                      autocomplete="current-password"1085                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1086                    />1087                    <Show when={viewWordsError()}>1088                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1089                    </Show>1090                    <button1091                      class="peach-btn peach-btn--accent peach-btn--sm"1092                      style="align-self:flex-start"1093                      onClick={() => void handleViewRecoveryWords()}1094                      disabled={viewWordsBusy() || !viewWordsPassword()}1095                    >1096                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1097                    </button>1098                  </>1099                }1100              >1101                <div class="peach-recovery-words-grid">1102                  <For each={viewedWords()!}>1103                    {(word, i) => (1104                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1105                    )}1106                  </For>1107                </div>1108                <button1109                  class="peach-btn peach-btn--subtle peach-btn--sm"1110                  style="align-self:flex-start"1111                  onClick={closeViewWords}1112                >done</button>1113              </Show>1114            </div>1115          </Show>11161117          <p class="peach-settings-sub-label">1118            DURESS PASSWORD1119            <Show when={!isPro()}>1120              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1121            </Show>1122          </p>1123          <div class="peach-settings-row peach-settings-row--top">1124            <div class="peach-settings-row-main">1125              <span>1126                Duress password1127                <Show when={!isPro()}>1128                  <span class="peach-pro-badge">Pro</span>1129                </Show>1130              </span>1131              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1132                If entered under coercion, shows a decoy empty vault instead of your real one.1133              </span>1134            </div>1135            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1136              {duressEnabled() ? "on" : "off"}1137            </div>1138          </div>11391140          <Show when={duressEnabled() && !showDuressSetup()}>1141            <div class="peach-settings-row peach-settings-row--meta">1142              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1143                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.1144              </span>1145            </div>1146          </Show>11471148          <Show when={duressSuccess()}>1149            <div class="peach-settings-row peach-settings-row--meta">1150              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1151            </div>1152          </Show>11531154          <Show when={!showDuressSetup()}>1155            <div class="peach-settings-row peach-settings-row--meta">1156              <button1157                class="peach-btn peach-btn--subtle peach-btn--sm"1158                onClick={() => {1159                  // Pro gate entry-point: free users are redirected to the1160                  // upgrade screen before the setup form opens. The server-1161                  // side SETUP_DURESS handler is the single enforcement1162                  // point; this is UI redirection so the form never appears1163                  // for a user who can't complete it.1164                  if (!isPro() && !duressEnabled()) {1165                    props.onProGate?.("duress_vault");1166                    setActiveCard("pro");1167                    return;1168                  }1169                  setShowDuressSetup(true);1170                  setDuressError(null);1171                  setDuressSuccess(null);1172                  setDuressPassword("");1173                  setDuressConfirm("");1174                  setDuressMainPassword("");1175                }}1176              >1177                {duressEnabled() ? "Change / disable" : "Set up"}1178              </button>1179            </div>1180          </Show>11811182          <Show when={showDuressSetup()}>1183            <div class="peach-duress-form">1184              <label class="peach-label">main password (to confirm)</label>1185              <input1186                class="peach-settings-input"1187                type="password"1188                placeholder="your current master password"1189                autocomplete="current-password"1190                value={duressMainPassword()}1191                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1192              />1193              <Show when={!duressEnabled()}>1194                <label class="peach-label">new duress password</label>1195                <input1196                  class="peach-settings-input"1197                  type="password"1198                  placeholder="duress password (min 8 chars)"1199                  autocomplete="new-password"1200                  value={duressPassword()}1201                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1202                />1203                <label class="peach-label">confirm duress password</label>1204                <input1205                  class="peach-settings-input"1206                  type="password"1207                  placeholder="confirm duress password"1208                  autocomplete="new-password"1209                  value={duressConfirm()}1210                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1211                />1212              </Show>12131214              <Show when={duressError()}>1215                <p class="peach-hint peach-hint-error">{duressError()}</p>1216              </Show>12171218              <div class="peach-duress-actions">1219                <button1220                  class="peach-btn peach-btn--subtle peach-btn--sm"1221                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1222                >1223                  cancel1224                </button>1225                <Show when={duressEnabled()}>1226                  <button1227                    class="peach-btn peach-btn--ghost peach-btn--sm"1228                    style="color:var(--peach-breach)"1229                    onClick={() => void handleDisableDuress()}1230                    disabled={duressSaving()}1231                  >1232                    {duressSaving() ? "disabling..." : "disable duress"}1233                  </button>1234                </Show>1235                <Show when={!duressEnabled()}>1236                  <button1237                    class="peach-btn peach-btn--accent peach-btn--sm"1238                    onClick={() => void handleSetupDuress()}1239                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1240                  >1241                    {duressSaving() ? "saving..." : "save"}1242                  </button>1243                </Show>1244              </div>1245            </div>1246          </Show>12471248          <p class="peach-settings-sub-label">1249            CANARY ENTRIES1250            <Show when={!isPro()}>1251              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1252            </Show>1253          </p>1254          <div class="peach-settings-row peach-settings-row--top">1255            <div class="peach-settings-row-main">1256              <span>1257                Canary entries1258                <Show when={!isPro()}>1259                  <span class="peach-pro-badge">Pro</span>1260                </Show>1261              </span>1262              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1263                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1264              </span>1265            </div>1266            <span class="peach-settings-value" style="flex-shrink:0">1267              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1268            </span>1269          </div>1270          <Show when={canaryEntries().length > 0}>1271            <For each={canaryEntries()}>1272              {(entry) => (1273                <div class="peach-settings-row peach-settings-row--meta">1274                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1275                  <button1276                    class="peach-btn peach-btn--subtle peach-btn--sm"1277                    onClick={() => void handleTestCanary(entry)}1278                    disabled={canaryTestingId() === entry.id}1279                    style="flex-shrink:0"1280                  >1281                    {canaryTestingId() === entry.id1282                      ? "sending…"1283                      : canaryTestResult()?.id === entry.id1284                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1285                        : "test"}1286                  </button>1287                </div>1288              )}1289            </For>1290          </Show>12911292          <p class="peach-settings-sub-label">PASSKEYS</p>1293          <div class="peach-settings-row peach-settings-row--top">1294            <div class="peach-settings-row-main">1295              <span>Intercept passkey requests</span>1296              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1297                Peach can handle passkey sign-in and creation on supported Chromium browsers.1298              </span>1299            </div>1300            <button1301              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1302              onClick={() => void handleTogglePasskeys()}1303              aria-label="Toggle passkey interception"1304              disabled={!passkeyState()?.supported || passkeySaving()}1305            >1306              <span class="peach-toggle-knob"></span>1307            </button>1308          </div>1309          <div class="peach-settings-row peach-settings-row--meta">1310            <span>Status</span>1311            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1312          </div>1313          <div class="peach-settings-row peach-settings-row--meta">1314            <span>Browser</span>1315            <span class="peach-settings-value">1316              {passkeyState()?.browserLabel ?? "Unknown"}1317              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1318            </span>1319          </div>1320          <div class="peach-info-notice" style="margin-top:8px">1321            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1322          </div>1323          <Show when={passkeyState()?.conflict}>1324            <div class="peach-settings-row peach-settings-row--meta">1325              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1326            </div>1327          </Show>1328        </div>1329      </div>1330    );1331  }13321333  function renderVault() {1334    return (1335      <div class="peach-settings-body">1336        <div class="peach-settings-group">1337          <p class="peach-settings-sub-label">ENTRIES</p>1338          <div class="peach-settings-row peach-settings-row--meta">1339            <span>Entries</span>1340            <span class="peach-settings-value">{props.entriesCount}</span>1341          </div>1342          <div class="peach-settings-row peach-settings-row--meta">1343            <span>Duplicates</span>1344            <Show when={dedupDone() !== null} fallback={1345              <button1346                class="peach-btn peach-btn--subtle peach-btn--sm"1347                onClick={() => void handleCountDuplicates()}1348                disabled={dedupRunning()}1349              >1350                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1351              </button>1352            }>1353              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1354                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1355              </span>1356            </Show>1357          </div>1358          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1359            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1360              <div class="peach-restore-warning">1361                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1362              </div>1363              <div style="display:flex;gap:6px">1364                <button1365                  class="peach-btn peach-btn--subtle peach-btn--sm"1366                  onClick={() => setDedupCount(null)}1367                >1368                  cancel1369                </button>1370                <button1371                  class="peach-btn peach-btn--accent peach-btn--sm"1372                  onClick={() => void handleDeduplicate()}1373                  disabled={dedupRunning()}1374                >1375                  {dedupRunning() ? "removing..." : "remove duplicates"}1376                </button>1377              </div>1378            </div>1379          </Show>13801381          <p class="peach-settings-sub-label">MAINTENANCE</p>1382          <div class="peach-settings-row peach-settings-row--meta">1383            <span>Trash auto-delete</span>1384            <select1385              class="peach-settings-select"1386              value={String(props.trashRetentionDays)}1387              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1388            >1389              <option value="7">7 days</option>1390              <option value="30">30 days</option>1391              <option value="90">90 days</option>1392              <option value="0">Never</option>1393            </select>1394          </div>1395          <div class="peach-settings-row peach-settings-row--meta">1396            <span>Advanced Vault Health</span>1397            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1398              open1399            </button>1400          </div>1401          <Show when={declinedCardCount() > 0}>1402            <div class="peach-settings-row peach-settings-row--meta">1403              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1404              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1405                Clear1406              </button>1407            </div>1408          </Show>14091410          <p class="peach-settings-sub-label">DATA</p>1411          <div class="peach-settings-row peach-settings-row--meta">1412            <span>Import passwords</span>1413            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1414              open1415            </button>1416          </div>1417          <div class="peach-settings-row peach-settings-row--meta">1418            <span>Export backup</span>1419            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1420              open1421            </button>1422          </div>1423          <div class="peach-settings-row peach-settings-row--meta">1424            <span>1425              Peach Codex1426              <Show when={!isPro()}>1427                <span class="peach-pro-badge">Pro</span>1428              </Show>1429            </span>1430            <button1431              class="peach-btn peach-btn--subtle peach-btn--sm"1432              onClick={() => props.onOpenCodexGenerate?.()}1433            >1434              open1435            </button>1436          </div>1437          {/* Restore from Codex: intentionally never gated — a printed1438              Codex must remain restorable regardless of license state. */}1439          <div class="peach-settings-row peach-settings-row--meta">1440            <span>Restore from Codex</span>1441            <button1442              class="peach-btn peach-btn--subtle peach-btn--sm"1443              onClick={() => props.onOpenCodexRestore?.()}1444            >1445              open1446            </button>1447          </div>14481449          <span class="peach-settings-sub-label">VAULT HISTORY</span>1450          <Show when={historyLoading()}>1451            <p class="peach-settings-hint">Loading…</p>1452          </Show>1453          <Show when={!historyLoading() && historySnapshots().length === 0}>1454            <p class="peach-settings-hint">1455              No snapshots yet. Peach saves automatically before every change —1456              saves, syncs, and imports.1457            </p>1458          </Show>1459          <Show when={!historyLoading() && historySnapshots().length > 0}>1460            <p class="peach-settings-hint">1461              Restoring locks your vault. Unlock with the password that1462              encrypted that snapshot to confirm.1463            </p>1464            <div class="peach-history-list">1465              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1466                {(snap) => (1467                  <div class="peach-history-row">1468                    <div class="peach-history-meta">1469                      <span class="peach-history-time">1470                        {formatSnapshotTime(snap.savedAt)}1471                      </span>1472                      <span class="peach-history-sub">1473                        {[1474                          snap.entryCount !== null1475                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1476                            : null,1477                          reasonLabel(snap.reason),1478                        ]1479                          .filter(Boolean)1480                          .join(" · ")}1481                      </span>1482                    </div>1483                    <button1484                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1485                        historyRestoring() === snap.id ? " is-busy" : ""1486                      }`}1487                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1488                      disabled={historyRestoring() !== null}1489                    >1490                      <Show when={historyRestoring() === snap.id} fallback="Restore">1491                        …1492                      </Show>1493                    </button>1494                  </div>1495                )}1496              </For>1497            </div>1498            <Show when={historySnapshots().length > 3}>1499              <button1500                class="peach-settings-advanced-toggle"1501                onClick={() => setHistoryShowAll((v) => !v)}1502                aria-expanded={historyShowAll()}1503              >1504                <span class="peach-settings-hint" style="margin:0">1505                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1506                </span>1507                <span class="peach-settings-advanced-chevron">1508                  {historyShowAll() ? "▲" : "▼"}1509                </span>1510              </button>1511            </Show>1512            <p class="peach-history-footer">1513              {historySnapshots().length} of 10 snapshots1514            </p>1515          </Show>15161517          <button1518            class="peach-settings-advanced-toggle"1519            onClick={() => setShowVaultAdvanced((v) => !v)}1520            aria-expanded={showVaultAdvanced()}1521          >1522            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1523            <span class="peach-settings-advanced-chevron">1524              {showVaultAdvanced() ? "▲" : "▼"}1525            </span>1526          </button>15271528          <Show when={showVaultAdvanced()}>1529            <div class="peach-danger-card">1530              <div class="peach-danger-card-head">1531                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1532                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1533              </div>1534              <p class="peach-danger-card-copy">1535                Wipes the current vault and restores from a backup file.1536                Recoverable only if your backup is good.1537              </p>1538              <button1539                class="peach-danger-card-button"1540                onClick={() => props.onOpenFileImport?.()}1541              >1542                Choose backup file1543              </button>1544            </div>15451546            <Show when={props.onOpenPanic}>1547              <div class="peach-danger-card peach-danger-card--hazard">1548                <div class="peach-danger-card-head">1549                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1550                  <h3 class="peach-danger-card-title">Destroy vault</h3>1551                </div>1552                <p class="peach-danger-card-copy">1553                  Permanently wipes every entry, setting, and license on this1554                  device. There is no recovery.1555                </p>1556                <button1557                  class="peach-danger-card-button"1558                  onClick={() => props.onOpenPanic?.()}1559                >1560                  Begin destruction1561                </button>1562              </div>1563            </Show>1564          </Show>1565        </div>1566      </div>1567    );1568  }15691570  function renderSync() {1571    return (1572      <div class="peach-settings-body">1573        <div class="peach-settings-group">1574          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1575          <div1576            class="peach-settings-row peach-settings-row--meta"1577            style="align-items:center;gap:8px"1578            role="status"1579            aria-label={syncLightLabel()}1580            title={syncLightLabel()}1581          >1582            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1583            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1584              {syncFlash() ?? lastSyncLine()}1585            </span>1586          </div>1587          <Show when={pairedDevices().length > 0}>1588            <div class="peach-settings-row peach-settings-row--meta">1589              <span class="peach-settings-value" style="color:var(--peach-t2)">1590                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01591                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1592                  : ""}1593              </span>1594              <button1595                class="peach-btn peach-btn--subtle peach-btn--sm"1596                aria-label="Sync this vault now"1597                disabled={syncStatus() === "syncing"}1598                onClick={() => void syncNow()}1599              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1600            </div>1601          </Show>16021603          <p class="peach-settings-sub-label">ADD A DEVICE</p>1604          <button1605            class="peach-sync-pair-option"1606            onClick={() => props.onOpenSyncPairing?.("code")}1607          >1608            <span class="peach-sync-pair-option-icon" aria-hidden="true">1609              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1610                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1611                <path d="M9 15 L11 15" />1612              </svg>1613            </span>1614            <span class="peach-sync-pair-option-main">1615              <span class="peach-sync-pair-option-title">Show a code</span>1616              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1617            </span>1618            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1619          </button>1620          <Show when={!isPro()}>1621            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1622              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1623            </p>1624          </Show>16251626          <Show when={pairedDevices().length > 0}>1627            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1628            <For each={pairedDevices()}>1629              {(d) => (1630                <div class="peach-settings-row peach-settings-row--top">1631                  <div class="peach-settings-row-main">1632                    <span>{d.deviceName ?? "Unnamed device"}</span>1633                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1634                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1635                    </span>1636                  </div>1637                  <Show1638                    when={confirmUnpair() === d.deviceId}1639                    fallback={1640                      <button1641                        class="peach-btn peach-btn--subtle peach-btn--sm"1642                        onClick={() => setConfirmUnpair(d.deviceId)}1643                      >remove</button>1644                    }1645                  >1646                    {/* HOLE 4: revocation stops this device receiving future1647                        changes. It does NOT secure the vault against it — that1648                        device may already hold the recovery words, which are1649                        master-password-equivalent and restore the whole vault1650                        anywhere, forever, with no sync involved. Say so here,1651                        rather than leaving the user falsely reassured. */}1652                    <div class="peach-unpair-confirm">1653                      <p class="peach-unpair-confirm-note">1654                        Stops this device receiving future changes. It doesn't1655                        lock it out of what it already has — if it's lost or1656                        stolen, also change your master password and generate1657                        new recovery words.1658                      </p>1659                      <div style="display:flex;gap:6px;justify-content:flex-end">1660                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1661                        <button1662                          class="peach-btn peach-btn--sm"1663                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1664                          onClick={() => void unpairDevice(d.deviceId)}1665                        >remove</button>1666                      </div>1667                    </div>1668                  </Show>1669                </div>1670              )}1671            </For>1672          </Show>16731674          <Show when={pendingVouched().length > 0}>1675            <p class="peach-settings-sub-label">1676              PENDING DEVICES1677              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1678            </p>1679            <For each={pendingVouched()}>1680              {(dev) => (1681                <div class="peach-settings-row peach-settings-row--top">1682                  <div class="peach-settings-row-main">1683                    <span>{dev.deviceName}</span>1684                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1685                      Was added by another of your devices. Allow it to sync with this vault?1686                    </span>1687                  </div>1688                  <div style="display:flex;gap:6px;flex-shrink:0">1689                    <button1690                      class="peach-btn peach-btn--sm"1691                      disabled={vouchedBusy() === dev.deviceId}1692                      onClick={() => void confirmVouched(dev.deviceId)}1693                    >Allow</button>1694                    <button1695                      class="peach-btn peach-btn--subtle peach-btn--sm"1696                      disabled={vouchedBusy() === dev.deviceId}1697                      onClick={() => void rejectVouched(dev.deviceId)}1698                    >Reject</button>1699                  </div>1700                </div>1701              )}1702            </For>1703          </Show>17041705          <button1706            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1707            onClick={() => setShowSyncConnection((value) => !value)}1708            aria-expanded={showSyncConnection()}1709            aria-controls="peach-sync-connection"1710          >1711            <span>1712              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1713              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1714            </span>1715            <span class="peach-settings-advanced-chevron">1716              {showSyncConnection() ? "▲" : "▼"}1717            </span>1718          </button>1719          <Show when={showSyncConnection()}>1720            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1721              <SyncModeSelector />1722            </div>1723          </Show>17241725          <button1726            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1727            onClick={() => setShowSyncRecovery((value) => !value)}1728            aria-expanded={showSyncRecovery()}1729            aria-controls="peach-sync-recovery"1730          >1731            <span>1732              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1733              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1734            </span>1735            <span class="peach-settings-advanced-chevron">1736              {showSyncRecovery() ? "▲" : "▼"}1737            </span>1738          </button>1739          <Show when={showSyncRecovery()}>1740            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1741              <Show1742                when={rotateConfirmOpen()}1743                fallback={1744                  <div class="peach-settings-row peach-settings-row--top">1745                    <div class="peach-settings-row-main">1746                      <span>Rotate sync identity</span>1747                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1748                        Disconnects every device and replaces the keys used for sync.1749                      </span>1750                    </div>1751                    <button1752                      class="peach-btn peach-btn--subtle peach-btn--sm"1753                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1754                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1755                    >rotate</button>1756                  </div>1757                }1758              >1759                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1760                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1761                  <Show when={rotateError()}>1762                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1763                  </Show>1764                  <div style="display:flex;gap:6px;margin-top:10px">1765                    <button1766                      class="peach-btn peach-btn--subtle peach-btn--sm"1767                      disabled={rotating()}1768                      onClick={() => setRotateConfirmOpen(false)}1769                    >Cancel</button>1770                    <button1771                      class="peach-btn peach-btn--sm"1772                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1773                      disabled={rotating()}1774                      onClick={() => void doRotateSyncIdentity()}1775                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1776                  </div>1777                </div>1778              </Show>1779            </div>1780          </Show>1781        </div>1782      </div>1783    );1784  }17851786  function renderAppearance() {1787    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1788      { id: "medium", label: "Default" },1789      { id: "large", label: "Large" },1790    ];1791    return (1792      <div class="peach-settings-body">1793        <div class="peach-settings-group">1794          <p class="peach-settings-sub-label">THEME</p>1795          <div class="peach-settings-row peach-settings-row--meta">1796            <span>Theme</span>1797            <button1798              class="peach-btn peach-btn--subtle peach-btn--sm"1799              onClick={() =>1800                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1801              }1802            >1803              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1804            </button>1805          </div>18061807          <p class="peach-settings-sub-label">ACCENT</p>1808          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1809            <span>Accent</span>1810            <div class="peach-accent-picker">1811              <For each={ACCENT_COLORS}>1812                {(color) => (1813                  <button1814                    class="peach-accent-swatch peach-swatch"1815                    classList={{ "is-active": props.accentColor === color.name }}1816                    style={{ background: color.hex }}1817                    onClick={() => void props.onAccentChange(color.name)}1818                    aria-label={color.name}1819                    title={color.name}1820                  />1821                )}1822              </For>1823            </div>1824          </div>18251826          <p class="peach-settings-sub-label">TEXT SIZE</p>1827          <div class="peach-settings-row peach-settings-row--meta">1828            <span>Size</span>1829            <div class="peach-pill-group">1830              <For each={sizeOptions}>1831                {(opt) => (1832                  <button1833                    class="peach-btn peach-btn--ghost peach-btn--sm"1834                    classList={{ "is-active": props.textSize === opt.id }}1835                    onClick={() => void props.onTextSizeChange(opt.id)}1836                  >1837                    {opt.label}1838                  </button>1839                )}1840              </For>1841            </div>1842          </div>18431844          <p class="peach-settings-sub-label">MOTION</p>1845          <div class="peach-settings-row peach-settings-row--meta">1846            <span>Reduce motion</span>1847            <button1848              class="peach-btn peach-btn--subtle peach-btn--sm"1849              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1850            >1851              {props.reduceMotion ? "On" : "Off"}1852            </button>1853          </div>1854        </div>1855      </div>1856    );1857  }18581859  function renderShortcuts() {1860    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1861    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18621863    function startCapture(action: ShortcutAction) {1864      setConflictMsg(null);1865      setCapturingAction(action);1866    }1867    function cancelCapture() {1868      setCapturingAction(null);1869      setConflictMsg(null);1870    }1871    function commitBinding(action: ShortcutAction, binding: Keybinding) {1872      const conflict = findConflict(props.shortcuts, binding, action);1873      if (conflict) {1874        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1875        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1876        return;1877      }1878      const next = { ...props.shortcuts, [action]: binding };1879      setCapturingAction(null);1880      setConflictMsg(null);1881      void props.onShortcutsChange(next);1882    }1883    function resetAction(action: ShortcutAction) {1884      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1885      void props.onShortcutsChange(next);1886    }1887    function resetAll() {1888      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1889    }18901891    // Capture-mode key handler — wired via ref onto the chip element so it1892    // catches the next non-modifier keypress and ignores other bindings.1893    function handleCaptureKey(e: KeyboardEvent) {1894      e.preventDefault();1895      e.stopPropagation();1896      if (e.key === "Escape") { cancelCapture(); return; }1897      const binding = keybindingFromEvent(e);1898      if (!binding) return; // bare modifier — keep waiting1899      const action = capturingAction();1900      if (action) commitBinding(action, binding);1901    }19021903    return (1904      <div class="peach-settings-body">1905        <div class="peach-settings-group peach-shortcuts-group">1906          <p class="peach-settings-sub-label">ENTRY VIEW</p>1907          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1908            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1909          </For>1910        </div>19111912        <div class="peach-settings-group peach-shortcuts-group">1913          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1914          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1915            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1916          </For>1917        </div>19181919        <Show when={conflictMsg()}>1920          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1921        </Show>19221923        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1924          <div class="peach-settings-row peach-settings-row--meta">1925            <span>Inline autofill on page</span>1926            <a1927              class="peach-settings-link"1928              href="chrome://extensions/shortcuts"1929              onClick={(e) => {1930                e.preventDefault();1931                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1932              }}1933            >1934              ⌘⇧P — customize ↗1935            </a>1936          </div>1937          <button1938            class="peach-btn peach-btn--subtle peach-btn--sm"1939            style="align-self:flex-start"1940            onClick={resetAll}1941          >1942            Reset all to defaults1943          </button>1944        </div>1945      </div>1946    );19471948    function renderRow(1949      desc: typeof SHORTCUT_DESCRIPTORS[number],1950      capturing: () => ShortcutAction | null,1951      onStart: (a: ShortcutAction) => void,1952      onCancel: () => void,1953      onReset: (a: ShortcutAction) => void,1954      onKey: (e: KeyboardEvent) => void,1955    ) {1956      const isActive = () => capturing() === desc.action;1957      const binding = () => props.shortcuts[desc.action];1958      const isCustom = () => {1959        const b = binding();1960        const d = DEFAULT_BINDINGS[desc.action];1961        return b.key !== d.key1962          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1963          || Boolean(b.meta)  !== Boolean(d.meta)1964          || Boolean(b.shift) !== Boolean(d.shift)1965          || Boolean(b.alt)   !== Boolean(d.alt);1966      };1967      return (1968        <div class="peach-shortcut-row">1969          <span class="peach-shortcut-row-label">{desc.label}</span>1970          <button1971            class="peach-shortcut-chip"1972            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1973            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1974            onKeyDown={(e) => { if (isActive()) onKey(e); }}1975            ref={(el) => {1976              // Auto-focus when entering capture mode so the keydown listener catches the next press.1977              if (el) {1978                queueMicrotask(() => { if (isActive()) el.focus(); });1979              }1980            }}1981          >1982            {isActive() ? "Press a key…" : formatKeybinding(binding())}1983          </button>1984          <Show when={isCustom() && !isActive()}>1985            <button1986              class="peach-shortcut-reset"1987              title="Reset to default"1988              onClick={() => onReset(desc.action)}1989            >1990              ↺1991            </button>1992          </Show>1993        </div>1994      );1995    }1996  }19971998  const PRO_FEATURES: Array<{1999    feature: ProFeature;2000    name: string;2001    desc: string;2002  }> = [2003    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2004    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2005    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2006    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2007  ];20082009  const PRO_SECURITY_FEATURES: Array<{2010    feature: ProFeature;2011    name: string;2012    desc: string;2013  }> = [2014    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2015    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2016  ];20172018  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2019  const [showKey, setShowKey] = createSignal(false);20202021  const [deactivating, setDeactivating] = createSignal(false);20222023  async function handleDeactivateLicense() {2024    setDeactivating(true);2025    await sendMessage({ type: "DEACTIVATE_LICENSE" });2026    setLicenseInfo(null);2027    await props.onLicenseChange?.();2028    setDeactivating(false);2029  }20302031  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2032    setCanaryTestingId(entry.id);2033    setCanaryTestResult(null);2034    const res = await sendMessage({2035      type: "FIRE_CANARY_PING",2036      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2037    }) as { ok: boolean };2038    setCanaryTestResult({ id: entry.id, ok: res.ok });2039    setCanaryTestingId(null);2040    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2041  }20422043  function renderLicense() {2044    async function onActivate() {2045      await handleActivateLicense();2046      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2047        await props.onLicenseChange?.();2048      }2049    }20502051    return (2052      <div class="peach-settings-body">2053        <Show2054          when={isPro()}2055          fallback={2056                <div class="peach-pro-upgrade">2057                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20582059                  <h2 class="peach-pro-headline">2060                    Every<em>thing,</em><br />every<em>where.</em>2061                  </h2>20622063                  <p class="peach-pro-subtitle">2064                    One key unlocks every Pro feature — for the lifetime of the app.2065                  </p>20662067                  <div class="peach-pro-grid">2068                    <For each={PRO_FEATURES}>2069                      {(item, i) => (2070                        <div2071                          class="peach-pro-grid-item"2072                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2073                        >2074                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2075                          <span class="peach-pro-grid-name">{item.name}</span>2076                          <span class="peach-pro-grid-desc">{item.desc}</span>2077                        </div>2078                      )}2079                    </For>2080                  </div>20812082                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2083                  <div class="peach-pro-grid">2084                    <For each={PRO_SECURITY_FEATURES}>2085                      {(item, i) => (2086                        <div2087                          class="peach-pro-grid-item"2088                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2089                        >2090                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2091                          <span class="peach-pro-grid-name">{item.name}</span>2092                          <span class="peach-pro-grid-desc">{item.desc}</span>2093                        </div>2094                      )}2095                    </For>2096                  </div>20972098                  <div class="peach-pro-upgrade-actions">2099                    <p class="peach-pro-pricing">2100                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2101                    </p>2102                    <a2103                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2104                      target="_blank"2105                      rel="noopener noreferrer"2106                      class="peach-pro-upgrade-cta"2107                    >2108                      Get Peach Pro2109                    </a>2110                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2111                    <div class="peach-pro-key-inline">2112                      <input2113                        class="peach-pro-key-inline-input"2114                        type="text"2115                        placeholder="PEACH-XXXX-XXXX-XXXX"2116                        value={licenseKeyInput()}2117                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2118                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2119                        autocomplete="off"2120                        spellcheck={false}2121                      />2122                      <button2123                        class="peach-pro-key-apply"2124                        onClick={() => void onActivate()}2125                        disabled={activatingLicense() || !licenseKeyInput().trim()}2126                      >2127                        {activatingLicense() ? "…" : "APPLY"}2128                      </button>2129                    </div>2130                    <Show when={licenseError()}>2131                      <p class="peach-pro-key-error">{licenseError()}</p>2132                    </Show>2133                  </div>2134                </div>2135          }2136        >2137          <div class="peach-pro-active">2138            <div class="peach-pro-active-hero">2139              <button2140                class="peach-pro-active-mark-wrap"2141                type="button"2142                aria-label="Celebrate"2143                onClick={handleProCelebrationClick}2144              >2145                <PeachMark class="peach-pro-active-mark" />2146                <Show when={justActivated()}>2147                  <div class="peach-pro-sparks" aria-hidden="true">2148                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2149                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2150                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2151                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2152                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2153                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2154                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2155                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2156                  </div>2157                </Show>2158              </button>2159              <h2 class="peach-pro-active-title">Peach Pro</h2>2160              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2161              <Show when={justActivated()}>2162                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2163              </Show>2164            </div>21652166            <Show when={licenseKey()}>2167              <div class="peach-settings-license">2168                <span class="peach-settings-label">LICENSE KEY</span>2169                <div class="peach-settings-license-row">2170                  <Show2171                    when={showKey()}2172                    fallback={2173                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2174                        ••••••••••••••••••••••••••••2175                      </span>2176                    }2177                  >2178                    <span class="peach-settings-license-key">{licenseKey()!}</span>2179                  </Show>2180                  <div class="peach-settings-license-actions">2181                    <button2182                      class="peach-settings-license-copy"2183                      title="Copy license key"2184                      onClick={() => copyLicenseKey(licenseKey()!)}2185                    >2186                      {copied() ? "✓" : "copy"}2187                    </button>2188                    <button2189                      class="peach-settings-license-toggle"2190                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2191                      title={showKey() ? "Hide" : "Reveal"}2192                      onClick={() => setShowKey(v => !v)}2193                    >2194                      <Show2195                        when={showKey()}2196                        fallback={2197                          <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">2198                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2199                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2200                          </svg>2201                        }2202                      >2203                        <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">2204                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2205                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2206                          <line x1="2" y1="2" x2="12" y2="12" />2207                        </svg>2208                      </Show>2209                    </button>2210                  </div>2211                </div>2212              </div>2213            </Show>22142215            <div class="peach-pro-active-features">2216              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2217                {(item) => (2218                  <div class="peach-pro-active-feature">2219                    <span class="peach-pro-active-feature-mark">✦</span>2220                    <span class="peach-pro-active-feature-name">{item.name}</span>2221                  </div>2222                )}2223              </For>2224            </div>22252226            <button2227              class="peach-pro-deactivate"2228              onClick={() => void handleDeactivateLicense()}2229              disabled={deactivating()}2230            >2231              {deactivating() ? "Deactivating…" : "Deactivate"}2232            </button>2233          </div>2234        </Show>2235      </div>2236    );2237  }22382239  function renderFeedback() {2240    return (2241      <div class="peach-settings-body">2242        <div class="peach-settings-group peach-feedback-group">2243          <Show2244            when={feedbackState() === 'sent'}2245            fallback={2246              <>2247                <p class="peach-feedback-intro">2248                  Help us improve Peach. Every report goes directly to our inbox.2249                </p>22502251                <p class="peach-settings-sub-label">TYPE</p>2252                <div class="peach-feedback-chips">2253                  <For each={FEEDBACK_CATEGORIES}>2254                    {(cat) => {2255                      const selected = () => fbCategory() === cat;2256                      return (2257                        <button2258                          type="button"2259                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2260                          aria-pressed={selected()}2261                          onClick={() => setFbCategory(cat)}2262                        >2263                          {cat}2264                        </button>2265                      );2266                    }}2267                  </For>2268                </div>22692270                <p class="peach-settings-sub-label">MESSAGE</p>2271                <textarea2272                  class="peach-feedback-textarea"2273                  placeholder="Describe the issue or idea..."2274                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2275                  value={fbMessage()}2276                  onInput={(e) => setFbMessage(e.currentTarget.value)}2277                />22782279                <p class="peach-feedback-meta">2280                  Version {version} · will be included automatically2281                </p>22822283                <button2284                  type="button"2285                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2286                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2287                  onClick={() => void handleSubmitFeedback()}2288                >2289                  <Switch>2290                    <Match when={feedbackState() === 'idle'}>Send</Match>2291                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2292                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2293                  </Switch>2294                </button>22952296                <p class="peach-feedback-anon">2297                  Sent anonymously · no account required2298                </p>2299              </>2300            }2301          >2302            <div class="peach-feedback-thanks" role="status" aria-live="polite">2303              <p class="peach-feedback-thanks-title">Thank you</p>2304              <p>2305                Your note is in the queue. Someone will process it soon.2306              </p>2307            </div>2308          </Show>2309        </div>2310      </div>2311    );2312  }23132314  const bodies: StackBodies = {2315    protection: renderSecurity(),2316    vault: renderVault(),2317    sync: renderSync(),2318    appearance: renderAppearance(),2319    shortcuts: renderShortcuts(),2320    pro: renderLicense(),2321    feedback: renderFeedback(),2322  };23232324  return (2325    <Show2326      when={showNewRecoveryWords()}2327      fallback={2328        <div2329          class="peach-overlay-view peach-settings-overlay"2330          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2331          style="display:flex;flex-direction:column"2332        >2333          <StackDeck2334            active={activeCard()}2335            onActiveChange={setActiveCard}2336            onBack={props.onBack}2337            cards={cards()}2338            bodies={bodies}2339            version={version}2340            isFanning={isFanning()}2341            celebratingCardId={justActivated() ? "pro" : null}2342            isPro={isPro()}2343          />2344        </div>2345      }2346    >2347      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2348    </Show>2349  );2350}