Settings.tsx

101.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11  AccentColorName,12  PasskeyProviderState,13  ProFeature,14  VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18  describeSyncOutcome,19  syncIndicator,20  syncIndicatorLabel,21  type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24  DEFAULT_BINDINGS,25  findConflict,26  formatKeybinding,27  keybindingFromEvent,28  SHORTCUT_DESCRIPTORS,29  type Keybinding,30  type ShortcutAction,31} from "../../shared/shortcuts";32import { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36  valid: boolean;37  plan: "free" | "pro";38  issued?: string;39}4041interface SettingsProps {42  entriesCount: number;43  breachMonitoring: boolean;44  lockOnClose: boolean;45  autoLockMinutes: number;46  trashRetentionDays: number;47  theme: "dark" | "light";48  accentColor: AccentColorName;49  textSize: "small" | "medium" | "large";50  reduceMotion: boolean;51  isPro?: boolean;52  initialSection?: string | null;53  upgradeHighlight?: ProFeature | null;54  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57  onTrashRetentionChange: (days: number) => void | Promise<void>;58  onThemeChange: (theme: "dark" | "light") => void;59  onAccentChange: (name: AccentColorName) => void | Promise<void>;60  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62  shortcuts: Record<ShortcutAction, Keybinding>;63  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64  onLicenseChange?: () => void | Promise<void>;65  isExiting?: boolean;66  onBack: () => void;67  onImport: () => void;68  onExport: () => void;69  onBreachCheckDone: () => void;70  onOpenHealth?: () => void;71  onOpenSyncPairing?: (method?: "code") => void;72  onOpenFileImport?: () => void;73  onOpenPanic?: () => void;74  onOpenCodexGenerate?: () => void;75  onOpenCodexRestore?: () => void;76  /** Invoked from within Settings when a Pro-gated control is tapped on77   *  free tier. App owns the upgrade navigation + highlight state, so this78   *  callback is the single way for Settings to request that navigation. */79  onProGate?: (feature: ProFeature) => void;80  onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;85const PRO_CELEBRATION_CARD_STEP_MS = 150;86const PRO_CELEBRATION_THANK_YOU_MS = 3200;8788function sanitizeFeedbackMessage(value: string): string {89  return value90    .trim()91    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)92    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text93    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")94    .replace(/&/g, "&amp;")95    .replace(/</g, "&lt;")96    .replace(/>/g, "&gt;")97    .replace(/"/g, "&quot;")98    .replace(/'/g, "&#39;");99}100101function formatActivatedSince(issued: string | undefined): string {102  if (!issued) return "Active";103  try {104    const d = new Date(issued);105    if (Number.isNaN(d.getTime())) return "Active";106    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;107  } catch {108    return "Active";109  }110}111112function initialSectionToCard(value: string | null | undefined): CardId | null {113  if (!value) return null;114  if (value === "security") return "protection";115  if (value === "license") return "pro";116  if (117    value === "protection" ||118    value === "vault" ||119    value === "sync" ||120    value === "appearance" ||121    value === "shortcuts" ||122    value === "pro" ||123    value === "feedback"124  ) {125    return value;126  }127  return null;128}129130export function SettingsView(props: SettingsProps) {131  const manifest = chrome.runtime.getManifest();132  const version = manifest.version;133  const initial = props.initialSection;134  const [activeCard, setActiveCard] = createSignal<CardId>(135    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"136  );137138  createEffect(() => { props.onCardChange?.(activeCard()); });139140  const [justActivated, setJustActivated] = createSignal(false);141  const [isFanning, setIsFanning] = createSignal(false);142143  function triggerCelebration() {144    if (isFanning()) return;145    setJustActivated(false);146    setIsFanning(true);147    setActiveCard("pro");148    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];149    fanOrder.forEach((id, i) => {150      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);151    });152    window.setTimeout(() => {153      setActiveCard("pro");154      setIsFanning(false);155      setJustActivated(true);156      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);157    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);158  }159160  function handleProCelebrationClick(e: MouseEvent) {161    e.stopPropagation();162    triggerCelebration();163  }164165  const [checkingBreaches, setCheckingBreaches] = createSignal(false);166  const [breachResult, setBreachResult] = createSignal<string | null>(null);167  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);168  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");169  const [licenseError, setLicenseError] = createSignal<string | null>(null);170  const [, setLicenseSuccess] = createSignal<string | null>(null);171  const [activatingLicense, setActivatingLicense] = createSignal(false);172173  const isPro = () => props.isPro ?? false;174  const cards = createMemo<StackCardSpec[]>(() => [175    {176      id: "pro",177      num: "I · FOREVER",178      title: "Peach Pro",179      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",180      bgVar: "--stack-pro-bg",181      fgVar: "--stack-pro-fg",182      icon: "spark",183      onIconClick: isPro() ? triggerCelebration : undefined,184    },185    {186      id: "protection",187      num: "II · PROTECT",188      title: "Protection",189      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,190      bgVar: "--stack-protect-bg",191      fgVar: "--stack-protect-fg",192      icon: "shield",193    },194    {195      id: "vault",196      num: "III · KEEP",197      title: "Vault",198      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,199      bgVar: "--stack-vault-bg",200      fgVar: "--stack-vault-fg",201      icon: "lock",202    },203    {204      id: "sync",205      num: "IV · ECHO",206      title: "Sync",207      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",208      bgVar: "--stack-sync-bg",209      fgVar: "--stack-sync-fg",210      icon: "sync",211    },212    {213      id: "appearance",214      num: "V · DRESS",215      title: "Appearance",216      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,217      bgVar: "--stack-appear-bg",218      fgVar: "--stack-appear-fg",219      icon: "sun",220    },221    {222      id: "shortcuts",223      num: "VI · KEYS",224      title: "Shortcuts",225      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,226      bgVar: "--stack-shortcut-bg",227      fgVar: "--stack-shortcut-fg",228      icon: "keys",229    },230    {231      id: "feedback",232      num: "VII · ECHO",233      title: "Feedback",234      lede: `version ${version} · tell us what would make it better`,235      bgVar: "--stack-feedback-bg",236      fgVar: "--stack-feedback-fg",237      icon: "bubble",238    },239  ]);240  const [biometricSupported, setBiometricSupported] = createSignal(false);241  const [biometricStored, setBiometricStored] = createSignal(false);242  const [biometricBusy, setBiometricBusy] = createSignal(false);243  const [biometricError, setBiometricError] = createSignal<string | null>(null);244  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);245  const [passkeySaving, setPasskeySaving] = createSignal(false);246247  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting248  // explicit confirmation before they become sync targets.249  interface PendingVouched { deviceId: string; deviceName: string }250  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);251  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);252  // Paired-device management lives here — Settings is the sync hub.253  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);254  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);255  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);256  const [renameDraft, setRenameDraft] = createSignal("");257  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");258  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);259  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —260  // a transient toast is no use for diagnosing a phone that isn't syncing.261  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});262  const syncLight = () =>263    syncStatus() === "syncing"264      ? "busy"265      : syncIndicator(syncSummary(), pairedDevices().length);266  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());267  const lastSyncLine = () => {268    const s = syncSummary();269    if (!s.outcome) return "No sync attempted yet";270    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;271  };272  async function loadSyncState() {273    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {274      ok: boolean;275      data?: SyncOutcomeSummary;276    };277    if (r.ok && r.data) {278      setSyncSummary(r.data);279      setSyncStatus(r.data.status ?? "idle");280    }281  }282283  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;284  function syncRelativeTime(when: number | undefined): string {285    if (!when) return "Not synced yet";286    const elapsed = Date.now() - when;287    if (elapsed < 60_000) return "Just now";288    const minutes = Math.floor(elapsed / 60_000);289    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;290    const hours = Math.floor(minutes / 60);291    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;292    const days = Math.floor(hours / 24);293    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";294  }295  function isSyncStale(d: PairedDevice): boolean {296    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;297  }298  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {299    switch (t) {300      case "android": return "Android";301      case "ios": return "iPhone";302      case "extension": return "Browser";303      default: return "Device";304    }305  }306307  async function loadPairedDevices() {308    if (!isPro()) return;309    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {310      ok: boolean;311      data?: { devices: PairedDevice[] };312    };313    if (r.ok) setPairedDevices(r.data?.devices ?? []);314  }315  async function unpairDevice(deviceId: string) {316    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });317    setConfirmUnpair(null);318    await loadPairedDevices();319  }320  async function renameDevice(deviceId: string) {321    const name = renameDraft().trim();322    if (!name) return;323    const result = (await sendMessage({324      type: "RENAME_PAIRED_DEVICE",325      payload: { deviceId, name },326    })) as { ok: boolean };327    if (!result.ok) return;328    setRenameDeviceId(null);329    await loadPairedDevices();330  }331  async function syncNow() {332    setSyncStatus("syncing");333    setSyncFlash(null);334    const res = (await sendMessage({335      type: "TRIGGER_SYNC",336      payload: { reason: "manual", force: true },337    })) as { ok: boolean; error?: string };338    if (!res.ok) {339      setSyncStatus("error");340      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });341      return;342    }343    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the344    // outcome. A single delayed read used to land while the run was still345    // "syncing" and leave the button disabled forever.346    let s: SyncOutcomeSummary = { status: "syncing" };347    for (let i = 0; i < 40 && s.status === "syncing"; i++) {348      await new Promise((r) => setTimeout(r, 250));349      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {350        ok: boolean;351        data?: SyncOutcomeSummary;352      };353      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };354    }355    setSyncSummary(s);356    setSyncStatus(s.status ?? "idle");357    // The persistent line below carries the outcome; the flash is only the358    // "that click did something" acknowledgement.359    setSyncFlash(describeSyncOutcome(s));360    setTimeout(() => setSyncFlash(null), 4000);361    await loadPairedDevices();362  }363  // UI-4 / MV-8: rotate sync identity confirmation dialog.364  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);365  const [rotating, setRotating] = createSignal(false);366  const [rotateError, setRotateError] = createSignal<string | null>(null);367  const [showSyncConnection, setShowSyncConnection] = createSignal(false);368  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);369370  async function loadPendingVouched() {371    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {372      ok: boolean;373      data?: PendingVouched[];374    };375    if (r.ok) setPendingVouched(r.data ?? []);376  }377  async function confirmVouched(deviceId: string) {378    setVouchedBusy(deviceId);379    try {380      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });381      await loadPendingVouched();382    } finally {383      setVouchedBusy(null);384    }385  }386  async function rejectVouched(deviceId: string) {387    setVouchedBusy(deviceId);388    try {389      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });390      await loadPendingVouched();391    } finally {392      setVouchedBusy(null);393    }394  }395  async function doRotateSyncIdentity() {396    setRotateError(null);397    setRotating(true);398    try {399      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {400        ok: boolean;401        error?: string;402      };403      if (!r.ok) {404        setRotateError(r.error ?? "Rotation failed.");405        return;406      }407      setRotateConfirmOpen(false);408      // Backend set peach_recovery_pending; reuse the existing recovery-words409      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).410      setShowNewRecoveryWords(true);411    } finally {412      setRotating(false);413    }414  }415416  const [dedupCount, setDedupCount] = createSignal<number | null>(null);417  const [dedupRunning, setDedupRunning] = createSignal(false);418  const [dedupDone, setDedupDone] = createSignal<number | null>(null);419420  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);421422  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted423  // BEFORE every real-mode vault write so the user can roll back if a sync424  // or import overwrites the wrong state.425  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);426  const [historyLoading, setHistoryLoading] = createSignal(false);427  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);428  const [historyShowAll, setHistoryShowAll] = createSignal(false);429430  async function loadVaultHistory() {431    setHistoryLoading(true);432    try {433      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });434      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];435      setHistorySnapshots(list);436    } finally {437      setHistoryLoading(false);438    }439  }440441  async function handleRestoreVaultSnapshot(id: string) {442    if (historyRestoring()) return;443    setHistoryRestoring(id);444    const res = await sendMessage({445      type: "RESTORE_VAULT_SNAPSHOT",446      payload: { snapshotId: id },447    });448    setHistoryRestoring(null);449    if ((res as { ok: boolean }).ok) {450      // Vault is now locked — let the parent close Settings; App.tsx's451      // VAULT_LOCKED listener will route back to the lock screen.452      props.onBack();453    }454  }455456  function formatSnapshotTime(savedAt: number): string {457    const diff = Date.now() - savedAt;458    if (diff < 60_000) return "Just now";459    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;460    const d = new Date(savedAt);461    const isToday = new Date().toDateString() === d.toDateString();462    const isYest =463      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();464    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });465    if (isToday) return `Today ${t}`;466    if (isYest) return `Yesterday ${t}`;467    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;468  }469470  function reasonLabel(reason: VaultSnapshot["reason"]): string {471    return (472      {473        local_save: "saved",474        sync_received: "synced",475        import: "imported",476        restore: "restored",477      } as const478    )[reason] ?? reason;479  }480481  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);482483  const [showChangePw, setShowChangePw] = createSignal(false);484  const [changePwCurrent, setChangePwCurrent] = createSignal("");485  const [changePwNew, setChangePwNew] = createSignal("");486  const [changePwConfirm, setChangePwConfirm] = createSignal("");487  const [changePwSaving, setChangePwSaving] = createSignal(false);488  const [changePwError, setChangePwError] = createSignal<string | null>(null);489  const [changePwSuccess] = createSignal(false);490  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);491492  const [pinEnabled, setPinEnabled] = createSignal(false);493  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);494  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");495  const [pinValue, setPinValue] = createSignal("");496  const [pinConfirm, setPinConfirm] = createSignal("");497  const [pinSaving, setPinSaving] = createSignal(false);498  const [pinError, setPinError] = createSignal<string | null>(null);499500  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.501  const [showViewWords, setShowViewWords] = createSignal(false);502  const [viewWordsPassword, setViewWordsPassword] = createSignal("");503  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);504  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);505  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);506507  async function handleViewRecoveryWords() {508    setViewWordsError(null);509    if (!viewWordsPassword()) {510      setViewWordsError("Enter your master password.");511      return;512    }513    setViewWordsBusy(true);514    try {515      const r = (await sendMessage({516        type: "GET_RECOVERY_WORDS",517        payload: { password: viewWordsPassword() },518      })) as { ok: boolean; data?: string[]; error?: string };519      if (r.ok) {520        setViewedWords(r.data ?? []);521        setViewWordsPassword("");522        return;523      }524      switch (r.error) {525        case "wrong_password":526          setViewWordsError("Incorrect password");527          break;528        case "password_required":529          setViewWordsError("Enter your master password.");530          break;531        case "recovery_not_setup":532          setViewWordsError("Recovery words aren't set up for this vault yet.");533          break;534        default:535          setViewWordsError("Couldn't retrieve recovery words.");536      }537    } finally {538      setViewWordsBusy(false);539    }540  }541  function closeViewWords() {542    setShowViewWords(false);543    setViewWordsPassword("");544    setViewWordsError(null);545    setViewedWords(null);546  }547548  const [duressEnabled, setDuressEnabled] = createSignal(false);549  const [showDuressSetup, setShowDuressSetup] = createSignal(false);550  const [duressPassword, setDuressPassword] = createSignal("");551  const [duressConfirm, setDuressConfirm] = createSignal("");552  const [duressMainPassword, setDuressMainPassword] = createSignal("");553  const [duressSaving, setDuressSaving] = createSignal(false);554  const [duressError, setDuressError] = createSignal<string | null>(null);555  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);556557  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);558559  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);560  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);561562  const [fbCategory, setFbCategory] = createSignal("Bug report");563  const [fbMessage, setFbMessage] = createSignal("");564  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';565  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');566567  createEffect(() => {568    if (props.upgradeHighlight) {569      setActiveCard("pro");570    }571  });572573  onMount(async () => {574    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([575      sendMessage({ type: "GET_LICENSE" }),576      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),577      sendMessage({ type: "IS_DURESS_ENABLED" }),578      sendMessage({ type: "GET_SETTINGS" }),579      sendMessage({ type: "GET_LICENSE_KEY" }),580    ]);581582    // Vault history loads in the background — the Vault sub-view picks it up583    // when the user navigates in. Loading it lazily keeps Settings boot fast.584    void loadVaultHistory();585586    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the587    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.588    void loadPendingVouched();589    void loadPairedDevices();590    void loadSyncState();591    const onBroadcast = (msg: unknown) => {592      const m = msg as { type?: string; payload?: unknown } | null;593      if (m?.type === "VOUCHED_DEVICE_PENDING") {594        void loadPendingVouched();595      }596      if (m?.type === "SYNC_STATE_CHANGED") {597        const payload = (m.payload ?? {}) as SyncOutcomeSummary;598        setSyncSummary(payload);599        setSyncStatus(payload.status ?? "idle");600      }601    };602    chrome.runtime?.onMessage?.addListener(onBroadcast);603    onCleanup(() => {604      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }605    });606607    if ((licenseRes as { ok: boolean }).ok) {608      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);609    }610611    if ((passkeyRes as { ok: boolean }).ok) {612      setPasskeyState(613        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null614      );615    }616617    if ((duressRes as { ok: boolean }).ok) {618      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);619    }620621    if ((settingsRes as { ok: boolean }).ok) {622      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;623      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);624      setPinEnabled(sData?.pinEnabled === true);625    }626627    if ((keyRes as { ok: boolean }).ok) {628      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);629    }630631    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });632    if ((entriesRes as { ok: boolean }).ok) {633      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];634      setCanaryEntries(635        entries636          .filter((e) => e.canary?.webhookUrl)637          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))638      );639    }640641642    if (isNativePlatform()) {643      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");644      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");645      try {646        if (biometric) {647          const a = await biometric.isAvailable();648          setBiometricSupported(a.available);649        }650        if (keystore) {651          const k = await keystore.hasMasterKey();652          setBiometricStored(k.exists);653        }654      } catch { /* missing plugins — leave defaults */ }655    }656  });657658  async function handleEnableBiometrics() {659    setBiometricBusy(true);660    setBiometricError(null);661    try {662      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {663        ok: boolean;664        data?: { masterKeyHex: string };665        error?: string;666      };667      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {668        setBiometricError(keyRes.error === "vault_locked"669          ? "unlock the vault first"670          : "couldn't read derived key");671        return;672      }673      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");674      if (!keystore) {675        setBiometricError("biometric plugin unavailable");676        return;677      }678      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });679      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });680      setBiometricStored(true);681    } catch (e) {682      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");683    } finally {684      setBiometricBusy(false);685    }686  }687688  async function handleDisableBiometrics() {689    setBiometricBusy(true);690    setBiometricError(null);691    try {692      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");693      if (keystore) await keystore.clearMasterKey();694      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });695      setBiometricStored(false);696    } catch (e) {697      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");698    } finally {699      setBiometricBusy(false);700    }701  }702703  function resetPinFlow() {704    setPinFlow(null);705    setPinCurrentPassword("");706    setPinValue("");707    setPinConfirm("");708    setPinError(null);709  }710711  async function handleSetupPin() {712    setPinError(null);713    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }714    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }715    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }716    setPinSaving(true);717    const res = await sendMessage({718      type: "SETUP_PIN",719      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },720    }) as { ok: boolean; error?: string };721    if (res.ok) {722      setPinEnabled(true);723      resetPinFlow();724    } else {725      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");726    }727    setPinSaving(false);728  }729730  async function handleDisablePin() {731    setPinError(null);732    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }733    setPinSaving(true);734    const res = await sendMessage({735      type: "DISABLE_PIN",736      payload: { currentPassword: pinCurrentPassword() },737    }) as { ok: boolean; error?: string };738    if (res.ok) {739      setPinEnabled(false);740      resetPinFlow();741    } else {742      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");743    }744    setPinSaving(false);745  }746747  async function handleChangePassword() {748    setChangePwError(null);749    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }750    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }751    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }752    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }753    setChangePwSaving(true);754    const res = await sendMessage({755      type: "CHANGE_PASSWORD",756      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },757    }) as { ok: boolean; error?: string };758    if (res.ok) {759      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");760      setShowChangePw(false);761      // LV-4: the user just authenticated (current password), so the recovery762      // words reveal that follows is exempt from the re-auth gate. Set the763      // pending flag the same way the create/recovery flow does; the display764      // clears it on confirm.765      await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });766      setShowNewRecoveryWords(true);767    } else {768      setChangePwError(res.error ?? "failed to change password");769    }770    setChangePwSaving(false);771  }772773  const [copied, setCopied] = createSignal(false);774775  async function copyLicenseKey(key: string) {776    await navigator.clipboard.writeText(key);777    setCopied(true);778    setTimeout(() => setCopied(false), 2000);779  }780781  async function handleActivateLicense() {782    const key = licenseKeyInput().trim();783    if (!key) return;784    setActivatingLicense(true);785    setLicenseError(null);786    setLicenseSuccess(null);787    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });788    setActivatingLicense(false);789    if ((res as { ok: boolean }).ok) {790      const data = (res as { ok: boolean; data?: LicenseInfo }).data;791      if (data?.valid) {792        setLicenseInfo(data);793        setLicenseKey(key);794        setLicenseSuccess("License activated!");795        setLicenseKeyInput("");796      } else {797        setLicenseError("Invalid license key");798      }799    } else {800      setLicenseError("Activation failed");801    }802  }803804  async function handleTogglePasskeys() {805    const current = passkeyState();806    if (!current || !current.supported) return;807    setPasskeySaving(true);808    const res = await sendMessage({809      type: "SET_PASSKEY_PROVIDER_ENABLED",810      payload: { enabled: !current.enabled },811    });812    setPasskeySaving(false);813    if ((res as { ok: boolean }).ok) {814      setPasskeyState(815        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null816      );817    }818  }819820  async function handleCheckAllBreaches() {821    setCheckingBreaches(true);822    setBreachResult(null);823    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });824    setCheckingBreaches(false);825    if ((res as { ok: boolean }).ok) {826      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};827      const breached = Object.values(data).filter((count) => count > 0).length;828      setBreachResult(829        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"830      );831      props.onBreachCheckDone();832    }833  }834835836  async function handleSetupDuress() {837    const mainPw = duressMainPassword().trim();838    const duressPw = duressPassword().trim();839    const confirmPw = duressConfirm().trim();840841    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }842    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }843    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }844    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }845846    setDuressSaving(true);847    setDuressError(null);848849    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };850    if (!verifyRes.ok || !verifyRes.data) {851      setDuressError("incorrect main password");852      setDuressSaving(false);853      return;854    }855856    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };857    setDuressSaving(false);858    if (res.ok) {859      setDuressEnabled(true);860      setShowDuressSetup(false);861      setDuressPassword("");862      setDuressConfirm("");863      setDuressMainPassword("");864      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");865      setTimeout(() => setDuressSuccess(null), 5000);866    } else {867      setDuressError(868        res.error === "session_expired"869          ? "Vault session expired — lock and unlock Peach, then try again"870          : res.error ?? "failed to set duress password"871      );872    }873  }874875  async function handleClearDeclinedCards() {876    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });877    setDeclinedCardCount(0);878  }879880  async function handleCountDuplicates() {881    setDedupRunning(true);882    setDedupDone(null);883    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };884    setDedupRunning(false);885    if (res.ok) setDedupCount(res.data ?? 0);886  }887888  async function handleDeduplicate() {889    setDedupRunning(true);890    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };891    setDedupRunning(false);892    if (res.ok) {893      setDedupDone(res.data ?? 0);894      setDedupCount(0);895    }896  }897898  async function handleDisableDuress() {899    const mainPw = duressMainPassword().trim();900    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }901902    setDuressSaving(true);903    setDuressError(null);904905    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };906    if (!verifyRes.ok || !verifyRes.data) {907      setDuressError("incorrect main password");908      setDuressSaving(false);909      return;910    }911912    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };913    setDuressSaving(false);914    if (res.ok) {915      setDuressEnabled(false);916      setShowDuressSetup(false);917      setDuressMainPassword("");918      setDuressSuccess("Duress password disabled.");919      setTimeout(() => setDuressSuccess(null), 4000);920    } else {921      setDuressError(res.error ?? "failed to disable duress password");922    }923  }924925  async function handleSubmitFeedback() {926    const message = sanitizeFeedbackMessage(fbMessage());927    if (!message) return;928929    setFeedbackState('sending');930931    try {932      const category = FEEDBACK_CATEGORIES.includes(fbCategory())933        ? fbCategory()934        : FEEDBACK_CATEGORIES[0];935      const res = await fetch('https://api.web3forms.com/submit', {936        method: 'POST',937        headers: { 'Content-Type': 'application/json' },938        body: JSON.stringify({939          access_key: WEB3FORMS_KEY,940          subject: `Peach Feedback - ${category}`,941          message,942          from_name: 'Peach User',943          // Honeypot — leave blank944          botcheck: '',945        }),946      });947948      if (!res.ok) throw new Error(`HTTP ${res.status}`);949      const data = await res.json() as { success: boolean; message?: string };950      if (!data.success) throw new Error(data.message ?? 'unknown error');951952      setFeedbackState('sent');953      setFbMessage('');954      setFbCategory(FEEDBACK_CATEGORIES[0]);955    } catch (err) {956      console.error('[feedback]', err);957      setFeedbackState('error');958      setTimeout(() => setFeedbackState('idle'), 4000);959    }960  }961962  function renderSecurity() {963    return (964      <div class="peach-settings-body">965        <div class="peach-settings-group">966          <p class="peach-settings-sub-label">LOCKING</p>967          <div class="peach-settings-row">968            <span>Lock on close</span>969            <button970              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}971              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}972              aria-label="Toggle lock on close"973            >974              <span class="peach-toggle-knob"></span>975            </button>976          </div>977          <div class="peach-settings-row">978            <span>Auto-lock after</span>979            <select980              class="peach-settings-select"981              value={String(props.autoLockMinutes)}982              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}983            >984              <option value="1">1 minute</option>985              <option value="5">5 minutes</option>986              <option value="15">15 minutes</option>987              <option value="30">30 minutes</option>988              <option value="0">Never</option>989            </select>990          </div>991992          <p class="peach-settings-sub-label">QUICK UNLOCK</p>993          <div class="peach-settings-row peach-settings-row--meta">994            <span>PIN unlock</span>995            <button996              class="peach-btn peach-btn--subtle peach-btn--sm"997              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}998            >999              {pinEnabled() ? "remove" : "set up"}1000            </button>1001          </div>1002          <Show when={pinFlow() === "setup"}>1003            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1004              <input1005                class="peach-settings-input"1006                type="password"1007                placeholder="current password"1008                value={pinCurrentPassword()}1009                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1010                autocomplete="current-password"1011              />1012              <input1013                class="peach-settings-input"1014                type="password"1015                inputmode="numeric"1016                maxlength="12"1017                placeholder="new 4–12 digit PIN"1018                value={pinValue()}1019                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1020                autocomplete="off"1021              />1022              <input1023                class="peach-settings-input"1024                type="password"1025                inputmode="numeric"1026                maxlength="12"1027                placeholder="confirm PIN"1028                value={pinConfirm()}1029                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1030                autocomplete="off"1031                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1032              />1033              <Show when={pinError()}>1034                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1035              </Show>1036              <button1037                class="peach-btn peach-btn--accent peach-btn--sm"1038                style="align-self:flex-start"1039                onClick={() => void handleSetupPin()}1040                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1041              >1042                {pinSaving() ? "saving…" : "save PIN"}1043              </button>1044            </div>1045          </Show>1046          <Show when={pinFlow() === "disable"}>1047            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1048              <input1049                class="peach-settings-input"1050                type="password"1051                placeholder="current password"1052                value={pinCurrentPassword()}1053                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1054                autocomplete="current-password"1055                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1056              />1057              <Show when={pinError()}>1058                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1059              </Show>1060              <button1061                class="peach-btn peach-btn--subtle peach-btn--sm"1062                style="align-self:flex-start"1063                onClick={() => void handleDisablePin()}1064                disabled={pinSaving() || !pinCurrentPassword()}1065              >1066                {pinSaving() ? "removing…" : "remove PIN"}1067              </button>1068            </div>1069          </Show>10701071          <Show when={isNativePlatform() && biometricSupported()}>1072            <p class="peach-settings-sub-label">BIOMETRICS</p>1073            <div class="peach-settings-row">1074              <span>Unlock with biometrics</span>1075              <button1076                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}1077                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}1078                disabled={biometricBusy()}1079                aria-label="Toggle biometric unlock"1080              >1081                <span class="peach-toggle-knob"></span>1082              </button>1083            </div>1084            <Show when={biometricError()}>1085              <div class="peach-settings-row peach-settings-row--meta">1086                <span class="peach-hint peach-hint-error">{biometricError()}</span>1087              </div>1088            </Show>1089          </Show>10901091          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1092          <div class="peach-settings-row">1093            <span>Breach monitoring</span>1094            <button1095              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1096              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1097              aria-label="Toggle breach monitoring"1098            >1099              <span class="peach-toggle-knob"></span>1100            </button>1101          </div>1102          <div class="peach-settings-row peach-settings-row--meta">1103            <span>Check all passwords</span>1104            <button1105              class="peach-btn peach-btn--subtle peach-btn--sm"1106              onClick={() => void handleCheckAllBreaches()}1107              disabled={checkingBreaches()}1108            >1109              {checkingBreaches() ? "checking..." : "run"}1110            </button>1111          </div>1112          <Show when={breachResult()}>1113            <div class="peach-settings-row peach-settings-row--meta">1114              <span>Last check</span>1115              <span class="peach-settings-value">{breachResult()}</span>1116            </div>1117          </Show>11181119          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1120          <div class="peach-settings-row peach-settings-row--meta">1121            <span>Master password</span>1122            <button1123              class="peach-btn peach-btn--subtle peach-btn--sm"1124              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1125            >1126              {showChangePw() ? "cancel" : "change"}1127            </button>1128          </div>1129          <Show when={showChangePw()}>1130            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1131              <input1132                class="peach-settings-input"1133                type="password"1134                placeholder="current password"1135                value={changePwCurrent()}1136                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1137                autocomplete="current-password"1138              />1139              <input1140                class="peach-settings-input"1141                type="password"1142                placeholder="new password (min 12 chars)"1143                value={changePwNew()}1144                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1145                autocomplete="new-password"1146              />1147              <input1148                class="peach-settings-input"1149                type="password"1150                placeholder="confirm new password"1151                value={changePwConfirm()}1152                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1153                autocomplete="new-password"1154                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1155              />1156              <Show when={changePwError()}>1157                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1158              </Show>1159              <Show when={changePwSuccess()}>1160                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1161              </Show>1162              <button1163                class="peach-btn peach-btn--accent peach-btn--sm"1164                style="align-self:flex-start"1165                onClick={() => void handleChangePassword()}1166                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1167              >1168                {changePwSaving() ? "saving…" : "save new password"}1169              </button>1170            </div>1171          </Show>11721173          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1174          <div class="peach-settings-row peach-settings-row--meta">1175            <span>Recovery words</span>1176            <button1177              class="peach-btn peach-btn--subtle peach-btn--sm"1178              onClick={() => {1179                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1180              }}1181            >1182              {showViewWords() ? "cancel" : "view"}1183            </button>1184          </div>1185          <Show when={showViewWords()}>1186            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1187              <Show1188                when={viewedWords()}1189                fallback={1190                  <>1191                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1192                      Enter your master password to reveal your recovery words.1193                    </p>1194                    <input1195                      class="peach-settings-input"1196                      type="password"1197                      placeholder="master password"1198                      value={viewWordsPassword()}1199                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1200                      autocomplete="current-password"1201                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1202                    />1203                    <Show when={viewWordsError()}>1204                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1205                    </Show>1206                    <button1207                      class="peach-btn peach-btn--accent peach-btn--sm"1208                      style="align-self:flex-start"1209                      onClick={() => void handleViewRecoveryWords()}1210                      disabled={viewWordsBusy() || !viewWordsPassword()}1211                    >1212                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1213                    </button>1214                  </>1215                }1216              >1217                <div class="peach-recovery-words-grid">1218                  <For each={viewedWords()!}>1219                    {(word, i) => (1220                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1221                    )}1222                  </For>1223                </div>1224                <button1225                  class="peach-btn peach-btn--subtle peach-btn--sm"1226                  style="align-self:flex-start"1227                  onClick={closeViewWords}1228                >done</button>1229              </Show>1230            </div>1231          </Show>12321233          <p class="peach-settings-sub-label">1234            DURESS PASSWORD1235            <Show when={!isPro()}>1236              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1237            </Show>1238          </p>1239          <div class="peach-settings-row peach-settings-row--top">1240            <div class="peach-settings-row-main">1241              <span>1242                Duress password1243                <Show when={!isPro()}>1244                  <span class="peach-pro-badge">Pro</span>1245                </Show>1246              </span>1247              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1248                If entered under coercion, shows a decoy empty vault instead of your real one.1249              </span>1250            </div>1251            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1252              {duressEnabled() ? "on" : "off"}1253            </div>1254          </div>12551256          <Show when={duressEnabled() && !showDuressSetup()}>1257            <div class="peach-settings-row peach-settings-row--meta">1258              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1259                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.1260              </span>1261            </div>1262          </Show>12631264          <Show when={duressSuccess()}>1265            <div class="peach-settings-row peach-settings-row--meta">1266              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1267            </div>1268          </Show>12691270          <Show when={!showDuressSetup()}>1271            <div class="peach-settings-row peach-settings-row--meta">1272              <button1273                class="peach-btn peach-btn--subtle peach-btn--sm"1274                onClick={() => {1275                  // Pro gate entry-point: free users are redirected to the1276                  // upgrade screen before the setup form opens. The server-1277                  // side SETUP_DURESS handler is the single enforcement1278                  // point; this is UI redirection so the form never appears1279                  // for a user who can't complete it.1280                  if (!isPro() && !duressEnabled()) {1281                    props.onProGate?.("duress_vault");1282                    setActiveCard("pro");1283                    return;1284                  }1285                  setShowDuressSetup(true);1286                  setDuressError(null);1287                  setDuressSuccess(null);1288                  setDuressPassword("");1289                  setDuressConfirm("");1290                  setDuressMainPassword("");1291                }}1292              >1293                {duressEnabled() ? "Change / disable" : "Set up"}1294              </button>1295            </div>1296          </Show>12971298          <Show when={showDuressSetup()}>1299            <div class="peach-duress-form">1300              <label class="peach-label">main password (to confirm)</label>1301              <input1302                class="peach-settings-input"1303                type="password"1304                placeholder="your current master password"1305                autocomplete="current-password"1306                value={duressMainPassword()}1307                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1308              />1309              <Show when={!duressEnabled()}>1310                <label class="peach-label">new duress password</label>1311                <input1312                  class="peach-settings-input"1313                  type="password"1314                  placeholder="duress password (min 8 chars)"1315                  autocomplete="new-password"1316                  value={duressPassword()}1317                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1318                />1319                <label class="peach-label">confirm duress password</label>1320                <input1321                  class="peach-settings-input"1322                  type="password"1323                  placeholder="confirm duress password"1324                  autocomplete="new-password"1325                  value={duressConfirm()}1326                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1327                />1328              </Show>13291330              <Show when={duressError()}>1331                <p class="peach-hint peach-hint-error">{duressError()}</p>1332              </Show>13331334              <div class="peach-duress-actions">1335                <button1336                  class="peach-btn peach-btn--subtle peach-btn--sm"1337                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1338                >1339                  cancel1340                </button>1341                <Show when={duressEnabled()}>1342                  <button1343                    class="peach-btn peach-btn--ghost peach-btn--sm"1344                    style="color:var(--peach-breach)"1345                    onClick={() => void handleDisableDuress()}1346                    disabled={duressSaving()}1347                  >1348                    {duressSaving() ? "disabling..." : "disable duress"}1349                  </button>1350                </Show>1351                <Show when={!duressEnabled()}>1352                  <button1353                    class="peach-btn peach-btn--accent peach-btn--sm"1354                    onClick={() => void handleSetupDuress()}1355                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1356                  >1357                    {duressSaving() ? "saving..." : "save"}1358                  </button>1359                </Show>1360              </div>1361            </div>1362          </Show>13631364          <p class="peach-settings-sub-label">1365            CANARY ENTRIES1366            <Show when={!isPro()}>1367              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1368            </Show>1369          </p>1370          <div class="peach-settings-row peach-settings-row--top">1371            <div class="peach-settings-row-main">1372              <span>1373                Canary entries1374                <Show when={!isPro()}>1375                  <span class="peach-pro-badge">Pro</span>1376                </Show>1377              </span>1378              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1379                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1380              </span>1381            </div>1382            <span class="peach-settings-value" style="flex-shrink:0">1383              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1384            </span>1385          </div>1386          <Show when={canaryEntries().length > 0}>1387            <For each={canaryEntries()}>1388              {(entry) => (1389                <div class="peach-settings-row peach-settings-row--meta">1390                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1391                  <button1392                    class="peach-btn peach-btn--subtle peach-btn--sm"1393                    onClick={() => void handleTestCanary(entry)}1394                    disabled={canaryTestingId() === entry.id}1395                    style="flex-shrink:0"1396                  >1397                    {canaryTestingId() === entry.id1398                      ? "sending…"1399                      : canaryTestResult()?.id === entry.id1400                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1401                        : "test"}1402                  </button>1403                </div>1404              )}1405            </For>1406          </Show>14071408          <p class="peach-settings-sub-label">PASSKEYS</p>1409          <div class="peach-settings-row peach-settings-row--top">1410            <div class="peach-settings-row-main">1411              <span>Intercept passkey requests</span>1412              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1413                Peach can handle passkey sign-in and creation on supported Chromium browsers.1414              </span>1415            </div>1416            <button1417              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1418              onClick={() => void handleTogglePasskeys()}1419              aria-label="Toggle passkey interception"1420              disabled={!passkeyState()?.supported || passkeySaving()}1421            >1422              <span class="peach-toggle-knob"></span>1423            </button>1424          </div>1425          <div class="peach-settings-row peach-settings-row--meta">1426            <span>Status</span>1427            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1428          </div>1429          <div class="peach-settings-row peach-settings-row--meta">1430            <span>Browser</span>1431            <span class="peach-settings-value">1432              {passkeyState()?.browserLabel ?? "Unknown"}1433              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1434            </span>1435          </div>1436          <div class="peach-info-notice" style="margin-top:8px">1437            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1438          </div>1439          <Show when={passkeyState()?.conflict}>1440            <div class="peach-settings-row peach-settings-row--meta">1441              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1442            </div>1443          </Show>1444        </div>1445      </div>1446    );1447  }14481449  function renderVault() {1450    return (1451      <div class="peach-settings-body">1452        <div class="peach-settings-group">1453          <p class="peach-settings-sub-label">ENTRIES</p>1454          <div class="peach-settings-row peach-settings-row--meta">1455            <span>Entries</span>1456            <span class="peach-settings-value">{props.entriesCount}</span>1457          </div>1458          <div class="peach-settings-row peach-settings-row--meta">1459            <span>Duplicates</span>1460            <Show when={dedupDone() !== null} fallback={1461              <button1462                class="peach-btn peach-btn--subtle peach-btn--sm"1463                onClick={() => void handleCountDuplicates()}1464                disabled={dedupRunning()}1465              >1466                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1467              </button>1468            }>1469              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1470                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1471              </span>1472            </Show>1473          </div>1474          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1475            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1476              <div class="peach-restore-warning">1477                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1478              </div>1479              <div style="display:flex;gap:6px">1480                <button1481                  class="peach-btn peach-btn--subtle peach-btn--sm"1482                  onClick={() => setDedupCount(null)}1483                >1484                  cancel1485                </button>1486                <button1487                  class="peach-btn peach-btn--accent peach-btn--sm"1488                  onClick={() => void handleDeduplicate()}1489                  disabled={dedupRunning()}1490                >1491                  {dedupRunning() ? "removing..." : "remove duplicates"}1492                </button>1493              </div>1494            </div>1495          </Show>14961497          <p class="peach-settings-sub-label">MAINTENANCE</p>1498          <div class="peach-settings-row peach-settings-row--meta">1499            <span>Trash auto-delete</span>1500            <select1501              class="peach-settings-select"1502              value={String(props.trashRetentionDays)}1503              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1504            >1505              <option value="7">7 days</option>1506              <option value="30">30 days</option>1507              <option value="90">90 days</option>1508              <option value="0">Never</option>1509            </select>1510          </div>1511          <div class="peach-settings-row peach-settings-row--meta">1512            <span>Advanced Vault Health</span>1513            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1514              open1515            </button>1516          </div>1517          <Show when={declinedCardCount() > 0}>1518            <div class="peach-settings-row peach-settings-row--meta">1519              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1520              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1521                Clear1522              </button>1523            </div>1524          </Show>15251526          <p class="peach-settings-sub-label">DATA</p>1527          <div class="peach-settings-row peach-settings-row--meta">1528            <span>Import passwords</span>1529            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1530              open1531            </button>1532          </div>1533          <div class="peach-settings-row peach-settings-row--meta">1534            <span>Export backup</span>1535            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1536              open1537            </button>1538          </div>1539          <div class="peach-settings-row peach-settings-row--meta">1540            <span>1541              Peach Codex1542              <Show when={!isPro()}>1543                <span class="peach-pro-badge">Pro</span>1544              </Show>1545            </span>1546            <button1547              class="peach-btn peach-btn--subtle peach-btn--sm"1548              onClick={() => props.onOpenCodexGenerate?.()}1549            >1550              open1551            </button>1552          </div>1553          {/* Restore from Codex: intentionally never gated — a printed1554              Codex must remain restorable regardless of license state. */}1555          <div class="peach-settings-row peach-settings-row--meta">1556            <span>Restore from Codex</span>1557            <button1558              class="peach-btn peach-btn--subtle peach-btn--sm"1559              onClick={() => props.onOpenCodexRestore?.()}1560            >1561              open1562            </button>1563          </div>15641565          <span class="peach-settings-sub-label">VAULT HISTORY</span>1566          <Show when={historyLoading()}>1567            <p class="peach-settings-hint">Loading…</p>1568          </Show>1569          <Show when={!historyLoading() && historySnapshots().length === 0}>1570            <p class="peach-settings-hint">1571              No snapshots yet. Peach saves automatically before every change —1572              saves, syncs, and imports.1573            </p>1574          </Show>1575          <Show when={!historyLoading() && historySnapshots().length > 0}>1576            <p class="peach-settings-hint">1577              Restoring locks your vault. Unlock with the password that1578              encrypted that snapshot to confirm.1579            </p>1580            <div class="peach-history-list">1581              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1582                {(snap) => (1583                  <div class="peach-history-row">1584                    <div class="peach-history-meta">1585                      <span class="peach-history-time">1586                        {formatSnapshotTime(snap.savedAt)}1587                      </span>1588                      <span class="peach-history-sub">1589                        {[1590                          snap.entryCount !== null1591                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1592                            : null,1593                          reasonLabel(snap.reason),1594                        ]1595                          .filter(Boolean)1596                          .join(" · ")}1597                      </span>1598                    </div>1599                    <button1600                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1601                        historyRestoring() === snap.id ? " is-busy" : ""1602                      }`}1603                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1604                      disabled={historyRestoring() !== null}1605                    >1606                      <Show when={historyRestoring() === snap.id} fallback="Restore">1607                        …1608                      </Show>1609                    </button>1610                  </div>1611                )}1612              </For>1613            </div>1614            <Show when={historySnapshots().length > 3}>1615              <button1616                class="peach-settings-advanced-toggle"1617                onClick={() => setHistoryShowAll((v) => !v)}1618                aria-expanded={historyShowAll()}1619              >1620                <span class="peach-settings-hint" style="margin:0">1621                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1622                </span>1623                <span class="peach-settings-advanced-chevron">1624                  {historyShowAll() ? "▲" : "▼"}1625                </span>1626              </button>1627            </Show>1628            <p class="peach-history-footer">1629              {historySnapshots().length} of 10 snapshots1630            </p>1631          </Show>16321633          <button1634            class="peach-settings-advanced-toggle"1635            onClick={() => setShowVaultAdvanced((v) => !v)}1636            aria-expanded={showVaultAdvanced()}1637          >1638            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1639            <span class="peach-settings-advanced-chevron">1640              {showVaultAdvanced() ? "▲" : "▼"}1641            </span>1642          </button>16431644          <Show when={showVaultAdvanced()}>1645            <div class="peach-danger-card">1646              <div class="peach-danger-card-head">1647                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1648                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1649              </div>1650              <p class="peach-danger-card-copy">1651                Wipes the current vault and restores from a backup file.1652                Recoverable only if your backup is good.1653              </p>1654              <button1655                class="peach-danger-card-button"1656                onClick={() => props.onOpenFileImport?.()}1657              >1658                Choose backup file1659              </button>1660            </div>16611662            <Show when={props.onOpenPanic}>1663              <div class="peach-danger-card peach-danger-card--hazard">1664                <div class="peach-danger-card-head">1665                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1666                  <h3 class="peach-danger-card-title">Destroy vault</h3>1667                </div>1668                <p class="peach-danger-card-copy">1669                  Permanently wipes every entry, setting, and license on this1670                  device. There is no recovery.1671                </p>1672                <button1673                  class="peach-danger-card-button"1674                  onClick={() => props.onOpenPanic?.()}1675                >1676                  Begin destruction1677                </button>1678              </div>1679            </Show>1680          </Show>1681        </div>1682      </div>1683    );1684  }16851686  function renderSync() {1687    return (1688      <div class="peach-settings-body">1689        <div class="peach-settings-group">1690          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1691          <div1692            class="peach-settings-row peach-settings-row--meta"1693            style="align-items:center;gap:8px"1694            role="status"1695            aria-label={syncLightLabel()}1696            title={syncLightLabel()}1697          >1698            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1699            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1700              {syncFlash() ?? lastSyncLine()}1701            </span>1702          </div>1703          <Show when={pairedDevices().length > 0}>1704            <div class="peach-settings-row peach-settings-row--meta">1705              <span class="peach-settings-value" style="color:var(--peach-t2)">1706                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01707                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1708                  : ""}1709              </span>1710              <button1711                class="peach-btn peach-btn--subtle peach-btn--sm"1712                aria-label="Sync this vault now"1713                disabled={syncStatus() === "syncing"}1714                onClick={() => void syncNow()}1715              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1716            </div>1717          </Show>17181719          <p class="peach-settings-sub-label">ADD A DEVICE</p>1720          <button1721            class="peach-sync-pair-option"1722            onClick={() => props.onOpenSyncPairing?.("code")}1723          >1724            <span class="peach-sync-pair-option-icon" aria-hidden="true">1725              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1726                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1727                <path d="M9 15 L11 15" />1728              </svg>1729            </span>1730            <span class="peach-sync-pair-option-main">1731              <span class="peach-sync-pair-option-title">Show a code</span>1732              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1733            </span>1734            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1735          </button>1736          <Show when={!isPro()}>1737            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1738              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1739            </p>1740          </Show>17411742          <Show when={pairedDevices().length > 0}>1743            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1744            <For each={pairedDevices()}>1745              {(d) => (1746                <div class="peach-settings-row peach-settings-row--top">1747                  <div class="peach-settings-row-main">1748                    <Show1749                      when={renameDeviceId() === d.deviceId}1750                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1751                    >1752                      <input1753                        class="peach-settings-input"1754                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1755                        maxlength={64}1756                        value={renameDraft()}1757                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1758                        onKeyDown={(event) => {1759                          if (event.key === "Enter") void renameDevice(d.deviceId);1760                          if (event.key === "Escape") setRenameDeviceId(null);1761                        }}1762                      />1763                    </Show>1764                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1765                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1766                    </span>1767                  </div>1768                  <Show1769                    when={confirmUnpair() === d.deviceId}1770                    fallback={1771                      <div style="display:flex;gap:4px;flex-shrink:0">1772                        <Show1773                          when={renameDeviceId() === d.deviceId}1774                          fallback={1775                            <button1776                              class="peach-btn peach-btn--subtle peach-btn--sm"1777                              onClick={() => {1778                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1779                                setRenameDeviceId(d.deviceId);1780                              }}1781                            >rename</button>1782                          }1783                        >1784                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1785                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1786                        </Show>1787                        <button1788                          class="peach-btn peach-btn--subtle peach-btn--sm"1789                          onClick={() => setConfirmUnpair(d.deviceId)}1790                        >remove</button>1791                      </div>1792                    }1793                  >1794                    {/* HOLE 4: revocation stops this device receiving future1795                        changes. It does NOT secure the vault against it — that1796                        device may already hold the recovery words, which are1797                        master-password-equivalent and restore the whole vault1798                        anywhere, forever, with no sync involved. Say so here,1799                        rather than leaving the user falsely reassured. */}1800                    <div class="peach-unpair-confirm">1801                      <p class="peach-unpair-confirm-note">1802                        Stops this device receiving future changes. It doesn't1803                        lock it out of what it already has — if it's lost or1804                        stolen, also change your master password and generate1805                        new recovery words.1806                      </p>1807                      <div style="display:flex;gap:6px;justify-content:flex-end">1808                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1809                        <button1810                          class="peach-btn peach-btn--sm"1811                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1812                          onClick={() => void unpairDevice(d.deviceId)}1813                        >remove</button>1814                      </div>1815                    </div>1816                  </Show>1817                </div>1818              )}1819            </For>1820          </Show>18211822          <Show when={pendingVouched().length > 0}>1823            <p class="peach-settings-sub-label">1824              PENDING DEVICES1825              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1826            </p>1827            <For each={pendingVouched()}>1828              {(dev) => (1829                <div class="peach-settings-row peach-settings-row--top">1830                  <div class="peach-settings-row-main">1831                    <span>{dev.deviceName}</span>1832                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1833                      Was added by another of your devices. Allow it to sync with this vault?1834                    </span>1835                  </div>1836                  <div style="display:flex;gap:6px;flex-shrink:0">1837                    <button1838                      class="peach-btn peach-btn--sm"1839                      disabled={vouchedBusy() === dev.deviceId}1840                      onClick={() => void confirmVouched(dev.deviceId)}1841                    >Allow</button>1842                    <button1843                      class="peach-btn peach-btn--subtle peach-btn--sm"1844                      disabled={vouchedBusy() === dev.deviceId}1845                      onClick={() => void rejectVouched(dev.deviceId)}1846                    >Reject</button>1847                  </div>1848                </div>1849              )}1850            </For>1851          </Show>18521853          <button1854            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1855            onClick={() => setShowSyncConnection((value) => !value)}1856            aria-expanded={showSyncConnection()}1857            aria-controls="peach-sync-connection"1858          >1859            <span>1860              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1861              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1862            </span>1863            <span class="peach-settings-advanced-chevron">1864              {showSyncConnection() ? "▲" : "▼"}1865            </span>1866          </button>1867          <Show when={showSyncConnection()}>1868            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1869              <SyncModeSelector />1870            </div>1871          </Show>18721873          <button1874            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1875            onClick={() => setShowSyncRecovery((value) => !value)}1876            aria-expanded={showSyncRecovery()}1877            aria-controls="peach-sync-recovery"1878          >1879            <span>1880              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1881              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1882            </span>1883            <span class="peach-settings-advanced-chevron">1884              {showSyncRecovery() ? "▲" : "▼"}1885            </span>1886          </button>1887          <Show when={showSyncRecovery()}>1888            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1889              <Show1890                when={rotateConfirmOpen()}1891                fallback={1892                  <div class="peach-settings-row peach-settings-row--top">1893                    <div class="peach-settings-row-main">1894                      <span>Rotate sync identity</span>1895                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1896                        Disconnects every device and replaces the keys used for sync.1897                      </span>1898                    </div>1899                    <button1900                      class="peach-btn peach-btn--subtle peach-btn--sm"1901                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1902                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1903                    >rotate</button>1904                  </div>1905                }1906              >1907                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1908                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1909                  <Show when={rotateError()}>1910                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1911                  </Show>1912                  <div style="display:flex;gap:6px;margin-top:10px">1913                    <button1914                      class="peach-btn peach-btn--subtle peach-btn--sm"1915                      disabled={rotating()}1916                      onClick={() => setRotateConfirmOpen(false)}1917                    >Cancel</button>1918                    <button1919                      class="peach-btn peach-btn--sm"1920                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1921                      disabled={rotating()}1922                      onClick={() => void doRotateSyncIdentity()}1923                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1924                  </div>1925                </div>1926              </Show>1927            </div>1928          </Show>1929        </div>1930      </div>1931    );1932  }19331934  function renderAppearance() {1935    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1936      { id: "medium", label: "Default" },1937      { id: "large", label: "Large" },1938    ];1939    return (1940      <div class="peach-settings-body">1941        <div class="peach-settings-group">1942          <p class="peach-settings-sub-label">THEME</p>1943          <div class="peach-settings-row peach-settings-row--meta">1944            <span>Theme</span>1945            <button1946              class="peach-btn peach-btn--subtle peach-btn--sm"1947              onClick={() =>1948                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1949              }1950            >1951              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1952            </button>1953          </div>19541955          <p class="peach-settings-sub-label">ACCENT</p>1956          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1957            <span>Accent</span>1958            <div class="peach-accent-picker">1959              <For each={ACCENT_COLORS}>1960                {(color) => (1961                  <button1962                    class="peach-accent-swatch peach-swatch"1963                    classList={{ "is-active": props.accentColor === color.name }}1964                    style={{ background: color.hex }}1965                    onClick={() => void props.onAccentChange(color.name)}1966                    aria-label={color.name}1967                    title={color.name}1968                  />1969                )}1970              </For>1971            </div>1972          </div>19731974          <p class="peach-settings-sub-label">TEXT SIZE</p>1975          <div class="peach-settings-row peach-settings-row--meta">1976            <span>Size</span>1977            <div class="peach-pill-group">1978              <For each={sizeOptions}>1979                {(opt) => (1980                  <button1981                    class="peach-btn peach-btn--ghost peach-btn--sm"1982                    classList={{ "is-active": props.textSize === opt.id }}1983                    onClick={() => void props.onTextSizeChange(opt.id)}1984                  >1985                    {opt.label}1986                  </button>1987                )}1988              </For>1989            </div>1990          </div>19911992          <p class="peach-settings-sub-label">MOTION</p>1993          <div class="peach-settings-row peach-settings-row--meta">1994            <span>Reduce motion</span>1995            <button1996              class="peach-btn peach-btn--subtle peach-btn--sm"1997              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1998            >1999              {props.reduceMotion ? "On" : "Off"}2000            </button>2001          </div>2002        </div>2003      </div>2004    );2005  }20062007  function renderShortcuts() {2008    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2009    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);20102011    function startCapture(action: ShortcutAction) {2012      setConflictMsg(null);2013      setCapturingAction(action);2014    }2015    function cancelCapture() {2016      setCapturingAction(null);2017      setConflictMsg(null);2018    }2019    function commitBinding(action: ShortcutAction, binding: Keybinding) {2020      const conflict = findConflict(props.shortcuts, binding, action);2021      if (conflict) {2022        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2023        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2024        return;2025      }2026      const next = { ...props.shortcuts, [action]: binding };2027      setCapturingAction(null);2028      setConflictMsg(null);2029      void props.onShortcutsChange(next);2030    }2031    function resetAction(action: ShortcutAction) {2032      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2033      void props.onShortcutsChange(next);2034    }2035    function resetAll() {2036      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2037    }20382039    // Capture-mode key handler — wired via ref onto the chip element so it2040    // catches the next non-modifier keypress and ignores other bindings.2041    function handleCaptureKey(e: KeyboardEvent) {2042      e.preventDefault();2043      e.stopPropagation();2044      if (e.key === "Escape") { cancelCapture(); return; }2045      const binding = keybindingFromEvent(e);2046      if (!binding) return; // bare modifier — keep waiting2047      const action = capturingAction();2048      if (action) commitBinding(action, binding);2049    }20502051    return (2052      <div class="peach-settings-body">2053        <div class="peach-settings-group peach-shortcuts-group">2054          <p class="peach-settings-sub-label">ENTRY VIEW</p>2055          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2056            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2057          </For>2058        </div>20592060        <div class="peach-settings-group peach-shortcuts-group">2061          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2062          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2063            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2064          </For>2065        </div>20662067        <Show when={conflictMsg()}>2068          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2069        </Show>20702071        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2072          <div class="peach-settings-row peach-settings-row--meta">2073            <span>Inline autofill on page</span>2074            <a2075              class="peach-settings-link"2076              href="chrome://extensions/shortcuts"2077              onClick={(e) => {2078                e.preventDefault();2079                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2080              }}2081            >2082              ⌘⇧P — customize ↗2083            </a>2084          </div>2085          <button2086            class="peach-btn peach-btn--subtle peach-btn--sm"2087            style="align-self:flex-start"2088            onClick={resetAll}2089          >2090            Reset all to defaults2091          </button>2092        </div>2093      </div>2094    );20952096    function renderRow(2097      desc: typeof SHORTCUT_DESCRIPTORS[number],2098      capturing: () => ShortcutAction | null,2099      onStart: (a: ShortcutAction) => void,2100      onCancel: () => void,2101      onReset: (a: ShortcutAction) => void,2102      onKey: (e: KeyboardEvent) => void,2103    ) {2104      const isActive = () => capturing() === desc.action;2105      const binding = () => props.shortcuts[desc.action];2106      const isCustom = () => {2107        const b = binding();2108        const d = DEFAULT_BINDINGS[desc.action];2109        return b.key !== d.key2110          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2111          || Boolean(b.meta)  !== Boolean(d.meta)2112          || Boolean(b.shift) !== Boolean(d.shift)2113          || Boolean(b.alt)   !== Boolean(d.alt);2114      };2115      return (2116        <div class="peach-shortcut-row">2117          <span class="peach-shortcut-row-label">{desc.label}</span>2118          <button2119            class="peach-shortcut-chip"2120            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2121            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2122            onKeyDown={(e) => { if (isActive()) onKey(e); }}2123            ref={(el) => {2124              // Auto-focus when entering capture mode so the keydown listener catches the next press.2125              if (el) {2126                queueMicrotask(() => { if (isActive()) el.focus(); });2127              }2128            }}2129          >2130            {isActive() ? "Press a key…" : formatKeybinding(binding())}2131          </button>2132          <Show when={isCustom() && !isActive()}>2133            <button2134              class="peach-shortcut-reset"2135              title="Reset to default"2136              onClick={() => onReset(desc.action)}2137            >2138              ↺2139            </button>2140          </Show>2141        </div>2142      );2143    }2144  }21452146  const PRO_FEATURES: Array<{2147    feature: ProFeature;2148    name: string;2149    desc: string;2150  }> = [2151    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2152    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2153    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2154    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2155  ];21562157  const PRO_SECURITY_FEATURES: Array<{2158    feature: ProFeature;2159    name: string;2160    desc: string;2161  }> = [2162    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2163    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2164  ];21652166  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2167  const [showKey, setShowKey] = createSignal(false);21682169  const [deactivating, setDeactivating] = createSignal(false);21702171  async function handleDeactivateLicense() {2172    setDeactivating(true);2173    await sendMessage({ type: "DEACTIVATE_LICENSE" });2174    setLicenseInfo(null);2175    await props.onLicenseChange?.();2176    setDeactivating(false);2177  }21782179  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2180    setCanaryTestingId(entry.id);2181    setCanaryTestResult(null);2182    const res = await sendMessage({2183      type: "FIRE_CANARY_PING",2184      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2185    }) as { ok: boolean };2186    setCanaryTestResult({ id: entry.id, ok: res.ok });2187    setCanaryTestingId(null);2188    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2189  }21902191  function renderLicense() {2192    async function onActivate() {2193      await handleActivateLicense();2194      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2195        await props.onLicenseChange?.();2196      }2197    }21982199    return (2200      <div class="peach-settings-body">2201        <Show2202          when={isPro()}2203          fallback={2204                <div class="peach-pro-upgrade">2205                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>22062207                  <h2 class="peach-pro-headline">2208                    Every<em>thing,</em><br />every<em>where.</em>2209                  </h2>22102211                  <p class="peach-pro-subtitle">2212                    One key unlocks every Pro feature — for the lifetime of the app.2213                  </p>22142215                  <div class="peach-pro-grid">2216                    <For each={PRO_FEATURES}>2217                      {(item, i) => (2218                        <div2219                          class="peach-pro-grid-item"2220                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2221                        >2222                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2223                          <span class="peach-pro-grid-name">{item.name}</span>2224                          <span class="peach-pro-grid-desc">{item.desc}</span>2225                        </div>2226                      )}2227                    </For>2228                  </div>22292230                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2231                  <div class="peach-pro-grid">2232                    <For each={PRO_SECURITY_FEATURES}>2233                      {(item, i) => (2234                        <div2235                          class="peach-pro-grid-item"2236                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2237                        >2238                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2239                          <span class="peach-pro-grid-name">{item.name}</span>2240                          <span class="peach-pro-grid-desc">{item.desc}</span>2241                        </div>2242                      )}2243                    </For>2244                  </div>22452246                  <div class="peach-pro-upgrade-actions">2247                    <p class="peach-pro-pricing">2248                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2249                    </p>2250                    <a2251                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2252                      target="_blank"2253                      rel="noopener noreferrer"2254                      class="peach-pro-upgrade-cta"2255                    >2256                      Get Peach Pro2257                    </a>2258                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2259                    <div class="peach-pro-key-inline">2260                      <input2261                        class="peach-pro-key-inline-input"2262                        type="text"2263                        placeholder="PEACH-XXXX-XXXX-XXXX"2264                        value={licenseKeyInput()}2265                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2266                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2267                        autocomplete="off"2268                        spellcheck={false}2269                      />2270                      <button2271                        class="peach-pro-key-apply"2272                        onClick={() => void onActivate()}2273                        disabled={activatingLicense() || !licenseKeyInput().trim()}2274                      >2275                        {activatingLicense() ? "…" : "APPLY"}2276                      </button>2277                    </div>2278                    <Show when={licenseError()}>2279                      <p class="peach-pro-key-error">{licenseError()}</p>2280                    </Show>2281                  </div>2282                </div>2283          }2284        >2285          <div class="peach-pro-active">2286            <div class="peach-pro-active-hero">2287              <button2288                class="peach-pro-active-mark-wrap"2289                type="button"2290                aria-label="Celebrate"2291                onClick={handleProCelebrationClick}2292              >2293                <PeachMark class="peach-pro-active-mark" />2294                <Show when={justActivated()}>2295                  <div class="peach-pro-sparks" aria-hidden="true">2296                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2297                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2298                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2299                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2300                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2301                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2302                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2303                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2304                  </div>2305                </Show>2306              </button>2307              <h2 class="peach-pro-active-title">Peach Pro</h2>2308              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2309              <Show when={justActivated()}>2310                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2311              </Show>2312            </div>23132314            <Show when={licenseKey()}>2315              <div class="peach-settings-license">2316                <span class="peach-settings-label">LICENSE KEY</span>2317                <div class="peach-settings-license-row">2318                  <Show2319                    when={showKey()}2320                    fallback={2321                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2322                        ••••••••••••••••••••••••••••2323                      </span>2324                    }2325                  >2326                    <span class="peach-settings-license-key">{licenseKey()!}</span>2327                  </Show>2328                  <div class="peach-settings-license-actions">2329                    <button2330                      class="peach-settings-license-copy"2331                      title="Copy license key"2332                      onClick={() => copyLicenseKey(licenseKey()!)}2333                    >2334                      {copied() ? "✓" : "copy"}2335                    </button>2336                    <button2337                      class="peach-settings-license-toggle"2338                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2339                      title={showKey() ? "Hide" : "Reveal"}2340                      onClick={() => setShowKey(v => !v)}2341                    >2342                      <Show2343                        when={showKey()}2344                        fallback={2345                          <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">2346                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2347                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2348                          </svg>2349                        }2350                      >2351                        <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">2352                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2353                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2354                          <line x1="2" y1="2" x2="12" y2="12" />2355                        </svg>2356                      </Show>2357                    </button>2358                  </div>2359                </div>2360              </div>2361            </Show>23622363            <div class="peach-pro-active-features">2364              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2365                {(item) => (2366                  <div class="peach-pro-active-feature">2367                    <span class="peach-pro-active-feature-mark">✦</span>2368                    <span class="peach-pro-active-feature-name">{item.name}</span>2369                  </div>2370                )}2371              </For>2372            </div>23732374            <button2375              class="peach-pro-deactivate"2376              onClick={() => void handleDeactivateLicense()}2377              disabled={deactivating()}2378            >2379              {deactivating() ? "Deactivating…" : "Deactivate"}2380            </button>2381          </div>2382        </Show>2383      </div>2384    );2385  }23862387  function renderFeedback() {2388    return (2389      <div class="peach-settings-body">2390        <div class="peach-settings-group peach-feedback-group">2391          <Show2392            when={feedbackState() === 'sent'}2393            fallback={2394              <>2395                <p class="peach-feedback-intro">2396                  Help us improve Peach. Every report goes directly to our inbox.2397                </p>23982399                <p class="peach-settings-sub-label">TYPE</p>2400                <div class="peach-feedback-chips">2401                  <For each={FEEDBACK_CATEGORIES}>2402                    {(cat) => {2403                      const selected = () => fbCategory() === cat;2404                      return (2405                        <button2406                          type="button"2407                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2408                          aria-pressed={selected()}2409                          onClick={() => setFbCategory(cat)}2410                        >2411                          {cat}2412                        </button>2413                      );2414                    }}2415                  </For>2416                </div>24172418                <p class="peach-settings-sub-label">MESSAGE</p>2419                <textarea2420                  class="peach-feedback-textarea"2421                  placeholder="Describe the issue or idea..."2422                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2423                  value={fbMessage()}2424                  onInput={(e) => setFbMessage(e.currentTarget.value)}2425                />24262427                <p class="peach-feedback-meta">2428                  Version {version} · will be included automatically2429                </p>24302431                <button2432                  type="button"2433                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2434                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2435                  onClick={() => void handleSubmitFeedback()}2436                >2437                  <Switch>2438                    <Match when={feedbackState() === 'idle'}>Send</Match>2439                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2440                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2441                  </Switch>2442                </button>24432444                <p class="peach-feedback-anon">2445                  Sent anonymously · no account required2446                </p>2447              </>2448            }2449          >2450            <div class="peach-feedback-thanks" role="status" aria-live="polite">2451              <p class="peach-feedback-thanks-title">Thank you</p>2452              <p>2453                Your note is in the queue. Someone will process it soon.2454              </p>2455            </div>2456          </Show>2457        </div>2458      </div>2459    );2460  }24612462  const bodies: StackBodies = {2463    protection: renderSecurity(),2464    vault: renderVault(),2465    sync: renderSync(),2466    appearance: renderAppearance(),2467    shortcuts: renderShortcuts(),2468    pro: renderLicense(),2469    feedback: renderFeedback(),2470  };24712472  return (2473    <Show2474      when={showNewRecoveryWords()}2475      fallback={2476        <div2477          class="peach-overlay-view peach-settings-overlay"2478          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2479          style="display:flex;flex-direction:column"2480        >2481          <StackDeck2482            active={activeCard()}2483            onActiveChange={setActiveCard}2484            onBack={props.onBack}2485            cards={cards()}2486            bodies={bodies}2487            version={version}2488            isFanning={isFanning()}2489            celebratingCardId={justActivated() ? "pro" : null}2490            isPro={isPro()}2491          />2492        </div>2493      }2494    >2495      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2496    </Show>2497  );2498}