Settings.tsx

107.2 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13  AccentColorName,14  PasskeyProviderState,15  ProFeature,16  VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20  describeSyncOutcome,21  syncIndicator,22  syncIndicatorLabel,23  type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import type { SyncGeneration } from "../../background/sync-generation";26import {27  DEFAULT_BINDINGS,28  findConflict,29  formatKeybinding,30  keybindingFromEvent,31  SHORTCUT_DESCRIPTORS,32  type Keybinding,33  type ShortcutAction,34} from "../../shared/shortcuts";35import { WEB3FORMS_KEY } from "../../shared/config";3637interface LicenseInfo {38  valid: boolean;39  plan: "free" | "pro";40  issued?: string;41}4243interface SettingsProps {44  entriesCount: number;45  breachMonitoring: boolean;46  lockOnClose: boolean;47  autoLockMinutes: number;48  lockedSyncRequiresUnlock: boolean;49  trashRetentionDays: number;50  theme: "dark" | "light";51  accentColor: AccentColorName;52  textSize: "small" | "medium" | "large";53  reduceMotion: boolean;54  isPro?: boolean;55  initialSection?: string | null;56  upgradeHighlight?: ProFeature | null;57  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;58  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;59  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;60  onLockedSyncRequiresUnlockChange: (enabled: boolean) => void | Promise<void>;61  onTrashRetentionChange: (days: number) => void | Promise<void>;62  onThemeChange: (theme: "dark" | "light") => void;63  onAccentChange: (name: AccentColorName) => void | Promise<void>;64  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;65  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;66  shortcuts: Record<ShortcutAction, Keybinding>;67  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;68  onLicenseChange?: () => void | Promise<void>;69  isExiting?: boolean;70  onBack: () => void;71  onImport: () => void;72  onExport: () => void;73  onBreachCheckDone: () => void;74  onOpenHealth?: () => void;75  onOpenSyncPairing?: (method?: "code") => void;76  onOpenFileImport?: () => void;77  onOpenPanic?: () => void;78  onOpenCodexGenerate?: () => void;79  onOpenCodexRestore?: () => void;80  /** Invoked from within Settings when a Pro-gated control is tapped on81   *  free tier. App owns the upgrade navigation + highlight state, so this82   *  callback is the single way for Settings to request that navigation. */83  onProGate?: (feature: ProFeature) => void;84  onCardChange?: (id: CardId) => void;85}8687const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];88const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;89const PRO_CELEBRATION_CARD_STEP_MS = 150;90const PRO_CELEBRATION_THANK_YOU_MS = 3200;9192function sanitizeFeedbackMessage(value: string): string {93  return value94    .trim()95    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)96    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text97    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")98    .replace(/&/g, "&amp;")99    .replace(/</g, "&lt;")100    .replace(/>/g, "&gt;")101    .replace(/"/g, "&quot;")102    .replace(/'/g, "&#39;");103}104105function formatActivatedSince(issued: string | undefined): string {106  if (!issued) return "Active";107  try {108    const d = new Date(issued);109    if (Number.isNaN(d.getTime())) return "Active";110    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;111  } catch {112    return "Active";113  }114}115116function initialSectionToCard(value: string | null | undefined): CardId | null {117  if (!value) return null;118  if (value === "security") return "protection";119  if (value === "license") return "pro";120  if (121    value === "protection" ||122    value === "vault" ||123    value === "sync" ||124    value === "appearance" ||125    value === "shortcuts" ||126    value === "pro" ||127    value === "feedback"128  ) {129    return value;130  }131  return null;132}133134/**135 * §11 migration copy. It must never imply that old peers keep working: the136 * whole point of the reset is that they cannot.137 */138const SECURE_RESET_DISCLOSURE =139  "Peach will archive this device's old Sync trust and start the new Sync generation. " +140  "Every other device must be updated first, then re-paired with this one — they will " +141  "not continue on their own. Your vault and your recovery words are not changed.";142143function secureResetHeadline(state: string | undefined): string {144  switch (state) {145    case "LEGACY_ACTIVE":146      return "Legacy Sync generation";147    case "RESET_REQUIRED":148      return "Reset required";149    case "RESET_CONFIRMED":150      return "Reset not completed";151    case "V2_ANCHOR":152    case "REPAIRING":153      return "Not re-paired";154    case "V2_ACTIVE":155      return "New Sync generation";156    case "UNKNOWN":157      return "Sync generation unreadable";158    default:159      return "Sync generation";160  }161}162163function secureResetDetail(state: string | undefined): string {164  switch (state) {165    case "LEGACY_ACTIVE":166      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.";167    case "RESET_REQUIRED":168      return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";169    case "RESET_CONFIRMED":170      return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";171    case "V2_ANCHOR":172    case "REPAIRING":173      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.";174    case "V2_ACTIVE":175      return "This device is on the new Sync generation. Each device is re-paired individually.";176    case "UNKNOWN":177      return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";178    default:179      return "Sync state is unavailable.";180  }181}182183export function SettingsView(props: SettingsProps) {184  const manifest = chrome.runtime.getManifest();185  const version = manifest.version;186  const initial = props.initialSection;187  const [activeCard, setActiveCard] = createSignal<CardId>(188    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"189  );190191  createEffect(() => { props.onCardChange?.(activeCard()); });192193  const [justActivated, setJustActivated] = createSignal(false);194  const [isFanning, setIsFanning] = createSignal(false);195196  function triggerCelebration() {197    if (isFanning()) return;198    setJustActivated(false);199    setIsFanning(true);200    setActiveCard("pro");201    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];202    fanOrder.forEach((id, i) => {203      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);204    });205    window.setTimeout(() => {206      setActiveCard("pro");207      setIsFanning(false);208      setJustActivated(true);209      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);210    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);211  }212213  function handleProCelebrationClick(e: MouseEvent) {214    e.stopPropagation();215    triggerCelebration();216  }217218  const [checkingBreaches, setCheckingBreaches] = createSignal(false);219  const [breachResult, setBreachResult] = createSignal<string | null>(null);220  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);221  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");222  const [licenseError, setLicenseError] = createSignal<string | null>(null);223  const [, setLicenseSuccess] = createSignal<string | null>(null);224  const [activatingLicense, setActivatingLicense] = createSignal(false);225226  const isPro = () => props.isPro ?? false;227  const cards = createMemo<StackCardSpec[]>(() => [228    {229      id: "pro",230      num: "I · FOREVER",231      title: "Peach Pro",232      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",233      bgVar: "--stack-pro-bg",234      fgVar: "--stack-pro-fg",235      icon: "spark",236      onIconClick: isPro() ? triggerCelebration : undefined,237    },238    {239      id: "protection",240      num: "II · PROTECT",241      title: "Protection",242      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,243      bgVar: "--stack-protect-bg",244      fgVar: "--stack-protect-fg",245      icon: "shield",246    },247    {248      id: "vault",249      num: "III · KEEP",250      title: "Vault",251      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,252      bgVar: "--stack-vault-bg",253      fgVar: "--stack-vault-fg",254      icon: "lock",255    },256    {257      id: "sync",258      num: "IV · ECHO",259      title: "Sync",260      lede: isPro() ? "End to End Encryption" : "Pro · encrypted device sync",261      bgVar: "--stack-sync-bg",262      fgVar: "--stack-sync-fg",263      icon: "sync",264    },265    {266      id: "appearance",267      num: "V · DRESS",268      title: "Appearance",269      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,270      bgVar: "--stack-appear-bg",271      fgVar: "--stack-appear-fg",272      icon: "sun",273    },274    {275      id: "shortcuts",276      num: "VI · KEYS",277      title: "Shortcuts",278      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,279      bgVar: "--stack-shortcut-bg",280      fgVar: "--stack-shortcut-fg",281      icon: "keys",282    },283    {284      id: "feedback",285      num: "VII · ECHO",286      title: "Feedback",287      lede: `version ${version} · tell us what would make it better`,288      bgVar: "--stack-feedback-bg",289      fgVar: "--stack-feedback-fg",290      icon: "bubble",291    },292  ]);293  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);294  const [passkeySaving, setPasskeySaving] = createSignal(false);295296  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting297  // explicit confirmation before they become sync targets.298  interface PendingVouched {299    deviceId: string;300    deviceName: string;301    longTermPublicKeyHex: string;302    receivedAt: number;303    vouchedByDeviceId: string;304  }305  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);306  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);307  // Paired-device management lives here — Settings is the sync hub.308  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);309  const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);310311  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);312  const [unpairError, setUnpairError] = createSignal<string | null>(null);313  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);314  const [renameDraft, setRenameDraft] = createSignal("");315  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");316  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);317  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —318  // a transient toast is no use for diagnosing a phone that isn't syncing.319  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});320  // §11: the Sync generation this installation is on. Not secret — a version321  // and a state name — so it renders the same in any session.322  const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);323  const [resetOpen, setResetOpen] = createSignal(false);324  const [resetPassword, setResetPassword] = createSignal("");325  const [resetError, setResetError] = createSignal<string | null>(null);326  const [resetting, setResetting] = createSignal(false);327  const syncLight = () =>328    syncStatus() === "syncing"329      ? "busy"330      : syncIndicator(syncSummary(), pairedDevices().length);331  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());332  const lastSyncLine = () => {333    const s = syncSummary();334    if (!s.outcome) return "No sync attempted yet";335    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;336  };337  async function loadSyncState() {338    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {339      ok: boolean;340      data?: SyncOutcomeSummary;341    };342    if (r.ok && r.data) {343      setSyncSummary(r.data);344      setSyncStatus(r.data.status ?? "idle");345    }346  }347348  async function loadGeneration() {349    const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {350      ok: boolean;351      data?: SyncGeneration | null;352    };353    if (r.ok) setGeneration(r.data ?? null);354  }355356  async function runSecureReset() {357    setResetting(true);358    const r = (await sendMessage({359      type: "SECURE_RESET",360      payload: { password: resetPassword() },361    })) as { ok: boolean; data?: SyncGeneration | null; error?: string };362    setResetting(false);363    setResetPassword("");364    if (!r.ok) {365      setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");366      await loadGeneration();367      return;368    }369    setResetError(null);370    setResetOpen(false);371    if (r.data) setGeneration(r.data);372  }373374  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;375  function syncRelativeTime(when: number | undefined): string {376    if (!when) return "Not synced yet";377    const elapsed = Date.now() - when;378    if (elapsed < 60_000) return "Just now";379    const minutes = Math.floor(elapsed / 60_000);380    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;381    const hours = Math.floor(minutes / 60);382    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;383    const days = Math.floor(hours / 24);384    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";385  }386  function isSyncStale(d: PairedDevice): boolean {387    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;388  }389  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {390    switch (t) {391      case "android": return "Android";392      case "ios": return "iPhone";393      case "extension": return "Browser";394      default: return "Device";395    }396  }397398  async function loadPairedDevices() {399    if (!isPro()) return;400    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {401      ok: boolean;402      error?: string;403      data?: { devices: PairedDevice[] };404    };405    if (r.ok) {406      setTrustDataCorrupted(false);407      setPairedDevices(r.data?.devices ?? []);408    } else if (r.error === "trust_data_corrupted") {409      setPairedDevices([]);410      setTrustDataCorrupted(true);411    }412  }413  async function unpairDevice(deviceId: string) {414    const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {415      ok: boolean;416      error?: string;417    };418    if (!result.ok) {419      setUnpairError(result.error ?? "Could not remove this device");420      return;421    }422    setUnpairError(null);423    setConfirmUnpair(null);424    await loadPairedDevices();425  }426  async function renameDevice(deviceId: string) {427    const name = renameDraft().trim();428    if (!name) return;429    const result = (await sendMessage({430      type: "RENAME_PAIRED_DEVICE",431      payload: { deviceId, name },432    })) as { ok: boolean };433    if (!result.ok) return;434    setRenameDeviceId(null);435    await loadPairedDevices();436  }437  async function syncNow() {438    setSyncStatus("syncing");439    setSyncFlash(null);440    const res = (await sendMessage({441      type: "TRIGGER_SYNC",442      payload: { reason: "manual", force: true },443    })) as { ok: boolean; error?: string };444    if (!res.ok) {445      setSyncStatus("error");446      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });447      return;448    }449    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the450    // outcome. A single delayed read used to land while the run was still451    // "syncing" and leave the button disabled forever.452    let s: SyncOutcomeSummary = { status: "syncing" };453    for (let i = 0; i < 40 && s.status === "syncing"; i++) {454      await new Promise((r) => setTimeout(r, 250));455      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {456        ok: boolean;457        data?: SyncOutcomeSummary;458      };459      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };460    }461    setSyncSummary(s);462    setSyncStatus(s.status ?? "idle");463    // The persistent line below carries the outcome; the flash is only the464    // "that click did something" acknowledgement.465    setSyncFlash(describeSyncOutcome(s));466    setTimeout(() => setSyncFlash(null), 4000);467    await loadPairedDevices();468  }469  // UI-4 / MV-8: rotate sync identity confirmation dialog.470  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);471  const [rotating, setRotating] = createSignal(false);472  const [rotateError, setRotateError] = createSignal<string | null>(null);473  const [showSyncConnection, setShowSyncConnection] = createSignal(false);474  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);475476  async function loadPendingVouched() {477    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {478      ok: boolean;479      data?: PendingVouched[];480    };481    if (r.ok) setPendingVouched(r.data ?? []);482  }483  async function confirmVouched(deviceId: string) {484    setVouchedBusy(deviceId);485    try {486      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });487      await loadPendingVouched();488    } finally {489      setVouchedBusy(null);490    }491  }492  async function rejectVouched(deviceId: string) {493    setVouchedBusy(deviceId);494    try {495      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });496      await loadPendingVouched();497    } finally {498      setVouchedBusy(null);499    }500  }501  async function doRotateSyncIdentity() {502    setRotateError(null);503    setRotating(true);504    try {505      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {506        ok: boolean;507        error?: string;508      };509      if (!r.ok) {510        setRotateError(r.error ?? "Rotation failed.");511        return;512      }513      setRotateConfirmOpen(false);514      // Backend set peach_recovery_pending; reuse the existing recovery-words515      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).516      setShowNewRecoveryWords(true);517    } finally {518      setRotating(false);519    }520  }521522  const [dedupCount, setDedupCount] = createSignal<number | null>(null);523  const [dedupRunning, setDedupRunning] = createSignal(false);524  const [dedupDone, setDedupDone] = createSignal<number | null>(null);525526  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);527528  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted529  // BEFORE every real-mode vault write so the user can roll back if a sync530  // or import overwrites the wrong state.531  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);532  const [historyLoading, setHistoryLoading] = createSignal(false);533  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);534  const [historyShowAll, setHistoryShowAll] = createSignal(false);535536  async function loadVaultHistory() {537    setHistoryLoading(true);538    try {539      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });540      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];541      setHistorySnapshots(list);542    } finally {543      setHistoryLoading(false);544    }545  }546547  async function handleRestoreVaultSnapshot(id: string) {548    if (historyRestoring()) return;549    setHistoryRestoring(id);550    const res = await sendMessage({551      type: "RESTORE_VAULT_SNAPSHOT",552      payload: { snapshotId: id },553    });554    setHistoryRestoring(null);555    if ((res as { ok: boolean }).ok) {556      // Vault is now locked — let the parent close Settings; App.tsx's557      // VAULT_LOCKED listener will route back to the lock screen.558      props.onBack();559    }560  }561562  function formatSnapshotTime(savedAt: number): string {563    const diff = Date.now() - savedAt;564    if (diff < 60_000) return "Just now";565    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;566    const d = new Date(savedAt);567    const isToday = new Date().toDateString() === d.toDateString();568    const isYest =569      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();570    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });571    if (isToday) return `Today ${t}`;572    if (isYest) return `Yesterday ${t}`;573    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;574  }575576  function reasonLabel(reason: VaultSnapshot["reason"]): string {577    return (578      {579        local_save: "saved",580        sync_received: "synced",581        import: "imported",582        restore: "restored",583      } as const584    )[reason] ?? reason;585  }586587  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);588  const [showWhatsNew, setShowWhatsNew] = createSignal(false);589590  const [showChangePw, setShowChangePw] = createSignal(false);591  const [changePwCurrent, setChangePwCurrent] = createSignal("");592  const [changePwNew, setChangePwNew] = createSignal("");593  const [changePwConfirm, setChangePwConfirm] = createSignal("");594  const [changePwSaving, setChangePwSaving] = createSignal(false);595  const [changePwError, setChangePwError] = createSignal<string | null>(null);596  const [changePwSuccess] = createSignal(false);597  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);598599  const [pinEnabled, setPinEnabled] = createSignal(false);600  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);601  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");602  const [pinValue, setPinValue] = createSignal("");603  const [pinConfirm, setPinConfirm] = createSignal("");604  const [pinSaving, setPinSaving] = createSignal(false);605  const [pinError, setPinError] = createSignal<string | null>(null);606607  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.608  const [showViewWords, setShowViewWords] = createSignal(false);609  const [viewWordsPassword, setViewWordsPassword] = createSignal("");610  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);611  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);612  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);613614  async function handleViewRecoveryWords() {615    setViewWordsError(null);616    if (!viewWordsPassword()) {617      setViewWordsError("Enter your master password.");618      return;619    }620    setViewWordsBusy(true);621    try {622      const r = (await sendMessage({623        type: "GET_RECOVERY_WORDS",624        payload: { password: viewWordsPassword() },625      })) as { ok: boolean; data?: string[]; error?: string };626      if (r.ok) {627        setViewedWords(r.data ?? []);628        setViewWordsPassword("");629        return;630      }631      switch (r.error) {632        case "wrong_password":633          setViewWordsError("Incorrect password");634          break;635        case "password_required":636          setViewWordsError("Enter your master password.");637          break;638        case "recovery_not_setup":639          setViewWordsError("Recovery words aren't set up for this vault yet.");640          break;641        default:642          setViewWordsError("Couldn't retrieve recovery words.");643      }644    } finally {645      setViewWordsBusy(false);646    }647  }648  function closeViewWords() {649    setShowViewWords(false);650    setViewWordsPassword("");651    setViewWordsError(null);652    setViewedWords(null);653  }654655  const [duressEnabled, setDuressEnabled] = createSignal(false);656  const [showDuressSetup, setShowDuressSetup] = createSignal(false);657  const [duressPassword, setDuressPassword] = createSignal("");658  const [duressConfirm, setDuressConfirm] = createSignal("");659  const [duressMainPassword, setDuressMainPassword] = createSignal("");660  const [duressSaving, setDuressSaving] = createSignal(false);661  const [duressError, setDuressError] = createSignal<string | null>(null);662  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);663664  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);665666  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);667  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);668669  const [fbCategory, setFbCategory] = createSignal("Bug report");670  const [fbMessage, setFbMessage] = createSignal("");671  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';672  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');673674  createEffect(() => {675    if (props.upgradeHighlight) {676      setActiveCard("pro");677    }678  });679680  onMount(async () => {681    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([682      sendMessage({ type: "GET_LICENSE" }),683      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),684      sendMessage({ type: "IS_DURESS_ENABLED" }),685      sendMessage({ type: "GET_SETTINGS" }),686      sendMessage({ type: "GET_LICENSE_KEY" }),687    ]);688689    // Vault history loads in the background — the Vault sub-view picks it up690    // when the user navigates in. Loading it lazily keeps Settings boot fast.691    void loadVaultHistory();692693    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the694    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.695    void loadPendingVouched();696    void loadPairedDevices();697    void loadSyncState();698    void loadGeneration();699    const onBroadcast = (msg: unknown) => {700      const m = msg as { type?: string; payload?: unknown } | null;701      if (m?.type === "VOUCHED_DEVICE_PENDING") {702        void loadPendingVouched();703      }704      if (m?.type === "SYNC_STATE_CHANGED") {705        const payload = (m.payload ?? {}) as SyncOutcomeSummary;706        setSyncSummary(payload);707        setSyncStatus(payload.status ?? "idle");708      }709    };710    chrome.runtime?.onMessage?.addListener(onBroadcast);711    onCleanup(() => {712      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }713    });714715    if ((licenseRes as { ok: boolean }).ok) {716      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);717    }718719    if ((passkeyRes as { ok: boolean }).ok) {720      setPasskeyState(721        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null722      );723    }724725    if ((duressRes as { ok: boolean }).ok) {726      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);727    }728729    if ((settingsRes as { ok: boolean }).ok) {730      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;731      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);732      setPinEnabled(sData?.pinEnabled === true);733    }734735    if ((keyRes as { ok: boolean }).ok) {736      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);737    }738739    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });740    if ((entriesRes as { ok: boolean }).ok) {741      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];742      setCanaryEntries(743        entries744          .filter((e) => e.canary?.webhookUrl)745          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))746      );747    }748749  });750751  function resetPinFlow() {752    setPinFlow(null);753    setPinCurrentPassword("");754    setPinValue("");755    setPinConfirm("");756    setPinError(null);757  }758759  async function handleSetupPin() {760    setPinError(null);761    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }762    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }763    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }764    setPinSaving(true);765    const res = await sendMessage({766      type: "SETUP_PIN",767      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },768    }) as { ok: boolean; error?: string };769    if (res.ok) {770      setPinEnabled(true);771      resetPinFlow();772    } else {773      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");774    }775    setPinSaving(false);776  }777778  async function handleDisablePin() {779    setPinError(null);780    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }781    setPinSaving(true);782    const res = await sendMessage({783      type: "DISABLE_PIN",784      payload: { currentPassword: pinCurrentPassword() },785    }) as { ok: boolean; error?: string };786    if (res.ok) {787      setPinEnabled(false);788      resetPinFlow();789    } else {790      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");791    }792    setPinSaving(false);793  }794795  async function handleChangePassword() {796    setChangePwError(null);797    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }798    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }799    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }800    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }801    setChangePwSaving(true);802    const res = await sendMessage({803      type: "CHANGE_PASSWORD",804      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },805    }) as { ok: boolean; error?: string };806    if (res.ok) {807      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");808      setShowChangePw(false);809      // LV-4: the user just authenticated (current password), so the recovery810      // words reveal that follows is exempt from the re-auth gate. The811      // background owns this session-scoped exemption; the display clears it.812      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });813      setShowNewRecoveryWords(true);814    } else {815      setChangePwError(res.error ?? "failed to change password");816    }817    setChangePwSaving(false);818  }819820  const [copied, setCopied] = createSignal(false);821822  async function copyLicenseKey(key: string) {823    await navigator.clipboard.writeText(key);824    setCopied(true);825    setTimeout(() => setCopied(false), 2000);826  }827828  async function handleActivateLicense() {829    const key = licenseKeyInput().trim();830    if (!key) return;831    setActivatingLicense(true);832    setLicenseError(null);833    setLicenseSuccess(null);834    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });835    setActivatingLicense(false);836    if ((res as { ok: boolean }).ok) {837      const data = (res as { ok: boolean; data?: LicenseInfo }).data;838      if (data?.valid) {839        setLicenseInfo(data);840        setLicenseKey(key);841        setLicenseSuccess("License activated!");842        setLicenseKeyInput("");843      } else {844        setLicenseError("Invalid license key");845      }846    } else {847      setLicenseError("Activation failed");848    }849  }850851  async function handleTogglePasskeys() {852    const current = passkeyState();853    if (!current || !current.supported) return;854    setPasskeySaving(true);855    const res = await sendMessage({856      type: "SET_PASSKEY_PROVIDER_ENABLED",857      payload: { enabled: !current.enabled },858    });859    setPasskeySaving(false);860    if ((res as { ok: boolean }).ok) {861      setPasskeyState(862        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null863      );864    }865  }866867  async function handleCheckAllBreaches() {868    setCheckingBreaches(true);869    setBreachResult(null);870    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });871    setCheckingBreaches(false);872    if ((res as { ok: boolean }).ok) {873      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};874      const breached = Object.values(data).filter((count) => count > 0).length;875      setBreachResult(876        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"877      );878      props.onBreachCheckDone();879    }880  }881882883  async function handleSetupDuress() {884    const mainPw = duressMainPassword().trim();885    const duressPw = duressPassword().trim();886    const confirmPw = duressConfirm().trim();887888    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }889    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }890    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }891    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }892893    setDuressSaving(true);894    setDuressError(null);895896    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };897    if (!verifyRes.ok || !verifyRes.data) {898      setDuressError("incorrect main password");899      setDuressSaving(false);900      return;901    }902903    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };904    setDuressSaving(false);905    if (res.ok) {906      setDuressEnabled(true);907      setShowDuressSetup(false);908      setDuressPassword("");909      setDuressConfirm("");910      setDuressMainPassword("");911      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");912      setTimeout(() => setDuressSuccess(null), 5000);913    } else {914      setDuressError(915        res.error === "session_expired"916          ? "Vault session expired — lock and unlock Peach, then try again"917          : res.error ?? "failed to set duress password"918      );919    }920  }921922  async function handleClearDeclinedCards() {923    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });924    setDeclinedCardCount(0);925  }926927  async function handleCountDuplicates() {928    setDedupRunning(true);929    setDedupDone(null);930    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };931    setDedupRunning(false);932    if (res.ok) setDedupCount(res.data ?? 0);933  }934935  async function handleDeduplicate() {936    setDedupRunning(true);937    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };938    setDedupRunning(false);939    if (res.ok) {940      setDedupDone(res.data ?? 0);941      setDedupCount(0);942    }943  }944945  async function handleDisableDuress() {946    const mainPw = duressMainPassword().trim();947    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }948949    setDuressSaving(true);950    setDuressError(null);951952    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };953    if (!verifyRes.ok || !verifyRes.data) {954      setDuressError("incorrect main password");955      setDuressSaving(false);956      return;957    }958959    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };960    setDuressSaving(false);961    if (res.ok) {962      setDuressEnabled(false);963      setShowDuressSetup(false);964      setDuressMainPassword("");965      setDuressSuccess("Duress password disabled.");966      setTimeout(() => setDuressSuccess(null), 4000);967    } else {968      setDuressError(res.error ?? "failed to disable duress password");969    }970  }971972  async function handleSubmitFeedback() {973    const message = sanitizeFeedbackMessage(fbMessage());974    if (!message) return;975976    setFeedbackState('sending');977978    try {979      const category = FEEDBACK_CATEGORIES.includes(fbCategory())980        ? fbCategory()981        : FEEDBACK_CATEGORIES[0];982      const res = await fetch('https://api.web3forms.com/submit', {983        method: 'POST',984        headers: { 'Content-Type': 'application/json' },985        body: JSON.stringify({986          access_key: WEB3FORMS_KEY,987          subject: `Peach Feedback - ${category}`,988          message,989          from_name: 'Peach User',990          // Honeypot — leave blank991          botcheck: '',992        }),993      });994995      if (!res.ok) throw new Error(`HTTP ${res.status}`);996      const data = await res.json() as { success: boolean; message?: string };997      if (!data.success) throw new Error(data.message ?? 'unknown error');998999      setFeedbackState('sent');1000      setFbMessage('');1001      setFbCategory(FEEDBACK_CATEGORIES[0]);1002    } catch (err) {1003      console.error('[feedback]', err);1004      setFeedbackState('error');1005      setTimeout(() => setFeedbackState('idle'), 4000);1006    }1007  }10081009  function renderSecurity() {1010    return (1011      <div class="peach-settings-body">1012        <div class="peach-settings-group">1013          <p class="peach-settings-sub-label">LOCKING</p>1014          <div class="peach-settings-row">1015            <span>Lock on close</span>1016            <button1017              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1018              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1019              aria-label="Toggle lock on close"1020            >1021              <span class="peach-toggle-knob"></span>1022            </button>1023          </div>1024          <div class="peach-settings-row">1025            <span>Auto-lock after</span>1026            <select1027              class="peach-settings-select"1028              value={String(props.autoLockMinutes)}1029              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1030            >1031              <option value="1">1 minute</option>1032              <option value="5">5 minutes</option>1033              <option value="15">15 minutes</option>1034              <option value="30">30 minutes</option>1035              <option value="0">Never</option>1036            </select>1037          </div>1038          <p class="peach-settings-sub-label">SYNC WHILE LOCKED</p>1039          <div class="peach-settings-row">1040            <label for="locked-sync-custody">Private key custody</label>1041            <select1042              id="locked-sync-custody"1043              class="peach-settings-select"1044              value={props.lockedSyncRequiresUnlock ? "unlock_required" : "session"}1045              onChange={(e) => void props.onLockedSyncRequiresUnlockChange(e.currentTarget.value === "unlock_required")}1046            >1047              <option value="session">Keep Sync available</option>1048              <option value="unlock_required">Require unlock after restart</option>1049            </select>1050          </div>1051          <p class="peach-settings-help">1052            {props.lockedSyncRequiresUnlock1053              ? "The Sync identity is not persisted while locked. Unlock Peach after a background restart to resume Sync."1054              : "Keeps locked Sync available during this browser session."}1055          </p>1056          <p class="peach-settings-sub-label">QUICK UNLOCK</p>1057          <div class="peach-settings-row peach-settings-row--meta">1058            <span>PIN unlock</span>1059            <button1060              class="peach-btn peach-btn--subtle peach-btn--sm"1061              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1062            >1063              {pinEnabled() ? "remove" : "set up"}1064            </button>1065          </div>1066          <Show when={pinFlow() === "setup"}>1067            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1068              <input1069                class="peach-settings-input"1070                type="password"1071                placeholder="current password"1072                value={pinCurrentPassword()}1073                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1074                autocomplete="current-password"1075              />1076              <input1077                class="peach-settings-input"1078                type="password"1079                inputmode="numeric"1080                maxlength="12"1081                placeholder="new 4–12 digit PIN"1082                value={pinValue()}1083                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1084                autocomplete="off"1085              />1086              <input1087                class="peach-settings-input"1088                type="password"1089                inputmode="numeric"1090                maxlength="12"1091                placeholder="confirm PIN"1092                value={pinConfirm()}1093                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1094                autocomplete="off"1095                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1096              />1097              <Show when={pinError()}>1098                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1099              </Show>1100              <button1101                class="peach-btn peach-btn--accent peach-btn--sm"1102                style="align-self:flex-start"1103                onClick={() => void handleSetupPin()}1104                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1105              >1106                {pinSaving() ? "saving…" : "save PIN"}1107              </button>1108            </div>1109          </Show>1110          <Show when={pinFlow() === "disable"}>1111            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1112              <input1113                class="peach-settings-input"1114                type="password"1115                placeholder="current password"1116                value={pinCurrentPassword()}1117                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1118                autocomplete="current-password"1119                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1120              />1121              <Show when={pinError()}>1122                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1123              </Show>1124              <button1125                class="peach-btn peach-btn--subtle peach-btn--sm"1126                style="align-self:flex-start"1127                onClick={() => void handleDisablePin()}1128                disabled={pinSaving() || !pinCurrentPassword()}1129              >1130                {pinSaving() ? "removing…" : "remove PIN"}1131              </button>1132            </div>1133          </Show>11341135          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1136          <div class="peach-settings-row">1137            <span>Breach monitoring</span>1138            <button1139              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1140              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1141              aria-label="Toggle breach monitoring"1142            >1143              <span class="peach-toggle-knob"></span>1144            </button>1145          </div>1146          <div class="peach-settings-row peach-settings-row--meta">1147            <span>Check all passwords</span>1148            <button1149              class="peach-btn peach-btn--subtle peach-btn--sm"1150              onClick={() => void handleCheckAllBreaches()}1151              disabled={checkingBreaches()}1152            >1153              {checkingBreaches() ? "checking..." : "run"}1154            </button>1155          </div>1156          <Show when={breachResult()}>1157            <div class="peach-settings-row peach-settings-row--meta">1158              <span>Last check</span>1159              <span class="peach-settings-value">{breachResult()}</span>1160            </div>1161          </Show>11621163          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1164          <div class="peach-settings-row peach-settings-row--meta">1165            <span>Master password</span>1166            <button1167              class="peach-btn peach-btn--subtle peach-btn--sm"1168              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1169            >1170              {showChangePw() ? "cancel" : "change"}1171            </button>1172          </div>1173          <Show when={showChangePw()}>1174            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1175              <input1176                class="peach-settings-input"1177                type="password"1178                placeholder="current password"1179                value={changePwCurrent()}1180                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1181                autocomplete="current-password"1182              />1183              <input1184                class="peach-settings-input"1185                type="password"1186                placeholder="new password (min 12 chars)"1187                value={changePwNew()}1188                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1189                autocomplete="new-password"1190              />1191              <input1192                class="peach-settings-input"1193                type="password"1194                placeholder="confirm new password"1195                value={changePwConfirm()}1196                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1197                autocomplete="new-password"1198                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1199              />1200              <Show when={changePwError()}>1201                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1202              </Show>1203              <Show when={changePwSuccess()}>1204                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1205              </Show>1206              <button1207                class="peach-btn peach-btn--accent peach-btn--sm"1208                style="align-self:flex-start"1209                onClick={() => void handleChangePassword()}1210                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1211              >1212                {changePwSaving() ? "saving…" : "save new password"}1213              </button>1214            </div>1215          </Show>12161217          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1218          <div class="peach-settings-row peach-settings-row--meta">1219            <span>Recovery words</span>1220            <button1221              class="peach-btn peach-btn--subtle peach-btn--sm"1222              onClick={() => {1223                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1224              }}1225            >1226              {showViewWords() ? "cancel" : "view"}1227            </button>1228          </div>1229          <Show when={showViewWords()}>1230            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1231              <Show1232                when={viewedWords()}1233                fallback={1234                  <>1235                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1236                      Enter your master password to reveal your recovery words.1237                    </p>1238                    <input1239                      class="peach-settings-input"1240                      type="password"1241                      placeholder="master password"1242                      value={viewWordsPassword()}1243                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1244                      autocomplete="current-password"1245                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1246                    />1247                    <Show when={viewWordsError()}>1248                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1249                    </Show>1250                    <button1251                      class="peach-btn peach-btn--accent peach-btn--sm"1252                      style="align-self:flex-start"1253                      onClick={() => void handleViewRecoveryWords()}1254                      disabled={viewWordsBusy() || !viewWordsPassword()}1255                    >1256                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1257                    </button>1258                  </>1259                }1260              >1261                <div class="peach-recovery-words-grid">1262                  <For each={viewedWords()!}>1263                    {(word, i) => (1264                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1265                    )}1266                  </For>1267                </div>1268                <button1269                  class="peach-btn peach-btn--subtle peach-btn--sm"1270                  style="align-self:flex-start"1271                  onClick={closeViewWords}1272                >done</button>1273              </Show>1274            </div>1275          </Show>12761277          <p class="peach-settings-sub-label">1278            DURESS PASSWORD1279            <Show when={!isPro()}>1280              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1281            </Show>1282          </p>1283          <div class="peach-settings-row peach-settings-row--top">1284            <div class="peach-settings-row-main">1285              <span>1286                Duress password1287                <Show when={!isPro()}>1288                  <span class="peach-pro-badge">Pro</span>1289                </Show>1290              </span>1291              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1292                If entered under coercion, shows a decoy empty vault instead of your real one.1293              </span>1294            </div>1295            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1296              {duressEnabled() ? "on" : "off"}1297            </div>1298          </div>12991300          <Show when={duressEnabled() && !showDuressSetup()}>1301            <div class="peach-settings-row peach-settings-row--meta">1302              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1303                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.1304              </span>1305            </div>1306          </Show>13071308          <Show when={duressSuccess()}>1309            <div class="peach-settings-row peach-settings-row--meta">1310              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1311            </div>1312          </Show>13131314          <Show when={!showDuressSetup()}>1315            <div class="peach-settings-row peach-settings-row--meta">1316              <button1317                class="peach-btn peach-btn--subtle peach-btn--sm"1318                onClick={() => {1319                  // Pro gate entry-point: free users are redirected to the1320                  // upgrade screen before the setup form opens. The server-1321                  // side SETUP_DURESS handler is the single enforcement1322                  // point; this is UI redirection so the form never appears1323                  // for a user who can't complete it.1324                  if (!isPro() && !duressEnabled()) {1325                    props.onProGate?.("duress_vault");1326                    setActiveCard("pro");1327                    return;1328                  }1329                  setShowDuressSetup(true);1330                  setDuressError(null);1331                  setDuressSuccess(null);1332                  setDuressPassword("");1333                  setDuressConfirm("");1334                  setDuressMainPassword("");1335                }}1336              >1337                {duressEnabled() ? "Change / disable" : "Set up"}1338              </button>1339            </div>1340          </Show>13411342          <Show when={showDuressSetup()}>1343            <div class="peach-duress-form">1344              <label class="peach-label">main password (to confirm)</label>1345              <input1346                class="peach-settings-input"1347                type="password"1348                placeholder="your current master password"1349                autocomplete="current-password"1350                value={duressMainPassword()}1351                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1352              />1353              <Show when={!duressEnabled()}>1354                <label class="peach-label">new duress password</label>1355                <input1356                  class="peach-settings-input"1357                  type="password"1358                  placeholder="duress password (min 8 chars)"1359                  autocomplete="new-password"1360                  value={duressPassword()}1361                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1362                />1363                <label class="peach-label">confirm duress password</label>1364                <input1365                  class="peach-settings-input"1366                  type="password"1367                  placeholder="confirm duress password"1368                  autocomplete="new-password"1369                  value={duressConfirm()}1370                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1371                />1372              </Show>13731374              <Show when={duressError()}>1375                <p class="peach-hint peach-hint-error">{duressError()}</p>1376              </Show>13771378              <div class="peach-duress-actions">1379                <button1380                  class="peach-btn peach-btn--subtle peach-btn--sm"1381                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1382                >1383                  cancel1384                </button>1385                <Show when={duressEnabled()}>1386                  <button1387                    class="peach-btn peach-btn--ghost peach-btn--sm"1388                    style="color:var(--peach-breach)"1389                    onClick={() => void handleDisableDuress()}1390                    disabled={duressSaving()}1391                  >1392                    {duressSaving() ? "disabling..." : "disable duress"}1393                  </button>1394                </Show>1395                <Show when={!duressEnabled()}>1396                  <button1397                    class="peach-btn peach-btn--accent peach-btn--sm"1398                    onClick={() => void handleSetupDuress()}1399                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1400                  >1401                    {duressSaving() ? "saving..." : "save"}1402                  </button>1403                </Show>1404              </div>1405            </div>1406          </Show>14071408          <p class="peach-settings-sub-label">1409            CANARY ENTRIES1410            <Show when={!isPro()}>1411              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1412            </Show>1413          </p>1414          <div class="peach-settings-row peach-settings-row--top">1415            <div class="peach-settings-row-main">1416              <span>1417                Canary entries1418                <Show when={!isPro()}>1419                  <span class="peach-pro-badge">Pro</span>1420                </Show>1421              </span>1422              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1423                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1424              </span>1425            </div>1426            <span class="peach-settings-value" style="flex-shrink:0">1427              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1428            </span>1429          </div>1430          <Show when={canaryEntries().length > 0}>1431            <For each={canaryEntries()}>1432              {(entry) => (1433                <div class="peach-settings-row peach-settings-row--meta">1434                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1435                  <button1436                    class="peach-btn peach-btn--subtle peach-btn--sm"1437                    onClick={() => void handleTestCanary(entry)}1438                    disabled={canaryTestingId() === entry.id}1439                    style="flex-shrink:0"1440                  >1441                    {canaryTestingId() === entry.id1442                      ? "sending…"1443                      : canaryTestResult()?.id === entry.id1444                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1445                        : "test"}1446                  </button>1447                </div>1448              )}1449            </For>1450          </Show>14511452          <p class="peach-settings-sub-label">PASSKEYS</p>1453          <div class="peach-settings-row peach-settings-row--top">1454            <div class="peach-settings-row-main">1455              <span>Intercept passkey requests</span>1456              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1457                Peach can handle passkey sign-in and creation on supported Chromium browsers.1458              </span>1459            </div>1460            <button1461              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1462              onClick={() => void handleTogglePasskeys()}1463              aria-label="Toggle passkey interception"1464              disabled={!passkeyState()?.supported || passkeySaving()}1465            >1466              <span class="peach-toggle-knob"></span>1467            </button>1468          </div>1469          <div class="peach-settings-row peach-settings-row--meta">1470            <span>Status</span>1471            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1472          </div>1473          <div class="peach-settings-row peach-settings-row--meta">1474            <span>Browser</span>1475            <span class="peach-settings-value">1476              {passkeyState()?.browserLabel ?? "Unknown"}1477              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1478            </span>1479          </div>1480          <div class="peach-info-notice" style="margin-top:8px">1481            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1482          </div>1483          <Show when={passkeyState()?.conflict}>1484            <div class="peach-settings-row peach-settings-row--meta">1485              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1486            </div>1487          </Show>1488        </div>1489      </div>1490    );1491  }14921493  function renderVault() {1494    return (1495      <div class="peach-settings-body">1496        <div class="peach-settings-group">1497          <p class="peach-settings-sub-label">ENTRIES</p>1498          <div class="peach-settings-row peach-settings-row--meta">1499            <span>Entries</span>1500            <span class="peach-settings-value">{props.entriesCount}</span>1501          </div>1502          <div class="peach-settings-row peach-settings-row--meta">1503            <span>Duplicates</span>1504            <Show when={dedupDone() !== null} fallback={1505              <button1506                class="peach-btn peach-btn--subtle peach-btn--sm"1507                onClick={() => void handleCountDuplicates()}1508                disabled={dedupRunning()}1509              >1510                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1511              </button>1512            }>1513              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1514                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1515              </span>1516            </Show>1517          </div>1518          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1519            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1520              <div class="peach-restore-warning">1521                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1522              </div>1523              <div style="display:flex;gap:6px">1524                <button1525                  class="peach-btn peach-btn--subtle peach-btn--sm"1526                  onClick={() => setDedupCount(null)}1527                >1528                  cancel1529                </button>1530                <button1531                  class="peach-btn peach-btn--accent peach-btn--sm"1532                  onClick={() => void handleDeduplicate()}1533                  disabled={dedupRunning()}1534                >1535                  {dedupRunning() ? "removing..." : "remove duplicates"}1536                </button>1537              </div>1538            </div>1539          </Show>15401541          <p class="peach-settings-sub-label">MAINTENANCE</p>1542          <div class="peach-settings-row peach-settings-row--meta">1543            <span>Trash auto-delete</span>1544            <select1545              class="peach-settings-select"1546              value={String(props.trashRetentionDays)}1547              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1548            >1549              <option value="7">7 days</option>1550              <option value="30">30 days</option>1551              <option value="90">90 days</option>1552              <option value="0">Never</option>1553            </select>1554          </div>1555          <div class="peach-settings-row peach-settings-row--meta">1556            <span>Advanced Vault Health</span>1557            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1558              open1559            </button>1560          </div>1561          <Show when={declinedCardCount() > 0}>1562            <div class="peach-settings-row peach-settings-row--meta">1563              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1564              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1565                Clear1566              </button>1567            </div>1568          </Show>15691570          <p class="peach-settings-sub-label">DATA</p>1571          <div class="peach-settings-row peach-settings-row--meta">1572            <span>Import passwords</span>1573            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1574              open1575            </button>1576          </div>1577          <div class="peach-settings-row peach-settings-row--meta">1578            <span>Export backup</span>1579            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1580              open1581            </button>1582          </div>1583          <div class="peach-settings-row peach-settings-row--meta">1584            <span>1585              Peach Codex1586              <Show when={!isPro()}>1587                <span class="peach-pro-badge">Pro</span>1588              </Show>1589            </span>1590            <button1591              class="peach-btn peach-btn--subtle peach-btn--sm"1592              onClick={() => props.onOpenCodexGenerate?.()}1593            >1594              open1595            </button>1596          </div>1597          {/* Restore from Codex: intentionally never gated — a printed1598              Codex must remain restorable regardless of license state. */}1599          <div class="peach-settings-row peach-settings-row--meta">1600            <span>Restore from Codex</span>1601            <button1602              class="peach-btn peach-btn--subtle peach-btn--sm"1603              onClick={() => props.onOpenCodexRestore?.()}1604            >1605              open1606            </button>1607          </div>16081609          <Show when={hasReleaseNotes()}>1610            <p class="peach-settings-sub-label">RELEASE NOTES</p>1611            <div class="peach-settings-row peach-settings-row--meta">1612              <span>v{PEACH_VERSION}</span>1613              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1614                view1615              </button>1616            </div>1617          </Show>16181619          <span class="peach-settings-sub-label">VAULT HISTORY</span>1620          <Show when={historyLoading()}>1621            <p class="peach-settings-hint">Loading…</p>1622          </Show>1623          <Show when={!historyLoading() && historySnapshots().length === 0}>1624            <p class="peach-settings-hint">1625              No snapshots yet. Peach saves automatically before every change —1626              saves, syncs, and imports.1627            </p>1628          </Show>1629          <Show when={!historyLoading() && historySnapshots().length > 0}>1630            <p class="peach-settings-hint">1631              Restoring locks your vault. Unlock with the password that1632              encrypted that snapshot to confirm.1633            </p>1634            <div class="peach-history-list">1635              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1636                {(snap) => (1637                  <div class="peach-history-row">1638                    <div class="peach-history-meta">1639                      <span class="peach-history-time">1640                        {formatSnapshotTime(snap.savedAt)}1641                      </span>1642                      <span class="peach-history-sub">1643                        {[1644                          snap.entryCount !== null1645                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1646                            : null,1647                          reasonLabel(snap.reason),1648                        ]1649                          .filter(Boolean)1650                          .join(" · ")}1651                      </span>1652                    </div>1653                    <button1654                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1655                        historyRestoring() === snap.id ? " is-busy" : ""1656                      }`}1657                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1658                      disabled={historyRestoring() !== null}1659                    >1660                      <Show when={historyRestoring() === snap.id} fallback="Restore">1661                        …1662                      </Show>1663                    </button>1664                  </div>1665                )}1666              </For>1667            </div>1668            <Show when={historySnapshots().length > 3}>1669              <button1670                class="peach-settings-advanced-toggle"1671                onClick={() => setHistoryShowAll((v) => !v)}1672                aria-expanded={historyShowAll()}1673              >1674                <span class="peach-settings-hint" style="margin:0">1675                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1676                </span>1677                <span class="peach-settings-advanced-chevron">1678                  {historyShowAll() ? "▲" : "▼"}1679                </span>1680              </button>1681            </Show>1682            <p class="peach-history-footer">1683              {historySnapshots().length} of 10 snapshots1684            </p>1685          </Show>16861687          <button1688            class="peach-settings-advanced-toggle"1689            onClick={() => setShowVaultAdvanced((v) => !v)}1690            aria-expanded={showVaultAdvanced()}1691          >1692            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1693            <span class="peach-settings-advanced-chevron">1694              {showVaultAdvanced() ? "▲" : "▼"}1695            </span>1696          </button>16971698          <Show when={showVaultAdvanced()}>1699            <div class="peach-danger-card">1700              <div class="peach-danger-card-head">1701                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1702                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1703              </div>1704              <p class="peach-danger-card-copy">1705                Wipes the current vault and restores from a backup file.1706                Recoverable only if your backup is good.1707              </p>1708              <button1709                class="peach-danger-card-button"1710                onClick={() => props.onOpenFileImport?.()}1711              >1712                Choose backup file1713              </button>1714            </div>17151716            <Show when={props.onOpenPanic}>1717              <div class="peach-danger-card peach-danger-card--hazard">1718                <div class="peach-danger-card-head">1719                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1720                  <h3 class="peach-danger-card-title">Destroy vault</h3>1721                </div>1722                <p class="peach-danger-card-copy">1723                  Permanently wipes every entry, setting, and license on this1724                  device. There is no recovery.1725                </p>1726                <button1727                  class="peach-danger-card-button"1728                  onClick={() => props.onOpenPanic?.()}1729                >1730                  Begin destruction1731                </button>1732              </div>1733            </Show>1734          </Show>1735        </div>1736      </div>1737    );1738  }17391740  function renderSync() {1741    return (1742      <div class="peach-settings-body">1743        <div class="peach-settings-group">1744          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1745          <Show when={trustDataCorrupted()}>1746            <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1747              Device trust data is damaged — re-pair your devices after updating1748            </p>1749          </Show>1750          <div1751            class="peach-settings-row peach-settings-row--meta"1752            style="align-items:center;gap:8px"1753            aria-label={syncLightLabel()}1754            title={syncLightLabel()}1755          >1756            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1757            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1758              {syncFlash() ?? lastSyncLine()}1759            </span>1760            {/* The transfer counts already ride in the status line, so this row1761                was an empty flex sibling in every other case: it read as a dead1762                gap and held the button away from the status it belongs to. */}1763            <Show when={pairedDevices().length > 0}>1764              <button1765                class="peach-btn peach-btn--subtle peach-btn--sm"1766                aria-label="Sync this vault now"1767                disabled={syncStatus() === "syncing"}1768                onClick={() => void syncNow()}1769              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1770            </Show>1771          </div>17721773          {/* §11: this panel is a migration call to action, not a permanent1774              label. On the settled new generation there is nothing left to do1775              here, so it goes away entirely — Android applies the same rule. */}1776          <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1777            <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1778            <div class="peach-settings-row peach-settings-row--top">1779              <div class="peach-settings-row-main">1780                <span class="peach-settings-value" style="color:var(--peach-t1)">1781                  {secureResetHeadline(generation()?.state)}1782                </span>1783                <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1784                  {secureResetDetail(generation()?.state)}1785                </span>1786              </div>1787              <Show1788                when={1789                  generation()?.state === "LEGACY_ACTIVE" ||1790                  generation()?.state === "RESET_REQUIRED" ||1791                  generation()?.state === "RESET_CONFIRMED"1792                }1793              >1794                <button1795                  class="peach-btn peach-btn--subtle peach-btn--sm"1796                  onClick={() => {1797                    setResetError(null);1798                    setResetPassword("");1799                    setResetOpen(true);1800                  }}1801                >reset sync</button>1802              </Show>1803            </div>1804            <Show when={resetError()}>1805              <p1806                class="peach-settings-value"1807                role="alert"1808                style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1809              >{resetError()}</p>1810            </Show>1811            <Show when={resetOpen()}>1812              <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1813                <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1814                  {SECURE_RESET_DISCLOSURE}1815                </p>1816                <input1817                  class="peach-settings-input"1818                  type="password"1819                  placeholder="master password"1820                  value={resetPassword()}1821                  onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1822                  autocomplete="current-password"1823                />1824                <div style="display:flex;gap:4px">1825                  <button1826                    class="peach-btn peach-btn--subtle peach-btn--sm"1827                    disabled={resetting()}1828                    onClick={() => void runSecureReset()}1829                  >{resetting() ? "resetting…" : "reset"}</button>1830                  <button1831                    class="peach-btn peach-btn--subtle peach-btn--sm"1832                    disabled={resetting()}1833                    onClick={() => setResetOpen(false)}1834                  >cancel</button>1835                </div>1836              </div>1837            </Show>1838          </Show>18391840          <Show when={pairedDevices().length > 0}>1841            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1842            <For each={pairedDevices()}>1843              {(d) => (1844                <div class="peach-settings-row peach-settings-row--top">1845                  <div class="peach-settings-row-main">1846                    <Show1847                      when={renameDeviceId() === d.deviceId}1848                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1849                    >1850                      <input1851                        class="peach-settings-input"1852                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1853                        maxlength={64}1854                        value={renameDraft()}1855                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1856                        onKeyDown={(event) => {1857                          if (event.key === "Enter") void renameDevice(d.deviceId);1858                          if (event.key === "Escape") setRenameDeviceId(null);1859                        }}1860                      />1861                    </Show>1862                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1863                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1864                    </span>1865                  </div>1866                  <Show1867                    when={confirmUnpair() === d.deviceId}1868                    fallback={1869                      <div style="display:flex;gap:4px;flex-shrink:0">1870                        <Show1871                          when={renameDeviceId() === d.deviceId}1872                          fallback={1873                            <button1874                              class="peach-btn peach-btn--subtle peach-btn--sm"1875                              onClick={() => {1876                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1877                                setRenameDeviceId(d.deviceId);1878                              }}1879                            >rename</button>1880                          }1881                        >1882                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1883                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1884                        </Show>1885                        <button1886                          class="peach-btn peach-btn--subtle peach-btn--sm"1887                          onClick={() => {1888                            setUnpairError(null);1889                            setConfirmUnpair(d.deviceId);1890                          }}1891                        >remove</button>1892                      </div>1893                    }1894                  >1895                    {/* HOLE 4: revocation stops this device receiving future1896                        changes. It does NOT secure the vault against it — that1897                        device may already hold the recovery words, which are1898                        master-password-equivalent and restore the whole vault1899                        anywhere, forever, with no sync involved. Say so here,1900                        rather than leaving the user falsely reassured. */}1901                    <div class="peach-unpair-confirm">1902                      <p class="peach-unpair-confirm-note">1903                        Stops this device receiving future changes. It doesn't1904                        lock it out of what it already has — if it's lost or1905                        stolen, also change your master password and generate1906                        new recovery words.1907                      </p>1908                      <div style="display:flex;gap:6px;justify-content:flex-end">1909                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1910                        <button1911                          class="peach-btn peach-btn--sm"1912                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1913                          onClick={() => void unpairDevice(d.deviceId)}1914                        >remove</button>1915                      </div>1916                    </div>1917                      <Show when={unpairError()}>1918                        <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1919                          {unpairError()}1920                        </p>1921                      </Show>1922                  </Show>1923                </div>1924              )}1925            </For>1926          </Show>19271928          <Show when={pendingVouched().length > 0}>1929            <p class="peach-settings-sub-label">1930              PENDING DEVICES1931              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1932            </p>1933            <For each={pendingVouched()}>1934              {(dev) => (1935                <div class="peach-settings-row peach-settings-row--top">1936                  <div class="peach-settings-row-main">1937                    <span>{dev.deviceName}</span>1938                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1939                      Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1940                      requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1941                      vouched by device {dev.vouchedByDeviceId}. Allowing it can read1942                      existing vault contents and all future changes.1943                    </span>1944                  </div>1945                  <div style="display:flex;gap:6px;flex-shrink:0">1946                    <button1947                      class="peach-btn peach-btn--sm"1948                      disabled={vouchedBusy() === dev.deviceId}1949                      onClick={() => void confirmVouched(dev.deviceId)}1950                    >Allow</button>1951                    <button1952                      class="peach-btn peach-btn--subtle peach-btn--sm"1953                      disabled={vouchedBusy() === dev.deviceId}1954                      onClick={() => void rejectVouched(dev.deviceId)}1955                    >Reject</button>1956                  </div>1957                </div>1958              )}1959            </For>1960          </Show>19611962          <p class="peach-settings-sub-label">ADD A DEVICE</p>1963          <button1964            class="peach-sync-pair-option"1965            onClick={() => props.onOpenSyncPairing?.("code")}1966          >1967            <span class="peach-sync-pair-option-icon" aria-hidden="true">1968              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1969                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1970                <path d="M9 15 L11 15" />1971              </svg>1972            </span>1973            <span class="peach-sync-pair-option-main">1974              <span class="peach-sync-pair-option-title">Show a code</span>1975              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1976            </span>1977            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1978          </button>1979          <Show when={!isPro()}>1980            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1981              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1982            </p>1983          </Show>19841985          <button1986            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1987            onClick={() => setShowSyncConnection((value) => !value)}1988            aria-expanded={showSyncConnection()}1989            aria-controls="peach-sync-connection"1990          >1991            <span>1992              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1993              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1994            </span>1995            <span class="peach-settings-advanced-chevron">1996              {showSyncConnection() ? "▲" : "▼"}1997            </span>1998          </button>1999          <Show when={showSyncConnection()}>2000            <div id="peach-sync-connection" class="peach-sync-disclosure-content">2001              <SyncModeSelector />2002            </div>2003          </Show>20042005          <button2006            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"2007            onClick={() => setShowSyncRecovery((value) => !value)}2008            aria-expanded={showSyncRecovery()}2009            aria-controls="peach-sync-recovery"2010          >2011            <span>2012              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>2013              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>2014            </span>2015            <span class="peach-settings-advanced-chevron">2016              {showSyncRecovery() ? "▲" : "▼"}2017            </span>2018          </button>2019          <Show when={showSyncRecovery()}>2020            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2021              <Show2022                when={rotateConfirmOpen()}2023                fallback={2024                  <div class="peach-settings-row peach-settings-row--top">2025                    <div class="peach-settings-row-main">2026                      <span>Rotate sync identity</span>2027                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2028                        Disconnects every device and replaces the keys used for sync.2029                      </span>2030                    </div>2031                    <button2032                      class="peach-btn peach-btn--subtle peach-btn--sm"2033                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2034                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2035                    >rotate</button>2036                  </div>2037                }2038              >2039                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2040                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2041                  <Show when={rotateError()}>2042                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2043                  </Show>2044                  <div style="display:flex;gap:6px;margin-top:10px">2045                    <button2046                      class="peach-btn peach-btn--subtle peach-btn--sm"2047                      disabled={rotating()}2048                      onClick={() => setRotateConfirmOpen(false)}2049                    >Cancel</button>2050                    <button2051                      class="peach-btn peach-btn--sm"2052                      style="background:var(--peach-breach);border-color:var(--peach-breach)"2053                      disabled={rotating()}2054                      onClick={() => void doRotateSyncIdentity()}2055                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2056                  </div>2057                </div>2058              </Show>2059            </div>2060          </Show>2061        </div>2062      </div>2063    );2064  }20652066  function renderAppearance() {2067    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2068      { id: "medium", label: "Default" },2069      { id: "large", label: "Large" },2070    ];2071    return (2072      <div class="peach-settings-body">2073        <div class="peach-settings-group">2074          <p class="peach-settings-sub-label">THEME</p>2075          <div class="peach-settings-row peach-settings-row--meta">2076            <span>Theme</span>2077            <button2078              class="peach-btn peach-btn--subtle peach-btn--sm"2079              onClick={() =>2080                props.onThemeChange(props.theme === "dark" ? "light" : "dark")2081              }2082            >2083              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2084            </button>2085          </div>20862087          <p class="peach-settings-sub-label">ACCENT</p>2088          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2089            <span>Accent</span>2090            <div class="peach-accent-picker">2091              <For each={ACCENT_COLORS}>2092                {(color) => (2093                  <button2094                    class="peach-accent-swatch peach-swatch"2095                    classList={{ "is-active": props.accentColor === color.name }}2096                    style={{ background: color.hex }}2097                    onClick={() => void props.onAccentChange(color.name)}2098                    aria-label={color.name}2099                    title={color.name}2100                  />2101                )}2102              </For>2103            </div>2104          </div>21052106          <p class="peach-settings-sub-label">TEXT SIZE</p>2107          <div class="peach-settings-row peach-settings-row--meta">2108            <span>Size</span>2109            <div class="peach-pill-group">2110              <For each={sizeOptions}>2111                {(opt) => (2112                  <button2113                    class="peach-btn peach-btn--ghost peach-btn--sm"2114                    classList={{ "is-active": props.textSize === opt.id }}2115                    onClick={() => void props.onTextSizeChange(opt.id)}2116                  >2117                    {opt.label}2118                  </button>2119                )}2120              </For>2121            </div>2122          </div>21232124          <p class="peach-settings-sub-label">MOTION</p>2125          <div class="peach-settings-row peach-settings-row--meta">2126            <span>Reduce motion</span>2127            <button2128              class="peach-btn peach-btn--subtle peach-btn--sm"2129              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2130            >2131              {props.reduceMotion ? "On" : "Off"}2132            </button>2133          </div>2134        </div>2135      </div>2136    );2137  }21382139  function renderShortcuts() {2140    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2141    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21422143    function startCapture(action: ShortcutAction) {2144      setConflictMsg(null);2145      setCapturingAction(action);2146    }2147    function cancelCapture() {2148      setCapturingAction(null);2149      setConflictMsg(null);2150    }2151    function commitBinding(action: ShortcutAction, binding: Keybinding) {2152      const conflict = findConflict(props.shortcuts, binding, action);2153      if (conflict) {2154        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2155        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2156        return;2157      }2158      const next = { ...props.shortcuts, [action]: binding };2159      setCapturingAction(null);2160      setConflictMsg(null);2161      void props.onShortcutsChange(next);2162    }2163    function resetAction(action: ShortcutAction) {2164      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2165      void props.onShortcutsChange(next);2166    }2167    function resetAll() {2168      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2169    }21702171    // Capture-mode key handler — wired via ref onto the chip element so it2172    // catches the next non-modifier keypress and ignores other bindings.2173    function handleCaptureKey(e: KeyboardEvent) {2174      e.preventDefault();2175      e.stopPropagation();2176      if (e.key === "Escape") { cancelCapture(); return; }2177      const binding = keybindingFromEvent(e);2178      if (!binding) return; // bare modifier — keep waiting2179      const action = capturingAction();2180      if (action) commitBinding(action, binding);2181    }21822183    return (2184      <div class="peach-settings-body">2185        <div class="peach-settings-group peach-shortcuts-group">2186          <p class="peach-settings-sub-label">ENTRY VIEW</p>2187          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2188            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2189          </For>2190        </div>21912192        <div class="peach-settings-group peach-shortcuts-group">2193          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2194          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2195            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2196          </For>2197        </div>21982199        <Show when={conflictMsg()}>2200          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2201        </Show>22022203        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2204          <div class="peach-settings-row peach-settings-row--meta">2205            <span>Inline autofill on page</span>2206            <a2207              class="peach-settings-link"2208              href="chrome://extensions/shortcuts"2209              onClick={(e) => {2210                e.preventDefault();2211                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2212              }}2213            >2214              ⌘⇧P — customize ↗2215            </a>2216          </div>2217          <button2218            class="peach-btn peach-btn--subtle peach-btn--sm"2219            style="align-self:flex-start"2220            onClick={resetAll}2221          >2222            Reset all to defaults2223          </button>2224        </div>2225      </div>2226    );22272228    function renderRow(2229      desc: typeof SHORTCUT_DESCRIPTORS[number],2230      capturing: () => ShortcutAction | null,2231      onStart: (a: ShortcutAction) => void,2232      onCancel: () => void,2233      onReset: (a: ShortcutAction) => void,2234      onKey: (e: KeyboardEvent) => void,2235    ) {2236      const isActive = () => capturing() === desc.action;2237      const binding = () => props.shortcuts[desc.action];2238      const isCustom = () => {2239        const b = binding();2240        const d = DEFAULT_BINDINGS[desc.action];2241        return b.key !== d.key2242          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2243          || Boolean(b.meta)  !== Boolean(d.meta)2244          || Boolean(b.shift) !== Boolean(d.shift)2245          || Boolean(b.alt)   !== Boolean(d.alt);2246      };2247      return (2248        <div class="peach-shortcut-row">2249          <span class="peach-shortcut-row-label">{desc.label}</span>2250          <button2251            class="peach-shortcut-chip"2252            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2253            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2254            onKeyDown={(e) => { if (isActive()) onKey(e); }}2255            ref={(el) => {2256              // Auto-focus when entering capture mode so the keydown listener catches the next press.2257              if (el) {2258                queueMicrotask(() => { if (isActive()) el.focus(); });2259              }2260            }}2261          >2262            {isActive() ? "Press a key…" : formatKeybinding(binding())}2263          </button>2264          <Show when={isCustom() && !isActive()}>2265            <button2266              class="peach-shortcut-reset"2267              title="Reset to default"2268              onClick={() => onReset(desc.action)}2269            >2270              ↺2271            </button>2272          </Show>2273        </div>2274      );2275    }2276  }22772278  const PRO_FEATURES: Array<{2279    feature: ProFeature;2280    name: string;2281    desc: string;2282  }> = [2283    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2284    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2285    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2286    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2287  ];22882289  const PRO_SECURITY_FEATURES: Array<{2290    feature: ProFeature;2291    name: string;2292    desc: string;2293  }> = [2294    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2295    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2296  ];22972298  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2299  const [showKey, setShowKey] = createSignal(false);23002301  const [deactivating, setDeactivating] = createSignal(false);23022303  async function handleDeactivateLicense() {2304    setDeactivating(true);2305    await sendMessage({ type: "DEACTIVATE_LICENSE" });2306    setLicenseInfo(null);2307    await props.onLicenseChange?.();2308    setDeactivating(false);2309  }23102311  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2312    setCanaryTestingId(entry.id);2313    setCanaryTestResult(null);2314    const res = await sendMessage({2315      type: "FIRE_CANARY_PING",2316      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2317    }) as { ok: boolean };2318    setCanaryTestResult({ id: entry.id, ok: res.ok });2319    setCanaryTestingId(null);2320    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2321  }23222323  function renderLicense() {2324    async function onActivate() {2325      await handleActivateLicense();2326      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2327        await props.onLicenseChange?.();2328      }2329    }23302331    return (2332      <div class="peach-settings-body">2333        <Show2334          when={isPro()}2335          fallback={2336                <div class="peach-pro-upgrade">2337                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23382339                  <h2 class="peach-pro-headline">2340                    Every<em>thing,</em><br />every<em>where.</em>2341                  </h2>23422343                  <p class="peach-pro-subtitle">2344                    One key unlocks every Pro feature — for the lifetime of the app.2345                  </p>23462347                  <div class="peach-pro-grid">2348                    <For each={PRO_FEATURES}>2349                      {(item, i) => (2350                        <div2351                          class="peach-pro-grid-item"2352                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2353                        >2354                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2355                          <span class="peach-pro-grid-name">{item.name}</span>2356                          <span class="peach-pro-grid-desc">{item.desc}</span>2357                        </div>2358                      )}2359                    </For>2360                  </div>23612362                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2363                  <div class="peach-pro-grid">2364                    <For each={PRO_SECURITY_FEATURES}>2365                      {(item, i) => (2366                        <div2367                          class="peach-pro-grid-item"2368                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2369                        >2370                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2371                          <span class="peach-pro-grid-name">{item.name}</span>2372                          <span class="peach-pro-grid-desc">{item.desc}</span>2373                        </div>2374                      )}2375                    </For>2376                  </div>23772378                  <div class="peach-pro-upgrade-actions">2379                    <p class="peach-pro-pricing">2380                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2381                    </p>2382                    <a2383                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2384                      target="_blank"2385                      rel="noopener noreferrer"2386                      class="peach-pro-upgrade-cta"2387                    >2388                      Get Peach Pro2389                    </a>2390                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2391                    <div class="peach-pro-key-inline">2392                      <input2393                        class="peach-pro-key-inline-input"2394                        type="text"2395                        placeholder="PEACH-XXXX-XXXX-XXXX"2396                        value={licenseKeyInput()}2397                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2398                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2399                        autocomplete="off"2400                        spellcheck={false}2401                      />2402                      <button2403                        class="peach-pro-key-apply"2404                        onClick={() => void onActivate()}2405                        disabled={activatingLicense() || !licenseKeyInput().trim()}2406                      >2407                        {activatingLicense() ? "…" : "APPLY"}2408                      </button>2409                    </div>2410                    <Show when={licenseError()}>2411                      <p class="peach-pro-key-error">{licenseError()}</p>2412                    </Show>2413                  </div>2414                </div>2415          }2416        >2417          <div class="peach-pro-active">2418            <div class="peach-pro-active-hero">2419              <button2420                class="peach-pro-active-mark-wrap"2421                type="button"2422                aria-label="Celebrate"2423                onClick={handleProCelebrationClick}2424              >2425                <PeachMark class="peach-pro-active-mark" />2426                <Show when={justActivated()}>2427                  <div class="peach-pro-sparks" aria-hidden="true">2428                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2429                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2430                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2431                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2432                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2433                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2434                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2435                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2436                  </div>2437                </Show>2438              </button>2439              <h2 class="peach-pro-active-title">Peach Pro</h2>2440              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2441              <Show when={justActivated()}>2442                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2443              </Show>2444            </div>24452446            <Show when={licenseKey()}>2447              <div class="peach-settings-license">2448                <span class="peach-settings-label">LICENSE KEY</span>2449                <div class="peach-settings-license-row">2450                  <Show2451                    when={showKey()}2452                    fallback={2453                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2454                        ••••••••••••••••••••••••••••2455                      </span>2456                    }2457                  >2458                    <span class="peach-settings-license-key">{licenseKey()!}</span>2459                  </Show>2460                  <div class="peach-settings-license-actions">2461                    <button2462                      class="peach-settings-license-copy"2463                      title="Copy license key"2464                      onClick={() => copyLicenseKey(licenseKey()!)}2465                    >2466                      {copied() ? "✓" : "copy"}2467                    </button>2468                    <button2469                      class="peach-settings-license-toggle"2470                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2471                      title={showKey() ? "Hide" : "Reveal"}2472                      onClick={() => setShowKey(v => !v)}2473                    >2474                      <Show2475                        when={showKey()}2476                        fallback={2477                          <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">2478                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2479                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2480                          </svg>2481                        }2482                      >2483                        <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">2484                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2485                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2486                          <line x1="2" y1="2" x2="12" y2="12" />2487                        </svg>2488                      </Show>2489                    </button>2490                  </div>2491                </div>2492              </div>2493            </Show>24942495            <div class="peach-pro-active-features">2496              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2497                {(item) => (2498                  <div class="peach-pro-active-feature">2499                    <span class="peach-pro-active-feature-mark">✦</span>2500                    <span class="peach-pro-active-feature-name">{item.name}</span>2501                  </div>2502                )}2503              </For>2504            </div>25052506            <button2507              class="peach-pro-deactivate"2508              onClick={() => void handleDeactivateLicense()}2509              disabled={deactivating()}2510            >2511              {deactivating() ? "Deactivating…" : "Deactivate"}2512            </button>2513          </div>2514        </Show>2515      </div>2516    );2517  }25182519  function renderFeedback() {2520    return (2521      <div class="peach-settings-body">2522        <div class="peach-settings-group peach-feedback-group">2523          <Show2524            when={feedbackState() === 'sent'}2525            fallback={2526              <>2527                <p class="peach-feedback-intro">2528                  Help us improve Peach. Every report goes directly to our inbox.2529                </p>25302531                <p class="peach-settings-sub-label">TYPE</p>2532                <div class="peach-feedback-chips">2533                  <For each={FEEDBACK_CATEGORIES}>2534                    {(cat) => {2535                      const selected = () => fbCategory() === cat;2536                      return (2537                        <button2538                          type="button"2539                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2540                          aria-pressed={selected()}2541                          onClick={() => setFbCategory(cat)}2542                        >2543                          {cat}2544                        </button>2545                      );2546                    }}2547                  </For>2548                </div>25492550                <p class="peach-settings-sub-label">MESSAGE</p>2551                <textarea2552                  class="peach-feedback-textarea"2553                  placeholder="Describe the issue or idea..."2554                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2555                  value={fbMessage()}2556                  onInput={(e) => setFbMessage(e.currentTarget.value)}2557                />25582559                <p class="peach-feedback-meta">2560                  Version {version} · will be included automatically2561                </p>25622563                <button2564                  type="button"2565                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2566                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2567                  onClick={() => void handleSubmitFeedback()}2568                >2569                  <Switch>2570                    <Match when={feedbackState() === 'idle'}>Send</Match>2571                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2572                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2573                  </Switch>2574                </button>25752576                <p class="peach-feedback-anon">2577                  Sent anonymously · no account required2578                </p>2579              </>2580            }2581          >2582            <div class="peach-feedback-thanks" role="status" aria-live="polite">2583              <p class="peach-feedback-thanks-title">Thank you</p>2584              <p>2585                Your note is in the queue. Someone will process it soon.2586              </p>2587            </div>2588          </Show>2589        </div>2590      </div>2591    );2592  }25932594  const bodies: StackBodies = {2595    protection: renderSecurity(),2596    vault: renderVault(),2597    sync: renderSync(),2598    appearance: renderAppearance(),2599    shortcuts: renderShortcuts(),2600    pro: renderLicense(),2601    feedback: renderFeedback(),2602  };26032604  return (2605    <Show2606      when={showWhatsNew()}2607      fallback={2608    <Show2609      when={showNewRecoveryWords()}2610      fallback={2611        <div2612          class="peach-overlay-view peach-settings-overlay"2613          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2614          style="display:flex;flex-direction:column"2615        >2616          <StackDeck2617            active={activeCard()}2618            onActiveChange={setActiveCard}2619            onBack={props.onBack}2620            cards={cards()}2621            bodies={bodies}2622            version={version}2623            isFanning={isFanning()}2624            celebratingCardId={justActivated() ? "pro" : null}2625            isPro={isPro()}2626          />2627        </div>2628      }2629    >2630      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2631    </Show>2632      }2633    >2634      <div class="peach-overlay-view peach-settings-overlay">2635        <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2636      </div>2637    </Show>2638  );2639}