Settings.tsx

105.2 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 { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13  AccentColorName,14  PasskeyProviderState,15  ProFeature,16  VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20  describeSyncOutcome,21  syncIndicator,22  syncIndicatorLabel,23  type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import type { SyncGeneration } from "../../background/sync-generation";26import {27  DEFAULT_BINDINGS,28  findConflict,29  formatKeybinding,30  keybindingFromEvent,31  SHORTCUT_DESCRIPTORS,32  type Keybinding,33  type ShortcutAction,34} from "../../shared/shortcuts";35import { WEB3FORMS_KEY } from "../../shared/config";3637interface LicenseInfo {38  valid: boolean;39  plan: "free" | "pro";40  issued?: string;41}4243interface SettingsProps {44  entriesCount: number;45  breachMonitoring: boolean;46  lockOnClose: boolean;47  autoLockMinutes: number;48  trashRetentionDays: number;49  theme: "dark" | "light";50  accentColor: AccentColorName;51  textSize: "small" | "medium" | "large";52  reduceMotion: boolean;53  isPro?: boolean;54  initialSection?: string | null;55  upgradeHighlight?: ProFeature | null;56  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;57  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;58  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;59  onTrashRetentionChange: (days: number) => void | Promise<void>;60  onThemeChange: (theme: "dark" | "light") => void;61  onAccentChange: (name: AccentColorName) => void | Promise<void>;62  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;63  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;64  shortcuts: Record<ShortcutAction, Keybinding>;65  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;66  onLicenseChange?: () => void | Promise<void>;67  isExiting?: boolean;68  onBack: () => void;69  onImport: () => void;70  onExport: () => void;71  onBreachCheckDone: () => void;72  onOpenHealth?: () => void;73  onOpenSyncPairing?: (method?: "code") => void;74  onOpenFileImport?: () => void;75  onOpenPanic?: () => void;76  onOpenCodexGenerate?: () => void;77  onOpenCodexRestore?: () => void;78  /** Invoked from within Settings when a Pro-gated control is tapped on79   *  free tier. App owns the upgrade navigation + highlight state, so this80   *  callback is the single way for Settings to request that navigation. */81  onProGate?: (feature: ProFeature) => void;82  onCardChange?: (id: CardId) => void;83}8485const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];86const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;87const PRO_CELEBRATION_CARD_STEP_MS = 150;88const PRO_CELEBRATION_THANK_YOU_MS = 3200;8990function sanitizeFeedbackMessage(value: string): string {91  return value92    .trim()93    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)94    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text95    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")96    .replace(/&/g, "&amp;")97    .replace(/</g, "&lt;")98    .replace(/>/g, "&gt;")99    .replace(/"/g, "&quot;")100    .replace(/'/g, "&#39;");101}102103function formatActivatedSince(issued: string | undefined): string {104  if (!issued) return "Active";105  try {106    const d = new Date(issued);107    if (Number.isNaN(d.getTime())) return "Active";108    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;109  } catch {110    return "Active";111  }112}113114function initialSectionToCard(value: string | null | undefined): CardId | null {115  if (!value) return null;116  if (value === "security") return "protection";117  if (value === "license") return "pro";118  if (119    value === "protection" ||120    value === "vault" ||121    value === "sync" ||122    value === "appearance" ||123    value === "shortcuts" ||124    value === "pro" ||125    value === "feedback"126  ) {127    return value;128  }129  return null;130}131132/**133 * §11 migration copy. It must never imply that old peers keep working: the134 * whole point of the reset is that they cannot.135 */136const SECURE_RESET_DISCLOSURE =137  "Peach will archive this device's old Sync trust and start the new Sync generation. " +138  "Every other device must be updated first, then re-paired with this one — they will " +139  "not continue on their own. Your vault and your recovery words are not changed.";140141function secureResetHeadline(state: string | undefined): string {142  switch (state) {143    case "LEGACY_ACTIVE":144      return "Legacy Sync generation";145    case "RESET_REQUIRED":146      return "Reset required";147    case "RESET_CONFIRMED":148      return "Reset not completed";149    case "V2_ANCHOR":150    case "REPAIRING":151      return "Not re-paired";152    case "V2_ACTIVE":153      return "New Sync generation";154    case "UNKNOWN":155      return "Sync generation unreadable";156    default:157      return "Sync generation";158  }159}160161function secureResetDetail(state: string | undefined): string {162  switch (state) {163    case "LEGACY_ACTIVE":164      return "This device still holds the old Sync trust. Resetting retires it for good: update Peach on every other device, then re-pair each one.";165    case "RESET_REQUIRED":166      return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";167    case "RESET_CONFIRMED":168      return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";169    case "V2_ANCHOR":170    case "REPAIRING":171      return "This device anchors the new Sync generation and is not re-paired yet. Pair each device again; until then it keeps its own vault and cannot sync.";172    case "V2_ACTIVE":173      return "This device is on the new Sync generation. Each device is re-paired individually.";174    case "UNKNOWN":175      return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";176    default:177      return "Sync state is unavailable.";178  }179}180181export function SettingsView(props: SettingsProps) {182  const manifest = chrome.runtime.getManifest();183  const version = manifest.version;184  const initial = props.initialSection;185  const [activeCard, setActiveCard] = createSignal<CardId>(186    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"187  );188189  createEffect(() => { props.onCardChange?.(activeCard()); });190191  const [justActivated, setJustActivated] = createSignal(false);192  const [isFanning, setIsFanning] = createSignal(false);193194  function triggerCelebration() {195    if (isFanning()) return;196    setJustActivated(false);197    setIsFanning(true);198    setActiveCard("pro");199    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];200    fanOrder.forEach((id, i) => {201      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);202    });203    window.setTimeout(() => {204      setActiveCard("pro");205      setIsFanning(false);206      setJustActivated(true);207      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);208    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);209  }210211  function handleProCelebrationClick(e: MouseEvent) {212    e.stopPropagation();213    triggerCelebration();214  }215216  const [checkingBreaches, setCheckingBreaches] = createSignal(false);217  const [breachResult, setBreachResult] = createSignal<string | null>(null);218  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);219  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");220  const [licenseError, setLicenseError] = createSignal<string | null>(null);221  const [, setLicenseSuccess] = createSignal<string | null>(null);222  const [activatingLicense, setActivatingLicense] = createSignal(false);223224  const isPro = () => props.isPro ?? false;225  const cards = createMemo<StackCardSpec[]>(() => [226    {227      id: "pro",228      num: "I · FOREVER",229      title: "Peach Pro",230      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",231      bgVar: "--stack-pro-bg",232      fgVar: "--stack-pro-fg",233      icon: "spark",234      onIconClick: isPro() ? triggerCelebration : undefined,235    },236    {237      id: "protection",238      num: "II · PROTECT",239      title: "Protection",240      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,241      bgVar: "--stack-protect-bg",242      fgVar: "--stack-protect-fg",243      icon: "shield",244    },245    {246      id: "vault",247      num: "III · KEEP",248      title: "Vault",249      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,250      bgVar: "--stack-vault-bg",251      fgVar: "--stack-vault-fg",252      icon: "lock",253    },254    {255      id: "sync",256      num: "IV · ECHO",257      title: "Sync",258      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",259      bgVar: "--stack-sync-bg",260      fgVar: "--stack-sync-fg",261      icon: "sync",262    },263    {264      id: "appearance",265      num: "V · DRESS",266      title: "Appearance",267      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,268      bgVar: "--stack-appear-bg",269      fgVar: "--stack-appear-fg",270      icon: "sun",271    },272    {273      id: "shortcuts",274      num: "VI · KEYS",275      title: "Shortcuts",276      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,277      bgVar: "--stack-shortcut-bg",278      fgVar: "--stack-shortcut-fg",279      icon: "keys",280    },281    {282      id: "feedback",283      num: "VII · ECHO",284      title: "Feedback",285      lede: `version ${version} · tell us what would make it better`,286      bgVar: "--stack-feedback-bg",287      fgVar: "--stack-feedback-fg",288      icon: "bubble",289    },290  ]);291  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);292  const [passkeySaving, setPasskeySaving] = createSignal(false);293294  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting295  // explicit confirmation before they become sync targets.296  interface PendingVouched {297    deviceId: string;298    deviceName: string;299    longTermPublicKeyHex: string;300    receivedAt: number;301    vouchedByDeviceId: string;302  }303  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);304  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);305  // Paired-device management lives here — Settings is the sync hub.306  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);307  const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);308309  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);310  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);311  const [renameDraft, setRenameDraft] = createSignal("");312  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");313  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);314  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —315  // a transient toast is no use for diagnosing a phone that isn't syncing.316  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});317  // §11: the Sync generation this installation is on. Not secret — a version318  // and a state name — so it renders the same in any session.319  const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);320  const [resetOpen, setResetOpen] = createSignal(false);321  const [resetPassword, setResetPassword] = createSignal("");322  const [resetError, setResetError] = createSignal<string | null>(null);323  const [resetting, setResetting] = createSignal(false);324  const syncLight = () =>325    syncStatus() === "syncing"326      ? "busy"327      : syncIndicator(syncSummary(), pairedDevices().length);328  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());329  const lastSyncLine = () => {330    const s = syncSummary();331    if (!s.outcome) return "No sync attempted yet";332    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;333  };334  async function loadSyncState() {335    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {336      ok: boolean;337      data?: SyncOutcomeSummary;338    };339    if (r.ok && r.data) {340      setSyncSummary(r.data);341      setSyncStatus(r.data.status ?? "idle");342    }343  }344345  async function loadGeneration() {346    const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {347      ok: boolean;348      data?: SyncGeneration | null;349    };350    if (r.ok) setGeneration(r.data ?? null);351  }352353  async function runSecureReset() {354    setResetting(true);355    const r = (await sendMessage({356      type: "SECURE_RESET",357      payload: { password: resetPassword() },358    })) as { ok: boolean; data?: SyncGeneration | null; error?: string };359    setResetting(false);360    setResetPassword("");361    if (!r.ok) {362      setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");363      await loadGeneration();364      return;365    }366    setResetError(null);367    setResetOpen(false);368    if (r.data) setGeneration(r.data);369  }370371  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;372  function syncRelativeTime(when: number | undefined): string {373    if (!when) return "Not synced yet";374    const elapsed = Date.now() - when;375    if (elapsed < 60_000) return "Just now";376    const minutes = Math.floor(elapsed / 60_000);377    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;378    const hours = Math.floor(minutes / 60);379    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;380    const days = Math.floor(hours / 24);381    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";382  }383  function isSyncStale(d: PairedDevice): boolean {384    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;385  }386  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {387    switch (t) {388      case "android": return "Android";389      case "ios": return "iPhone";390      case "extension": return "Browser";391      default: return "Device";392    }393  }394395  async function loadPairedDevices() {396    if (!isPro()) return;397    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {398      ok: boolean;399      error?: string;400      data?: { devices: PairedDevice[] };401    };402    if (r.ok) {403      setTrustDataCorrupted(false);404      setPairedDevices(r.data?.devices ?? []);405    } else if (r.error === "trust_data_corrupted") {406      setPairedDevices([]);407      setTrustDataCorrupted(true);408    }409  }410  async function unpairDevice(deviceId: string) {411    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });412    setConfirmUnpair(null);413    await loadPairedDevices();414  }415  async function renameDevice(deviceId: string) {416    const name = renameDraft().trim();417    if (!name) return;418    const result = (await sendMessage({419      type: "RENAME_PAIRED_DEVICE",420      payload: { deviceId, name },421    })) as { ok: boolean };422    if (!result.ok) return;423    setRenameDeviceId(null);424    await loadPairedDevices();425  }426  async function syncNow() {427    setSyncStatus("syncing");428    setSyncFlash(null);429    const res = (await sendMessage({430      type: "TRIGGER_SYNC",431      payload: { reason: "manual", force: true },432    })) as { ok: boolean; error?: string };433    if (!res.ok) {434      setSyncStatus("error");435      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });436      return;437    }438    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the439    // outcome. A single delayed read used to land while the run was still440    // "syncing" and leave the button disabled forever.441    let s: SyncOutcomeSummary = { status: "syncing" };442    for (let i = 0; i < 40 && s.status === "syncing"; i++) {443      await new Promise((r) => setTimeout(r, 250));444      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {445        ok: boolean;446        data?: SyncOutcomeSummary;447      };448      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };449    }450    setSyncSummary(s);451    setSyncStatus(s.status ?? "idle");452    // The persistent line below carries the outcome; the flash is only the453    // "that click did something" acknowledgement.454    setSyncFlash(describeSyncOutcome(s));455    setTimeout(() => setSyncFlash(null), 4000);456    await loadPairedDevices();457  }458  // UI-4 / MV-8: rotate sync identity confirmation dialog.459  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);460  const [rotating, setRotating] = createSignal(false);461  const [rotateError, setRotateError] = createSignal<string | null>(null);462  const [showSyncConnection, setShowSyncConnection] = createSignal(false);463  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);464465  async function loadPendingVouched() {466    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {467      ok: boolean;468      data?: PendingVouched[];469    };470    if (r.ok) setPendingVouched(r.data ?? []);471  }472  async function confirmVouched(deviceId: string) {473    setVouchedBusy(deviceId);474    try {475      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });476      await loadPendingVouched();477    } finally {478      setVouchedBusy(null);479    }480  }481  async function rejectVouched(deviceId: string) {482    setVouchedBusy(deviceId);483    try {484      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });485      await loadPendingVouched();486    } finally {487      setVouchedBusy(null);488    }489  }490  async function doRotateSyncIdentity() {491    setRotateError(null);492    setRotating(true);493    try {494      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {495        ok: boolean;496        error?: string;497      };498      if (!r.ok) {499        setRotateError(r.error ?? "Rotation failed.");500        return;501      }502      setRotateConfirmOpen(false);503      // Backend set peach_recovery_pending; reuse the existing recovery-words504      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).505      setShowNewRecoveryWords(true);506    } finally {507      setRotating(false);508    }509  }510511  const [dedupCount, setDedupCount] = createSignal<number | null>(null);512  const [dedupRunning, setDedupRunning] = createSignal(false);513  const [dedupDone, setDedupDone] = createSignal<number | null>(null);514515  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);516517  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted518  // BEFORE every real-mode vault write so the user can roll back if a sync519  // or import overwrites the wrong state.520  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);521  const [historyLoading, setHistoryLoading] = createSignal(false);522  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);523  const [historyShowAll, setHistoryShowAll] = createSignal(false);524525  async function loadVaultHistory() {526    setHistoryLoading(true);527    try {528      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });529      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];530      setHistorySnapshots(list);531    } finally {532      setHistoryLoading(false);533    }534  }535536  async function handleRestoreVaultSnapshot(id: string) {537    if (historyRestoring()) return;538    setHistoryRestoring(id);539    const res = await sendMessage({540      type: "RESTORE_VAULT_SNAPSHOT",541      payload: { snapshotId: id },542    });543    setHistoryRestoring(null);544    if ((res as { ok: boolean }).ok) {545      // Vault is now locked — let the parent close Settings; App.tsx's546      // VAULT_LOCKED listener will route back to the lock screen.547      props.onBack();548    }549  }550551  function formatSnapshotTime(savedAt: number): string {552    const diff = Date.now() - savedAt;553    if (diff < 60_000) return "Just now";554    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;555    const d = new Date(savedAt);556    const isToday = new Date().toDateString() === d.toDateString();557    const isYest =558      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();559    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });560    if (isToday) return `Today ${t}`;561    if (isYest) return `Yesterday ${t}`;562    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;563  }564565  function reasonLabel(reason: VaultSnapshot["reason"]): string {566    return (567      {568        local_save: "saved",569        sync_received: "synced",570        import: "imported",571        restore: "restored",572      } as const573    )[reason] ?? reason;574  }575576  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);577  const [showWhatsNew, setShowWhatsNew] = createSignal(false);578579  const [showChangePw, setShowChangePw] = createSignal(false);580  const [changePwCurrent, setChangePwCurrent] = createSignal("");581  const [changePwNew, setChangePwNew] = createSignal("");582  const [changePwConfirm, setChangePwConfirm] = createSignal("");583  const [changePwSaving, setChangePwSaving] = createSignal(false);584  const [changePwError, setChangePwError] = createSignal<string | null>(null);585  const [changePwSuccess] = createSignal(false);586  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);587588  const [pinEnabled, setPinEnabled] = createSignal(false);589  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);590  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");591  const [pinValue, setPinValue] = createSignal("");592  const [pinConfirm, setPinConfirm] = createSignal("");593  const [pinSaving, setPinSaving] = createSignal(false);594  const [pinError, setPinError] = createSignal<string | null>(null);595596  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.597  const [showViewWords, setShowViewWords] = createSignal(false);598  const [viewWordsPassword, setViewWordsPassword] = createSignal("");599  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);600  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);601  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);602603  async function handleViewRecoveryWords() {604    setViewWordsError(null);605    if (!viewWordsPassword()) {606      setViewWordsError("Enter your master password.");607      return;608    }609    setViewWordsBusy(true);610    try {611      const r = (await sendMessage({612        type: "GET_RECOVERY_WORDS",613        payload: { password: viewWordsPassword() },614      })) as { ok: boolean; data?: string[]; error?: string };615      if (r.ok) {616        setViewedWords(r.data ?? []);617        setViewWordsPassword("");618        return;619      }620      switch (r.error) {621        case "wrong_password":622          setViewWordsError("Incorrect password");623          break;624        case "password_required":625          setViewWordsError("Enter your master password.");626          break;627        case "recovery_not_setup":628          setViewWordsError("Recovery words aren't set up for this vault yet.");629          break;630        default:631          setViewWordsError("Couldn't retrieve recovery words.");632      }633    } finally {634      setViewWordsBusy(false);635    }636  }637  function closeViewWords() {638    setShowViewWords(false);639    setViewWordsPassword("");640    setViewWordsError(null);641    setViewedWords(null);642  }643644  const [duressEnabled, setDuressEnabled] = createSignal(false);645  const [showDuressSetup, setShowDuressSetup] = createSignal(false);646  const [duressPassword, setDuressPassword] = createSignal("");647  const [duressConfirm, setDuressConfirm] = createSignal("");648  const [duressMainPassword, setDuressMainPassword] = createSignal("");649  const [duressSaving, setDuressSaving] = createSignal(false);650  const [duressError, setDuressError] = createSignal<string | null>(null);651  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);652653  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);654655  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);656  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);657658  const [fbCategory, setFbCategory] = createSignal("Bug report");659  const [fbMessage, setFbMessage] = createSignal("");660  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';661  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');662663  createEffect(() => {664    if (props.upgradeHighlight) {665      setActiveCard("pro");666    }667  });668669  onMount(async () => {670    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([671      sendMessage({ type: "GET_LICENSE" }),672      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),673      sendMessage({ type: "IS_DURESS_ENABLED" }),674      sendMessage({ type: "GET_SETTINGS" }),675      sendMessage({ type: "GET_LICENSE_KEY" }),676    ]);677678    // Vault history loads in the background — the Vault sub-view picks it up679    // when the user navigates in. Loading it lazily keeps Settings boot fast.680    void loadVaultHistory();681682    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the683    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.684    void loadPendingVouched();685    void loadPairedDevices();686    void loadSyncState();687    void loadGeneration();688    const onBroadcast = (msg: unknown) => {689      const m = msg as { type?: string; payload?: unknown } | null;690      if (m?.type === "VOUCHED_DEVICE_PENDING") {691        void loadPendingVouched();692      }693      if (m?.type === "SYNC_STATE_CHANGED") {694        const payload = (m.payload ?? {}) as SyncOutcomeSummary;695        setSyncSummary(payload);696        setSyncStatus(payload.status ?? "idle");697      }698    };699    chrome.runtime?.onMessage?.addListener(onBroadcast);700    onCleanup(() => {701      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }702    });703704    if ((licenseRes as { ok: boolean }).ok) {705      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);706    }707708    if ((passkeyRes as { ok: boolean }).ok) {709      setPasskeyState(710        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null711      );712    }713714    if ((duressRes as { ok: boolean }).ok) {715      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);716    }717718    if ((settingsRes as { ok: boolean }).ok) {719      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;720      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);721      setPinEnabled(sData?.pinEnabled === true);722    }723724    if ((keyRes as { ok: boolean }).ok) {725      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);726    }727728    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });729    if ((entriesRes as { ok: boolean }).ok) {730      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];731      setCanaryEntries(732        entries733          .filter((e) => e.canary?.webhookUrl)734          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))735      );736    }737738  });739740  function resetPinFlow() {741    setPinFlow(null);742    setPinCurrentPassword("");743    setPinValue("");744    setPinConfirm("");745    setPinError(null);746  }747748  async function handleSetupPin() {749    setPinError(null);750    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }751    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }752    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }753    setPinSaving(true);754    const res = await sendMessage({755      type: "SETUP_PIN",756      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },757    }) as { ok: boolean; error?: string };758    if (res.ok) {759      setPinEnabled(true);760      resetPinFlow();761    } else {762      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");763    }764    setPinSaving(false);765  }766767  async function handleDisablePin() {768    setPinError(null);769    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }770    setPinSaving(true);771    const res = await sendMessage({772      type: "DISABLE_PIN",773      payload: { currentPassword: pinCurrentPassword() },774    }) as { ok: boolean; error?: string };775    if (res.ok) {776      setPinEnabled(false);777      resetPinFlow();778    } else {779      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");780    }781    setPinSaving(false);782  }783784  async function handleChangePassword() {785    setChangePwError(null);786    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }787    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }788    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }789    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }790    setChangePwSaving(true);791    const res = await sendMessage({792      type: "CHANGE_PASSWORD",793      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },794    }) as { ok: boolean; error?: string };795    if (res.ok) {796      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");797      setShowChangePw(false);798      // LV-4: the user just authenticated (current password), so the recovery799      // words reveal that follows is exempt from the re-auth gate. The800      // background owns this session-scoped exemption; the display clears it.801      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });802      setShowNewRecoveryWords(true);803    } else {804      setChangePwError(res.error ?? "failed to change password");805    }806    setChangePwSaving(false);807  }808809  const [copied, setCopied] = createSignal(false);810811  async function copyLicenseKey(key: string) {812    await navigator.clipboard.writeText(key);813    setCopied(true);814    setTimeout(() => setCopied(false), 2000);815  }816817  async function handleActivateLicense() {818    const key = licenseKeyInput().trim();819    if (!key) return;820    setActivatingLicense(true);821    setLicenseError(null);822    setLicenseSuccess(null);823    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });824    setActivatingLicense(false);825    if ((res as { ok: boolean }).ok) {826      const data = (res as { ok: boolean; data?: LicenseInfo }).data;827      if (data?.valid) {828        setLicenseInfo(data);829        setLicenseKey(key);830        setLicenseSuccess("License activated!");831        setLicenseKeyInput("");832      } else {833        setLicenseError("Invalid license key");834      }835    } else {836      setLicenseError("Activation failed");837    }838  }839840  async function handleTogglePasskeys() {841    const current = passkeyState();842    if (!current || !current.supported) return;843    setPasskeySaving(true);844    const res = await sendMessage({845      type: "SET_PASSKEY_PROVIDER_ENABLED",846      payload: { enabled: !current.enabled },847    });848    setPasskeySaving(false);849    if ((res as { ok: boolean }).ok) {850      setPasskeyState(851        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null852      );853    }854  }855856  async function handleCheckAllBreaches() {857    setCheckingBreaches(true);858    setBreachResult(null);859    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });860    setCheckingBreaches(false);861    if ((res as { ok: boolean }).ok) {862      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};863      const breached = Object.values(data).filter((count) => count > 0).length;864      setBreachResult(865        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"866      );867      props.onBreachCheckDone();868    }869  }870871872  async function handleSetupDuress() {873    const mainPw = duressMainPassword().trim();874    const duressPw = duressPassword().trim();875    const confirmPw = duressConfirm().trim();876877    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }878    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }879    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }880    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }881882    setDuressSaving(true);883    setDuressError(null);884885    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };886    if (!verifyRes.ok || !verifyRes.data) {887      setDuressError("incorrect main password");888      setDuressSaving(false);889      return;890    }891892    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };893    setDuressSaving(false);894    if (res.ok) {895      setDuressEnabled(true);896      setShowDuressSetup(false);897      setDuressPassword("");898      setDuressConfirm("");899      setDuressMainPassword("");900      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");901      setTimeout(() => setDuressSuccess(null), 5000);902    } else {903      setDuressError(904        res.error === "session_expired"905          ? "Vault session expired — lock and unlock Peach, then try again"906          : res.error ?? "failed to set duress password"907      );908    }909  }910911  async function handleClearDeclinedCards() {912    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });913    setDeclinedCardCount(0);914  }915916  async function handleCountDuplicates() {917    setDedupRunning(true);918    setDedupDone(null);919    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };920    setDedupRunning(false);921    if (res.ok) setDedupCount(res.data ?? 0);922  }923924  async function handleDeduplicate() {925    setDedupRunning(true);926    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };927    setDedupRunning(false);928    if (res.ok) {929      setDedupDone(res.data ?? 0);930      setDedupCount(0);931    }932  }933934  async function handleDisableDuress() {935    const mainPw = duressMainPassword().trim();936    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }937938    setDuressSaving(true);939    setDuressError(null);940941    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };942    if (!verifyRes.ok || !verifyRes.data) {943      setDuressError("incorrect main password");944      setDuressSaving(false);945      return;946    }947948    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };949    setDuressSaving(false);950    if (res.ok) {951      setDuressEnabled(false);952      setShowDuressSetup(false);953      setDuressMainPassword("");954      setDuressSuccess("Duress password disabled.");955      setTimeout(() => setDuressSuccess(null), 4000);956    } else {957      setDuressError(res.error ?? "failed to disable duress password");958    }959  }960961  async function handleSubmitFeedback() {962    const message = sanitizeFeedbackMessage(fbMessage());963    if (!message) return;964965    setFeedbackState('sending');966967    try {968      const category = FEEDBACK_CATEGORIES.includes(fbCategory())969        ? fbCategory()970        : FEEDBACK_CATEGORIES[0];971      const res = await fetch('https://api.web3forms.com/submit', {972        method: 'POST',973        headers: { 'Content-Type': 'application/json' },974        body: JSON.stringify({975          access_key: WEB3FORMS_KEY,976          subject: `Peach Feedback - ${category}`,977          message,978          from_name: 'Peach User',979          // Honeypot — leave blank980          botcheck: '',981        }),982      });983984      if (!res.ok) throw new Error(`HTTP ${res.status}`);985      const data = await res.json() as { success: boolean; message?: string };986      if (!data.success) throw new Error(data.message ?? 'unknown error');987988      setFeedbackState('sent');989      setFbMessage('');990      setFbCategory(FEEDBACK_CATEGORIES[0]);991    } catch (err) {992      console.error('[feedback]', err);993      setFeedbackState('error');994      setTimeout(() => setFeedbackState('idle'), 4000);995    }996  }997998  function renderSecurity() {999    return (1000      <div class="peach-settings-body">1001        <div class="peach-settings-group">1002          <p class="peach-settings-sub-label">LOCKING</p>1003          <div class="peach-settings-row">1004            <span>Lock on close</span>1005            <button1006              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1007              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1008              aria-label="Toggle lock on close"1009            >1010              <span class="peach-toggle-knob"></span>1011            </button>1012          </div>1013          <div class="peach-settings-row">1014            <span>Auto-lock after</span>1015            <select1016              class="peach-settings-select"1017              value={String(props.autoLockMinutes)}1018              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1019            >1020              <option value="1">1 minute</option>1021              <option value="5">5 minutes</option>1022              <option value="15">15 minutes</option>1023              <option value="30">30 minutes</option>1024              <option value="0">Never</option>1025            </select>1026          </div>10271028          <p class="peach-settings-sub-label">QUICK UNLOCK</p>1029          <div class="peach-settings-row peach-settings-row--meta">1030            <span>PIN unlock</span>1031            <button1032              class="peach-btn peach-btn--subtle peach-btn--sm"1033              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1034            >1035              {pinEnabled() ? "remove" : "set up"}1036            </button>1037          </div>1038          <Show when={pinFlow() === "setup"}>1039            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1040              <input1041                class="peach-settings-input"1042                type="password"1043                placeholder="current password"1044                value={pinCurrentPassword()}1045                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1046                autocomplete="current-password"1047              />1048              <input1049                class="peach-settings-input"1050                type="password"1051                inputmode="numeric"1052                maxlength="12"1053                placeholder="new 4–12 digit PIN"1054                value={pinValue()}1055                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1056                autocomplete="off"1057              />1058              <input1059                class="peach-settings-input"1060                type="password"1061                inputmode="numeric"1062                maxlength="12"1063                placeholder="confirm PIN"1064                value={pinConfirm()}1065                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1066                autocomplete="off"1067                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1068              />1069              <Show when={pinError()}>1070                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1071              </Show>1072              <button1073                class="peach-btn peach-btn--accent peach-btn--sm"1074                style="align-self:flex-start"1075                onClick={() => void handleSetupPin()}1076                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1077              >1078                {pinSaving() ? "saving…" : "save PIN"}1079              </button>1080            </div>1081          </Show>1082          <Show when={pinFlow() === "disable"}>1083            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1084              <input1085                class="peach-settings-input"1086                type="password"1087                placeholder="current password"1088                value={pinCurrentPassword()}1089                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1090                autocomplete="current-password"1091                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1092              />1093              <Show when={pinError()}>1094                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1095              </Show>1096              <button1097                class="peach-btn peach-btn--subtle peach-btn--sm"1098                style="align-self:flex-start"1099                onClick={() => void handleDisablePin()}1100                disabled={pinSaving() || !pinCurrentPassword()}1101              >1102                {pinSaving() ? "removing…" : "remove PIN"}1103              </button>1104            </div>1105          </Show>11061107          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1108          <div class="peach-settings-row">1109            <span>Breach monitoring</span>1110            <button1111              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1112              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1113              aria-label="Toggle breach monitoring"1114            >1115              <span class="peach-toggle-knob"></span>1116            </button>1117          </div>1118          <div class="peach-settings-row peach-settings-row--meta">1119            <span>Check all passwords</span>1120            <button1121              class="peach-btn peach-btn--subtle peach-btn--sm"1122              onClick={() => void handleCheckAllBreaches()}1123              disabled={checkingBreaches()}1124            >1125              {checkingBreaches() ? "checking..." : "run"}1126            </button>1127          </div>1128          <Show when={breachResult()}>1129            <div class="peach-settings-row peach-settings-row--meta">1130              <span>Last check</span>1131              <span class="peach-settings-value">{breachResult()}</span>1132            </div>1133          </Show>11341135          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1136          <div class="peach-settings-row peach-settings-row--meta">1137            <span>Master password</span>1138            <button1139              class="peach-btn peach-btn--subtle peach-btn--sm"1140              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1141            >1142              {showChangePw() ? "cancel" : "change"}1143            </button>1144          </div>1145          <Show when={showChangePw()}>1146            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1147              <input1148                class="peach-settings-input"1149                type="password"1150                placeholder="current password"1151                value={changePwCurrent()}1152                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1153                autocomplete="current-password"1154              />1155              <input1156                class="peach-settings-input"1157                type="password"1158                placeholder="new password (min 12 chars)"1159                value={changePwNew()}1160                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1161                autocomplete="new-password"1162              />1163              <input1164                class="peach-settings-input"1165                type="password"1166                placeholder="confirm new password"1167                value={changePwConfirm()}1168                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1169                autocomplete="new-password"1170                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1171              />1172              <Show when={changePwError()}>1173                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1174              </Show>1175              <Show when={changePwSuccess()}>1176                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1177              </Show>1178              <button1179                class="peach-btn peach-btn--accent peach-btn--sm"1180                style="align-self:flex-start"1181                onClick={() => void handleChangePassword()}1182                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1183              >1184                {changePwSaving() ? "saving…" : "save new password"}1185              </button>1186            </div>1187          </Show>11881189          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1190          <div class="peach-settings-row peach-settings-row--meta">1191            <span>Recovery words</span>1192            <button1193              class="peach-btn peach-btn--subtle peach-btn--sm"1194              onClick={() => {1195                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1196              }}1197            >1198              {showViewWords() ? "cancel" : "view"}1199            </button>1200          </div>1201          <Show when={showViewWords()}>1202            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1203              <Show1204                when={viewedWords()}1205                fallback={1206                  <>1207                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1208                      Enter your master password to reveal your recovery words.1209                    </p>1210                    <input1211                      class="peach-settings-input"1212                      type="password"1213                      placeholder="master password"1214                      value={viewWordsPassword()}1215                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1216                      autocomplete="current-password"1217                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1218                    />1219                    <Show when={viewWordsError()}>1220                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1221                    </Show>1222                    <button1223                      class="peach-btn peach-btn--accent peach-btn--sm"1224                      style="align-self:flex-start"1225                      onClick={() => void handleViewRecoveryWords()}1226                      disabled={viewWordsBusy() || !viewWordsPassword()}1227                    >1228                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1229                    </button>1230                  </>1231                }1232              >1233                <div class="peach-recovery-words-grid">1234                  <For each={viewedWords()!}>1235                    {(word, i) => (1236                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1237                    )}1238                  </For>1239                </div>1240                <button1241                  class="peach-btn peach-btn--subtle peach-btn--sm"1242                  style="align-self:flex-start"1243                  onClick={closeViewWords}1244                >done</button>1245              </Show>1246            </div>1247          </Show>12481249          <p class="peach-settings-sub-label">1250            DURESS PASSWORD1251            <Show when={!isPro()}>1252              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1253            </Show>1254          </p>1255          <div class="peach-settings-row peach-settings-row--top">1256            <div class="peach-settings-row-main">1257              <span>1258                Duress password1259                <Show when={!isPro()}>1260                  <span class="peach-pro-badge">Pro</span>1261                </Show>1262              </span>1263              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1264                If entered under coercion, shows a decoy empty vault instead of your real one.1265              </span>1266            </div>1267            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1268              {duressEnabled() ? "on" : "off"}1269            </div>1270          </div>12711272          <Show when={duressEnabled() && !showDuressSetup()}>1273            <div class="peach-settings-row peach-settings-row--meta">1274              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1275                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.1276              </span>1277            </div>1278          </Show>12791280          <Show when={duressSuccess()}>1281            <div class="peach-settings-row peach-settings-row--meta">1282              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1283            </div>1284          </Show>12851286          <Show when={!showDuressSetup()}>1287            <div class="peach-settings-row peach-settings-row--meta">1288              <button1289                class="peach-btn peach-btn--subtle peach-btn--sm"1290                onClick={() => {1291                  // Pro gate entry-point: free users are redirected to the1292                  // upgrade screen before the setup form opens. The server-1293                  // side SETUP_DURESS handler is the single enforcement1294                  // point; this is UI redirection so the form never appears1295                  // for a user who can't complete it.1296                  if (!isPro() && !duressEnabled()) {1297                    props.onProGate?.("duress_vault");1298                    setActiveCard("pro");1299                    return;1300                  }1301                  setShowDuressSetup(true);1302                  setDuressError(null);1303                  setDuressSuccess(null);1304                  setDuressPassword("");1305                  setDuressConfirm("");1306                  setDuressMainPassword("");1307                }}1308              >1309                {duressEnabled() ? "Change / disable" : "Set up"}1310              </button>1311            </div>1312          </Show>13131314          <Show when={showDuressSetup()}>1315            <div class="peach-duress-form">1316              <label class="peach-label">main password (to confirm)</label>1317              <input1318                class="peach-settings-input"1319                type="password"1320                placeholder="your current master password"1321                autocomplete="current-password"1322                value={duressMainPassword()}1323                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1324              />1325              <Show when={!duressEnabled()}>1326                <label class="peach-label">new duress password</label>1327                <input1328                  class="peach-settings-input"1329                  type="password"1330                  placeholder="duress password (min 8 chars)"1331                  autocomplete="new-password"1332                  value={duressPassword()}1333                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1334                />1335                <label class="peach-label">confirm duress password</label>1336                <input1337                  class="peach-settings-input"1338                  type="password"1339                  placeholder="confirm duress password"1340                  autocomplete="new-password"1341                  value={duressConfirm()}1342                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1343                />1344              </Show>13451346              <Show when={duressError()}>1347                <p class="peach-hint peach-hint-error">{duressError()}</p>1348              </Show>13491350              <div class="peach-duress-actions">1351                <button1352                  class="peach-btn peach-btn--subtle peach-btn--sm"1353                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1354                >1355                  cancel1356                </button>1357                <Show when={duressEnabled()}>1358                  <button1359                    class="peach-btn peach-btn--ghost peach-btn--sm"1360                    style="color:var(--peach-breach)"1361                    onClick={() => void handleDisableDuress()}1362                    disabled={duressSaving()}1363                  >1364                    {duressSaving() ? "disabling..." : "disable duress"}1365                  </button>1366                </Show>1367                <Show when={!duressEnabled()}>1368                  <button1369                    class="peach-btn peach-btn--accent peach-btn--sm"1370                    onClick={() => void handleSetupDuress()}1371                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1372                  >1373                    {duressSaving() ? "saving..." : "save"}1374                  </button>1375                </Show>1376              </div>1377            </div>1378          </Show>13791380          <p class="peach-settings-sub-label">1381            CANARY ENTRIES1382            <Show when={!isPro()}>1383              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1384            </Show>1385          </p>1386          <div class="peach-settings-row peach-settings-row--top">1387            <div class="peach-settings-row-main">1388              <span>1389                Canary entries1390                <Show when={!isPro()}>1391                  <span class="peach-pro-badge">Pro</span>1392                </Show>1393              </span>1394              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1395                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1396              </span>1397            </div>1398            <span class="peach-settings-value" style="flex-shrink:0">1399              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1400            </span>1401          </div>1402          <Show when={canaryEntries().length > 0}>1403            <For each={canaryEntries()}>1404              {(entry) => (1405                <div class="peach-settings-row peach-settings-row--meta">1406                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1407                  <button1408                    class="peach-btn peach-btn--subtle peach-btn--sm"1409                    onClick={() => void handleTestCanary(entry)}1410                    disabled={canaryTestingId() === entry.id}1411                    style="flex-shrink:0"1412                  >1413                    {canaryTestingId() === entry.id1414                      ? "sending…"1415                      : canaryTestResult()?.id === entry.id1416                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1417                        : "test"}1418                  </button>1419                </div>1420              )}1421            </For>1422          </Show>14231424          <p class="peach-settings-sub-label">PASSKEYS</p>1425          <div class="peach-settings-row peach-settings-row--top">1426            <div class="peach-settings-row-main">1427              <span>Intercept passkey requests</span>1428              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1429                Peach can handle passkey sign-in and creation on supported Chromium browsers.1430              </span>1431            </div>1432            <button1433              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1434              onClick={() => void handleTogglePasskeys()}1435              aria-label="Toggle passkey interception"1436              disabled={!passkeyState()?.supported || passkeySaving()}1437            >1438              <span class="peach-toggle-knob"></span>1439            </button>1440          </div>1441          <div class="peach-settings-row peach-settings-row--meta">1442            <span>Status</span>1443            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1444          </div>1445          <div class="peach-settings-row peach-settings-row--meta">1446            <span>Browser</span>1447            <span class="peach-settings-value">1448              {passkeyState()?.browserLabel ?? "Unknown"}1449              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1450            </span>1451          </div>1452          <div class="peach-info-notice" style="margin-top:8px">1453            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1454          </div>1455          <Show when={passkeyState()?.conflict}>1456            <div class="peach-settings-row peach-settings-row--meta">1457              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1458            </div>1459          </Show>1460        </div>1461      </div>1462    );1463  }14641465  function renderVault() {1466    return (1467      <div class="peach-settings-body">1468        <div class="peach-settings-group">1469          <p class="peach-settings-sub-label">ENTRIES</p>1470          <div class="peach-settings-row peach-settings-row--meta">1471            <span>Entries</span>1472            <span class="peach-settings-value">{props.entriesCount}</span>1473          </div>1474          <div class="peach-settings-row peach-settings-row--meta">1475            <span>Duplicates</span>1476            <Show when={dedupDone() !== null} fallback={1477              <button1478                class="peach-btn peach-btn--subtle peach-btn--sm"1479                onClick={() => void handleCountDuplicates()}1480                disabled={dedupRunning()}1481              >1482                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1483              </button>1484            }>1485              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1486                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1487              </span>1488            </Show>1489          </div>1490          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1491            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1492              <div class="peach-restore-warning">1493                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1494              </div>1495              <div style="display:flex;gap:6px">1496                <button1497                  class="peach-btn peach-btn--subtle peach-btn--sm"1498                  onClick={() => setDedupCount(null)}1499                >1500                  cancel1501                </button>1502                <button1503                  class="peach-btn peach-btn--accent peach-btn--sm"1504                  onClick={() => void handleDeduplicate()}1505                  disabled={dedupRunning()}1506                >1507                  {dedupRunning() ? "removing..." : "remove duplicates"}1508                </button>1509              </div>1510            </div>1511          </Show>15121513          <p class="peach-settings-sub-label">MAINTENANCE</p>1514          <div class="peach-settings-row peach-settings-row--meta">1515            <span>Trash auto-delete</span>1516            <select1517              class="peach-settings-select"1518              value={String(props.trashRetentionDays)}1519              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1520            >1521              <option value="7">7 days</option>1522              <option value="30">30 days</option>1523              <option value="90">90 days</option>1524              <option value="0">Never</option>1525            </select>1526          </div>1527          <div class="peach-settings-row peach-settings-row--meta">1528            <span>Advanced Vault Health</span>1529            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1530              open1531            </button>1532          </div>1533          <Show when={declinedCardCount() > 0}>1534            <div class="peach-settings-row peach-settings-row--meta">1535              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1536              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1537                Clear1538              </button>1539            </div>1540          </Show>15411542          <p class="peach-settings-sub-label">DATA</p>1543          <div class="peach-settings-row peach-settings-row--meta">1544            <span>Import passwords</span>1545            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1546              open1547            </button>1548          </div>1549          <div class="peach-settings-row peach-settings-row--meta">1550            <span>Export backup</span>1551            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1552              open1553            </button>1554          </div>1555          <div class="peach-settings-row peach-settings-row--meta">1556            <span>1557              Peach Codex1558              <Show when={!isPro()}>1559                <span class="peach-pro-badge">Pro</span>1560              </Show>1561            </span>1562            <button1563              class="peach-btn peach-btn--subtle peach-btn--sm"1564              onClick={() => props.onOpenCodexGenerate?.()}1565            >1566              open1567            </button>1568          </div>1569          {/* Restore from Codex: intentionally never gated — a printed1570              Codex must remain restorable regardless of license state. */}1571          <div class="peach-settings-row peach-settings-row--meta">1572            <span>Restore from Codex</span>1573            <button1574              class="peach-btn peach-btn--subtle peach-btn--sm"1575              onClick={() => props.onOpenCodexRestore?.()}1576            >1577              open1578            </button>1579          </div>15801581          <Show when={hasReleaseNotes()}>1582            <p class="peach-settings-sub-label">RELEASE NOTES</p>1583            <div class="peach-settings-row peach-settings-row--meta">1584              <span>v{PEACH_VERSION}</span>1585              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1586                view1587              </button>1588            </div>1589          </Show>15901591          <span class="peach-settings-sub-label">VAULT HISTORY</span>1592          <Show when={historyLoading()}>1593            <p class="peach-settings-hint">Loading…</p>1594          </Show>1595          <Show when={!historyLoading() && historySnapshots().length === 0}>1596            <p class="peach-settings-hint">1597              No snapshots yet. Peach saves automatically before every change —1598              saves, syncs, and imports.1599            </p>1600          </Show>1601          <Show when={!historyLoading() && historySnapshots().length > 0}>1602            <p class="peach-settings-hint">1603              Restoring locks your vault. Unlock with the password that1604              encrypted that snapshot to confirm.1605            </p>1606            <div class="peach-history-list">1607              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1608                {(snap) => (1609                  <div class="peach-history-row">1610                    <div class="peach-history-meta">1611                      <span class="peach-history-time">1612                        {formatSnapshotTime(snap.savedAt)}1613                      </span>1614                      <span class="peach-history-sub">1615                        {[1616                          snap.entryCount !== null1617                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1618                            : null,1619                          reasonLabel(snap.reason),1620                        ]1621                          .filter(Boolean)1622                          .join(" · ")}1623                      </span>1624                    </div>1625                    <button1626                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1627                        historyRestoring() === snap.id ? " is-busy" : ""1628                      }`}1629                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1630                      disabled={historyRestoring() !== null}1631                    >1632                      <Show when={historyRestoring() === snap.id} fallback="Restore">1633                        …1634                      </Show>1635                    </button>1636                  </div>1637                )}1638              </For>1639            </div>1640            <Show when={historySnapshots().length > 3}>1641              <button1642                class="peach-settings-advanced-toggle"1643                onClick={() => setHistoryShowAll((v) => !v)}1644                aria-expanded={historyShowAll()}1645              >1646                <span class="peach-settings-hint" style="margin:0">1647                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1648                </span>1649                <span class="peach-settings-advanced-chevron">1650                  {historyShowAll() ? "▲" : "▼"}1651                </span>1652              </button>1653            </Show>1654            <p class="peach-history-footer">1655              {historySnapshots().length} of 10 snapshots1656            </p>1657          </Show>16581659          <button1660            class="peach-settings-advanced-toggle"1661            onClick={() => setShowVaultAdvanced((v) => !v)}1662            aria-expanded={showVaultAdvanced()}1663          >1664            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1665            <span class="peach-settings-advanced-chevron">1666              {showVaultAdvanced() ? "▲" : "▼"}1667            </span>1668          </button>16691670          <Show when={showVaultAdvanced()}>1671            <div class="peach-danger-card">1672              <div class="peach-danger-card-head">1673                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1674                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1675              </div>1676              <p class="peach-danger-card-copy">1677                Wipes the current vault and restores from a backup file.1678                Recoverable only if your backup is good.1679              </p>1680              <button1681                class="peach-danger-card-button"1682                onClick={() => props.onOpenFileImport?.()}1683              >1684                Choose backup file1685              </button>1686            </div>16871688            <Show when={props.onOpenPanic}>1689              <div class="peach-danger-card peach-danger-card--hazard">1690                <div class="peach-danger-card-head">1691                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1692                  <h3 class="peach-danger-card-title">Destroy vault</h3>1693                </div>1694                <p class="peach-danger-card-copy">1695                  Permanently wipes every entry, setting, and license on this1696                  device. There is no recovery.1697                </p>1698                <button1699                  class="peach-danger-card-button"1700                  onClick={() => props.onOpenPanic?.()}1701                >1702                  Begin destruction1703                </button>1704              </div>1705            </Show>1706          </Show>1707        </div>1708      </div>1709    );1710  }17111712  function renderSync() {1713    return (1714      <div class="peach-settings-body">1715        <div class="peach-settings-group">1716          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1717          <Show when={trustDataCorrupted()}>1718            <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1719              Device trust data is damaged — re-pair your devices after updating1720            </p>1721          </Show>1722          <div1723            class="peach-settings-row peach-settings-row--meta"1724            style="align-items:center;gap:8px"1725            aria-label={syncLightLabel()}1726            title={syncLightLabel()}1727          >1728            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1729            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1730              {syncFlash() ?? lastSyncLine()}1731            </span>1732          </div>1733          <Show when={pairedDevices().length > 0}>1734            <div class="peach-settings-row peach-settings-row--meta">1735              <span class="peach-settings-value" style="color:var(--peach-t2)">1736                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01737                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1738                  : ""}1739              </span>1740              <button1741                class="peach-btn peach-btn--subtle peach-btn--sm"1742                aria-label="Sync this vault now"1743                disabled={syncStatus() === "syncing"}1744                onClick={() => void syncNow()}1745              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1746            </div>1747          </Show>17481749          <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1750          <div class="peach-settings-row peach-settings-row--top">1751            <div class="peach-settings-row-main">1752              <span class="peach-settings-value" style="color:var(--peach-t1)">1753                {secureResetHeadline(generation()?.state)}1754              </span>1755              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1756                {secureResetDetail(generation()?.state)}1757              </span>1758            </div>1759            <Show1760              when={1761                generation()?.state === "LEGACY_ACTIVE" ||1762                generation()?.state === "RESET_REQUIRED" ||1763                generation()?.state === "RESET_CONFIRMED"1764              }1765            >1766              <button1767                class="peach-btn peach-btn--subtle peach-btn--sm"1768                onClick={() => {1769                  setResetError(null);1770                  setResetPassword("");1771                  setResetOpen(true);1772                }}1773              >reset sync</button>1774            </Show>1775          </div>1776          <Show when={resetError()}>1777            <p1778              class="peach-settings-value"1779              role="alert"1780              style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1781            >{resetError()}</p>1782          </Show>1783          <Show when={resetOpen()}>1784            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1785              <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1786                {SECURE_RESET_DISCLOSURE}1787              </p>1788              <input1789                class="peach-settings-input"1790                type="password"1791                placeholder="master password"1792                value={resetPassword()}1793                onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1794                autocomplete="current-password"1795              />1796              <div style="display:flex;gap:4px">1797                <button1798                  class="peach-btn peach-btn--subtle peach-btn--sm"1799                  disabled={resetting()}1800                  onClick={() => void runSecureReset()}1801                >{resetting() ? "resetting…" : "reset"}</button>1802                <button1803                  class="peach-btn peach-btn--subtle peach-btn--sm"1804                  disabled={resetting()}1805                  onClick={() => setResetOpen(false)}1806                >cancel</button>1807              </div>1808            </div>1809          </Show>18101811          <p class="peach-settings-sub-label">ADD A DEVICE</p>1812          <button1813            class="peach-sync-pair-option"1814            onClick={() => props.onOpenSyncPairing?.("code")}1815          >1816            <span class="peach-sync-pair-option-icon" aria-hidden="true">1817              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1818                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1819                <path d="M9 15 L11 15" />1820              </svg>1821            </span>1822            <span class="peach-sync-pair-option-main">1823              <span class="peach-sync-pair-option-title">Show a code</span>1824              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1825            </span>1826            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1827          </button>1828          <Show when={!isPro()}>1829            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1830              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1831            </p>1832          </Show>18331834          <Show when={pairedDevices().length > 0}>1835            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1836            <For each={pairedDevices()}>1837              {(d) => (1838                <div class="peach-settings-row peach-settings-row--top">1839                  <div class="peach-settings-row-main">1840                    <Show1841                      when={renameDeviceId() === d.deviceId}1842                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1843                    >1844                      <input1845                        class="peach-settings-input"1846                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1847                        maxlength={64}1848                        value={renameDraft()}1849                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1850                        onKeyDown={(event) => {1851                          if (event.key === "Enter") void renameDevice(d.deviceId);1852                          if (event.key === "Escape") setRenameDeviceId(null);1853                        }}1854                      />1855                    </Show>1856                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1857                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1858                    </span>1859                  </div>1860                  <Show1861                    when={confirmUnpair() === d.deviceId}1862                    fallback={1863                      <div style="display:flex;gap:4px;flex-shrink:0">1864                        <Show1865                          when={renameDeviceId() === d.deviceId}1866                          fallback={1867                            <button1868                              class="peach-btn peach-btn--subtle peach-btn--sm"1869                              onClick={() => {1870                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1871                                setRenameDeviceId(d.deviceId);1872                              }}1873                            >rename</button>1874                          }1875                        >1876                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1877                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1878                        </Show>1879                        <button1880                          class="peach-btn peach-btn--subtle peach-btn--sm"1881                          onClick={() => setConfirmUnpair(d.deviceId)}1882                        >remove</button>1883                      </div>1884                    }1885                  >1886                    {/* HOLE 4: revocation stops this device receiving future1887                        changes. It does NOT secure the vault against it — that1888                        device may already hold the recovery words, which are1889                        master-password-equivalent and restore the whole vault1890                        anywhere, forever, with no sync involved. Say so here,1891                        rather than leaving the user falsely reassured. */}1892                    <div class="peach-unpair-confirm">1893                      <p class="peach-unpair-confirm-note">1894                        Stops this device receiving future changes. It doesn't1895                        lock it out of what it already has — if it's lost or1896                        stolen, also change your master password and generate1897                        new recovery words.1898                      </p>1899                      <div style="display:flex;gap:6px;justify-content:flex-end">1900                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1901                        <button1902                          class="peach-btn peach-btn--sm"1903                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1904                          onClick={() => void unpairDevice(d.deviceId)}1905                        >remove</button>1906                      </div>1907                    </div>1908                  </Show>1909                </div>1910              )}1911            </For>1912          </Show>19131914          <Show when={pendingVouched().length > 0}>1915            <p class="peach-settings-sub-label">1916              PENDING DEVICES1917              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1918            </p>1919            <For each={pendingVouched()}>1920              {(dev) => (1921                <div class="peach-settings-row peach-settings-row--top">1922                  <div class="peach-settings-row-main">1923                    <span>{dev.deviceName}</span>1924                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1925                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1926                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1927                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1928                      existing vault contents and all future changes.1929                    </span>1930                  </div>1931                  <div style="display:flex;gap:6px;flex-shrink:0">1932                    <button1933                      class="peach-btn peach-btn--sm"1934                      disabled={vouchedBusy() === dev.deviceId}1935                      onClick={() => void confirmVouched(dev.deviceId)}1936                    >Allow</button>1937                    <button1938                      class="peach-btn peach-btn--subtle peach-btn--sm"1939                      disabled={vouchedBusy() === dev.deviceId}1940                      onClick={() => void rejectVouched(dev.deviceId)}1941                    >Reject</button>1942                  </div>1943                </div>1944              )}1945            </For>1946          </Show>19471948          <button1949            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1950            onClick={() => setShowSyncConnection((value) => !value)}1951            aria-expanded={showSyncConnection()}1952            aria-controls="peach-sync-connection"1953          >1954            <span>1955              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1956              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1957            </span>1958            <span class="peach-settings-advanced-chevron">1959              {showSyncConnection() ? "▲" : "▼"}1960            </span>1961          </button>1962          <Show when={showSyncConnection()}>1963            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1964              <SyncModeSelector />1965            </div>1966          </Show>19671968          <button1969            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1970            onClick={() => setShowSyncRecovery((value) => !value)}1971            aria-expanded={showSyncRecovery()}1972            aria-controls="peach-sync-recovery"1973          >1974            <span>1975              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1976              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1977            </span>1978            <span class="peach-settings-advanced-chevron">1979              {showSyncRecovery() ? "▲" : "▼"}1980            </span>1981          </button>1982          <Show when={showSyncRecovery()}>1983            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1984              <Show1985                when={rotateConfirmOpen()}1986                fallback={1987                  <div class="peach-settings-row peach-settings-row--top">1988                    <div class="peach-settings-row-main">1989                      <span>Rotate sync identity</span>1990                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1991                        Disconnects every device and replaces the keys used for sync.1992                      </span>1993                    </div>1994                    <button1995                      class="peach-btn peach-btn--subtle peach-btn--sm"1996                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1997                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1998                    >rotate</button>1999                  </div>2000                }2001              >2002                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2003                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2004                  <Show when={rotateError()}>2005                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2006                  </Show>2007                  <div style="display:flex;gap:6px;margin-top:10px">2008                    <button2009                      class="peach-btn peach-btn--subtle peach-btn--sm"2010                      disabled={rotating()}2011                      onClick={() => setRotateConfirmOpen(false)}2012                    >Cancel</button>2013                    <button2014                      class="peach-btn peach-btn--sm"2015                      style="background:var(--peach-breach);border-color:var(--peach-breach)"2016                      disabled={rotating()}2017                      onClick={() => void doRotateSyncIdentity()}2018                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2019                  </div>2020                </div>2021              </Show>2022            </div>2023          </Show>2024        </div>2025      </div>2026    );2027  }20282029  function renderAppearance() {2030    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2031      { id: "medium", label: "Default" },2032      { id: "large", label: "Large" },2033    ];2034    return (2035      <div class="peach-settings-body">2036        <div class="peach-settings-group">2037          <p class="peach-settings-sub-label">THEME</p>2038          <div class="peach-settings-row peach-settings-row--meta">2039            <span>Theme</span>2040            <button2041              class="peach-btn peach-btn--subtle peach-btn--sm"2042              onClick={() =>2043                props.onThemeChange(props.theme === "dark" ? "light" : "dark")2044              }2045            >2046              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2047            </button>2048          </div>20492050          <p class="peach-settings-sub-label">ACCENT</p>2051          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2052            <span>Accent</span>2053            <div class="peach-accent-picker">2054              <For each={ACCENT_COLORS}>2055                {(color) => (2056                  <button2057                    class="peach-accent-swatch peach-swatch"2058                    classList={{ "is-active": props.accentColor === color.name }}2059                    style={{ background: color.hex }}2060                    onClick={() => void props.onAccentChange(color.name)}2061                    aria-label={color.name}2062                    title={color.name}2063                  />2064                )}2065              </For>2066            </div>2067          </div>20682069          <p class="peach-settings-sub-label">TEXT SIZE</p>2070          <div class="peach-settings-row peach-settings-row--meta">2071            <span>Size</span>2072            <div class="peach-pill-group">2073              <For each={sizeOptions}>2074                {(opt) => (2075                  <button2076                    class="peach-btn peach-btn--ghost peach-btn--sm"2077                    classList={{ "is-active": props.textSize === opt.id }}2078                    onClick={() => void props.onTextSizeChange(opt.id)}2079                  >2080                    {opt.label}2081                  </button>2082                )}2083              </For>2084            </div>2085          </div>20862087          <p class="peach-settings-sub-label">MOTION</p>2088          <div class="peach-settings-row peach-settings-row--meta">2089            <span>Reduce motion</span>2090            <button2091              class="peach-btn peach-btn--subtle peach-btn--sm"2092              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2093            >2094              {props.reduceMotion ? "On" : "Off"}2095            </button>2096          </div>2097        </div>2098      </div>2099    );2100  }21012102  function renderShortcuts() {2103    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2104    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21052106    function startCapture(action: ShortcutAction) {2107      setConflictMsg(null);2108      setCapturingAction(action);2109    }2110    function cancelCapture() {2111      setCapturingAction(null);2112      setConflictMsg(null);2113    }2114    function commitBinding(action: ShortcutAction, binding: Keybinding) {2115      const conflict = findConflict(props.shortcuts, binding, action);2116      if (conflict) {2117        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2118        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2119        return;2120      }2121      const next = { ...props.shortcuts, [action]: binding };2122      setCapturingAction(null);2123      setConflictMsg(null);2124      void props.onShortcutsChange(next);2125    }2126    function resetAction(action: ShortcutAction) {2127      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2128      void props.onShortcutsChange(next);2129    }2130    function resetAll() {2131      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2132    }21332134    // Capture-mode key handler — wired via ref onto the chip element so it2135    // catches the next non-modifier keypress and ignores other bindings.2136    function handleCaptureKey(e: KeyboardEvent) {2137      e.preventDefault();2138      e.stopPropagation();2139      if (e.key === "Escape") { cancelCapture(); return; }2140      const binding = keybindingFromEvent(e);2141      if (!binding) return; // bare modifier — keep waiting2142      const action = capturingAction();2143      if (action) commitBinding(action, binding);2144    }21452146    return (2147      <div class="peach-settings-body">2148        <div class="peach-settings-group peach-shortcuts-group">2149          <p class="peach-settings-sub-label">ENTRY VIEW</p>2150          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2151            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2152          </For>2153        </div>21542155        <div class="peach-settings-group peach-shortcuts-group">2156          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2157          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2158            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2159          </For>2160        </div>21612162        <Show when={conflictMsg()}>2163          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2164        </Show>21652166        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2167          <div class="peach-settings-row peach-settings-row--meta">2168            <span>Inline autofill on page</span>2169            <a2170              class="peach-settings-link"2171              href="chrome://extensions/shortcuts"2172              onClick={(e) => {2173                e.preventDefault();2174                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2175              }}2176            >2177              ⌘⇧P — customize ↗2178            </a>2179          </div>2180          <button2181            class="peach-btn peach-btn--subtle peach-btn--sm"2182            style="align-self:flex-start"2183            onClick={resetAll}2184          >2185            Reset all to defaults2186          </button>2187        </div>2188      </div>2189    );21902191    function renderRow(2192      desc: typeof SHORTCUT_DESCRIPTORS[number],2193      capturing: () => ShortcutAction | null,2194      onStart: (a: ShortcutAction) => void,2195      onCancel: () => void,2196      onReset: (a: ShortcutAction) => void,2197      onKey: (e: KeyboardEvent) => void,2198    ) {2199      const isActive = () => capturing() === desc.action;2200      const binding = () => props.shortcuts[desc.action];2201      const isCustom = () => {2202        const b = binding();2203        const d = DEFAULT_BINDINGS[desc.action];2204        return b.key !== d.key2205          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2206          || Boolean(b.meta)  !== Boolean(d.meta)2207          || Boolean(b.shift) !== Boolean(d.shift)2208          || Boolean(b.alt)   !== Boolean(d.alt);2209      };2210      return (2211        <div class="peach-shortcut-row">2212          <span class="peach-shortcut-row-label">{desc.label}</span>2213          <button2214            class="peach-shortcut-chip"2215            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2216            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2217            onKeyDown={(e) => { if (isActive()) onKey(e); }}2218            ref={(el) => {2219              // Auto-focus when entering capture mode so the keydown listener catches the next press.2220              if (el) {2221                queueMicrotask(() => { if (isActive()) el.focus(); });2222              }2223            }}2224          >2225            {isActive() ? "Press a key…" : formatKeybinding(binding())}2226          </button>2227          <Show when={isCustom() && !isActive()}>2228            <button2229              class="peach-shortcut-reset"2230              title="Reset to default"2231              onClick={() => onReset(desc.action)}2232            >2233              ↺2234            </button>2235          </Show>2236        </div>2237      );2238    }2239  }22402241  const PRO_FEATURES: Array<{2242    feature: ProFeature;2243    name: string;2244    desc: string;2245  }> = [2246    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2247    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2248    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2249    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2250  ];22512252  const PRO_SECURITY_FEATURES: Array<{2253    feature: ProFeature;2254    name: string;2255    desc: string;2256  }> = [2257    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2258    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2259  ];22602261  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2262  const [showKey, setShowKey] = createSignal(false);22632264  const [deactivating, setDeactivating] = createSignal(false);22652266  async function handleDeactivateLicense() {2267    setDeactivating(true);2268    await sendMessage({ type: "DEACTIVATE_LICENSE" });2269    setLicenseInfo(null);2270    await props.onLicenseChange?.();2271    setDeactivating(false);2272  }22732274  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2275    setCanaryTestingId(entry.id);2276    setCanaryTestResult(null);2277    const res = await sendMessage({2278      type: "FIRE_CANARY_PING",2279      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2280    }) as { ok: boolean };2281    setCanaryTestResult({ id: entry.id, ok: res.ok });2282    setCanaryTestingId(null);2283    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2284  }22852286  function renderLicense() {2287    async function onActivate() {2288      await handleActivateLicense();2289      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2290        await props.onLicenseChange?.();2291      }2292    }22932294    return (2295      <div class="peach-settings-body">2296        <Show2297          when={isPro()}2298          fallback={2299                <div class="peach-pro-upgrade">2300                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23012302                  <h2 class="peach-pro-headline">2303                    Every<em>thing,</em><br />every<em>where.</em>2304                  </h2>23052306                  <p class="peach-pro-subtitle">2307                    One key unlocks every Pro feature — for the lifetime of the app.2308                  </p>23092310                  <div class="peach-pro-grid">2311                    <For each={PRO_FEATURES}>2312                      {(item, i) => (2313                        <div2314                          class="peach-pro-grid-item"2315                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2316                        >2317                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2318                          <span class="peach-pro-grid-name">{item.name}</span>2319                          <span class="peach-pro-grid-desc">{item.desc}</span>2320                        </div>2321                      )}2322                    </For>2323                  </div>23242325                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2326                  <div class="peach-pro-grid">2327                    <For each={PRO_SECURITY_FEATURES}>2328                      {(item, i) => (2329                        <div2330                          class="peach-pro-grid-item"2331                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2332                        >2333                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2334                          <span class="peach-pro-grid-name">{item.name}</span>2335                          <span class="peach-pro-grid-desc">{item.desc}</span>2336                        </div>2337                      )}2338                    </For>2339                  </div>23402341                  <div class="peach-pro-upgrade-actions">2342                    <p class="peach-pro-pricing">2343                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2344                    </p>2345                    <a2346                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2347                      target="_blank"2348                      rel="noopener noreferrer"2349                      class="peach-pro-upgrade-cta"2350                    >2351                      Get Peach Pro2352                    </a>2353                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2354                    <div class="peach-pro-key-inline">2355                      <input2356                        class="peach-pro-key-inline-input"2357                        type="text"2358                        placeholder="PEACH-XXXX-XXXX-XXXX"2359                        value={licenseKeyInput()}2360                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2361                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2362                        autocomplete="off"2363                        spellcheck={false}2364                      />2365                      <button2366                        class="peach-pro-key-apply"2367                        onClick={() => void onActivate()}2368                        disabled={activatingLicense() || !licenseKeyInput().trim()}2369                      >2370                        {activatingLicense() ? "…" : "APPLY"}2371                      </button>2372                    </div>2373                    <Show when={licenseError()}>2374                      <p class="peach-pro-key-error">{licenseError()}</p>2375                    </Show>2376                  </div>2377                </div>2378          }2379        >2380          <div class="peach-pro-active">2381            <div class="peach-pro-active-hero">2382              <button2383                class="peach-pro-active-mark-wrap"2384                type="button"2385                aria-label="Celebrate"2386                onClick={handleProCelebrationClick}2387              >2388                <PeachMark class="peach-pro-active-mark" />2389                <Show when={justActivated()}>2390                  <div class="peach-pro-sparks" aria-hidden="true">2391                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2392                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2393                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2394                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2395                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2396                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2397                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2398                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2399                  </div>2400                </Show>2401              </button>2402              <h2 class="peach-pro-active-title">Peach Pro</h2>2403              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2404              <Show when={justActivated()}>2405                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2406              </Show>2407            </div>24082409            <Show when={licenseKey()}>2410              <div class="peach-settings-license">2411                <span class="peach-settings-label">LICENSE KEY</span>2412                <div class="peach-settings-license-row">2413                  <Show2414                    when={showKey()}2415                    fallback={2416                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2417                        ••••••••••••••••••••••••••••2418                      </span>2419                    }2420                  >2421                    <span class="peach-settings-license-key">{licenseKey()!}</span>2422                  </Show>2423                  <div class="peach-settings-license-actions">2424                    <button2425                      class="peach-settings-license-copy"2426                      title="Copy license key"2427                      onClick={() => copyLicenseKey(licenseKey()!)}2428                    >2429                      {copied() ? "✓" : "copy"}2430                    </button>2431                    <button2432                      class="peach-settings-license-toggle"2433                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2434                      title={showKey() ? "Hide" : "Reveal"}2435                      onClick={() => setShowKey(v => !v)}2436                    >2437                      <Show2438                        when={showKey()}2439                        fallback={2440                          <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">2441                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2442                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2443                          </svg>2444                        }2445                      >2446                        <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">2447                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2448                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2449                          <line x1="2" y1="2" x2="12" y2="12" />2450                        </svg>2451                      </Show>2452                    </button>2453                  </div>2454                </div>2455              </div>2456            </Show>24572458            <div class="peach-pro-active-features">2459              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2460                {(item) => (2461                  <div class="peach-pro-active-feature">2462                    <span class="peach-pro-active-feature-mark">✦</span>2463                    <span class="peach-pro-active-feature-name">{item.name}</span>2464                  </div>2465                )}2466              </For>2467            </div>24682469            <button2470              class="peach-pro-deactivate"2471              onClick={() => void handleDeactivateLicense()}2472              disabled={deactivating()}2473            >2474              {deactivating() ? "Deactivating…" : "Deactivate"}2475            </button>2476          </div>2477        </Show>2478      </div>2479    );2480  }24812482  function renderFeedback() {2483    return (2484      <div class="peach-settings-body">2485        <div class="peach-settings-group peach-feedback-group">2486          <Show2487            when={feedbackState() === 'sent'}2488            fallback={2489              <>2490                <p class="peach-feedback-intro">2491                  Help us improve Peach. Every report goes directly to our inbox.2492                </p>24932494                <p class="peach-settings-sub-label">TYPE</p>2495                <div class="peach-feedback-chips">2496                  <For each={FEEDBACK_CATEGORIES}>2497                    {(cat) => {2498                      const selected = () => fbCategory() === cat;2499                      return (2500                        <button2501                          type="button"2502                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2503                          aria-pressed={selected()}2504                          onClick={() => setFbCategory(cat)}2505                        >2506                          {cat}2507                        </button>2508                      );2509                    }}2510                  </For>2511                </div>25122513                <p class="peach-settings-sub-label">MESSAGE</p>2514                <textarea2515                  class="peach-feedback-textarea"2516                  placeholder="Describe the issue or idea..."2517                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2518                  value={fbMessage()}2519                  onInput={(e) => setFbMessage(e.currentTarget.value)}2520                />25212522                <p class="peach-feedback-meta">2523                  Version {version} · will be included automatically2524                </p>25252526                <button2527                  type="button"2528                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2529                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2530                  onClick={() => void handleSubmitFeedback()}2531                >2532                  <Switch>2533                    <Match when={feedbackState() === 'idle'}>Send</Match>2534                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2535                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2536                  </Switch>2537                </button>25382539                <p class="peach-feedback-anon">2540                  Sent anonymously · no account required2541                </p>2542              </>2543            }2544          >2545            <div class="peach-feedback-thanks" role="status" aria-live="polite">2546              <p class="peach-feedback-thanks-title">Thank you</p>2547              <p>2548                Your note is in the queue. Someone will process it soon.2549              </p>2550            </div>2551          </Show>2552        </div>2553      </div>2554    );2555  }25562557  const bodies: StackBodies = {2558    protection: renderSecurity(),2559    vault: renderVault(),2560    sync: renderSync(),2561    appearance: renderAppearance(),2562    shortcuts: renderShortcuts(),2563    pro: renderLicense(),2564    feedback: renderFeedback(),2565  };25662567  return (2568    <Show2569      when={showWhatsNew()}2570      fallback={2571    <Show2572      when={showNewRecoveryWords()}2573      fallback={2574        <div2575          class="peach-overlay-view peach-settings-overlay"2576          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2577          style="display:flex;flex-direction:column"2578        >2579          <StackDeck2580            active={activeCard()}2581            onActiveChange={setActiveCard}2582            onBack={props.onBack}2583            cards={cards()}2584            bodies={bodies}2585            version={version}2586            isFanning={isFanning()}2587            celebratingCardId={justActivated() ? "pro" : null}2588            isPro={isPro()}2589          />2590        </div>2591      }2592    >2593      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2594    </Show>2595      }2596    >2597      <div class="peach-overlay-view peach-settings-overlay">2598        <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2599      </div>2600    </Show>2601  );2602}