Settings.tsx

106.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { 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() ? "End to End Encryption" : "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 [unpairError, setUnpairError] = createSignal<string | null>(null);311  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);312  const [renameDraft, setRenameDraft] = createSignal("");313  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");314  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);315  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —316  // a transient toast is no use for diagnosing a phone that isn't syncing.317  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});318  // §11: the Sync generation this installation is on. Not secret — a version319  // and a state name — so it renders the same in any session.320  const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);321  const [resetOpen, setResetOpen] = createSignal(false);322  const [resetPassword, setResetPassword] = createSignal("");323  const [resetError, setResetError] = createSignal<string | null>(null);324  const [resetting, setResetting] = createSignal(false);325  const syncLight = () =>326    syncStatus() === "syncing"327      ? "busy"328      : syncIndicator(syncSummary(), pairedDevices().length);329  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());330  const lastSyncLine = () => {331    const s = syncSummary();332    if (!s.outcome) return "No sync attempted yet";333    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;334  };335  async function loadSyncState() {336    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {337      ok: boolean;338      data?: SyncOutcomeSummary;339    };340    if (r.ok && r.data) {341      setSyncSummary(r.data);342      setSyncStatus(r.data.status ?? "idle");343    }344  }345346  async function loadGeneration() {347    const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {348      ok: boolean;349      data?: SyncGeneration | null;350    };351    if (r.ok) setGeneration(r.data ?? null);352  }353354  async function runSecureReset() {355    setResetting(true);356    const r = (await sendMessage({357      type: "SECURE_RESET",358      payload: { password: resetPassword() },359    })) as { ok: boolean; data?: SyncGeneration | null; error?: string };360    setResetting(false);361    setResetPassword("");362    if (!r.ok) {363      setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");364      await loadGeneration();365      return;366    }367    setResetError(null);368    setResetOpen(false);369    if (r.data) setGeneration(r.data);370  }371372  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;373  function syncRelativeTime(when: number | undefined): string {374    if (!when) return "Not synced yet";375    const elapsed = Date.now() - when;376    if (elapsed < 60_000) return "Just now";377    const minutes = Math.floor(elapsed / 60_000);378    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;379    const hours = Math.floor(minutes / 60);380    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;381    const days = Math.floor(hours / 24);382    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";383  }384  function isSyncStale(d: PairedDevice): boolean {385    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;386  }387  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {388    switch (t) {389      case "android": return "Android";390      case "ios": return "iPhone";391      case "extension": return "Browser";392      default: return "Device";393    }394  }395396  async function loadPairedDevices() {397    if (!isPro()) return;398    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {399      ok: boolean;400      error?: string;401      data?: { devices: PairedDevice[] };402    };403    if (r.ok) {404      setTrustDataCorrupted(false);405      setPairedDevices(r.data?.devices ?? []);406    } else if (r.error === "trust_data_corrupted") {407      setPairedDevices([]);408      setTrustDataCorrupted(true);409    }410  }411  async function unpairDevice(deviceId: string) {412    const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {413      ok: boolean;414      error?: string;415    };416    if (!result.ok) {417      setUnpairError(result.error ?? "Could not remove this device");418      return;419    }420    setUnpairError(null);421    setConfirmUnpair(null);422    await loadPairedDevices();423  }424  async function renameDevice(deviceId: string) {425    const name = renameDraft().trim();426    if (!name) return;427    const result = (await sendMessage({428      type: "RENAME_PAIRED_DEVICE",429      payload: { deviceId, name },430    })) as { ok: boolean };431    if (!result.ok) return;432    setRenameDeviceId(null);433    await loadPairedDevices();434  }435  async function syncNow() {436    setSyncStatus("syncing");437    setSyncFlash(null);438    const res = (await sendMessage({439      type: "TRIGGER_SYNC",440      payload: { reason: "manual", force: true },441    })) as { ok: boolean; error?: string };442    if (!res.ok) {443      setSyncStatus("error");444      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });445      return;446    }447    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the448    // outcome. A single delayed read used to land while the run was still449    // "syncing" and leave the button disabled forever.450    let s: SyncOutcomeSummary = { status: "syncing" };451    for (let i = 0; i < 40 && s.status === "syncing"; i++) {452      await new Promise((r) => setTimeout(r, 250));453      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {454        ok: boolean;455        data?: SyncOutcomeSummary;456      };457      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };458    }459    setSyncSummary(s);460    setSyncStatus(s.status ?? "idle");461    // The persistent line below carries the outcome; the flash is only the462    // "that click did something" acknowledgement.463    setSyncFlash(describeSyncOutcome(s));464    setTimeout(() => setSyncFlash(null), 4000);465    await loadPairedDevices();466  }467  // UI-4 / MV-8: rotate sync identity confirmation dialog.468  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);469  const [rotating, setRotating] = createSignal(false);470  const [rotateError, setRotateError] = createSignal<string | null>(null);471  const [showSyncConnection, setShowSyncConnection] = createSignal(false);472  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);473474  async function loadPendingVouched() {475    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {476      ok: boolean;477      data?: PendingVouched[];478    };479    if (r.ok) setPendingVouched(r.data ?? []);480  }481  async function confirmVouched(deviceId: string) {482    setVouchedBusy(deviceId);483    try {484      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });485      await loadPendingVouched();486    } finally {487      setVouchedBusy(null);488    }489  }490  async function rejectVouched(deviceId: string) {491    setVouchedBusy(deviceId);492    try {493      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });494      await loadPendingVouched();495    } finally {496      setVouchedBusy(null);497    }498  }499  async function doRotateSyncIdentity() {500    setRotateError(null);501    setRotating(true);502    try {503      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {504        ok: boolean;505        error?: string;506      };507      if (!r.ok) {508        setRotateError(r.error ?? "Rotation failed.");509        return;510      }511      setRotateConfirmOpen(false);512      // Backend set peach_recovery_pending; reuse the existing recovery-words513      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).514      setShowNewRecoveryWords(true);515    } finally {516      setRotating(false);517    }518  }519520  const [dedupCount, setDedupCount] = createSignal<number | null>(null);521  const [dedupRunning, setDedupRunning] = createSignal(false);522  const [dedupDone, setDedupDone] = createSignal<number | null>(null);523524  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);525526  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted527  // BEFORE every real-mode vault write so the user can roll back if a sync528  // or import overwrites the wrong state.529  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);530  const [historyLoading, setHistoryLoading] = createSignal(false);531  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);532  const [historyShowAll, setHistoryShowAll] = createSignal(false);533534  async function loadVaultHistory() {535    setHistoryLoading(true);536    try {537      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });538      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];539      setHistorySnapshots(list);540    } finally {541      setHistoryLoading(false);542    }543  }544545  async function handleRestoreVaultSnapshot(id: string) {546    if (historyRestoring()) return;547    setHistoryRestoring(id);548    const res = await sendMessage({549      type: "RESTORE_VAULT_SNAPSHOT",550      payload: { snapshotId: id },551    });552    setHistoryRestoring(null);553    if ((res as { ok: boolean }).ok) {554      // Vault is now locked — let the parent close Settings; App.tsx's555      // VAULT_LOCKED listener will route back to the lock screen.556      props.onBack();557    }558  }559560  function formatSnapshotTime(savedAt: number): string {561    const diff = Date.now() - savedAt;562    if (diff < 60_000) return "Just now";563    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;564    const d = new Date(savedAt);565    const isToday = new Date().toDateString() === d.toDateString();566    const isYest =567      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();568    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });569    if (isToday) return `Today ${t}`;570    if (isYest) return `Yesterday ${t}`;571    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;572  }573574  function reasonLabel(reason: VaultSnapshot["reason"]): string {575    return (576      {577        local_save: "saved",578        sync_received: "synced",579        import: "imported",580        restore: "restored",581      } as const582    )[reason] ?? reason;583  }584585  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);586  const [showWhatsNew, setShowWhatsNew] = createSignal(false);587588  const [showChangePw, setShowChangePw] = createSignal(false);589  const [changePwCurrent, setChangePwCurrent] = createSignal("");590  const [changePwNew, setChangePwNew] = createSignal("");591  const [changePwConfirm, setChangePwConfirm] = createSignal("");592  const [changePwSaving, setChangePwSaving] = createSignal(false);593  const [changePwError, setChangePwError] = createSignal<string | null>(null);594  const [changePwSuccess] = createSignal(false);595  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);596597  const [pinEnabled, setPinEnabled] = createSignal(false);598  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);599  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");600  const [pinValue, setPinValue] = createSignal("");601  const [pinConfirm, setPinConfirm] = createSignal("");602  const [pinSaving, setPinSaving] = createSignal(false);603  const [pinError, setPinError] = createSignal<string | null>(null);604605  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.606  const [showViewWords, setShowViewWords] = createSignal(false);607  const [viewWordsPassword, setViewWordsPassword] = createSignal("");608  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);609  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);610  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);611612  async function handleViewRecoveryWords() {613    setViewWordsError(null);614    if (!viewWordsPassword()) {615      setViewWordsError("Enter your master password.");616      return;617    }618    setViewWordsBusy(true);619    try {620      const r = (await sendMessage({621        type: "GET_RECOVERY_WORDS",622        payload: { password: viewWordsPassword() },623      })) as { ok: boolean; data?: string[]; error?: string };624      if (r.ok) {625        setViewedWords(r.data ?? []);626        setViewWordsPassword("");627        return;628      }629      switch (r.error) {630        case "wrong_password":631          setViewWordsError("Incorrect password");632          break;633        case "password_required":634          setViewWordsError("Enter your master password.");635          break;636        case "recovery_not_setup":637          setViewWordsError("Recovery words aren't set up for this vault yet.");638          break;639        default:640          setViewWordsError("Couldn't retrieve recovery words.");641      }642    } finally {643      setViewWordsBusy(false);644    }645  }646  function closeViewWords() {647    setShowViewWords(false);648    setViewWordsPassword("");649    setViewWordsError(null);650    setViewedWords(null);651  }652653  const [duressEnabled, setDuressEnabled] = createSignal(false);654  const [showDuressSetup, setShowDuressSetup] = createSignal(false);655  const [duressPassword, setDuressPassword] = createSignal("");656  const [duressConfirm, setDuressConfirm] = createSignal("");657  const [duressMainPassword, setDuressMainPassword] = createSignal("");658  const [duressSaving, setDuressSaving] = createSignal(false);659  const [duressError, setDuressError] = createSignal<string | null>(null);660  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);661662  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);663664  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);665  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);666667  const [fbCategory, setFbCategory] = createSignal("Bug report");668  const [fbMessage, setFbMessage] = createSignal("");669  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';670  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');671672  createEffect(() => {673    if (props.upgradeHighlight) {674      setActiveCard("pro");675    }676  });677678  onMount(async () => {679    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([680      sendMessage({ type: "GET_LICENSE" }),681      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),682      sendMessage({ type: "IS_DURESS_ENABLED" }),683      sendMessage({ type: "GET_SETTINGS" }),684      sendMessage({ type: "GET_LICENSE_KEY" }),685    ]);686687    // Vault history loads in the background — the Vault sub-view picks it up688    // when the user navigates in. Loading it lazily keeps Settings boot fast.689    void loadVaultHistory();690691    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the692    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.693    void loadPendingVouched();694    void loadPairedDevices();695    void loadSyncState();696    void loadGeneration();697    const onBroadcast = (msg: unknown) => {698      const m = msg as { type?: string; payload?: unknown } | null;699      if (m?.type === "VOUCHED_DEVICE_PENDING") {700        void loadPendingVouched();701      }702      if (m?.type === "SYNC_STATE_CHANGED") {703        const payload = (m.payload ?? {}) as SyncOutcomeSummary;704        setSyncSummary(payload);705        setSyncStatus(payload.status ?? "idle");706      }707    };708    chrome.runtime?.onMessage?.addListener(onBroadcast);709    onCleanup(() => {710      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }711    });712713    if ((licenseRes as { ok: boolean }).ok) {714      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);715    }716717    if ((passkeyRes as { ok: boolean }).ok) {718      setPasskeyState(719        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null720      );721    }722723    if ((duressRes as { ok: boolean }).ok) {724      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);725    }726727    if ((settingsRes as { ok: boolean }).ok) {728      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;729      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);730      setPinEnabled(sData?.pinEnabled === true);731    }732733    if ((keyRes as { ok: boolean }).ok) {734      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);735    }736737    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });738    if ((entriesRes as { ok: boolean }).ok) {739      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];740      setCanaryEntries(741        entries742          .filter((e) => e.canary?.webhookUrl)743          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))744      );745    }746747  });748749  function resetPinFlow() {750    setPinFlow(null);751    setPinCurrentPassword("");752    setPinValue("");753    setPinConfirm("");754    setPinError(null);755  }756757  async function handleSetupPin() {758    setPinError(null);759    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }760    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }761    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }762    setPinSaving(true);763    const res = await sendMessage({764      type: "SETUP_PIN",765      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },766    }) as { ok: boolean; error?: string };767    if (res.ok) {768      setPinEnabled(true);769      resetPinFlow();770    } else {771      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");772    }773    setPinSaving(false);774  }775776  async function handleDisablePin() {777    setPinError(null);778    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }779    setPinSaving(true);780    const res = await sendMessage({781      type: "DISABLE_PIN",782      payload: { currentPassword: pinCurrentPassword() },783    }) as { ok: boolean; error?: string };784    if (res.ok) {785      setPinEnabled(false);786      resetPinFlow();787    } else {788      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");789    }790    setPinSaving(false);791  }792793  async function handleChangePassword() {794    setChangePwError(null);795    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }796    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }797    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }798    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }799    setChangePwSaving(true);800    const res = await sendMessage({801      type: "CHANGE_PASSWORD",802      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },803    }) as { ok: boolean; error?: string };804    if (res.ok) {805      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");806      setShowChangePw(false);807      // LV-4: the user just authenticated (current password), so the recovery808      // words reveal that follows is exempt from the re-auth gate. The809      // background owns this session-scoped exemption; the display clears it.810      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });811      setShowNewRecoveryWords(true);812    } else {813      setChangePwError(res.error ?? "failed to change password");814    }815    setChangePwSaving(false);816  }817818  const [copied, setCopied] = createSignal(false);819820  async function copyLicenseKey(key: string) {821    await navigator.clipboard.writeText(key);822    setCopied(true);823    setTimeout(() => setCopied(false), 2000);824  }825826  async function handleActivateLicense() {827    const key = licenseKeyInput().trim();828    if (!key) return;829    setActivatingLicense(true);830    setLicenseError(null);831    setLicenseSuccess(null);832    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });833    setActivatingLicense(false);834    if ((res as { ok: boolean }).ok) {835      const data = (res as { ok: boolean; data?: LicenseInfo }).data;836      if (data?.valid) {837        setLicenseInfo(data);838        setLicenseKey(key);839        setLicenseSuccess("License activated!");840        setLicenseKeyInput("");841      } else {842        setLicenseError("Invalid license key");843      }844    } else {845      setLicenseError("Activation failed");846    }847  }848849  async function handleTogglePasskeys() {850    const current = passkeyState();851    if (!current || !current.supported) return;852    setPasskeySaving(true);853    const res = await sendMessage({854      type: "SET_PASSKEY_PROVIDER_ENABLED",855      payload: { enabled: !current.enabled },856    });857    setPasskeySaving(false);858    if ((res as { ok: boolean }).ok) {859      setPasskeyState(860        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null861      );862    }863  }864865  async function handleCheckAllBreaches() {866    setCheckingBreaches(true);867    setBreachResult(null);868    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });869    setCheckingBreaches(false);870    if ((res as { ok: boolean }).ok) {871      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};872      const breached = Object.values(data).filter((count) => count > 0).length;873      setBreachResult(874        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"875      );876      props.onBreachCheckDone();877    }878  }879880881  async function handleSetupDuress() {882    const mainPw = duressMainPassword().trim();883    const duressPw = duressPassword().trim();884    const confirmPw = duressConfirm().trim();885886    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }887    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }888    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }889    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }890891    setDuressSaving(true);892    setDuressError(null);893894    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };895    if (!verifyRes.ok || !verifyRes.data) {896      setDuressError("incorrect main password");897      setDuressSaving(false);898      return;899    }900901    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };902    setDuressSaving(false);903    if (res.ok) {904      setDuressEnabled(true);905      setShowDuressSetup(false);906      setDuressPassword("");907      setDuressConfirm("");908      setDuressMainPassword("");909      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");910      setTimeout(() => setDuressSuccess(null), 5000);911    } else {912      setDuressError(913        res.error === "session_expired"914          ? "Vault session expired — lock and unlock Peach, then try again"915          : res.error ?? "failed to set duress password"916      );917    }918  }919920  async function handleClearDeclinedCards() {921    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });922    setDeclinedCardCount(0);923  }924925  async function handleCountDuplicates() {926    setDedupRunning(true);927    setDedupDone(null);928    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };929    setDedupRunning(false);930    if (res.ok) setDedupCount(res.data ?? 0);931  }932933  async function handleDeduplicate() {934    setDedupRunning(true);935    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };936    setDedupRunning(false);937    if (res.ok) {938      setDedupDone(res.data ?? 0);939      setDedupCount(0);940    }941  }942943  async function handleDisableDuress() {944    const mainPw = duressMainPassword().trim();945    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }946947    setDuressSaving(true);948    setDuressError(null);949950    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };951    if (!verifyRes.ok || !verifyRes.data) {952      setDuressError("incorrect main password");953      setDuressSaving(false);954      return;955    }956957    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };958    setDuressSaving(false);959    if (res.ok) {960      setDuressEnabled(false);961      setShowDuressSetup(false);962      setDuressMainPassword("");963      setDuressSuccess("Duress password disabled.");964      setTimeout(() => setDuressSuccess(null), 4000);965    } else {966      setDuressError(res.error ?? "failed to disable duress password");967    }968  }969970  async function handleSubmitFeedback() {971    const message = sanitizeFeedbackMessage(fbMessage());972    if (!message) return;973974    setFeedbackState('sending');975976    try {977      const category = FEEDBACK_CATEGORIES.includes(fbCategory())978        ? fbCategory()979        : FEEDBACK_CATEGORIES[0];980      const res = await fetch('https://api.web3forms.com/submit', {981        method: 'POST',982        headers: { 'Content-Type': 'application/json' },983        body: JSON.stringify({984          access_key: WEB3FORMS_KEY,985          subject: `Peach Feedback - ${category}`,986          message,987          from_name: 'Peach User',988          // Honeypot — leave blank989          botcheck: '',990        }),991      });992993      if (!res.ok) throw new Error(`HTTP ${res.status}`);994      const data = await res.json() as { success: boolean; message?: string };995      if (!data.success) throw new Error(data.message ?? 'unknown error');996997      setFeedbackState('sent');998      setFbMessage('');999      setFbCategory(FEEDBACK_CATEGORIES[0]);1000    } catch (err) {1001      console.error('[feedback]', err);1002      setFeedbackState('error');1003      setTimeout(() => setFeedbackState('idle'), 4000);1004    }1005  }10061007  function renderSecurity() {1008    return (1009      <div class="peach-settings-body">1010        <div class="peach-settings-group">1011          <p class="peach-settings-sub-label">LOCKING</p>1012          <div class="peach-settings-row">1013            <span>Lock on close</span>1014            <button1015              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1016              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1017              aria-label="Toggle lock on close"1018            >1019              <span class="peach-toggle-knob"></span>1020            </button>1021          </div>1022          <div class="peach-settings-row">1023            <span>Auto-lock after</span>1024            <select1025              class="peach-settings-select"1026              value={String(props.autoLockMinutes)}1027              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1028            >1029              <option value="1">1 minute</option>1030              <option value="5">5 minutes</option>1031              <option value="15">15 minutes</option>1032              <option value="30">30 minutes</option>1033              <option value="0">Never</option>1034            </select>1035          </div>10361037          <p class="peach-settings-sub-label">QUICK UNLOCK</p>1038          <div class="peach-settings-row peach-settings-row--meta">1039            <span>PIN unlock</span>1040            <button1041              class="peach-btn peach-btn--subtle peach-btn--sm"1042              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1043            >1044              {pinEnabled() ? "remove" : "set up"}1045            </button>1046          </div>1047          <Show when={pinFlow() === "setup"}>1048            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1049              <input1050                class="peach-settings-input"1051                type="password"1052                placeholder="current password"1053                value={pinCurrentPassword()}1054                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1055                autocomplete="current-password"1056              />1057              <input1058                class="peach-settings-input"1059                type="password"1060                inputmode="numeric"1061                maxlength="12"1062                placeholder="new 4–12 digit PIN"1063                value={pinValue()}1064                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1065                autocomplete="off"1066              />1067              <input1068                class="peach-settings-input"1069                type="password"1070                inputmode="numeric"1071                maxlength="12"1072                placeholder="confirm PIN"1073                value={pinConfirm()}1074                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1075                autocomplete="off"1076                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1077              />1078              <Show when={pinError()}>1079                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1080              </Show>1081              <button1082                class="peach-btn peach-btn--accent peach-btn--sm"1083                style="align-self:flex-start"1084                onClick={() => void handleSetupPin()}1085                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1086              >1087                {pinSaving() ? "saving…" : "save PIN"}1088              </button>1089            </div>1090          </Show>1091          <Show when={pinFlow() === "disable"}>1092            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1093              <input1094                class="peach-settings-input"1095                type="password"1096                placeholder="current password"1097                value={pinCurrentPassword()}1098                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1099                autocomplete="current-password"1100                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1101              />1102              <Show when={pinError()}>1103                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1104              </Show>1105              <button1106                class="peach-btn peach-btn--subtle peach-btn--sm"1107                style="align-self:flex-start"1108                onClick={() => void handleDisablePin()}1109                disabled={pinSaving() || !pinCurrentPassword()}1110              >1111                {pinSaving() ? "removing…" : "remove PIN"}1112              </button>1113            </div>1114          </Show>11151116          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1117          <div class="peach-settings-row">1118            <span>Breach monitoring</span>1119            <button1120              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1121              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1122              aria-label="Toggle breach monitoring"1123            >1124              <span class="peach-toggle-knob"></span>1125            </button>1126          </div>1127          <div class="peach-settings-row peach-settings-row--meta">1128            <span>Check all passwords</span>1129            <button1130              class="peach-btn peach-btn--subtle peach-btn--sm"1131              onClick={() => void handleCheckAllBreaches()}1132              disabled={checkingBreaches()}1133            >1134              {checkingBreaches() ? "checking..." : "run"}1135            </button>1136          </div>1137          <Show when={breachResult()}>1138            <div class="peach-settings-row peach-settings-row--meta">1139              <span>Last check</span>1140              <span class="peach-settings-value">{breachResult()}</span>1141            </div>1142          </Show>11431144          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1145          <div class="peach-settings-row peach-settings-row--meta">1146            <span>Master password</span>1147            <button1148              class="peach-btn peach-btn--subtle peach-btn--sm"1149              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1150            >1151              {showChangePw() ? "cancel" : "change"}1152            </button>1153          </div>1154          <Show when={showChangePw()}>1155            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1156              <input1157                class="peach-settings-input"1158                type="password"1159                placeholder="current password"1160                value={changePwCurrent()}1161                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1162                autocomplete="current-password"1163              />1164              <input1165                class="peach-settings-input"1166                type="password"1167                placeholder="new password (min 12 chars)"1168                value={changePwNew()}1169                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1170                autocomplete="new-password"1171              />1172              <input1173                class="peach-settings-input"1174                type="password"1175                placeholder="confirm new password"1176                value={changePwConfirm()}1177                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1178                autocomplete="new-password"1179                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1180              />1181              <Show when={changePwError()}>1182                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1183              </Show>1184              <Show when={changePwSuccess()}>1185                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1186              </Show>1187              <button1188                class="peach-btn peach-btn--accent peach-btn--sm"1189                style="align-self:flex-start"1190                onClick={() => void handleChangePassword()}1191                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1192              >1193                {changePwSaving() ? "saving…" : "save new password"}1194              </button>1195            </div>1196          </Show>11971198          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1199          <div class="peach-settings-row peach-settings-row--meta">1200            <span>Recovery words</span>1201            <button1202              class="peach-btn peach-btn--subtle peach-btn--sm"1203              onClick={() => {1204                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1205              }}1206            >1207              {showViewWords() ? "cancel" : "view"}1208            </button>1209          </div>1210          <Show when={showViewWords()}>1211            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1212              <Show1213                when={viewedWords()}1214                fallback={1215                  <>1216                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1217                      Enter your master password to reveal your recovery words.1218                    </p>1219                    <input1220                      class="peach-settings-input"1221                      type="password"1222                      placeholder="master password"1223                      value={viewWordsPassword()}1224                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1225                      autocomplete="current-password"1226                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1227                    />1228                    <Show when={viewWordsError()}>1229                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1230                    </Show>1231                    <button1232                      class="peach-btn peach-btn--accent peach-btn--sm"1233                      style="align-self:flex-start"1234                      onClick={() => void handleViewRecoveryWords()}1235                      disabled={viewWordsBusy() || !viewWordsPassword()}1236                    >1237                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1238                    </button>1239                  </>1240                }1241              >1242                <div class="peach-recovery-words-grid">1243                  <For each={viewedWords()!}>1244                    {(word, i) => (1245                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1246                    )}1247                  </For>1248                </div>1249                <button1250                  class="peach-btn peach-btn--subtle peach-btn--sm"1251                  style="align-self:flex-start"1252                  onClick={closeViewWords}1253                >done</button>1254              </Show>1255            </div>1256          </Show>12571258          <p class="peach-settings-sub-label">1259            DURESS PASSWORD1260            <Show when={!isPro()}>1261              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1262            </Show>1263          </p>1264          <div class="peach-settings-row peach-settings-row--top">1265            <div class="peach-settings-row-main">1266              <span>1267                Duress password1268                <Show when={!isPro()}>1269                  <span class="peach-pro-badge">Pro</span>1270                </Show>1271              </span>1272              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1273                If entered under coercion, shows a decoy empty vault instead of your real one.1274              </span>1275            </div>1276            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1277              {duressEnabled() ? "on" : "off"}1278            </div>1279          </div>12801281          <Show when={duressEnabled() && !showDuressSetup()}>1282            <div class="peach-settings-row peach-settings-row--meta">1283              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1284                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.1285              </span>1286            </div>1287          </Show>12881289          <Show when={duressSuccess()}>1290            <div class="peach-settings-row peach-settings-row--meta">1291              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1292            </div>1293          </Show>12941295          <Show when={!showDuressSetup()}>1296            <div class="peach-settings-row peach-settings-row--meta">1297              <button1298                class="peach-btn peach-btn--subtle peach-btn--sm"1299                onClick={() => {1300                  // Pro gate entry-point: free users are redirected to the1301                  // upgrade screen before the setup form opens. The server-1302                  // side SETUP_DURESS handler is the single enforcement1303                  // point; this is UI redirection so the form never appears1304                  // for a user who can't complete it.1305                  if (!isPro() && !duressEnabled()) {1306                    props.onProGate?.("duress_vault");1307                    setActiveCard("pro");1308                    return;1309                  }1310                  setShowDuressSetup(true);1311                  setDuressError(null);1312                  setDuressSuccess(null);1313                  setDuressPassword("");1314                  setDuressConfirm("");1315                  setDuressMainPassword("");1316                }}1317              >1318                {duressEnabled() ? "Change / disable" : "Set up"}1319              </button>1320            </div>1321          </Show>13221323          <Show when={showDuressSetup()}>1324            <div class="peach-duress-form">1325              <label class="peach-label">main password (to confirm)</label>1326              <input1327                class="peach-settings-input"1328                type="password"1329                placeholder="your current master password"1330                autocomplete="current-password"1331                value={duressMainPassword()}1332                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1333              />1334              <Show when={!duressEnabled()}>1335                <label class="peach-label">new duress password</label>1336                <input1337                  class="peach-settings-input"1338                  type="password"1339                  placeholder="duress password (min 8 chars)"1340                  autocomplete="new-password"1341                  value={duressPassword()}1342                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1343                />1344                <label class="peach-label">confirm duress password</label>1345                <input1346                  class="peach-settings-input"1347                  type="password"1348                  placeholder="confirm duress password"1349                  autocomplete="new-password"1350                  value={duressConfirm()}1351                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1352                />1353              </Show>13541355              <Show when={duressError()}>1356                <p class="peach-hint peach-hint-error">{duressError()}</p>1357              </Show>13581359              <div class="peach-duress-actions">1360                <button1361                  class="peach-btn peach-btn--subtle peach-btn--sm"1362                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1363                >1364                  cancel1365                </button>1366                <Show when={duressEnabled()}>1367                  <button1368                    class="peach-btn peach-btn--ghost peach-btn--sm"1369                    style="color:var(--peach-breach)"1370                    onClick={() => void handleDisableDuress()}1371                    disabled={duressSaving()}1372                  >1373                    {duressSaving() ? "disabling..." : "disable duress"}1374                  </button>1375                </Show>1376                <Show when={!duressEnabled()}>1377                  <button1378                    class="peach-btn peach-btn--accent peach-btn--sm"1379                    onClick={() => void handleSetupDuress()}1380                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1381                  >1382                    {duressSaving() ? "saving..." : "save"}1383                  </button>1384                </Show>1385              </div>1386            </div>1387          </Show>13881389          <p class="peach-settings-sub-label">1390            CANARY ENTRIES1391            <Show when={!isPro()}>1392              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1393            </Show>1394          </p>1395          <div class="peach-settings-row peach-settings-row--top">1396            <div class="peach-settings-row-main">1397              <span>1398                Canary entries1399                <Show when={!isPro()}>1400                  <span class="peach-pro-badge">Pro</span>1401                </Show>1402              </span>1403              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1404                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1405              </span>1406            </div>1407            <span class="peach-settings-value" style="flex-shrink:0">1408              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1409            </span>1410          </div>1411          <Show when={canaryEntries().length > 0}>1412            <For each={canaryEntries()}>1413              {(entry) => (1414                <div class="peach-settings-row peach-settings-row--meta">1415                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1416                  <button1417                    class="peach-btn peach-btn--subtle peach-btn--sm"1418                    onClick={() => void handleTestCanary(entry)}1419                    disabled={canaryTestingId() === entry.id}1420                    style="flex-shrink:0"1421                  >1422                    {canaryTestingId() === entry.id1423                      ? "sending…"1424                      : canaryTestResult()?.id === entry.id1425                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1426                        : "test"}1427                  </button>1428                </div>1429              )}1430            </For>1431          </Show>14321433          <p class="peach-settings-sub-label">PASSKEYS</p>1434          <div class="peach-settings-row peach-settings-row--top">1435            <div class="peach-settings-row-main">1436              <span>Intercept passkey requests</span>1437              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1438                Peach can handle passkey sign-in and creation on supported Chromium browsers.1439              </span>1440            </div>1441            <button1442              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1443              onClick={() => void handleTogglePasskeys()}1444              aria-label="Toggle passkey interception"1445              disabled={!passkeyState()?.supported || passkeySaving()}1446            >1447              <span class="peach-toggle-knob"></span>1448            </button>1449          </div>1450          <div class="peach-settings-row peach-settings-row--meta">1451            <span>Status</span>1452            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1453          </div>1454          <div class="peach-settings-row peach-settings-row--meta">1455            <span>Browser</span>1456            <span class="peach-settings-value">1457              {passkeyState()?.browserLabel ?? "Unknown"}1458              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1459            </span>1460          </div>1461          <div class="peach-info-notice" style="margin-top:8px">1462            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1463          </div>1464          <Show when={passkeyState()?.conflict}>1465            <div class="peach-settings-row peach-settings-row--meta">1466              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1467            </div>1468          </Show>1469        </div>1470      </div>1471    );1472  }14731474  function renderVault() {1475    return (1476      <div class="peach-settings-body">1477        <div class="peach-settings-group">1478          <p class="peach-settings-sub-label">ENTRIES</p>1479          <div class="peach-settings-row peach-settings-row--meta">1480            <span>Entries</span>1481            <span class="peach-settings-value">{props.entriesCount}</span>1482          </div>1483          <div class="peach-settings-row peach-settings-row--meta">1484            <span>Duplicates</span>1485            <Show when={dedupDone() !== null} fallback={1486              <button1487                class="peach-btn peach-btn--subtle peach-btn--sm"1488                onClick={() => void handleCountDuplicates()}1489                disabled={dedupRunning()}1490              >1491                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1492              </button>1493            }>1494              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1495                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1496              </span>1497            </Show>1498          </div>1499          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1500            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1501              <div class="peach-restore-warning">1502                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1503              </div>1504              <div style="display:flex;gap:6px">1505                <button1506                  class="peach-btn peach-btn--subtle peach-btn--sm"1507                  onClick={() => setDedupCount(null)}1508                >1509                  cancel1510                </button>1511                <button1512                  class="peach-btn peach-btn--accent peach-btn--sm"1513                  onClick={() => void handleDeduplicate()}1514                  disabled={dedupRunning()}1515                >1516                  {dedupRunning() ? "removing..." : "remove duplicates"}1517                </button>1518              </div>1519            </div>1520          </Show>15211522          <p class="peach-settings-sub-label">MAINTENANCE</p>1523          <div class="peach-settings-row peach-settings-row--meta">1524            <span>Trash auto-delete</span>1525            <select1526              class="peach-settings-select"1527              value={String(props.trashRetentionDays)}1528              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1529            >1530              <option value="7">7 days</option>1531              <option value="30">30 days</option>1532              <option value="90">90 days</option>1533              <option value="0">Never</option>1534            </select>1535          </div>1536          <div class="peach-settings-row peach-settings-row--meta">1537            <span>Advanced Vault Health</span>1538            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1539              open1540            </button>1541          </div>1542          <Show when={declinedCardCount() > 0}>1543            <div class="peach-settings-row peach-settings-row--meta">1544              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1545              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1546                Clear1547              </button>1548            </div>1549          </Show>15501551          <p class="peach-settings-sub-label">DATA</p>1552          <div class="peach-settings-row peach-settings-row--meta">1553            <span>Import passwords</span>1554            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1555              open1556            </button>1557          </div>1558          <div class="peach-settings-row peach-settings-row--meta">1559            <span>Export backup</span>1560            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1561              open1562            </button>1563          </div>1564          <div class="peach-settings-row peach-settings-row--meta">1565            <span>1566              Peach Codex1567              <Show when={!isPro()}>1568                <span class="peach-pro-badge">Pro</span>1569              </Show>1570            </span>1571            <button1572              class="peach-btn peach-btn--subtle peach-btn--sm"1573              onClick={() => props.onOpenCodexGenerate?.()}1574            >1575              open1576            </button>1577          </div>1578          {/* Restore from Codex: intentionally never gated — a printed1579              Codex must remain restorable regardless of license state. */}1580          <div class="peach-settings-row peach-settings-row--meta">1581            <span>Restore from Codex</span>1582            <button1583              class="peach-btn peach-btn--subtle peach-btn--sm"1584              onClick={() => props.onOpenCodexRestore?.()}1585            >1586              open1587            </button>1588          </div>15891590          <Show when={hasReleaseNotes()}>1591            <p class="peach-settings-sub-label">RELEASE NOTES</p>1592            <div class="peach-settings-row peach-settings-row--meta">1593              <span>v{PEACH_VERSION}</span>1594              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1595                view1596              </button>1597            </div>1598          </Show>15991600          <span class="peach-settings-sub-label">VAULT HISTORY</span>1601          <Show when={historyLoading()}>1602            <p class="peach-settings-hint">Loading…</p>1603          </Show>1604          <Show when={!historyLoading() && historySnapshots().length === 0}>1605            <p class="peach-settings-hint">1606              No snapshots yet. Peach saves automatically before every change —1607              saves, syncs, and imports.1608            </p>1609          </Show>1610          <Show when={!historyLoading() && historySnapshots().length > 0}>1611            <p class="peach-settings-hint">1612              Restoring locks your vault. Unlock with the password that1613              encrypted that snapshot to confirm.1614            </p>1615            <div class="peach-history-list">1616              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1617                {(snap) => (1618                  <div class="peach-history-row">1619                    <div class="peach-history-meta">1620                      <span class="peach-history-time">1621                        {formatSnapshotTime(snap.savedAt)}1622                      </span>1623                      <span class="peach-history-sub">1624                        {[1625                          snap.entryCount !== null1626                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1627                            : null,1628                          reasonLabel(snap.reason),1629                        ]1630                          .filter(Boolean)1631                          .join(" · ")}1632                      </span>1633                    </div>1634                    <button1635                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1636                        historyRestoring() === snap.id ? " is-busy" : ""1637                      }`}1638                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1639                      disabled={historyRestoring() !== null}1640                    >1641                      <Show when={historyRestoring() === snap.id} fallback="Restore">1642                        …1643                      </Show>1644                    </button>1645                  </div>1646                )}1647              </For>1648            </div>1649            <Show when={historySnapshots().length > 3}>1650              <button1651                class="peach-settings-advanced-toggle"1652                onClick={() => setHistoryShowAll((v) => !v)}1653                aria-expanded={historyShowAll()}1654              >1655                <span class="peach-settings-hint" style="margin:0">1656                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1657                </span>1658                <span class="peach-settings-advanced-chevron">1659                  {historyShowAll() ? "▲" : "▼"}1660                </span>1661              </button>1662            </Show>1663            <p class="peach-history-footer">1664              {historySnapshots().length} of 10 snapshots1665            </p>1666          </Show>16671668          <button1669            class="peach-settings-advanced-toggle"1670            onClick={() => setShowVaultAdvanced((v) => !v)}1671            aria-expanded={showVaultAdvanced()}1672          >1673            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1674            <span class="peach-settings-advanced-chevron">1675              {showVaultAdvanced() ? "▲" : "▼"}1676            </span>1677          </button>16781679          <Show when={showVaultAdvanced()}>1680            <div class="peach-danger-card">1681              <div class="peach-danger-card-head">1682                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1683                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1684              </div>1685              <p class="peach-danger-card-copy">1686                Wipes the current vault and restores from a backup file.1687                Recoverable only if your backup is good.1688              </p>1689              <button1690                class="peach-danger-card-button"1691                onClick={() => props.onOpenFileImport?.()}1692              >1693                Choose backup file1694              </button>1695            </div>16961697            <Show when={props.onOpenPanic}>1698              <div class="peach-danger-card peach-danger-card--hazard">1699                <div class="peach-danger-card-head">1700                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1701                  <h3 class="peach-danger-card-title">Destroy vault</h3>1702                </div>1703                <p class="peach-danger-card-copy">1704                  Permanently wipes every entry, setting, and license on this1705                  device. There is no recovery.1706                </p>1707                <button1708                  class="peach-danger-card-button"1709                  onClick={() => props.onOpenPanic?.()}1710                >1711                  Begin destruction1712                </button>1713              </div>1714            </Show>1715          </Show>1716        </div>1717      </div>1718    );1719  }17201721  function renderSync() {1722    return (1723      <div class="peach-settings-body">1724        <div class="peach-settings-group">1725          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1726          <Show when={trustDataCorrupted()}>1727            <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1728              Device trust data is damaged — re-pair your devices after updating1729            </p>1730          </Show>1731          <div1732            class="peach-settings-row peach-settings-row--meta"1733            style="align-items:center;gap:8px"1734            aria-label={syncLightLabel()}1735            title={syncLightLabel()}1736          >1737            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1738            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1739              {syncFlash() ?? lastSyncLine()}1740            </span>1741            {/* The transfer counts already ride in the status line, so this row1742                was an empty flex sibling in every other case: it read as a dead1743                gap and held the button away from the status it belongs to. */}1744            <Show when={pairedDevices().length > 0}>1745              <button1746                class="peach-btn peach-btn--subtle peach-btn--sm"1747                aria-label="Sync this vault now"1748                disabled={syncStatus() === "syncing"}1749                onClick={() => void syncNow()}1750              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1751            </Show>1752          </div>17531754          {/* §11: this panel is a migration call to action, not a permanent1755              label. On the settled new generation there is nothing left to do1756              here, so it goes away entirely — Android applies the same rule. */}1757          <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1758            <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1759            <div class="peach-settings-row peach-settings-row--top">1760              <div class="peach-settings-row-main">1761                <span class="peach-settings-value" style="color:var(--peach-t1)">1762                  {secureResetHeadline(generation()?.state)}1763                </span>1764                <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1765                  {secureResetDetail(generation()?.state)}1766                </span>1767              </div>1768              <Show1769                when={1770                  generation()?.state === "LEGACY_ACTIVE" ||1771                  generation()?.state === "RESET_REQUIRED" ||1772                  generation()?.state === "RESET_CONFIRMED"1773                }1774              >1775                <button1776                  class="peach-btn peach-btn--subtle peach-btn--sm"1777                  onClick={() => {1778                    setResetError(null);1779                    setResetPassword("");1780                    setResetOpen(true);1781                  }}1782                >reset sync</button>1783              </Show>1784            </div>1785            <Show when={resetError()}>1786              <p1787                class="peach-settings-value"1788                role="alert"1789                style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1790              >{resetError()}</p>1791            </Show>1792            <Show when={resetOpen()}>1793              <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1794                <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1795                  {SECURE_RESET_DISCLOSURE}1796                </p>1797                <input1798                  class="peach-settings-input"1799                  type="password"1800                  placeholder="master password"1801                  value={resetPassword()}1802                  onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1803                  autocomplete="current-password"1804                />1805                <div style="display:flex;gap:4px">1806                  <button1807                    class="peach-btn peach-btn--subtle peach-btn--sm"1808                    disabled={resetting()}1809                    onClick={() => void runSecureReset()}1810                  >{resetting() ? "resetting…" : "reset"}</button>1811                  <button1812                    class="peach-btn peach-btn--subtle peach-btn--sm"1813                    disabled={resetting()}1814                    onClick={() => setResetOpen(false)}1815                  >cancel</button>1816                </div>1817              </div>1818            </Show>1819          </Show>18201821          <Show when={pairedDevices().length > 0}>1822            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1823            <For each={pairedDevices()}>1824              {(d) => (1825                <div class="peach-settings-row peach-settings-row--top">1826                  <div class="peach-settings-row-main">1827                    <Show1828                      when={renameDeviceId() === d.deviceId}1829                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1830                    >1831                      <input1832                        class="peach-settings-input"1833                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1834                        maxlength={64}1835                        value={renameDraft()}1836                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1837                        onKeyDown={(event) => {1838                          if (event.key === "Enter") void renameDevice(d.deviceId);1839                          if (event.key === "Escape") setRenameDeviceId(null);1840                        }}1841                      />1842                    </Show>1843                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1844                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1845                    </span>1846                  </div>1847                  <Show1848                    when={confirmUnpair() === d.deviceId}1849                    fallback={1850                      <div style="display:flex;gap:4px;flex-shrink:0">1851                        <Show1852                          when={renameDeviceId() === d.deviceId}1853                          fallback={1854                            <button1855                              class="peach-btn peach-btn--subtle peach-btn--sm"1856                              onClick={() => {1857                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1858                                setRenameDeviceId(d.deviceId);1859                              }}1860                            >rename</button>1861                          }1862                        >1863                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1864                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1865                        </Show>1866                        <button1867                          class="peach-btn peach-btn--subtle peach-btn--sm"1868                          onClick={() => {1869                            setUnpairError(null);1870                            setConfirmUnpair(d.deviceId);1871                          }}1872                        >remove</button>1873                      </div>1874                    }1875                  >1876                    {/* HOLE 4: revocation stops this device receiving future1877                        changes. It does NOT secure the vault against it — that1878                        device may already hold the recovery words, which are1879                        master-password-equivalent and restore the whole vault1880                        anywhere, forever, with no sync involved. Say so here,1881                        rather than leaving the user falsely reassured. */}1882                    <div class="peach-unpair-confirm">1883                      <p class="peach-unpair-confirm-note">1884                        Stops this device receiving future changes. It doesn't1885                        lock it out of what it already has — if it's lost or1886                        stolen, also change your master password and generate1887                        new recovery words.1888                      </p>1889                      <div style="display:flex;gap:6px;justify-content:flex-end">1890                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1891                        <button1892                          class="peach-btn peach-btn--sm"1893                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1894                          onClick={() => void unpairDevice(d.deviceId)}1895                        >remove</button>1896                      </div>1897                    </div>1898                      <Show when={unpairError()}>1899                        <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1900                          {unpairError()}1901                        </p>1902                      </Show>1903                  </Show>1904                </div>1905              )}1906            </For>1907          </Show>19081909          <Show when={pendingVouched().length > 0}>1910            <p class="peach-settings-sub-label">1911              PENDING DEVICES1912              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1913            </p>1914            <For each={pendingVouched()}>1915              {(dev) => (1916                <div class="peach-settings-row peach-settings-row--top">1917                  <div class="peach-settings-row-main">1918                    <span>{dev.deviceName}</span>1919                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1920                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1921                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1922                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1923                      existing vault contents and all future changes.1924                    </span>1925                  </div>1926                  <div style="display:flex;gap:6px;flex-shrink:0">1927                    <button1928                      class="peach-btn peach-btn--sm"1929                      disabled={vouchedBusy() === dev.deviceId}1930                      onClick={() => void confirmVouched(dev.deviceId)}1931                    >Allow</button>1932                    <button1933                      class="peach-btn peach-btn--subtle peach-btn--sm"1934                      disabled={vouchedBusy() === dev.deviceId}1935                      onClick={() => void rejectVouched(dev.deviceId)}1936                    >Reject</button>1937                  </div>1938                </div>1939              )}1940            </For>1941          </Show>19421943          <p class="peach-settings-sub-label">ADD A DEVICE</p>1944          <button1945            class="peach-sync-pair-option"1946            onClick={() => props.onOpenSyncPairing?.("code")}1947          >1948            <span class="peach-sync-pair-option-icon" aria-hidden="true">1949              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1950                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1951                <path d="M9 15 L11 15" />1952              </svg>1953            </span>1954            <span class="peach-sync-pair-option-main">1955              <span class="peach-sync-pair-option-title">Show a code</span>1956              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1957            </span>1958            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1959          </button>1960          <Show when={!isPro()}>1961            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1962              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1963            </p>1964          </Show>19651966          <button1967            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1968            onClick={() => setShowSyncConnection((value) => !value)}1969            aria-expanded={showSyncConnection()}1970            aria-controls="peach-sync-connection"1971          >1972            <span>1973              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1974              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1975            </span>1976            <span class="peach-settings-advanced-chevron">1977              {showSyncConnection() ? "▲" : "▼"}1978            </span>1979          </button>1980          <Show when={showSyncConnection()}>1981            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1982              <SyncModeSelector />1983            </div>1984          </Show>19851986          <button1987            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1988            onClick={() => setShowSyncRecovery((value) => !value)}1989            aria-expanded={showSyncRecovery()}1990            aria-controls="peach-sync-recovery"1991          >1992            <span>1993              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1994              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1995            </span>1996            <span class="peach-settings-advanced-chevron">1997              {showSyncRecovery() ? "▲" : "▼"}1998            </span>1999          </button>2000          <Show when={showSyncRecovery()}>2001            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2002              <Show2003                when={rotateConfirmOpen()}2004                fallback={2005                  <div class="peach-settings-row peach-settings-row--top">2006                    <div class="peach-settings-row-main">2007                      <span>Rotate sync identity</span>2008                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2009                        Disconnects every device and replaces the keys used for sync.2010                      </span>2011                    </div>2012                    <button2013                      class="peach-btn peach-btn--subtle peach-btn--sm"2014                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2015                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2016                    >rotate</button>2017                  </div>2018                }2019              >2020                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2021                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2022                  <Show when={rotateError()}>2023                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2024                  </Show>2025                  <div style="display:flex;gap:6px;margin-top:10px">2026                    <button2027                      class="peach-btn peach-btn--subtle peach-btn--sm"2028                      disabled={rotating()}2029                      onClick={() => setRotateConfirmOpen(false)}2030                    >Cancel</button>2031                    <button2032                      class="peach-btn peach-btn--sm"2033                      style="background:var(--peach-breach);border-color:var(--peach-breach)"2034                      disabled={rotating()}2035                      onClick={() => void doRotateSyncIdentity()}2036                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2037                  </div>2038                </div>2039              </Show>2040            </div>2041          </Show>2042        </div>2043      </div>2044    );2045  }20462047  function renderAppearance() {2048    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2049      { id: "medium", label: "Default" },2050      { id: "large", label: "Large" },2051    ];2052    return (2053      <div class="peach-settings-body">2054        <div class="peach-settings-group">2055          <p class="peach-settings-sub-label">THEME</p>2056          <div class="peach-settings-row peach-settings-row--meta">2057            <span>Theme</span>2058            <button2059              class="peach-btn peach-btn--subtle peach-btn--sm"2060              onClick={() =>2061                props.onThemeChange(props.theme === "dark" ? "light" : "dark")2062              }2063            >2064              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2065            </button>2066          </div>20672068          <p class="peach-settings-sub-label">ACCENT</p>2069          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2070            <span>Accent</span>2071            <div class="peach-accent-picker">2072              <For each={ACCENT_COLORS}>2073                {(color) => (2074                  <button2075                    class="peach-accent-swatch peach-swatch"2076                    classList={{ "is-active": props.accentColor === color.name }}2077                    style={{ background: color.hex }}2078                    onClick={() => void props.onAccentChange(color.name)}2079                    aria-label={color.name}2080                    title={color.name}2081                  />2082                )}2083              </For>2084            </div>2085          </div>20862087          <p class="peach-settings-sub-label">TEXT SIZE</p>2088          <div class="peach-settings-row peach-settings-row--meta">2089            <span>Size</span>2090            <div class="peach-pill-group">2091              <For each={sizeOptions}>2092                {(opt) => (2093                  <button2094                    class="peach-btn peach-btn--ghost peach-btn--sm"2095                    classList={{ "is-active": props.textSize === opt.id }}2096                    onClick={() => void props.onTextSizeChange(opt.id)}2097                  >2098                    {opt.label}2099                  </button>2100                )}2101              </For>2102            </div>2103          </div>21042105          <p class="peach-settings-sub-label">MOTION</p>2106          <div class="peach-settings-row peach-settings-row--meta">2107            <span>Reduce motion</span>2108            <button2109              class="peach-btn peach-btn--subtle peach-btn--sm"2110              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2111            >2112              {props.reduceMotion ? "On" : "Off"}2113            </button>2114          </div>2115        </div>2116      </div>2117    );2118  }21192120  function renderShortcuts() {2121    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2122    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21232124    function startCapture(action: ShortcutAction) {2125      setConflictMsg(null);2126      setCapturingAction(action);2127    }2128    function cancelCapture() {2129      setCapturingAction(null);2130      setConflictMsg(null);2131    }2132    function commitBinding(action: ShortcutAction, binding: Keybinding) {2133      const conflict = findConflict(props.shortcuts, binding, action);2134      if (conflict) {2135        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2136        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2137        return;2138      }2139      const next = { ...props.shortcuts, [action]: binding };2140      setCapturingAction(null);2141      setConflictMsg(null);2142      void props.onShortcutsChange(next);2143    }2144    function resetAction(action: ShortcutAction) {2145      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2146      void props.onShortcutsChange(next);2147    }2148    function resetAll() {2149      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2150    }21512152    // Capture-mode key handler — wired via ref onto the chip element so it2153    // catches the next non-modifier keypress and ignores other bindings.2154    function handleCaptureKey(e: KeyboardEvent) {2155      e.preventDefault();2156      e.stopPropagation();2157      if (e.key === "Escape") { cancelCapture(); return; }2158      const binding = keybindingFromEvent(e);2159      if (!binding) return; // bare modifier — keep waiting2160      const action = capturingAction();2161      if (action) commitBinding(action, binding);2162    }21632164    return (2165      <div class="peach-settings-body">2166        <div class="peach-settings-group peach-shortcuts-group">2167          <p class="peach-settings-sub-label">ENTRY VIEW</p>2168          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2169            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2170          </For>2171        </div>21722173        <div class="peach-settings-group peach-shortcuts-group">2174          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2175          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2176            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2177          </For>2178        </div>21792180        <Show when={conflictMsg()}>2181          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2182        </Show>21832184        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2185          <div class="peach-settings-row peach-settings-row--meta">2186            <span>Inline autofill on page</span>2187            <a2188              class="peach-settings-link"2189              href="chrome://extensions/shortcuts"2190              onClick={(e) => {2191                e.preventDefault();2192                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2193              }}2194            >2195              ⌘⇧P — customize ↗2196            </a>2197          </div>2198          <button2199            class="peach-btn peach-btn--subtle peach-btn--sm"2200            style="align-self:flex-start"2201            onClick={resetAll}2202          >2203            Reset all to defaults2204          </button>2205        </div>2206      </div>2207    );22082209    function renderRow(2210      desc: typeof SHORTCUT_DESCRIPTORS[number],2211      capturing: () => ShortcutAction | null,2212      onStart: (a: ShortcutAction) => void,2213      onCancel: () => void,2214      onReset: (a: ShortcutAction) => void,2215      onKey: (e: KeyboardEvent) => void,2216    ) {2217      const isActive = () => capturing() === desc.action;2218      const binding = () => props.shortcuts[desc.action];2219      const isCustom = () => {2220        const b = binding();2221        const d = DEFAULT_BINDINGS[desc.action];2222        return b.key !== d.key2223          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2224          || Boolean(b.meta)  !== Boolean(d.meta)2225          || Boolean(b.shift) !== Boolean(d.shift)2226          || Boolean(b.alt)   !== Boolean(d.alt);2227      };2228      return (2229        <div class="peach-shortcut-row">2230          <span class="peach-shortcut-row-label">{desc.label}</span>2231          <button2232            class="peach-shortcut-chip"2233            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2234            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2235            onKeyDown={(e) => { if (isActive()) onKey(e); }}2236            ref={(el) => {2237              // Auto-focus when entering capture mode so the keydown listener catches the next press.2238              if (el) {2239                queueMicrotask(() => { if (isActive()) el.focus(); });2240              }2241            }}2242          >2243            {isActive() ? "Press a key…" : formatKeybinding(binding())}2244          </button>2245          <Show when={isCustom() && !isActive()}>2246            <button2247              class="peach-shortcut-reset"2248              title="Reset to default"2249              onClick={() => onReset(desc.action)}2250            >2251              ↺2252            </button>2253          </Show>2254        </div>2255      );2256    }2257  }22582259  const PRO_FEATURES: Array<{2260    feature: ProFeature;2261    name: string;2262    desc: string;2263  }> = [2264    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2265    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2266    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2267    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2268  ];22692270  const PRO_SECURITY_FEATURES: Array<{2271    feature: ProFeature;2272    name: string;2273    desc: string;2274  }> = [2275    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2276    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2277  ];22782279  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2280  const [showKey, setShowKey] = createSignal(false);22812282  const [deactivating, setDeactivating] = createSignal(false);22832284  async function handleDeactivateLicense() {2285    setDeactivating(true);2286    await sendMessage({ type: "DEACTIVATE_LICENSE" });2287    setLicenseInfo(null);2288    await props.onLicenseChange?.();2289    setDeactivating(false);2290  }22912292  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2293    setCanaryTestingId(entry.id);2294    setCanaryTestResult(null);2295    const res = await sendMessage({2296      type: "FIRE_CANARY_PING",2297      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2298    }) as { ok: boolean };2299    setCanaryTestResult({ id: entry.id, ok: res.ok });2300    setCanaryTestingId(null);2301    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2302  }23032304  function renderLicense() {2305    async function onActivate() {2306      await handleActivateLicense();2307      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2308        await props.onLicenseChange?.();2309      }2310    }23112312    return (2313      <div class="peach-settings-body">2314        <Show2315          when={isPro()}2316          fallback={2317                <div class="peach-pro-upgrade">2318                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23192320                  <h2 class="peach-pro-headline">2321                    Every<em>thing,</em><br />every<em>where.</em>2322                  </h2>23232324                  <p class="peach-pro-subtitle">2325                    One key unlocks every Pro feature — for the lifetime of the app.2326                  </p>23272328                  <div class="peach-pro-grid">2329                    <For each={PRO_FEATURES}>2330                      {(item, i) => (2331                        <div2332                          class="peach-pro-grid-item"2333                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2334                        >2335                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2336                          <span class="peach-pro-grid-name">{item.name}</span>2337                          <span class="peach-pro-grid-desc">{item.desc}</span>2338                        </div>2339                      )}2340                    </For>2341                  </div>23422343                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2344                  <div class="peach-pro-grid">2345                    <For each={PRO_SECURITY_FEATURES}>2346                      {(item, i) => (2347                        <div2348                          class="peach-pro-grid-item"2349                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2350                        >2351                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2352                          <span class="peach-pro-grid-name">{item.name}</span>2353                          <span class="peach-pro-grid-desc">{item.desc}</span>2354                        </div>2355                      )}2356                    </For>2357                  </div>23582359                  <div class="peach-pro-upgrade-actions">2360                    <p class="peach-pro-pricing">2361                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2362                    </p>2363                    <a2364                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2365                      target="_blank"2366                      rel="noopener noreferrer"2367                      class="peach-pro-upgrade-cta"2368                    >2369                      Get Peach Pro2370                    </a>2371                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2372                    <div class="peach-pro-key-inline">2373                      <input2374                        class="peach-pro-key-inline-input"2375                        type="text"2376                        placeholder="PEACH-XXXX-XXXX-XXXX"2377                        value={licenseKeyInput()}2378                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2379                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2380                        autocomplete="off"2381                        spellcheck={false}2382                      />2383                      <button2384                        class="peach-pro-key-apply"2385                        onClick={() => void onActivate()}2386                        disabled={activatingLicense() || !licenseKeyInput().trim()}2387                      >2388                        {activatingLicense() ? "…" : "APPLY"}2389                      </button>2390                    </div>2391                    <Show when={licenseError()}>2392                      <p class="peach-pro-key-error">{licenseError()}</p>2393                    </Show>2394                  </div>2395                </div>2396          }2397        >2398          <div class="peach-pro-active">2399            <div class="peach-pro-active-hero">2400              <button2401                class="peach-pro-active-mark-wrap"2402                type="button"2403                aria-label="Celebrate"2404                onClick={handleProCelebrationClick}2405              >2406                <PeachMark class="peach-pro-active-mark" />2407                <Show when={justActivated()}>2408                  <div class="peach-pro-sparks" aria-hidden="true">2409                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2410                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2411                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2412                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2413                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2414                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2415                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2416                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2417                  </div>2418                </Show>2419              </button>2420              <h2 class="peach-pro-active-title">Peach Pro</h2>2421              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2422              <Show when={justActivated()}>2423                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2424              </Show>2425            </div>24262427            <Show when={licenseKey()}>2428              <div class="peach-settings-license">2429                <span class="peach-settings-label">LICENSE KEY</span>2430                <div class="peach-settings-license-row">2431                  <Show2432                    when={showKey()}2433                    fallback={2434                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2435                        ••••••••••••••••••••••••••••2436                      </span>2437                    }2438                  >2439                    <span class="peach-settings-license-key">{licenseKey()!}</span>2440                  </Show>2441                  <div class="peach-settings-license-actions">2442                    <button2443                      class="peach-settings-license-copy"2444                      title="Copy license key"2445                      onClick={() => copyLicenseKey(licenseKey()!)}2446                    >2447                      {copied() ? "✓" : "copy"}2448                    </button>2449                    <button2450                      class="peach-settings-license-toggle"2451                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2452                      title={showKey() ? "Hide" : "Reveal"}2453                      onClick={() => setShowKey(v => !v)}2454                    >2455                      <Show2456                        when={showKey()}2457                        fallback={2458                          <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">2459                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2460                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2461                          </svg>2462                        }2463                      >2464                        <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">2465                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2466                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2467                          <line x1="2" y1="2" x2="12" y2="12" />2468                        </svg>2469                      </Show>2470                    </button>2471                  </div>2472                </div>2473              </div>2474            </Show>24752476            <div class="peach-pro-active-features">2477              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2478                {(item) => (2479                  <div class="peach-pro-active-feature">2480                    <span class="peach-pro-active-feature-mark">✦</span>2481                    <span class="peach-pro-active-feature-name">{item.name}</span>2482                  </div>2483                )}2484              </For>2485            </div>24862487            <button2488              class="peach-pro-deactivate"2489              onClick={() => void handleDeactivateLicense()}2490              disabled={deactivating()}2491            >2492              {deactivating() ? "Deactivating…" : "Deactivate"}2493            </button>2494          </div>2495        </Show>2496      </div>2497    );2498  }24992500  function renderFeedback() {2501    return (2502      <div class="peach-settings-body">2503        <div class="peach-settings-group peach-feedback-group">2504          <Show2505            when={feedbackState() === 'sent'}2506            fallback={2507              <>2508                <p class="peach-feedback-intro">2509                  Help us improve Peach. Every report goes directly to our inbox.2510                </p>25112512                <p class="peach-settings-sub-label">TYPE</p>2513                <div class="peach-feedback-chips">2514                  <For each={FEEDBACK_CATEGORIES}>2515                    {(cat) => {2516                      const selected = () => fbCategory() === cat;2517                      return (2518                        <button2519                          type="button"2520                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2521                          aria-pressed={selected()}2522                          onClick={() => setFbCategory(cat)}2523                        >2524                          {cat}2525                        </button>2526                      );2527                    }}2528                  </For>2529                </div>25302531                <p class="peach-settings-sub-label">MESSAGE</p>2532                <textarea2533                  class="peach-feedback-textarea"2534                  placeholder="Describe the issue or idea..."2535                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2536                  value={fbMessage()}2537                  onInput={(e) => setFbMessage(e.currentTarget.value)}2538                />25392540                <p class="peach-feedback-meta">2541                  Version {version} · will be included automatically2542                </p>25432544                <button2545                  type="button"2546                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2547                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2548                  onClick={() => void handleSubmitFeedback()}2549                >2550                  <Switch>2551                    <Match when={feedbackState() === 'idle'}>Send</Match>2552                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2553                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2554                  </Switch>2555                </button>25562557                <p class="peach-feedback-anon">2558                  Sent anonymously · no account required2559                </p>2560              </>2561            }2562          >2563            <div class="peach-feedback-thanks" role="status" aria-live="polite">2564              <p class="peach-feedback-thanks-title">Thank you</p>2565              <p>2566                Your note is in the queue. Someone will process it soon.2567              </p>2568            </div>2569          </Show>2570        </div>2571      </div>2572    );2573  }25742575  const bodies: StackBodies = {2576    protection: renderSecurity(),2577    vault: renderVault(),2578    sync: renderSync(),2579    appearance: renderAppearance(),2580    shortcuts: renderShortcuts(),2581    pro: renderLicense(),2582    feedback: renderFeedback(),2583  };25842585  return (2586    <Show2587      when={showWhatsNew()}2588      fallback={2589    <Show2590      when={showNewRecoveryWords()}2591      fallback={2592        <div2593          class="peach-overlay-view peach-settings-overlay"2594          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2595          style="display:flex;flex-direction:column"2596        >2597          <StackDeck2598            active={activeCard()}2599            onActiveChange={setActiveCard}2600            onBack={props.onBack}2601            cards={cards()}2602            bodies={bodies}2603            version={version}2604            isFanning={isFanning()}2605            celebratingCardId={justActivated() ? "pro" : null}2606            isPro={isPro()}2607          />2608        </div>2609      }2610    >2611      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2612    </Show>2613      }2614    >2615      <div class="peach-overlay-view peach-settings-overlay">2616        <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2617      </div>2618    </Show>2619  );2620}