Settings.tsx

106.2 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13  AccentColorName,14  PasskeyProviderState,15  ProFeature,16  VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20  describeSyncOutcome,21  syncIndicator,22  syncIndicatorLabel,23  type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import type { SyncGeneration } from "../../background/sync-generation";26import {27  DEFAULT_BINDINGS,28  findConflict,29  formatKeybinding,30  keybindingFromEvent,31  SHORTCUT_DESCRIPTORS,32  type Keybinding,33  type ShortcutAction,34} from "../../shared/shortcuts";35import { WEB3FORMS_KEY } from "../../shared/config";36import { PRO_FEATURES, PRO_SECURITY_FEATURES } from "../pro-features";3738interface LicenseInfo {39  valid: boolean;40  plan: "free" | "pro";41  issued?: string;42}4344interface SettingsProps {45  entriesCount: number;46  breachMonitoring: boolean;47  lockOnClose: boolean;48  autoLockMinutes: number;49  lockedSyncRequiresUnlock: boolean;50  trashRetentionDays: number;51  theme: "dark" | "light";52  accentColor: AccentColorName;53  textSize: "small" | "medium" | "large";54  reduceMotion: boolean;55  isPro?: boolean;56  initialSection?: string | null;57  upgradeHighlight?: ProFeature | null;58  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;59  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;60  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;61  onLockedSyncRequiresUnlockChange: (enabled: boolean) => void | Promise<void>;62  onTrashRetentionChange: (days: number) => void | Promise<void>;63  onThemeChange: (theme: "dark" | "light") => void;64  onAccentChange: (name: AccentColorName) => void | Promise<void>;65  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;66  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;67  shortcuts: Record<ShortcutAction, Keybinding>;68  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;69  onLicenseChange?: () => void | Promise<void>;70  isExiting?: boolean;71  onBack: () => void;72  onImport: () => void;73  onExport: () => void;74  onBreachCheckDone: () => void;75  onOpenHealth?: () => void;76  onOpenSyncPairing?: (method?: "code") => void;77  onOpenFileImport?: () => void;78  onOpenPanic?: () => void;79  onOpenCodexGenerate?: () => void;80  onOpenCodexRestore?: () => void;81  /** Invoked from within Settings when a Pro-gated control is tapped on82   *  free tier. App owns the upgrade navigation + highlight state, so this83   *  callback is the single way for Settings to request that navigation. */84  onProGate?: (feature: ProFeature) => void;85  onCardChange?: (id: CardId) => void;86}8788const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];89const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;90const PRO_CELEBRATION_CARD_STEP_MS = 150;91const PRO_CELEBRATION_THANK_YOU_MS = 3200;9293function sanitizeFeedbackMessage(value: string): string {94  return value95    .trim()96    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)97    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text98    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")99    .replace(/&/g, "&amp;")100    .replace(/</g, "&lt;")101    .replace(/>/g, "&gt;")102    .replace(/"/g, "&quot;")103    .replace(/'/g, "&#39;");104}105106function formatActivatedSince(issued: string | undefined): string {107  if (!issued) return "Active";108  try {109    const d = new Date(issued);110    if (Number.isNaN(d.getTime())) return "Active";111    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;112  } catch {113    return "Active";114  }115}116117function initialSectionToCard(value: string | null | undefined): CardId | null {118  if (!value) return null;119  if (value === "security") return "protection";120  if (value === "license") return "pro";121  if (122    value === "protection" ||123    value === "vault" ||124    value === "sync" ||125    value === "appearance" ||126    value === "shortcuts" ||127    value === "pro" ||128    value === "feedback"129  ) {130    return value;131  }132  return null;133}134135/**136 * §11 migration copy. It must never imply that old peers keep working: the137 * whole point of the reset is that they cannot.138 */139const SECURE_RESET_DISCLOSURE =140  "Peach will archive this device's old Sync trust and start the new Sync generation. " +141  "Every other device must be updated first, then re-paired with this one — they will " +142  "not continue on their own. Your vault and your recovery words are not changed.";143144function secureResetHeadline(state: string | undefined): string {145  switch (state) {146    case "LEGACY_ACTIVE":147      return "Legacy Sync generation";148    case "RESET_REQUIRED":149      return "Reset required";150    case "RESET_CONFIRMED":151      return "Reset not completed";152    case "V2_ANCHOR":153    case "REPAIRING":154      return "Not re-paired";155    case "V2_ACTIVE":156      return "New Sync generation";157    case "UNKNOWN":158      return "Sync generation unreadable";159    default:160      return "Sync generation";161  }162}163164function secureResetDetail(state: string | undefined): string {165  switch (state) {166    case "LEGACY_ACTIVE":167      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.";168    case "RESET_REQUIRED":169      return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";170    case "RESET_CONFIRMED":171      return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";172    case "V2_ANCHOR":173    case "REPAIRING":174      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.";175    case "V2_ACTIVE":176      return "This device is on the new Sync generation. Each device is re-paired individually.";177    case "UNKNOWN":178      return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";179    default:180      return "Sync state is unavailable.";181  }182}183184export function SettingsView(props: SettingsProps) {185  const manifest = chrome.runtime.getManifest();186  const version = manifest.version;187  const initial = props.initialSection;188  const [activeCard, setActiveCard] = createSignal<CardId>(189    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"190  );191192  createEffect(() => { props.onCardChange?.(activeCard()); });193194  const [justActivated, setJustActivated] = createSignal(false);195  const [isFanning, setIsFanning] = createSignal(false);196197  function triggerCelebration() {198    if (isFanning()) return;199    setJustActivated(false);200    setIsFanning(true);201    setActiveCard("pro");202    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];203    fanOrder.forEach((id, i) => {204      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);205    });206    window.setTimeout(() => {207      setActiveCard("pro");208      setIsFanning(false);209      setJustActivated(true);210      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);211    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);212  }213214  function handleProCelebrationClick(e: MouseEvent) {215    e.stopPropagation();216    triggerCelebration();217  }218219  const [checkingBreaches, setCheckingBreaches] = createSignal(false);220  const [breachResult, setBreachResult] = createSignal<string | null>(null);221  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);222  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");223  const [licenseError, setLicenseError] = createSignal<string | null>(null);224  const [, setLicenseSuccess] = createSignal<string | null>(null);225  const [activatingLicense, setActivatingLicense] = createSignal(false);226227  const isPro = () => props.isPro ?? false;228  const cards = createMemo<StackCardSpec[]>(() => [229    {230      id: "pro",231      num: "I · FOREVER",232      title: "Peach Pro",233      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",234      bgVar: "--stack-pro-bg",235      fgVar: "--stack-pro-fg",236      icon: "spark",237      onIconClick: isPro() ? triggerCelebration : undefined,238    },239    {240      id: "protection",241      num: "II · PROTECT",242      title: "Protection",243      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,244      bgVar: "--stack-protect-bg",245      fgVar: "--stack-protect-fg",246      icon: "shield",247    },248    {249      id: "vault",250      num: "III · KEEP",251      title: "Vault",252      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,253      bgVar: "--stack-vault-bg",254      fgVar: "--stack-vault-fg",255      icon: "lock",256    },257    {258      id: "sync",259      num: "IV · ECHO",260      title: "Sync",261      lede: isPro() ? "End to End Encryption" : "Pro · encrypted device sync",262      bgVar: "--stack-sync-bg",263      fgVar: "--stack-sync-fg",264      icon: "sync",265    },266    {267      id: "appearance",268      num: "V · DRESS",269      title: "Appearance",270      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,271      bgVar: "--stack-appear-bg",272      fgVar: "--stack-appear-fg",273      icon: "sun",274    },275    {276      id: "shortcuts",277      num: "VI · KEYS",278      title: "Shortcuts",279      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,280      bgVar: "--stack-shortcut-bg",281      fgVar: "--stack-shortcut-fg",282      icon: "keys",283    },284    {285      id: "feedback",286      num: "VII · ECHO",287      title: "Feedback",288      lede: `version ${version} · tell us what would make it better`,289      bgVar: "--stack-feedback-bg",290      fgVar: "--stack-feedback-fg",291      icon: "bubble",292    },293  ]);294  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);295  const [passkeySaving, setPasskeySaving] = createSignal(false);296297  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting298  // explicit confirmation before they become sync targets.299  interface PendingVouched {300    deviceId: string;301    deviceName: string;302    longTermPublicKeyHex: string;303    receivedAt: number;304    vouchedByDeviceId: string;305  }306  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);307  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);308  // Paired-device management lives here — Settings is the sync hub.309  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);310  const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);311312  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);313  const [unpairError, setUnpairError] = createSignal<string | null>(null);314  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);315  const [renameDraft, setRenameDraft] = createSignal("");316  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");317  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);318  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —319  // a transient toast is no use for diagnosing a phone that isn't syncing.320  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});321  // §11: the Sync generation this installation is on. Not secret — a version322  // and a state name — so it renders the same in any session.323  const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);324  const [resetOpen, setResetOpen] = createSignal(false);325  const [resetPassword, setResetPassword] = createSignal("");326  const [resetError, setResetError] = createSignal<string | null>(null);327  const [resetting, setResetting] = createSignal(false);328  const syncLight = () =>329    syncStatus() === "syncing"330      ? "busy"331      : syncIndicator(syncSummary(), pairedDevices().length);332  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());333  const lastSyncLine = () => {334    const s = syncSummary();335    if (!s.outcome) return "No sync attempted yet";336    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;337  };338  async function loadSyncState() {339    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {340      ok: boolean;341      data?: SyncOutcomeSummary;342    };343    if (r.ok && r.data) {344      setSyncSummary(r.data);345      setSyncStatus(r.data.status ?? "idle");346    }347  }348349  async function loadGeneration() {350    const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {351      ok: boolean;352      data?: SyncGeneration | null;353    };354    if (r.ok) setGeneration(r.data ?? null);355  }356357  async function runSecureReset() {358    setResetting(true);359    const r = (await sendMessage({360      type: "SECURE_RESET",361      payload: { password: resetPassword() },362    })) as { ok: boolean; data?: SyncGeneration | null; error?: string };363    setResetting(false);364    setResetPassword("");365    if (!r.ok) {366      setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");367      await loadGeneration();368      return;369    }370    setResetError(null);371    setResetOpen(false);372    if (r.data) setGeneration(r.data);373  }374375  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;376  function syncRelativeTime(when: number | undefined): string {377    if (!when) return "Not synced yet";378    const elapsed = Date.now() - when;379    if (elapsed < 60_000) return "Just now";380    const minutes = Math.floor(elapsed / 60_000);381    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;382    const hours = Math.floor(minutes / 60);383    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;384    const days = Math.floor(hours / 24);385    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";386  }387  function isSyncStale(d: PairedDevice): boolean {388    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;389  }390  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {391    switch (t) {392      case "android": return "Android";393      case "ios": return "iPhone";394      case "extension": return "Browser";395      default: return "Device";396    }397  }398399  async function loadPairedDevices() {400    if (!isPro()) return;401    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {402      ok: boolean;403      error?: string;404      data?: { devices: PairedDevice[] };405    };406    if (r.ok) {407      setTrustDataCorrupted(false);408      setPairedDevices(r.data?.devices ?? []);409    } else if (r.error === "trust_data_corrupted") {410      setPairedDevices([]);411      setTrustDataCorrupted(true);412    }413  }414  async function unpairDevice(deviceId: string) {415    const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {416      ok: boolean;417      error?: string;418    };419    if (!result.ok) {420      setUnpairError(result.error ?? "Could not remove this device");421      return;422    }423    setUnpairError(null);424    setConfirmUnpair(null);425    await loadPairedDevices();426  }427  async function renameDevice(deviceId: string) {428    const name = renameDraft().trim();429    if (!name) return;430    const result = (await sendMessage({431      type: "RENAME_PAIRED_DEVICE",432      payload: { deviceId, name },433    })) as { ok: boolean };434    if (!result.ok) return;435    setRenameDeviceId(null);436    await loadPairedDevices();437  }438  async function syncNow() {439    setSyncStatus("syncing");440    setSyncFlash(null);441    const res = (await sendMessage({442      type: "TRIGGER_SYNC",443      payload: { reason: "manual", force: true },444    })) as { ok: boolean; error?: string };445    if (!res.ok) {446      setSyncStatus("error");447      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });448      return;449    }450    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the451    // outcome. A single delayed read used to land while the run was still452    // "syncing" and leave the button disabled forever.453    let s: SyncOutcomeSummary = { status: "syncing" };454    for (let i = 0; i < 40 && s.status === "syncing"; i++) {455      await new Promise((r) => setTimeout(r, 250));456      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {457        ok: boolean;458        data?: SyncOutcomeSummary;459      };460      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };461    }462    setSyncSummary(s);463    setSyncStatus(s.status ?? "idle");464    // The persistent line below carries the outcome; the flash is only the465    // "that click did something" acknowledgement.466    setSyncFlash(describeSyncOutcome(s));467    setTimeout(() => setSyncFlash(null), 4000);468    await loadPairedDevices();469  }470  // UI-4 / MV-8: rotate sync identity confirmation dialog.471  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);472  const [rotating, setRotating] = createSignal(false);473  const [rotateError, setRotateError] = createSignal<string | null>(null);474  const [showSyncConnection, setShowSyncConnection] = createSignal(false);475  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);476477  async function loadPendingVouched() {478    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {479      ok: boolean;480      data?: PendingVouched[];481    };482    if (r.ok) setPendingVouched(r.data ?? []);483  }484  async function confirmVouched(deviceId: string) {485    setVouchedBusy(deviceId);486    try {487      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });488      await loadPendingVouched();489    } finally {490      setVouchedBusy(null);491    }492  }493  async function rejectVouched(deviceId: string) {494    setVouchedBusy(deviceId);495    try {496      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });497      await loadPendingVouched();498    } finally {499      setVouchedBusy(null);500    }501  }502  async function doRotateSyncIdentity() {503    setRotateError(null);504    setRotating(true);505    try {506      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {507        ok: boolean;508        error?: string;509      };510      if (!r.ok) {511        setRotateError(r.error ?? "Rotation failed.");512        return;513      }514      setRotateConfirmOpen(false);515      // Backend set peach_recovery_pending; reuse the existing recovery-words516      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).517      setShowNewRecoveryWords(true);518    } finally {519      setRotating(false);520    }521  }522523  const [dedupCount, setDedupCount] = createSignal<number | null>(null);524  const [dedupRunning, setDedupRunning] = createSignal(false);525  const [dedupDone, setDedupDone] = createSignal<number | null>(null);526527  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);528529  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted530  // BEFORE every real-mode vault write so the user can roll back if a sync531  // or import overwrites the wrong state.532  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);533  const [historyLoading, setHistoryLoading] = createSignal(false);534  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);535  const [historyShowAll, setHistoryShowAll] = createSignal(false);536537  async function loadVaultHistory() {538    setHistoryLoading(true);539    try {540      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });541      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];542      setHistorySnapshots(list);543    } finally {544      setHistoryLoading(false);545    }546  }547548  async function handleRestoreVaultSnapshot(id: string) {549    if (historyRestoring()) return;550    setHistoryRestoring(id);551    const res = await sendMessage({552      type: "RESTORE_VAULT_SNAPSHOT",553      payload: { snapshotId: id },554    });555    setHistoryRestoring(null);556    if ((res as { ok: boolean }).ok) {557      // Vault is now locked — let the parent close Settings; App.tsx's558      // VAULT_LOCKED listener will route back to the lock screen.559      props.onBack();560    }561  }562563  function formatSnapshotTime(savedAt: number): string {564    const diff = Date.now() - savedAt;565    if (diff < 60_000) return "Just now";566    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;567    const d = new Date(savedAt);568    const isToday = new Date().toDateString() === d.toDateString();569    const isYest =570      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();571    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });572    if (isToday) return `Today ${t}`;573    if (isYest) return `Yesterday ${t}`;574    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;575  }576577  function reasonLabel(reason: VaultSnapshot["reason"]): string {578    return (579      {580        local_save: "saved",581        sync_received: "synced",582        import: "imported",583        restore: "restored",584      } as const585    )[reason] ?? reason;586  }587588  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);589  const [showWhatsNew, setShowWhatsNew] = createSignal(false);590591  const [showChangePw, setShowChangePw] = createSignal(false);592  const [changePwCurrent, setChangePwCurrent] = createSignal("");593  const [changePwNew, setChangePwNew] = createSignal("");594  const [changePwConfirm, setChangePwConfirm] = createSignal("");595  const [changePwSaving, setChangePwSaving] = createSignal(false);596  const [changePwError, setChangePwError] = createSignal<string | null>(null);597  const [changePwSuccess] = createSignal(false);598  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);599600  const [pinEnabled, setPinEnabled] = createSignal(false);601  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);602  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");603  const [pinValue, setPinValue] = createSignal("");604  const [pinConfirm, setPinConfirm] = createSignal("");605  const [pinSaving, setPinSaving] = createSignal(false);606  const [pinError, setPinError] = createSignal<string | null>(null);607608  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.609  const [showViewWords, setShowViewWords] = createSignal(false);610  const [viewWordsPassword, setViewWordsPassword] = createSignal("");611  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);612  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);613  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);614615  async function handleViewRecoveryWords() {616    setViewWordsError(null);617    if (!viewWordsPassword()) {618      setViewWordsError("Enter your master password.");619      return;620    }621    setViewWordsBusy(true);622    try {623      const r = (await sendMessage({624        type: "GET_RECOVERY_WORDS",625        payload: { password: viewWordsPassword() },626      })) as { ok: boolean; data?: string[]; error?: string };627      if (r.ok) {628        setViewedWords(r.data ?? []);629        setViewWordsPassword("");630        return;631      }632      switch (r.error) {633        case "wrong_password":634          setViewWordsError("Incorrect password");635          break;636        case "password_required":637          setViewWordsError("Enter your master password.");638          break;639        case "recovery_not_setup":640          setViewWordsError("Recovery words aren't set up for this vault yet.");641          break;642        default:643          setViewWordsError("Couldn't retrieve recovery words.");644      }645    } finally {646      setViewWordsBusy(false);647    }648  }649  function closeViewWords() {650    setShowViewWords(false);651    setViewWordsPassword("");652    setViewWordsError(null);653    setViewedWords(null);654  }655656  const [duressEnabled, setDuressEnabled] = createSignal(false);657  const [showDuressSetup, setShowDuressSetup] = createSignal(false);658  const [duressPassword, setDuressPassword] = createSignal("");659  const [duressConfirm, setDuressConfirm] = createSignal("");660  const [duressMainPassword, setDuressMainPassword] = createSignal("");661  const [duressSaving, setDuressSaving] = createSignal(false);662  const [duressError, setDuressError] = createSignal<string | null>(null);663  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);664665  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);666667  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);668  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);669670  const [fbCategory, setFbCategory] = createSignal("Bug report");671  const [fbMessage, setFbMessage] = createSignal("");672  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';673  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');674675  createEffect(() => {676    if (props.upgradeHighlight) {677      setActiveCard("pro");678    }679  });680681  onMount(async () => {682    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([683      sendMessage({ type: "GET_LICENSE" }),684      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),685      sendMessage({ type: "IS_DURESS_ENABLED" }),686      sendMessage({ type: "GET_SETTINGS" }),687      sendMessage({ type: "GET_LICENSE_KEY" }),688    ]);689690    // Vault history loads in the background — the Vault sub-view picks it up691    // when the user navigates in. Loading it lazily keeps Settings boot fast.692    void loadVaultHistory();693694    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the695    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.696    void loadPendingVouched();697    void loadPairedDevices();698    void loadSyncState();699    void loadGeneration();700    const onBroadcast = (msg: unknown) => {701      const m = msg as { type?: string; payload?: unknown } | null;702      if (m?.type === "VOUCHED_DEVICE_PENDING") {703        void loadPendingVouched();704      }705      if (m?.type === "SYNC_STATE_CHANGED") {706        const payload = (m.payload ?? {}) as SyncOutcomeSummary;707        setSyncSummary(payload);708        setSyncStatus(payload.status ?? "idle");709      }710    };711    chrome.runtime?.onMessage?.addListener(onBroadcast);712    onCleanup(() => {713      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }714    });715716    if ((licenseRes as { ok: boolean }).ok) {717      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);718    }719720    if ((passkeyRes as { ok: boolean }).ok) {721      setPasskeyState(722        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null723      );724    }725726    if ((duressRes as { ok: boolean }).ok) {727      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);728    }729730    if ((settingsRes as { ok: boolean }).ok) {731      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;732      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);733      setPinEnabled(sData?.pinEnabled === true);734    }735736    if ((keyRes as { ok: boolean }).ok) {737      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);738    }739740    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });741    if ((entriesRes as { ok: boolean }).ok) {742      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];743      setCanaryEntries(744        entries745          .filter((e) => e.canary?.webhookUrl)746          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))747      );748    }749750  });751752  function resetPinFlow() {753    setPinFlow(null);754    setPinCurrentPassword("");755    setPinValue("");756    setPinConfirm("");757    setPinError(null);758  }759760  async function handleSetupPin() {761    setPinError(null);762    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }763    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }764    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }765    setPinSaving(true);766    const res = await sendMessage({767      type: "SETUP_PIN",768      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },769    }) as { ok: boolean; error?: string };770    if (res.ok) {771      setPinEnabled(true);772      resetPinFlow();773    } else {774      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");775    }776    setPinSaving(false);777  }778779  async function handleDisablePin() {780    setPinError(null);781    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }782    setPinSaving(true);783    const res = await sendMessage({784      type: "DISABLE_PIN",785      payload: { currentPassword: pinCurrentPassword() },786    }) as { ok: boolean; error?: string };787    if (res.ok) {788      setPinEnabled(false);789      resetPinFlow();790    } else {791      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");792    }793    setPinSaving(false);794  }795796  async function handleChangePassword() {797    setChangePwError(null);798    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }799    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }800    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }801    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }802    setChangePwSaving(true);803    const res = await sendMessage({804      type: "CHANGE_PASSWORD",805      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },806    }) as { ok: boolean; error?: string };807    if (res.ok) {808      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");809      setShowChangePw(false);810      // LV-4: the user just authenticated (current password), so the recovery811      // words reveal that follows is exempt from the re-auth gate. The812      // background owns this session-scoped exemption; the display clears it.813      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });814      setShowNewRecoveryWords(true);815    } else {816      setChangePwError(res.error ?? "failed to change password");817    }818    setChangePwSaving(false);819  }820821  const [copied, setCopied] = createSignal(false);822823  async function copyLicenseKey(key: string) {824    await navigator.clipboard.writeText(key);825    setCopied(true);826    setTimeout(() => setCopied(false), 2000);827  }828829  async function handleActivateLicense() {830    const key = licenseKeyInput().trim();831    if (!key) return;832    setActivatingLicense(true);833    setLicenseError(null);834    setLicenseSuccess(null);835    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });836    setActivatingLicense(false);837    if ((res as { ok: boolean }).ok) {838      const data = (res as { ok: boolean; data?: LicenseInfo }).data;839      if (data?.valid) {840        setLicenseInfo(data);841        setLicenseKey(key);842        setLicenseSuccess("License activated!");843        setLicenseKeyInput("");844      } else {845        setLicenseError("Invalid license key");846      }847    } else {848      setLicenseError("Activation failed");849    }850  }851852  async function handleTogglePasskeys() {853    const current = passkeyState();854    if (!current || !current.supported) return;855    setPasskeySaving(true);856    const res = await sendMessage({857      type: "SET_PASSKEY_PROVIDER_ENABLED",858      payload: { enabled: !current.enabled },859    });860    setPasskeySaving(false);861    if ((res as { ok: boolean }).ok) {862      setPasskeyState(863        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null864      );865    }866  }867868  async function handleCheckAllBreaches() {869    setCheckingBreaches(true);870    setBreachResult(null);871    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });872    setCheckingBreaches(false);873    if ((res as { ok: boolean }).ok) {874      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};875      const breached = Object.values(data).filter((count) => count > 0).length;876      setBreachResult(877        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"878      );879      props.onBreachCheckDone();880    }881  }882883884  async function handleSetupDuress() {885    const mainPw = duressMainPassword().trim();886    const duressPw = duressPassword().trim();887    const confirmPw = duressConfirm().trim();888889    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }890    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }891    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }892    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }893894    setDuressSaving(true);895    setDuressError(null);896897    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };898    if (!verifyRes.ok || !verifyRes.data) {899      setDuressError("incorrect main password");900      setDuressSaving(false);901      return;902    }903904    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };905    setDuressSaving(false);906    if (res.ok) {907      setDuressEnabled(true);908      setShowDuressSetup(false);909      setDuressPassword("");910      setDuressConfirm("");911      setDuressMainPassword("");912      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");913      setTimeout(() => setDuressSuccess(null), 5000);914    } else {915      setDuressError(916        res.error === "session_expired"917          ? "Vault session expired — lock and unlock Peach, then try again"918          : res.error ?? "failed to set duress password"919      );920    }921  }922923  async function handleClearDeclinedCards() {924    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });925    setDeclinedCardCount(0);926  }927928  async function handleCountDuplicates() {929    setDedupRunning(true);930    setDedupDone(null);931    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };932    setDedupRunning(false);933    if (res.ok) setDedupCount(res.data ?? 0);934  }935936  async function handleDeduplicate() {937    setDedupRunning(true);938    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };939    setDedupRunning(false);940    if (res.ok) {941      setDedupDone(res.data ?? 0);942      setDedupCount(0);943    }944  }945946  async function handleDisableDuress() {947    const mainPw = duressMainPassword().trim();948    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }949950    setDuressSaving(true);951    setDuressError(null);952953    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };954    if (!verifyRes.ok || !verifyRes.data) {955      setDuressError("incorrect main password");956      setDuressSaving(false);957      return;958    }959960    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };961    setDuressSaving(false);962    if (res.ok) {963      setDuressEnabled(false);964      setShowDuressSetup(false);965      setDuressMainPassword("");966      setDuressSuccess("Duress password disabled.");967      setTimeout(() => setDuressSuccess(null), 4000);968    } else {969      setDuressError(res.error ?? "failed to disable duress password");970    }971  }972973  async function handleSubmitFeedback() {974    const message = sanitizeFeedbackMessage(fbMessage());975    if (!message) return;976977    setFeedbackState('sending');978979    try {980      const category = FEEDBACK_CATEGORIES.includes(fbCategory())981        ? fbCategory()982        : FEEDBACK_CATEGORIES[0];983      const res = await fetch('https://api.web3forms.com/submit', {984        method: 'POST',985        headers: { 'Content-Type': 'application/json' },986        body: JSON.stringify({987          access_key: WEB3FORMS_KEY,988          subject: `Peach Feedback - ${category}`,989          message,990          from_name: 'Peach User',991          // Honeypot — leave blank992          botcheck: '',993        }),994      });995996      if (!res.ok) throw new Error(`HTTP ${res.status}`);997      const data = await res.json() as { success: boolean; message?: string };998      if (!data.success) throw new Error(data.message ?? 'unknown error');9991000      setFeedbackState('sent');1001      setFbMessage('');1002      setFbCategory(FEEDBACK_CATEGORIES[0]);1003    } catch (err) {1004      console.error('[feedback]', err);1005      setFeedbackState('error');1006      setTimeout(() => setFeedbackState('idle'), 4000);1007    }1008  }10091010  function renderSecurity() {1011    return (1012      <div class="peach-settings-body">1013        <div class="peach-settings-group">1014          <p class="peach-settings-sub-label">LOCKING</p>1015          <div class="peach-settings-row">1016            <span>Lock on close</span>1017            <button1018              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1019              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1020              aria-label="Toggle lock on close"1021            >1022              <span class="peach-toggle-knob"></span>1023            </button>1024          </div>1025          <div class="peach-settings-row">1026            <span>Auto-lock after</span>1027            <select1028              class="peach-settings-select"1029              value={String(props.autoLockMinutes)}1030              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1031            >1032              <option value="1">1 minute</option>1033              <option value="5">5 minutes</option>1034              <option value="15">15 minutes</option>1035              <option value="30">30 minutes</option>1036              <option value="0">Never</option>1037            </select>1038          </div>1039          <p class="peach-settings-sub-label">SYNC WHILE LOCKED</p>1040          <div class="peach-settings-row">1041            <span>Require unlock after restart</span>1042            <button1043              class={`peach-toggle ${props.lockedSyncRequiresUnlock ? "is-on" : ""}`}1044              onClick={() => void props.onLockedSyncRequiresUnlockChange(!props.lockedSyncRequiresUnlock)}1045              aria-label="Toggle unlock requirement for locked Sync after restart"1046              aria-pressed={props.lockedSyncRequiresUnlock}1047            >1048              <span class="peach-toggle-knob"></span>1049            </button>1050          </div>1051          <p class="peach-settings-sub-label">QUICK UNLOCK</p>1052          <div class="peach-settings-row peach-settings-row--meta">1053            <span>PIN unlock</span>1054            <button1055              class="peach-btn peach-btn--subtle peach-btn--sm"1056              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1057            >1058              {pinEnabled() ? "remove" : "set up"}1059            </button>1060          </div>1061          <Show when={pinFlow() === "setup"}>1062            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1063              <input1064                class="peach-settings-input"1065                type="password"1066                placeholder="current password"1067                value={pinCurrentPassword()}1068                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1069                autocomplete="current-password"1070              />1071              <input1072                class="peach-settings-input"1073                type="password"1074                inputmode="numeric"1075                maxlength="12"1076                placeholder="new 4–12 digit PIN"1077                value={pinValue()}1078                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1079                autocomplete="off"1080              />1081              <input1082                class="peach-settings-input"1083                type="password"1084                inputmode="numeric"1085                maxlength="12"1086                placeholder="confirm PIN"1087                value={pinConfirm()}1088                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1089                autocomplete="off"1090                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1091              />1092              <Show when={pinError()}>1093                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1094              </Show>1095              <button1096                class="peach-btn peach-btn--accent peach-btn--sm"1097                style="align-self:flex-start"1098                onClick={() => void handleSetupPin()}1099                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1100              >1101                {pinSaving() ? "saving…" : "save PIN"}1102              </button>1103            </div>1104          </Show>1105          <Show when={pinFlow() === "disable"}>1106            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1107              <input1108                class="peach-settings-input"1109                type="password"1110                placeholder="current password"1111                value={pinCurrentPassword()}1112                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1113                autocomplete="current-password"1114                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1115              />1116              <Show when={pinError()}>1117                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1118              </Show>1119              <button1120                class="peach-btn peach-btn--subtle peach-btn--sm"1121                style="align-self:flex-start"1122                onClick={() => void handleDisablePin()}1123                disabled={pinSaving() || !pinCurrentPassword()}1124              >1125                {pinSaving() ? "removing…" : "remove PIN"}1126              </button>1127            </div>1128          </Show>11291130          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1131          <div class="peach-settings-row">1132            <span>Breach monitoring</span>1133            <button1134              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1135              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1136              aria-label="Toggle breach monitoring"1137            >1138              <span class="peach-toggle-knob"></span>1139            </button>1140          </div>1141          <div class="peach-settings-row peach-settings-row--meta">1142            <span>Check all passwords</span>1143            <button1144              class="peach-btn peach-btn--subtle peach-btn--sm"1145              onClick={() => void handleCheckAllBreaches()}1146              disabled={checkingBreaches()}1147            >1148              {checkingBreaches() ? "checking..." : "run"}1149            </button>1150          </div>1151          <Show when={breachResult()}>1152            <div class="peach-settings-row peach-settings-row--meta">1153              <span>Last check</span>1154              <span class="peach-settings-value">{breachResult()}</span>1155            </div>1156          </Show>11571158          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1159          <div class="peach-settings-row peach-settings-row--meta">1160            <span>Master password</span>1161            <button1162              class="peach-btn peach-btn--subtle peach-btn--sm"1163              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1164            >1165              {showChangePw() ? "cancel" : "change"}1166            </button>1167          </div>1168          <Show when={showChangePw()}>1169            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1170              <input1171                class="peach-settings-input"1172                type="password"1173                placeholder="current password"1174                value={changePwCurrent()}1175                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1176                autocomplete="current-password"1177              />1178              <input1179                class="peach-settings-input"1180                type="password"1181                placeholder="new password (min 12 chars)"1182                value={changePwNew()}1183                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1184                autocomplete="new-password"1185              />1186              <input1187                class="peach-settings-input"1188                type="password"1189                placeholder="confirm new password"1190                value={changePwConfirm()}1191                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1192                autocomplete="new-password"1193                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1194              />1195              <Show when={changePwError()}>1196                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1197              </Show>1198              <Show when={changePwSuccess()}>1199                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1200              </Show>1201              <button1202                class="peach-btn peach-btn--accent peach-btn--sm"1203                style="align-self:flex-start"1204                onClick={() => void handleChangePassword()}1205                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1206              >1207                {changePwSaving() ? "saving…" : "save new password"}1208              </button>1209            </div>1210          </Show>12111212          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1213          <div class="peach-settings-row peach-settings-row--meta">1214            <span>Recovery words</span>1215            <button1216              class="peach-btn peach-btn--subtle peach-btn--sm"1217              onClick={() => {1218                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1219              }}1220            >1221              {showViewWords() ? "cancel" : "view"}1222            </button>1223          </div>1224          <Show when={showViewWords()}>1225            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1226              <Show1227                when={viewedWords()}1228                fallback={1229                  <>1230                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1231                      Enter your master password to reveal your recovery words.1232                    </p>1233                    <input1234                      class="peach-settings-input"1235                      type="password"1236                      placeholder="master password"1237                      value={viewWordsPassword()}1238                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1239                      autocomplete="current-password"1240                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1241                    />1242                    <Show when={viewWordsError()}>1243                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1244                    </Show>1245                    <button1246                      class="peach-btn peach-btn--accent peach-btn--sm"1247                      style="align-self:flex-start"1248                      onClick={() => void handleViewRecoveryWords()}1249                      disabled={viewWordsBusy() || !viewWordsPassword()}1250                    >1251                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1252                    </button>1253                  </>1254                }1255              >1256                <div class="peach-recovery-words-grid">1257                  <For each={viewedWords()!}>1258                    {(word, i) => (1259                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1260                    )}1261                  </For>1262                </div>1263                <button1264                  class="peach-btn peach-btn--subtle peach-btn--sm"1265                  style="align-self:flex-start"1266                  onClick={closeViewWords}1267                >done</button>1268              </Show>1269            </div>1270          </Show>12711272          <p class="peach-settings-sub-label">1273            DURESS PASSWORD1274            <Show when={!isPro()}>1275              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1276            </Show>1277          </p>1278          <div class="peach-settings-row peach-settings-row--top">1279            <div class="peach-settings-row-main">1280              <span>1281                Duress password1282                <Show when={!isPro()}>1283                  <span class="peach-pro-badge">Pro</span>1284                </Show>1285              </span>1286              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1287                If entered under coercion, shows a decoy empty vault instead of your real one.1288              </span>1289            </div>1290            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1291              {duressEnabled() ? "on" : "off"}1292            </div>1293          </div>12941295          <Show when={duressEnabled() && !showDuressSetup()}>1296            <div class="peach-settings-row peach-settings-row--meta">1297              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1298                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.1299              </span>1300            </div>1301          </Show>13021303          <Show when={duressSuccess()}>1304            <div class="peach-settings-row peach-settings-row--meta">1305              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1306            </div>1307          </Show>13081309          <Show when={!showDuressSetup()}>1310            <div class="peach-settings-row peach-settings-row--meta">1311              <button1312                class="peach-btn peach-btn--subtle peach-btn--sm"1313                onClick={() => {1314                  // Pro gate entry-point: free users are redirected to the1315                  // upgrade screen before the setup form opens. The server-1316                  // side SETUP_DURESS handler is the single enforcement1317                  // point; this is UI redirection so the form never appears1318                  // for a user who can't complete it.1319                  if (!isPro() && !duressEnabled()) {1320                    props.onProGate?.("duress_vault");1321                    setActiveCard("pro");1322                    return;1323                  }1324                  setShowDuressSetup(true);1325                  setDuressError(null);1326                  setDuressSuccess(null);1327                  setDuressPassword("");1328                  setDuressConfirm("");1329                  setDuressMainPassword("");1330                }}1331              >1332                {duressEnabled() ? "Change / disable" : "Set up"}1333              </button>1334            </div>1335          </Show>13361337          <Show when={showDuressSetup()}>1338            <div class="peach-duress-form">1339              <label class="peach-label">main password (to confirm)</label>1340              <input1341                class="peach-settings-input"1342                type="password"1343                placeholder="your current master password"1344                autocomplete="current-password"1345                value={duressMainPassword()}1346                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1347              />1348              <Show when={!duressEnabled()}>1349                <label class="peach-label">new duress password</label>1350                <input1351                  class="peach-settings-input"1352                  type="password"1353                  placeholder="duress password (min 8 chars)"1354                  autocomplete="new-password"1355                  value={duressPassword()}1356                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1357                />1358                <label class="peach-label">confirm duress password</label>1359                <input1360                  class="peach-settings-input"1361                  type="password"1362                  placeholder="confirm duress password"1363                  autocomplete="new-password"1364                  value={duressConfirm()}1365                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1366                />1367              </Show>13681369              <Show when={duressError()}>1370                <p class="peach-hint peach-hint-error">{duressError()}</p>1371              </Show>13721373              <div class="peach-duress-actions">1374                <button1375                  class="peach-btn peach-btn--subtle peach-btn--sm"1376                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1377                >1378                  cancel1379                </button>1380                <Show when={duressEnabled()}>1381                  <button1382                    class="peach-btn peach-btn--ghost peach-btn--sm"1383                    style="color:var(--peach-breach)"1384                    onClick={() => void handleDisableDuress()}1385                    disabled={duressSaving()}1386                  >1387                    {duressSaving() ? "disabling..." : "disable duress"}1388                  </button>1389                </Show>1390                <Show when={!duressEnabled()}>1391                  <button1392                    class="peach-btn peach-btn--accent peach-btn--sm"1393                    onClick={() => void handleSetupDuress()}1394                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1395                  >1396                    {duressSaving() ? "saving..." : "save"}1397                  </button>1398                </Show>1399              </div>1400            </div>1401          </Show>14021403          <p class="peach-settings-sub-label">1404            CANARY ENTRIES1405            <Show when={!isPro()}>1406              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1407            </Show>1408          </p>1409          <div class="peach-settings-row peach-settings-row--top">1410            <div class="peach-settings-row-main">1411              <span>1412                Canary entries1413                <Show when={!isPro()}>1414                  <span class="peach-pro-badge">Pro</span>1415                </Show>1416              </span>1417              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1418                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1419              </span>1420            </div>1421            <span class="peach-settings-value" style="flex-shrink:0">1422              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1423            </span>1424          </div>1425          <Show when={canaryEntries().length > 0}>1426            <For each={canaryEntries()}>1427              {(entry) => (1428                <div class="peach-settings-row peach-settings-row--meta">1429                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1430                  <button1431                    class="peach-btn peach-btn--subtle peach-btn--sm"1432                    onClick={() => void handleTestCanary(entry)}1433                    disabled={canaryTestingId() === entry.id}1434                    style="flex-shrink:0"1435                  >1436                    {canaryTestingId() === entry.id1437                      ? "sending…"1438                      : canaryTestResult()?.id === entry.id1439                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1440                        : "test"}1441                  </button>1442                </div>1443              )}1444            </For>1445          </Show>14461447          <p class="peach-settings-sub-label">PASSKEYS</p>1448          <div class="peach-settings-row peach-settings-row--top">1449            <div class="peach-settings-row-main">1450              <span>Intercept passkey requests</span>1451              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1452                Peach can handle passkey sign-in and creation on supported Chromium browsers.1453              </span>1454            </div>1455            <button1456              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1457              onClick={() => void handleTogglePasskeys()}1458              aria-label="Toggle passkey interception"1459              disabled={!passkeyState()?.supported || passkeySaving()}1460            >1461              <span class="peach-toggle-knob"></span>1462            </button>1463          </div>1464          <div class="peach-settings-row peach-settings-row--meta">1465            <span>Status</span>1466            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1467          </div>1468          <div class="peach-settings-row peach-settings-row--meta">1469            <span>Browser</span>1470            <span class="peach-settings-value">1471              {passkeyState()?.browserLabel ?? "Unknown"}1472              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1473            </span>1474          </div>1475          <div class="peach-info-notice" style="margin-top:8px">1476            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1477          </div>1478          <Show when={passkeyState()?.conflict}>1479            <div class="peach-settings-row peach-settings-row--meta">1480              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1481            </div>1482          </Show>1483        </div>1484      </div>1485    );1486  }14871488  function renderVault() {1489    return (1490      <div class="peach-settings-body">1491        <div class="peach-settings-group">1492          <p class="peach-settings-sub-label">ENTRIES</p>1493          <div class="peach-settings-row peach-settings-row--meta">1494            <span>Entries</span>1495            <span class="peach-settings-value">{props.entriesCount}</span>1496          </div>1497          <div class="peach-settings-row peach-settings-row--meta">1498            <span>Duplicates</span>1499            <Show when={dedupDone() !== null} fallback={1500              <button1501                class="peach-btn peach-btn--subtle peach-btn--sm"1502                onClick={() => void handleCountDuplicates()}1503                disabled={dedupRunning()}1504              >1505                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1506              </button>1507            }>1508              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1509                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1510              </span>1511            </Show>1512          </div>1513          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1514            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1515              <div class="peach-restore-warning">1516                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1517              </div>1518              <div style="display:flex;gap:6px">1519                <button1520                  class="peach-btn peach-btn--subtle peach-btn--sm"1521                  onClick={() => setDedupCount(null)}1522                >1523                  cancel1524                </button>1525                <button1526                  class="peach-btn peach-btn--accent peach-btn--sm"1527                  onClick={() => void handleDeduplicate()}1528                  disabled={dedupRunning()}1529                >1530                  {dedupRunning() ? "removing..." : "remove duplicates"}1531                </button>1532              </div>1533            </div>1534          </Show>15351536          <p class="peach-settings-sub-label">MAINTENANCE</p>1537          <div class="peach-settings-row peach-settings-row--meta">1538            <span>Trash auto-delete</span>1539            <select1540              class="peach-settings-select"1541              value={String(props.trashRetentionDays)}1542              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1543            >1544              <option value="7">7 days</option>1545              <option value="30">30 days</option>1546              <option value="90">90 days</option>1547              <option value="0">Never</option>1548            </select>1549          </div>1550          <div class="peach-settings-row peach-settings-row--meta">1551            <span>Advanced Vault Health</span>1552            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1553              open1554            </button>1555          </div>1556          <Show when={declinedCardCount() > 0}>1557            <div class="peach-settings-row peach-settings-row--meta">1558              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1559              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1560                Clear1561              </button>1562            </div>1563          </Show>15641565          <p class="peach-settings-sub-label">DATA</p>1566          <div class="peach-settings-row peach-settings-row--meta">1567            <span>Import passwords</span>1568            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1569              open1570            </button>1571          </div>1572          <div class="peach-settings-row peach-settings-row--meta">1573            <span>Export backup</span>1574            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1575              open1576            </button>1577          </div>1578          <div class="peach-settings-row peach-settings-row--meta">1579            <span>1580              Peach Codex1581              <Show when={!isPro()}>1582                <span class="peach-pro-badge">Pro</span>1583              </Show>1584            </span>1585            <button1586              class="peach-btn peach-btn--subtle peach-btn--sm"1587              onClick={() => props.onOpenCodexGenerate?.()}1588            >1589              open1590            </button>1591          </div>1592          {/* Restore from Codex: intentionally never gated — a printed1593              Codex must remain restorable regardless of license state. */}1594          <div class="peach-settings-row peach-settings-row--meta">1595            <span>Restore from Codex</span>1596            <button1597              class="peach-btn peach-btn--subtle peach-btn--sm"1598              onClick={() => props.onOpenCodexRestore?.()}1599            >1600              open1601            </button>1602          </div>16031604          <Show when={hasReleaseNotes()}>1605            <p class="peach-settings-sub-label">RELEASE NOTES</p>1606            <div class="peach-settings-row peach-settings-row--meta">1607              <span>v{PEACH_VERSION}</span>1608              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1609                view1610              </button>1611            </div>1612          </Show>16131614          <span class="peach-settings-sub-label">VAULT HISTORY</span>1615          <Show when={historyLoading()}>1616            <p class="peach-settings-hint">Loading…</p>1617          </Show>1618          <Show when={!historyLoading() && historySnapshots().length === 0}>1619            <p class="peach-settings-hint">1620              No snapshots yet. Peach saves automatically before every change —1621              saves, syncs, and imports.1622            </p>1623          </Show>1624          <Show when={!historyLoading() && historySnapshots().length > 0}>1625            <p class="peach-settings-hint">1626              Restoring locks your vault. Unlock with the password that1627              encrypted that snapshot to confirm.1628            </p>1629            <div class="peach-history-list">1630              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1631                {(snap) => (1632                  <div class="peach-history-row">1633                    <div class="peach-history-meta">1634                      <span class="peach-history-time">1635                        {formatSnapshotTime(snap.savedAt)}1636                      </span>1637                      <span class="peach-history-sub">1638                        {[1639                          snap.entryCount !== null1640                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1641                            : null,1642                          reasonLabel(snap.reason),1643                        ]1644                          .filter(Boolean)1645                          .join(" · ")}1646                      </span>1647                    </div>1648                    <button1649                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1650                        historyRestoring() === snap.id ? " is-busy" : ""1651                      }`}1652                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1653                      disabled={historyRestoring() !== null}1654                    >1655                      <Show when={historyRestoring() === snap.id} fallback="Restore">1656                        …1657                      </Show>1658                    </button>1659                  </div>1660                )}1661              </For>1662            </div>1663            <Show when={historySnapshots().length > 3}>1664              <button1665                class="peach-settings-advanced-toggle"1666                onClick={() => setHistoryShowAll((v) => !v)}1667                aria-expanded={historyShowAll()}1668              >1669                <span class="peach-settings-hint" style="margin:0">1670                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1671                </span>1672                <span class="peach-settings-advanced-chevron">1673                  {historyShowAll() ? "▲" : "▼"}1674                </span>1675              </button>1676            </Show>1677            <p class="peach-history-footer">1678              {historySnapshots().length} of 10 snapshots1679            </p>1680          </Show>16811682          <button1683            class="peach-settings-advanced-toggle"1684            onClick={() => setShowVaultAdvanced((v) => !v)}1685            aria-expanded={showVaultAdvanced()}1686          >1687            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1688            <span class="peach-settings-advanced-chevron">1689              {showVaultAdvanced() ? "▲" : "▼"}1690            </span>1691          </button>16921693          <Show when={showVaultAdvanced()}>1694            <div class="peach-danger-card">1695              <div class="peach-danger-card-head">1696                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1697                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1698              </div>1699              <p class="peach-danger-card-copy">1700                Wipes the current vault and restores from a backup file.1701                Recoverable only if your backup is good.1702              </p>1703              <button1704                class="peach-danger-card-button"1705                onClick={() => props.onOpenFileImport?.()}1706              >1707                Choose backup file1708              </button>1709            </div>17101711            <Show when={props.onOpenPanic}>1712              <div class="peach-danger-card peach-danger-card--hazard">1713                <div class="peach-danger-card-head">1714                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1715                  <h3 class="peach-danger-card-title">Destroy vault</h3>1716                </div>1717                <p class="peach-danger-card-copy">1718                  Permanently wipes every entry, setting, and license on this1719                  device. There is no recovery.1720                </p>1721                <button1722                  class="peach-danger-card-button"1723                  onClick={() => props.onOpenPanic?.()}1724                >1725                  Begin destruction1726                </button>1727              </div>1728            </Show>1729          </Show>1730        </div>1731      </div>1732    );1733  }17341735  function renderSync() {1736    return (1737      <div class="peach-settings-body">1738        <div class="peach-settings-group">1739          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1740          <Show when={trustDataCorrupted()}>1741            <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1742              Device trust data is damaged — re-pair your devices after updating1743            </p>1744          </Show>1745          <div1746            class="peach-settings-row peach-settings-row--meta"1747            style="align-items:center;gap:8px"1748            aria-label={syncLightLabel()}1749            title={syncLightLabel()}1750          >1751            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1752            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1753              {syncFlash() ?? lastSyncLine()}1754            </span>1755            {/* The transfer counts already ride in the status line, so this row1756                was an empty flex sibling in every other case: it read as a dead1757                gap and held the button away from the status it belongs to. */}1758            <Show when={pairedDevices().length > 0}>1759              <button1760                class="peach-btn peach-btn--subtle peach-btn--sm"1761                aria-label="Sync this vault now"1762                disabled={syncStatus() === "syncing"}1763                onClick={() => void syncNow()}1764              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1765            </Show>1766          </div>17671768          {/* §11: this panel is a migration call to action, not a permanent1769              label. On the settled new generation there is nothing left to do1770              here, so it goes away entirely — Android applies the same rule. */}1771          <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1772            <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1773            <div class="peach-settings-row peach-settings-row--top">1774              <div class="peach-settings-row-main">1775                <span class="peach-settings-value" style="color:var(--peach-t1)">1776                  {secureResetHeadline(generation()?.state)}1777                </span>1778                <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1779                  {secureResetDetail(generation()?.state)}1780                </span>1781              </div>1782              <Show1783                when={1784                  generation()?.state === "LEGACY_ACTIVE" ||1785                  generation()?.state === "RESET_REQUIRED" ||1786                  generation()?.state === "RESET_CONFIRMED"1787                }1788              >1789                <button1790                  class="peach-btn peach-btn--subtle peach-btn--sm"1791                  onClick={() => {1792                    setResetError(null);1793                    setResetPassword("");1794                    setResetOpen(true);1795                  }}1796                >reset sync</button>1797              </Show>1798            </div>1799            <Show when={resetError()}>1800              <p1801                class="peach-settings-value"1802                role="alert"1803                style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1804              >{resetError()}</p>1805            </Show>1806            <Show when={resetOpen()}>1807              <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1808                <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1809                  {SECURE_RESET_DISCLOSURE}1810                </p>1811                <input1812                  class="peach-settings-input"1813                  type="password"1814                  placeholder="master password"1815                  value={resetPassword()}1816                  onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1817                  autocomplete="current-password"1818                />1819                <div style="display:flex;gap:4px">1820                  <button1821                    class="peach-btn peach-btn--subtle peach-btn--sm"1822                    disabled={resetting()}1823                    onClick={() => void runSecureReset()}1824                  >{resetting() ? "resetting…" : "reset"}</button>1825                  <button1826                    class="peach-btn peach-btn--subtle peach-btn--sm"1827                    disabled={resetting()}1828                    onClick={() => setResetOpen(false)}1829                  >cancel</button>1830                </div>1831              </div>1832            </Show>1833          </Show>18341835          <Show when={pairedDevices().length > 0}>1836            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1837            <For each={pairedDevices()}>1838              {(d) => (1839                <div class="peach-settings-row peach-settings-row--top">1840                  <div class="peach-settings-row-main">1841                    <Show1842                      when={renameDeviceId() === d.deviceId}1843                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1844                    >1845                      <input1846                        class="peach-settings-input"1847                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1848                        maxlength={64}1849                        value={renameDraft()}1850                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1851                        onKeyDown={(event) => {1852                          if (event.key === "Enter") void renameDevice(d.deviceId);1853                          if (event.key === "Escape") setRenameDeviceId(null);1854                        }}1855                      />1856                    </Show>1857                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1858                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1859                    </span>1860                  </div>1861                  <Show1862                    when={confirmUnpair() === d.deviceId}1863                    fallback={1864                      <div style="display:flex;gap:4px;flex-shrink:0">1865                        <Show1866                          when={renameDeviceId() === d.deviceId}1867                          fallback={1868                            <button1869                              class="peach-btn peach-btn--subtle peach-btn--sm"1870                              onClick={() => {1871                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1872                                setRenameDeviceId(d.deviceId);1873                              }}1874                            >rename</button>1875                          }1876                        >1877                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1878                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1879                        </Show>1880                        <button1881                          class="peach-btn peach-btn--subtle peach-btn--sm"1882                          onClick={() => {1883                            setUnpairError(null);1884                            setConfirmUnpair(d.deviceId);1885                          }}1886                        >remove</button>1887                      </div>1888                    }1889                  >1890                    {/* HOLE 4: revocation stops this device receiving future1891                        changes. It does NOT secure the vault against it — that1892                        device may already hold the recovery words, which are1893                        master-password-equivalent and restore the whole vault1894                        anywhere, forever, with no sync involved. Say so here,1895                        rather than leaving the user falsely reassured. */}1896                    <div class="peach-unpair-confirm">1897                      <p class="peach-unpair-confirm-note">1898                        Stops this device receiving future changes. It doesn't1899                        lock it out of what it already has — if it's lost or1900                        stolen, also change your master password and generate1901                        new recovery words.1902                      </p>1903                      <div style="display:flex;gap:6px;justify-content:flex-end">1904                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1905                        <button1906                          class="peach-btn peach-btn--sm"1907                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1908                          onClick={() => void unpairDevice(d.deviceId)}1909                        >remove</button>1910                      </div>1911                    </div>1912                      <Show when={unpairError()}>1913                        <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1914                          {unpairError()}1915                        </p>1916                      </Show>1917                  </Show>1918                </div>1919              )}1920            </For>1921          </Show>19221923          <Show when={pendingVouched().length > 0}>1924            <p class="peach-settings-sub-label">1925              PENDING DEVICES1926              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1927            </p>1928            <For each={pendingVouched()}>1929              {(dev) => (1930                <div class="peach-settings-row peach-settings-row--top">1931                  <div class="peach-settings-row-main">1932                    <span>{dev.deviceName}</span>1933                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1934                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1935                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1936                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1937                      existing vault contents and all future changes.1938                    </span>1939                  </div>1940                  <div style="display:flex;gap:6px;flex-shrink:0">1941                    <button1942                      class="peach-btn peach-btn--sm"1943                      disabled={vouchedBusy() === dev.deviceId}1944                      onClick={() => void confirmVouched(dev.deviceId)}1945                    >Allow</button>1946                    <button1947                      class="peach-btn peach-btn--subtle peach-btn--sm"1948                      disabled={vouchedBusy() === dev.deviceId}1949                      onClick={() => void rejectVouched(dev.deviceId)}1950                    >Reject</button>1951                  </div>1952                </div>1953              )}1954            </For>1955          </Show>19561957          <p class="peach-settings-sub-label">ADD A DEVICE</p>1958          <button1959            class="peach-sync-pair-option"1960            onClick={() => props.onOpenSyncPairing?.("code")}1961          >1962            <span class="peach-sync-pair-option-icon" aria-hidden="true">1963              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1964                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1965                <path d="M9 15 L11 15" />1966              </svg>1967            </span>1968            <span class="peach-sync-pair-option-main">1969              <span class="peach-sync-pair-option-title">Show a code</span>1970              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1971            </span>1972            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1973          </button>1974          <Show when={!isPro()}>1975            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1976              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1977            </p>1978          </Show>19791980          <button1981            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1982            onClick={() => setShowSyncConnection((value) => !value)}1983            aria-expanded={showSyncConnection()}1984            aria-controls="peach-sync-connection"1985          >1986            <span>1987              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1988              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1989            </span>1990            <span class="peach-settings-advanced-chevron">1991              {showSyncConnection() ? "▲" : "▼"}1992            </span>1993          </button>1994          <Show when={showSyncConnection()}>1995            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1996              <SyncModeSelector />1997            </div>1998          </Show>19992000          <button2001            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"2002            onClick={() => setShowSyncRecovery((value) => !value)}2003            aria-expanded={showSyncRecovery()}2004            aria-controls="peach-sync-recovery"2005          >2006            <span>2007              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>2008              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>2009            </span>2010            <span class="peach-settings-advanced-chevron">2011              {showSyncRecovery() ? "▲" : "▼"}2012            </span>2013          </button>2014          <Show when={showSyncRecovery()}>2015            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2016              <Show2017                when={rotateConfirmOpen()}2018                fallback={2019                  <div class="peach-settings-row peach-settings-row--top">2020                    <div class="peach-settings-row-main">2021                      <span>Rotate sync identity</span>2022                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2023                        Disconnects every device and replaces the keys used for sync.2024                      </span>2025                    </div>2026                    <button2027                      class="peach-btn peach-btn--subtle peach-btn--sm"2028                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2029                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2030                    >rotate</button>2031                  </div>2032                }2033              >2034                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2035                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2036                  <Show when={rotateError()}>2037                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2038                  </Show>2039                  <div style="display:flex;gap:6px;margin-top:10px">2040                    <button2041                      class="peach-btn peach-btn--subtle peach-btn--sm"2042                      disabled={rotating()}2043                      onClick={() => setRotateConfirmOpen(false)}2044                    >Cancel</button>2045                    <button2046                      class="peach-btn peach-btn--sm"2047                      style="background:var(--peach-breach);border-color:var(--peach-breach)"2048                      disabled={rotating()}2049                      onClick={() => void doRotateSyncIdentity()}2050                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2051                  </div>2052                </div>2053              </Show>2054            </div>2055          </Show>2056        </div>2057      </div>2058    );2059  }20602061  function renderAppearance() {2062    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2063      { id: "medium", label: "Default" },2064      { id: "large", label: "Large" },2065    ];2066    return (2067      <div class="peach-settings-body">2068        <div class="peach-settings-group">2069          <p class="peach-settings-sub-label">THEME</p>2070          <div class="peach-settings-row peach-settings-row--meta">2071            <span>Theme</span>2072            <button2073              class="peach-btn peach-btn--subtle peach-btn--sm"2074              onClick={() =>2075                props.onThemeChange(props.theme === "dark" ? "light" : "dark")2076              }2077            >2078              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2079            </button>2080          </div>20812082          <p class="peach-settings-sub-label">ACCENT</p>2083          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2084            <span>Accent</span>2085            <div class="peach-accent-picker">2086              <For each={ACCENT_COLORS}>2087                {(color) => (2088                  <button2089                    class="peach-accent-swatch peach-swatch"2090                    classList={{ "is-active": props.accentColor === color.name }}2091                    style={{ background: color.hex }}2092                    onClick={() => void props.onAccentChange(color.name)}2093                    aria-label={color.name}2094                    title={color.name}2095                  />2096                )}2097              </For>2098            </div>2099          </div>21002101          <p class="peach-settings-sub-label">TEXT SIZE</p>2102          <div class="peach-settings-row peach-settings-row--meta">2103            <span>Size</span>2104            <div class="peach-pill-group">2105              <For each={sizeOptions}>2106                {(opt) => (2107                  <button2108                    class="peach-btn peach-btn--ghost peach-btn--sm"2109                    classList={{ "is-active": props.textSize === opt.id }}2110                    onClick={() => void props.onTextSizeChange(opt.id)}2111                  >2112                    {opt.label}2113                  </button>2114                )}2115              </For>2116            </div>2117          </div>21182119          <p class="peach-settings-sub-label">MOTION</p>2120          <div class="peach-settings-row peach-settings-row--meta">2121            <span>Reduce motion</span>2122            <button2123              class="peach-btn peach-btn--subtle peach-btn--sm"2124              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2125            >2126              {props.reduceMotion ? "On" : "Off"}2127            </button>2128          </div>2129        </div>2130      </div>2131    );2132  }21332134  function renderShortcuts() {2135    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2136    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21372138    function startCapture(action: ShortcutAction) {2139      setConflictMsg(null);2140      setCapturingAction(action);2141    }2142    function cancelCapture() {2143      setCapturingAction(null);2144      setConflictMsg(null);2145    }2146    function commitBinding(action: ShortcutAction, binding: Keybinding) {2147      const conflict = findConflict(props.shortcuts, binding, action);2148      if (conflict) {2149        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2150        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2151        return;2152      }2153      const next = { ...props.shortcuts, [action]: binding };2154      setCapturingAction(null);2155      setConflictMsg(null);2156      void props.onShortcutsChange(next);2157    }2158    function resetAction(action: ShortcutAction) {2159      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2160      void props.onShortcutsChange(next);2161    }2162    function resetAll() {2163      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2164    }21652166    // Capture-mode key handler — wired via ref onto the chip element so it2167    // catches the next non-modifier keypress and ignores other bindings.2168    function handleCaptureKey(e: KeyboardEvent) {2169      e.preventDefault();2170      e.stopPropagation();2171      if (e.key === "Escape") { cancelCapture(); return; }2172      const binding = keybindingFromEvent(e);2173      if (!binding) return; // bare modifier — keep waiting2174      const action = capturingAction();2175      if (action) commitBinding(action, binding);2176    }21772178    return (2179      <div class="peach-settings-body">2180        <div class="peach-settings-group peach-shortcuts-group">2181          <p class="peach-settings-sub-label">ENTRY VIEW</p>2182          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2183            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2184          </For>2185        </div>21862187        <div class="peach-settings-group peach-shortcuts-group">2188          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2189          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2190            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2191          </For>2192        </div>21932194        <Show when={conflictMsg()}>2195          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2196        </Show>21972198        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2199          <div class="peach-settings-row peach-settings-row--meta">2200            <span>Inline autofill on page</span>2201            <a2202              class="peach-settings-link"2203              href="chrome://extensions/shortcuts"2204              onClick={(e) => {2205                e.preventDefault();2206                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2207              }}2208            >2209              ⌘⇧P — customize ↗2210            </a>2211          </div>2212          <button2213            class="peach-btn peach-btn--subtle peach-btn--sm"2214            style="align-self:flex-start"2215            onClick={resetAll}2216          >2217            Reset all to defaults2218          </button>2219        </div>2220      </div>2221    );22222223    function renderRow(2224      desc: typeof SHORTCUT_DESCRIPTORS[number],2225      capturing: () => ShortcutAction | null,2226      onStart: (a: ShortcutAction) => void,2227      onCancel: () => void,2228      onReset: (a: ShortcutAction) => void,2229      onKey: (e: KeyboardEvent) => void,2230    ) {2231      const isActive = () => capturing() === desc.action;2232      const binding = () => props.shortcuts[desc.action];2233      const isCustom = () => {2234        const b = binding();2235        const d = DEFAULT_BINDINGS[desc.action];2236        return b.key !== d.key2237          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2238          || Boolean(b.meta)  !== Boolean(d.meta)2239          || Boolean(b.shift) !== Boolean(d.shift)2240          || Boolean(b.alt)   !== Boolean(d.alt);2241      };2242      return (2243        <div class="peach-shortcut-row">2244          <span class="peach-shortcut-row-label">{desc.label}</span>2245          <button2246            class="peach-shortcut-chip"2247            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2248            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2249            onKeyDown={(e) => { if (isActive()) onKey(e); }}2250            ref={(el) => {2251              // Auto-focus when entering capture mode so the keydown listener catches the next press.2252              if (el) {2253                queueMicrotask(() => { if (isActive()) el.focus(); });2254              }2255            }}2256          >2257            {isActive() ? "Press a key…" : formatKeybinding(binding())}2258          </button>2259          <Show when={isCustom() && !isActive()}>2260            <button2261              class="peach-shortcut-reset"2262              title="Reset to default"2263              onClick={() => onReset(desc.action)}2264            >2265              ↺2266            </button>2267          </Show>2268        </div>2269      );2270    }2271  }22722273  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2274  const [showKey, setShowKey] = createSignal(false);22752276  const [deactivating, setDeactivating] = createSignal(false);22772278  async function handleDeactivateLicense() {2279    setDeactivating(true);2280    await sendMessage({ type: "DEACTIVATE_LICENSE" });2281    setLicenseInfo(null);2282    await props.onLicenseChange?.();2283    setDeactivating(false);2284  }22852286  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2287    setCanaryTestingId(entry.id);2288    setCanaryTestResult(null);2289    const res = await sendMessage({2290      type: "FIRE_CANARY_PING",2291      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2292    }) as { ok: boolean };2293    setCanaryTestResult({ id: entry.id, ok: res.ok });2294    setCanaryTestingId(null);2295    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2296  }22972298  function renderLicense() {2299    async function onActivate() {2300      await handleActivateLicense();2301      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2302        await props.onLicenseChange?.();2303      }2304    }23052306    return (2307      <div class="peach-settings-body">2308        <Show2309          when={isPro()}2310          fallback={2311                <div class="peach-pro-upgrade">2312                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23132314                  <h2 class="peach-pro-headline">2315                    Every<em>thing,</em><br />every<em>where.</em>2316                  </h2>23172318                  <p class="peach-pro-subtitle">2319                    One key unlocks every Pro feature — for the lifetime of the app.2320                  </p>23212322                  <div class="peach-pro-grid">2323                    <For each={PRO_FEATURES}>2324                      {(item, i) => (2325                        <div2326                          class="peach-pro-grid-item"2327                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2328                        >2329                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2330                          <span class="peach-pro-grid-name">{item.name}</span>2331                          <span class="peach-pro-grid-desc">{item.desc}</span>2332                        </div>2333                      )}2334                    </For>2335                  </div>23362337                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2338                  <div class="peach-pro-grid">2339                    <For each={PRO_SECURITY_FEATURES}>2340                      {(item, i) => (2341                        <div2342                          class="peach-pro-grid-item"2343                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2344                        >2345                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2346                          <span class="peach-pro-grid-name">{item.name}</span>2347                          <span class="peach-pro-grid-desc">{item.desc}</span>2348                        </div>2349                      )}2350                    </For>2351                  </div>23522353                  <div class="peach-pro-upgrade-actions">2354                    <p class="peach-pro-pricing">2355                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2356                    </p>2357                    <a2358                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2359                      target="_blank"2360                      rel="noopener noreferrer"2361                      class="peach-pro-upgrade-cta"2362                    >2363                      Get Peach Pro2364                    </a>2365                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2366                    <div class="peach-pro-key-inline">2367                      <input2368                        class="peach-pro-key-inline-input"2369                        type="text"2370                        placeholder="PEACH-XXXX-XXXX-XXXX"2371                        value={licenseKeyInput()}2372                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2373                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2374                        autocomplete="off"2375                        spellcheck={false}2376                      />2377                      <button2378                        class="peach-pro-key-apply"2379                        onClick={() => void onActivate()}2380                        disabled={activatingLicense() || !licenseKeyInput().trim()}2381                      >2382                        {activatingLicense() ? "…" : "APPLY"}2383                      </button>2384                    </div>2385                    <Show when={licenseError()}>2386                      <p class="peach-pro-key-error">{licenseError()}</p>2387                    </Show>2388                  </div>2389                </div>2390          }2391        >2392          <div class="peach-pro-active">2393            <div class="peach-pro-active-hero">2394              <button2395                class="peach-pro-active-mark-wrap"2396                type="button"2397                aria-label="Celebrate"2398                onClick={handleProCelebrationClick}2399              >2400                <PeachMark class="peach-pro-active-mark" />2401                <Show when={justActivated()}>2402                  <div class="peach-pro-sparks" aria-hidden="true">2403                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2404                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2405                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2406                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2407                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2408                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2409                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2410                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2411                  </div>2412                </Show>2413              </button>2414              <h2 class="peach-pro-active-title">Peach Pro</h2>2415              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2416              <Show when={justActivated()}>2417                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2418              </Show>2419            </div>24202421            <Show when={licenseKey()}>2422              <div class="peach-settings-license">2423                <span class="peach-settings-label">LICENSE KEY</span>2424                <div class="peach-settings-license-row">2425                  <Show2426                    when={showKey()}2427                    fallback={2428                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2429                        ••••••••••••••••••••••••••••2430                      </span>2431                    }2432                  >2433                    <span class="peach-settings-license-key">{licenseKey()!}</span>2434                  </Show>2435                  <div class="peach-settings-license-actions">2436                    <button2437                      class="peach-settings-license-copy"2438                      title="Copy license key"2439                      onClick={() => copyLicenseKey(licenseKey()!)}2440                    >2441                      {copied() ? "✓" : "copy"}2442                    </button>2443                    <button2444                      class="peach-settings-license-toggle"2445                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2446                      title={showKey() ? "Hide" : "Reveal"}2447                      onClick={() => setShowKey(v => !v)}2448                    >2449                      <Show2450                        when={showKey()}2451                        fallback={2452                          <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">2453                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2454                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2455                          </svg>2456                        }2457                      >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                          <line x1="2" y1="2" x2="12" y2="12" />2462                        </svg>2463                      </Show>2464                    </button>2465                  </div>2466                </div>2467              </div>2468            </Show>24692470            <div class="peach-pro-active-features">2471              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2472                {(item) => (2473                  <div class="peach-pro-active-feature">2474                    <span class="peach-pro-active-feature-mark">✦</span>2475                    <span class="peach-pro-active-feature-name">{item.name}</span>2476                  </div>2477                )}2478              </For>2479            </div>24802481            <button2482              class="peach-pro-deactivate"2483              onClick={() => void handleDeactivateLicense()}2484              disabled={deactivating()}2485            >2486              {deactivating() ? "Deactivating…" : "Deactivate"}2487            </button>2488          </div>2489        </Show>2490      </div>2491    );2492  }24932494  function renderFeedback() {2495    return (2496      <div class="peach-settings-body">2497        <div class="peach-settings-group peach-feedback-group">2498          <Show2499            when={feedbackState() === 'sent'}2500            fallback={2501              <>2502                <p class="peach-feedback-intro">2503                  Help us improve Peach. Every report goes directly to our inbox.2504                </p>25052506                <p class="peach-settings-sub-label">TYPE</p>2507                <div class="peach-feedback-chips">2508                  <For each={FEEDBACK_CATEGORIES}>2509                    {(cat) => {2510                      const selected = () => fbCategory() === cat;2511                      return (2512                        <button2513                          type="button"2514                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2515                          aria-pressed={selected()}2516                          onClick={() => setFbCategory(cat)}2517                        >2518                          {cat}2519                        </button>2520                      );2521                    }}2522                  </For>2523                </div>25242525                <p class="peach-settings-sub-label">MESSAGE</p>2526                <textarea2527                  class="peach-feedback-textarea"2528                  placeholder="Describe the issue or idea..."2529                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2530                  value={fbMessage()}2531                  onInput={(e) => setFbMessage(e.currentTarget.value)}2532                />25332534                <p class="peach-feedback-meta">2535                  Version {version} · will be included automatically2536                </p>25372538                <button2539                  type="button"2540                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2541                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2542                  onClick={() => void handleSubmitFeedback()}2543                >2544                  <Switch>2545                    <Match when={feedbackState() === 'idle'}>Send</Match>2546                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2547                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2548                  </Switch>2549                </button>25502551                <p class="peach-feedback-anon">2552                  Sent anonymously · no account required2553                </p>2554              </>2555            }2556          >2557            <div class="peach-feedback-thanks" role="status" aria-live="polite">2558              <p class="peach-feedback-thanks-title">Thank you</p>2559              <p>2560                Your note is in the queue. Someone will process it soon.2561              </p>2562            </div>2563          </Show>2564        </div>2565      </div>2566    );2567  }25682569  const bodies: StackBodies = {2570    protection: renderSecurity(),2571    vault: renderVault(),2572    sync: renderSync(),2573    appearance: renderAppearance(),2574    shortcuts: renderShortcuts(),2575    pro: renderLicense(),2576    feedback: renderFeedback(),2577  };25782579  return (2580    <Show2581      when={showWhatsNew()}2582      fallback={2583    <Show2584      when={showNewRecoveryWords()}2585      fallback={2586        <div2587          class="peach-overlay-view peach-settings-overlay"2588          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2589          style="display:flex;flex-direction:column"2590        >2591          <StackDeck2592            active={activeCard()}2593            onActiveChange={setActiveCard}2594            onBack={props.onBack}2595            cards={cards()}2596            bodies={bodies}2597            version={version}2598            isFanning={isFanning()}2599            celebratingCardId={justActivated() ? "pro" : null}2600            isPro={isPro()}2601          />2602        </div>2603      }2604    >2605      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2606    </Show>2607      }2608    >2609      <div class="peach-overlay-view peach-settings-overlay">2610        <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2611      </div>2612    </Show>2613  );2614}