Settings.tsx

97.7 KB · raw

1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11  AccentColorName,12  PasskeyProviderState,13  ProFeature,14  VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18  describeSyncOutcome,19  syncIndicator,20  syncIndicatorLabel,21  type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24  DEFAULT_BINDINGS,25  findConflict,26  formatKeybinding,27  keybindingFromEvent,28  SHORTCUT_DESCRIPTORS,29  type Keybinding,30  type ShortcutAction,31} from "../../shared/shortcuts";32import { WEB3FORMS_KEY } from "../../shared/config";3334interface LicenseInfo {35  valid: boolean;36  plan: "free" | "pro";37  issued?: string;38}3940interface SettingsProps {41  entriesCount: number;42  breachMonitoring: boolean;43  lockOnClose: boolean;44  autoLockMinutes: number;45  trashRetentionDays: number;46  theme: "dark" | "light";47  accentColor: AccentColorName;48  textSize: "small" | "medium" | "large";49  reduceMotion: boolean;50  isPro?: boolean;51  initialSection?: string | null;52  upgradeHighlight?: ProFeature | null;53  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;54  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;55  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;56  onTrashRetentionChange: (days: number) => void | Promise<void>;57  onThemeChange: (theme: "dark" | "light") => void;58  onAccentChange: (name: AccentColorName) => void | Promise<void>;59  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;60  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;61  shortcuts: Record<ShortcutAction, Keybinding>;62  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;63  onLicenseChange?: () => void | Promise<void>;64  isExiting?: boolean;65  onBack: () => void;66  onImport: () => void;67  onExport: () => void;68  onBreachCheckDone: () => void;69  onOpenHealth?: () => void;70  onOpenSyncPairing?: (method?: "code") => void;71  onOpenFileImport?: () => void;72  onOpenPanic?: () => void;73  onOpenCodexGenerate?: () => void;74  onOpenCodexRestore?: () => void;75  /** Invoked from within Settings when a Pro-gated control is tapped on76   *  free tier. App owns the upgrade navigation + highlight state, so this77   *  callback is the single way for Settings to request that navigation. */78  onProGate?: (feature: ProFeature) => void;79  onCardChange?: (id: CardId) => void;80}8182const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];83const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;84const PRO_CELEBRATION_CARD_STEP_MS = 150;85const PRO_CELEBRATION_THANK_YOU_MS = 3200;8687function sanitizeFeedbackMessage(value: string): string {88  return value89    .trim()90    .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)91    // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strips control characters from feedback text92    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")93    .replace(/&/g, "&amp;")94    .replace(/</g, "&lt;")95    .replace(/>/g, "&gt;")96    .replace(/"/g, "&quot;")97    .replace(/'/g, "&#39;");98}99100function formatActivatedSince(issued: string | undefined): string {101  if (!issued) return "Active";102  try {103    const d = new Date(issued);104    if (Number.isNaN(d.getTime())) return "Active";105    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;106  } catch {107    return "Active";108  }109}110111function initialSectionToCard(value: string | null | undefined): CardId | null {112  if (!value) return null;113  if (value === "security") return "protection";114  if (value === "license") return "pro";115  if (116    value === "protection" ||117    value === "vault" ||118    value === "sync" ||119    value === "appearance" ||120    value === "shortcuts" ||121    value === "pro" ||122    value === "feedback"123  ) {124    return value;125  }126  return null;127}128129export function SettingsView(props: SettingsProps) {130  const manifest = chrome.runtime.getManifest();131  const version = manifest.version;132  const initial = props.initialSection;133  const [activeCard, setActiveCard] = createSignal<CardId>(134    props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"135  );136137  createEffect(() => { props.onCardChange?.(activeCard()); });138139  const [justActivated, setJustActivated] = createSignal(false);140  const [isFanning, setIsFanning] = createSignal(false);141142  function triggerCelebration() {143    if (isFanning()) return;144    setJustActivated(false);145    setIsFanning(true);146    setActiveCard("pro");147    const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];148    fanOrder.forEach((id, i) => {149      window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);150    });151    window.setTimeout(() => {152      setActiveCard("pro");153      setIsFanning(false);154      setJustActivated(true);155      window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);156    }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);157  }158159  function handleProCelebrationClick(e: MouseEvent) {160    e.stopPropagation();161    triggerCelebration();162  }163164  const [checkingBreaches, setCheckingBreaches] = createSignal(false);165  const [breachResult, setBreachResult] = createSignal<string | null>(null);166  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);167  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");168  const [licenseError, setLicenseError] = createSignal<string | null>(null);169  const [, setLicenseSuccess] = createSignal<string | null>(null);170  const [activatingLicense, setActivatingLicense] = createSignal(false);171172  const isPro = () => props.isPro ?? false;173  const cards = createMemo<StackCardSpec[]>(() => [174    {175      id: "pro",176      num: "I · FOREVER",177      title: "Peach Pro",178      lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",179      bgVar: "--stack-pro-bg",180      fgVar: "--stack-pro-fg",181      icon: "spark",182      onIconClick: isPro() ? triggerCelebration : undefined,183    },184    {185      id: "protection",186      num: "II · PROTECT",187      title: "Protection",188      lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,189      bgVar: "--stack-protect-bg",190      fgVar: "--stack-protect-fg",191      icon: "shield",192    },193    {194      id: "vault",195      num: "III · KEEP",196      title: "Vault",197      lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,198      bgVar: "--stack-vault-bg",199      fgVar: "--stack-vault-fg",200      icon: "lock",201    },202    {203      id: "sync",204      num: "IV · ECHO",205      title: "Sync",206      lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",207      bgVar: "--stack-sync-bg",208      fgVar: "--stack-sync-fg",209      icon: "sync",210    },211    {212      id: "appearance",213      num: "V · DRESS",214      title: "Appearance",215      lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,216      bgVar: "--stack-appear-bg",217      fgVar: "--stack-appear-fg",218      icon: "sun",219    },220    {221      id: "shortcuts",222      num: "VI · KEYS",223      title: "Shortcuts",224      lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,225      bgVar: "--stack-shortcut-bg",226      fgVar: "--stack-shortcut-fg",227      icon: "keys",228    },229    {230      id: "feedback",231      num: "VII · ECHO",232      title: "Feedback",233      lede: `version ${version} · tell us what would make it better`,234      bgVar: "--stack-feedback-bg",235      fgVar: "--stack-feedback-fg",236      icon: "bubble",237    },238  ]);239  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);240  const [passkeySaving, setPasskeySaving] = createSignal(false);241242  // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting243  // explicit confirmation before they become sync targets.244  interface PendingVouched { deviceId: string; deviceName: string }245  const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);246  const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);247  // Paired-device management lives here — Settings is the sync hub.248  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);249  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);250  const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);251  const [renameDraft, setRenameDraft] = createSignal("");252  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");253  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);254  // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —255  // a transient toast is no use for diagnosing a phone that isn't syncing.256  const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});257  const syncLight = () =>258    syncStatus() === "syncing"259      ? "busy"260      : syncIndicator(syncSummary(), pairedDevices().length);261  const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());262  const lastSyncLine = () => {263    const s = syncSummary();264    if (!s.outcome) return "No sync attempted yet";265    return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;266  };267  async function loadSyncState() {268    const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {269      ok: boolean;270      data?: SyncOutcomeSummary;271    };272    if (r.ok && r.data) {273      setSyncSummary(r.data);274      setSyncStatus(r.data.status ?? "idle");275    }276  }277278  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;279  function syncRelativeTime(when: number | undefined): string {280    if (!when) return "Not synced yet";281    const elapsed = Date.now() - when;282    if (elapsed < 60_000) return "Just now";283    const minutes = Math.floor(elapsed / 60_000);284    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;285    const hours = Math.floor(minutes / 60);286    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;287    const days = Math.floor(hours / 24);288    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";289  }290  function isSyncStale(d: PairedDevice): boolean {291    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;292  }293  function deviceTypeLabel(t: PairedDevice["deviceType"]): string {294    switch (t) {295      case "android": return "Android";296      case "ios": return "iPhone";297      case "extension": return "Browser";298      default: return "Device";299    }300  }301302  async function loadPairedDevices() {303    if (!isPro()) return;304    const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {305      ok: boolean;306      data?: { devices: PairedDevice[] };307    };308    if (r.ok) setPairedDevices(r.data?.devices ?? []);309  }310  async function unpairDevice(deviceId: string) {311    await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });312    setConfirmUnpair(null);313    await loadPairedDevices();314  }315  async function renameDevice(deviceId: string) {316    const name = renameDraft().trim();317    if (!name) return;318    const result = (await sendMessage({319      type: "RENAME_PAIRED_DEVICE",320      payload: { deviceId, name },321    })) as { ok: boolean };322    if (!result.ok) return;323    setRenameDeviceId(null);324    await loadPairedDevices();325  }326  async function syncNow() {327    setSyncStatus("syncing");328    setSyncFlash(null);329    const res = (await sendMessage({330      type: "TRIGGER_SYNC",331      payload: { reason: "manual", force: true },332    })) as { ok: boolean; error?: string };333    if (!res.ok) {334      setSyncStatus("error");335      setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });336      return;337    }338    // TRIGGER_SYNC returns as soon as the run is queued, so poll for the339    // outcome. A single delayed read used to land while the run was still340    // "syncing" and leave the button disabled forever.341    let s: SyncOutcomeSummary = { status: "syncing" };342    for (let i = 0; i < 40 && s.status === "syncing"; i++) {343      await new Promise((r) => setTimeout(r, 250));344      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {345        ok: boolean;346        data?: SyncOutcomeSummary;347      };348      s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };349    }350    setSyncSummary(s);351    setSyncStatus(s.status ?? "idle");352    // The persistent line below carries the outcome; the flash is only the353    // "that click did something" acknowledgement.354    setSyncFlash(describeSyncOutcome(s));355    setTimeout(() => setSyncFlash(null), 4000);356    await loadPairedDevices();357  }358  // UI-4 / MV-8: rotate sync identity confirmation dialog.359  const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);360  const [rotating, setRotating] = createSignal(false);361  const [rotateError, setRotateError] = createSignal<string | null>(null);362  const [showSyncConnection, setShowSyncConnection] = createSignal(false);363  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);364365  async function loadPendingVouched() {366    const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {367      ok: boolean;368      data?: PendingVouched[];369    };370    if (r.ok) setPendingVouched(r.data ?? []);371  }372  async function confirmVouched(deviceId: string) {373    setVouchedBusy(deviceId);374    try {375      await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });376      await loadPendingVouched();377    } finally {378      setVouchedBusy(null);379    }380  }381  async function rejectVouched(deviceId: string) {382    setVouchedBusy(deviceId);383    try {384      await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });385      await loadPendingVouched();386    } finally {387      setVouchedBusy(null);388    }389  }390  async function doRotateSyncIdentity() {391    setRotateError(null);392    setRotating(true);393    try {394      const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {395        ok: boolean;396        error?: string;397      };398      if (!r.ok) {399        setRotateError(r.error ?? "Rotation failed.");400        return;401      }402      setRotateConfirmOpen(false);403      // Backend set peach_recovery_pending; reuse the existing recovery-words404      // display (its GET_RECOVERY_WORDS fetch is exempted during pending).405      setShowNewRecoveryWords(true);406    } finally {407      setRotating(false);408    }409  }410411  const [dedupCount, setDedupCount] = createSignal<number | null>(null);412  const [dedupRunning, setDedupRunning] = createSignal(false);413  const [dedupDone, setDedupDone] = createSignal<number | null>(null);414415  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);416417  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted418  // BEFORE every real-mode vault write so the user can roll back if a sync419  // or import overwrites the wrong state.420  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);421  const [historyLoading, setHistoryLoading] = createSignal(false);422  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);423  const [historyShowAll, setHistoryShowAll] = createSignal(false);424425  async function loadVaultHistory() {426    setHistoryLoading(true);427    try {428      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });429      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];430      setHistorySnapshots(list);431    } finally {432      setHistoryLoading(false);433    }434  }435436  async function handleRestoreVaultSnapshot(id: string) {437    if (historyRestoring()) return;438    setHistoryRestoring(id);439    const res = await sendMessage({440      type: "RESTORE_VAULT_SNAPSHOT",441      payload: { snapshotId: id },442    });443    setHistoryRestoring(null);444    if ((res as { ok: boolean }).ok) {445      // Vault is now locked — let the parent close Settings; App.tsx's446      // VAULT_LOCKED listener will route back to the lock screen.447      props.onBack();448    }449  }450451  function formatSnapshotTime(savedAt: number): string {452    const diff = Date.now() - savedAt;453    if (diff < 60_000) return "Just now";454    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;455    const d = new Date(savedAt);456    const isToday = new Date().toDateString() === d.toDateString();457    const isYest =458      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();459    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });460    if (isToday) return `Today ${t}`;461    if (isYest) return `Yesterday ${t}`;462    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;463  }464465  function reasonLabel(reason: VaultSnapshot["reason"]): string {466    return (467      {468        local_save: "saved",469        sync_received: "synced",470        import: "imported",471        restore: "restored",472      } as const473    )[reason] ?? reason;474  }475476  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);477478  const [showChangePw, setShowChangePw] = createSignal(false);479  const [changePwCurrent, setChangePwCurrent] = createSignal("");480  const [changePwNew, setChangePwNew] = createSignal("");481  const [changePwConfirm, setChangePwConfirm] = createSignal("");482  const [changePwSaving, setChangePwSaving] = createSignal(false);483  const [changePwError, setChangePwError] = createSignal<string | null>(null);484  const [changePwSuccess] = createSignal(false);485  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);486487  const [pinEnabled, setPinEnabled] = createSignal(false);488  const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);489  const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");490  const [pinValue, setPinValue] = createSignal("");491  const [pinConfirm, setPinConfirm] = createSignal("");492  const [pinSaving, setPinSaving] = createSignal(false);493  const [pinError, setPinError] = createSignal<string | null>(null);494495  // UI-5 / LV-4: view existing recovery words behind a fresh password proof.496  const [showViewWords, setShowViewWords] = createSignal(false);497  const [viewWordsPassword, setViewWordsPassword] = createSignal("");498  const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);499  const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);500  const [viewWordsBusy, setViewWordsBusy] = createSignal(false);501502  async function handleViewRecoveryWords() {503    setViewWordsError(null);504    if (!viewWordsPassword()) {505      setViewWordsError("Enter your master password.");506      return;507    }508    setViewWordsBusy(true);509    try {510      const r = (await sendMessage({511        type: "GET_RECOVERY_WORDS",512        payload: { password: viewWordsPassword() },513      })) as { ok: boolean; data?: string[]; error?: string };514      if (r.ok) {515        setViewedWords(r.data ?? []);516        setViewWordsPassword("");517        return;518      }519      switch (r.error) {520        case "wrong_password":521          setViewWordsError("Incorrect password");522          break;523        case "password_required":524          setViewWordsError("Enter your master password.");525          break;526        case "recovery_not_setup":527          setViewWordsError("Recovery words aren't set up for this vault yet.");528          break;529        default:530          setViewWordsError("Couldn't retrieve recovery words.");531      }532    } finally {533      setViewWordsBusy(false);534    }535  }536  function closeViewWords() {537    setShowViewWords(false);538    setViewWordsPassword("");539    setViewWordsError(null);540    setViewedWords(null);541  }542543  const [duressEnabled, setDuressEnabled] = createSignal(false);544  const [showDuressSetup, setShowDuressSetup] = createSignal(false);545  const [duressPassword, setDuressPassword] = createSignal("");546  const [duressConfirm, setDuressConfirm] = createSignal("");547  const [duressMainPassword, setDuressMainPassword] = createSignal("");548  const [duressSaving, setDuressSaving] = createSignal(false);549  const [duressError, setDuressError] = createSignal<string | null>(null);550  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);551552  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);553554  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);555  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);556557  const [fbCategory, setFbCategory] = createSignal("Bug report");558  const [fbMessage, setFbMessage] = createSignal("");559  type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';560  const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');561562  createEffect(() => {563    if (props.upgradeHighlight) {564      setActiveCard("pro");565    }566  });567568  onMount(async () => {569    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([570      sendMessage({ type: "GET_LICENSE" }),571      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),572      sendMessage({ type: "IS_DURESS_ENABLED" }),573      sendMessage({ type: "GET_SETTINGS" }),574      sendMessage({ type: "GET_LICENSE_KEY" }),575    ]);576577    // Vault history loads in the background — the Vault sub-view picks it up578    // when the user navigates in. Loading it lazily keeps Settings boot fast.579    void loadVaultHistory();580581    // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the582    // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.583    void loadPendingVouched();584    void loadPairedDevices();585    void loadSyncState();586    const onBroadcast = (msg: unknown) => {587      const m = msg as { type?: string; payload?: unknown } | null;588      if (m?.type === "VOUCHED_DEVICE_PENDING") {589        void loadPendingVouched();590      }591      if (m?.type === "SYNC_STATE_CHANGED") {592        const payload = (m.payload ?? {}) as SyncOutcomeSummary;593        setSyncSummary(payload);594        setSyncStatus(payload.status ?? "idle");595      }596    };597    chrome.runtime?.onMessage?.addListener(onBroadcast);598    onCleanup(() => {599      try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ }600    });601602    if ((licenseRes as { ok: boolean }).ok) {603      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);604    }605606    if ((passkeyRes as { ok: boolean }).ok) {607      setPasskeyState(608        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null609      );610    }611612    if ((duressRes as { ok: boolean }).ok) {613      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);614    }615616    if ((settingsRes as { ok: boolean }).ok) {617      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;618      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);619      setPinEnabled(sData?.pinEnabled === true);620    }621622    if ((keyRes as { ok: boolean }).ok) {623      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);624    }625626    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });627    if ((entriesRes as { ok: boolean }).ok) {628      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];629      setCanaryEntries(630        entries631          .filter((e) => e.canary?.webhookUrl)632          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))633      );634    }635636  });637638  function resetPinFlow() {639    setPinFlow(null);640    setPinCurrentPassword("");641    setPinValue("");642    setPinConfirm("");643    setPinError(null);644  }645646  async function handleSetupPin() {647    setPinError(null);648    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }649    if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }650    if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }651    setPinSaving(true);652    const res = await sendMessage({653      type: "SETUP_PIN",654      payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },655    }) as { ok: boolean; error?: string };656    if (res.ok) {657      setPinEnabled(true);658      resetPinFlow();659    } else {660      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");661    }662    setPinSaving(false);663  }664665  async function handleDisablePin() {666    setPinError(null);667    if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }668    setPinSaving(true);669    const res = await sendMessage({670      type: "DISABLE_PIN",671      payload: { currentPassword: pinCurrentPassword() },672    }) as { ok: boolean; error?: string };673    if (res.ok) {674      setPinEnabled(false);675      resetPinFlow();676    } else {677      setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");678    }679    setPinSaving(false);680  }681682  async function handleChangePassword() {683    setChangePwError(null);684    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }685    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }686    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }687    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }688    setChangePwSaving(true);689    const res = await sendMessage({690      type: "CHANGE_PASSWORD",691      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },692    }) as { ok: boolean; error?: string };693    if (res.ok) {694      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");695      setShowChangePw(false);696      // LV-4: the user just authenticated (current password), so the recovery697      // words reveal that follows is exempt from the re-auth gate. The698      // background owns this session-scoped exemption; the display clears it.699      await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });700      setShowNewRecoveryWords(true);701    } else {702      setChangePwError(res.error ?? "failed to change password");703    }704    setChangePwSaving(false);705  }706707  const [copied, setCopied] = createSignal(false);708709  async function copyLicenseKey(key: string) {710    await navigator.clipboard.writeText(key);711    setCopied(true);712    setTimeout(() => setCopied(false), 2000);713  }714715  async function handleActivateLicense() {716    const key = licenseKeyInput().trim();717    if (!key) return;718    setActivatingLicense(true);719    setLicenseError(null);720    setLicenseSuccess(null);721    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });722    setActivatingLicense(false);723    if ((res as { ok: boolean }).ok) {724      const data = (res as { ok: boolean; data?: LicenseInfo }).data;725      if (data?.valid) {726        setLicenseInfo(data);727        setLicenseKey(key);728        setLicenseSuccess("License activated!");729        setLicenseKeyInput("");730      } else {731        setLicenseError("Invalid license key");732      }733    } else {734      setLicenseError("Activation failed");735    }736  }737738  async function handleTogglePasskeys() {739    const current = passkeyState();740    if (!current || !current.supported) return;741    setPasskeySaving(true);742    const res = await sendMessage({743      type: "SET_PASSKEY_PROVIDER_ENABLED",744      payload: { enabled: !current.enabled },745    });746    setPasskeySaving(false);747    if ((res as { ok: boolean }).ok) {748      setPasskeyState(749        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null750      );751    }752  }753754  async function handleCheckAllBreaches() {755    setCheckingBreaches(true);756    setBreachResult(null);757    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });758    setCheckingBreaches(false);759    if ((res as { ok: boolean }).ok) {760      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};761      const breached = Object.values(data).filter((count) => count > 0).length;762      setBreachResult(763        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"764      );765      props.onBreachCheckDone();766    }767  }768769770  async function handleSetupDuress() {771    const mainPw = duressMainPassword().trim();772    const duressPw = duressPassword().trim();773    const confirmPw = duressConfirm().trim();774775    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }776    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }777    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }778    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }779780    setDuressSaving(true);781    setDuressError(null);782783    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };784    if (!verifyRes.ok || !verifyRes.data) {785      setDuressError("incorrect main password");786      setDuressSaving(false);787      return;788    }789790    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };791    setDuressSaving(false);792    if (res.ok) {793      setDuressEnabled(true);794      setShowDuressSetup(false);795      setDuressPassword("");796      setDuressConfirm("");797      setDuressMainPassword("");798      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");799      setTimeout(() => setDuressSuccess(null), 5000);800    } else {801      setDuressError(802        res.error === "session_expired"803          ? "Vault session expired — lock and unlock Peach, then try again"804          : res.error ?? "failed to set duress password"805      );806    }807  }808809  async function handleClearDeclinedCards() {810    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });811    setDeclinedCardCount(0);812  }813814  async function handleCountDuplicates() {815    setDedupRunning(true);816    setDedupDone(null);817    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };818    setDedupRunning(false);819    if (res.ok) setDedupCount(res.data ?? 0);820  }821822  async function handleDeduplicate() {823    setDedupRunning(true);824    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };825    setDedupRunning(false);826    if (res.ok) {827      setDedupDone(res.data ?? 0);828      setDedupCount(0);829    }830  }831832  async function handleDisableDuress() {833    const mainPw = duressMainPassword().trim();834    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }835836    setDuressSaving(true);837    setDuressError(null);838839    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };840    if (!verifyRes.ok || !verifyRes.data) {841      setDuressError("incorrect main password");842      setDuressSaving(false);843      return;844    }845846    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };847    setDuressSaving(false);848    if (res.ok) {849      setDuressEnabled(false);850      setShowDuressSetup(false);851      setDuressMainPassword("");852      setDuressSuccess("Duress password disabled.");853      setTimeout(() => setDuressSuccess(null), 4000);854    } else {855      setDuressError(res.error ?? "failed to disable duress password");856    }857  }858859  async function handleSubmitFeedback() {860    const message = sanitizeFeedbackMessage(fbMessage());861    if (!message) return;862863    setFeedbackState('sending');864865    try {866      const category = FEEDBACK_CATEGORIES.includes(fbCategory())867        ? fbCategory()868        : FEEDBACK_CATEGORIES[0];869      const res = await fetch('https://api.web3forms.com/submit', {870        method: 'POST',871        headers: { 'Content-Type': 'application/json' },872        body: JSON.stringify({873          access_key: WEB3FORMS_KEY,874          subject: `Peach Feedback - ${category}`,875          message,876          from_name: 'Peach User',877          // Honeypot — leave blank878          botcheck: '',879        }),880      });881882      if (!res.ok) throw new Error(`HTTP ${res.status}`);883      const data = await res.json() as { success: boolean; message?: string };884      if (!data.success) throw new Error(data.message ?? 'unknown error');885886      setFeedbackState('sent');887      setFbMessage('');888      setFbCategory(FEEDBACK_CATEGORIES[0]);889    } catch (err) {890      console.error('[feedback]', err);891      setFeedbackState('error');892      setTimeout(() => setFeedbackState('idle'), 4000);893    }894  }895896  function renderSecurity() {897    return (898      <div class="peach-settings-body">899        <div class="peach-settings-group">900          <p class="peach-settings-sub-label">LOCKING</p>901          <div class="peach-settings-row">902            <span>Lock on close</span>903            <button904              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}905              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}906              aria-label="Toggle lock on close"907            >908              <span class="peach-toggle-knob"></span>909            </button>910          </div>911          <div class="peach-settings-row">912            <span>Auto-lock after</span>913            <select914              class="peach-settings-select"915              value={String(props.autoLockMinutes)}916              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}917            >918              <option value="1">1 minute</option>919              <option value="5">5 minutes</option>920              <option value="15">15 minutes</option>921              <option value="30">30 minutes</option>922              <option value="0">Never</option>923            </select>924          </div>925926          <p class="peach-settings-sub-label">QUICK UNLOCK</p>927          <div class="peach-settings-row peach-settings-row--meta">928            <span>PIN unlock</span>929            <button930              class="peach-btn peach-btn--subtle peach-btn--sm"931              onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}932            >933              {pinEnabled() ? "remove" : "set up"}934            </button>935          </div>936          <Show when={pinFlow() === "setup"}>937            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">938              <input939                class="peach-settings-input"940                type="password"941                placeholder="current password"942                value={pinCurrentPassword()}943                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}944                autocomplete="current-password"945              />946              <input947                class="peach-settings-input"948                type="password"949                inputmode="numeric"950                maxlength="12"951                placeholder="new 4–12 digit PIN"952                value={pinValue()}953                onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}954                autocomplete="off"955              />956              <input957                class="peach-settings-input"958                type="password"959                inputmode="numeric"960                maxlength="12"961                placeholder="confirm PIN"962                value={pinConfirm()}963                onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}964                autocomplete="off"965                onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}966              />967              <Show when={pinError()}>968                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>969              </Show>970              <button971                class="peach-btn peach-btn--accent peach-btn--sm"972                style="align-self:flex-start"973                onClick={() => void handleSetupPin()}974                disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}975              >976                {pinSaving() ? "saving…" : "save PIN"}977              </button>978            </div>979          </Show>980          <Show when={pinFlow() === "disable"}>981            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">982              <input983                class="peach-settings-input"984                type="password"985                placeholder="current password"986                value={pinCurrentPassword()}987                onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}988                autocomplete="current-password"989                onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}990              />991              <Show when={pinError()}>992                <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>993              </Show>994              <button995                class="peach-btn peach-btn--subtle peach-btn--sm"996                style="align-self:flex-start"997                onClick={() => void handleDisablePin()}998                disabled={pinSaving() || !pinCurrentPassword()}999              >1000                {pinSaving() ? "removing…" : "remove PIN"}1001              </button>1002            </div>1003          </Show>10041005          <p class="peach-settings-sub-label">BREACH PROTECTION</p>1006          <div class="peach-settings-row">1007            <span>Breach monitoring</span>1008            <button1009              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1010              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1011              aria-label="Toggle breach monitoring"1012            >1013              <span class="peach-toggle-knob"></span>1014            </button>1015          </div>1016          <div class="peach-settings-row peach-settings-row--meta">1017            <span>Check all passwords</span>1018            <button1019              class="peach-btn peach-btn--subtle peach-btn--sm"1020              onClick={() => void handleCheckAllBreaches()}1021              disabled={checkingBreaches()}1022            >1023              {checkingBreaches() ? "checking..." : "run"}1024            </button>1025          </div>1026          <Show when={breachResult()}>1027            <div class="peach-settings-row peach-settings-row--meta">1028              <span>Last check</span>1029              <span class="peach-settings-value">{breachResult()}</span>1030            </div>1031          </Show>10321033          <p class="peach-settings-sub-label">MASTER PASSWORD</p>1034          <div class="peach-settings-row peach-settings-row--meta">1035            <span>Master password</span>1036            <button1037              class="peach-btn peach-btn--subtle peach-btn--sm"1038              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1039            >1040              {showChangePw() ? "cancel" : "change"}1041            </button>1042          </div>1043          <Show when={showChangePw()}>1044            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1045              <input1046                class="peach-settings-input"1047                type="password"1048                placeholder="current password"1049                value={changePwCurrent()}1050                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1051                autocomplete="current-password"1052              />1053              <input1054                class="peach-settings-input"1055                type="password"1056                placeholder="new password (min 12 chars)"1057                value={changePwNew()}1058                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1059                autocomplete="new-password"1060              />1061              <input1062                class="peach-settings-input"1063                type="password"1064                placeholder="confirm new password"1065                value={changePwConfirm()}1066                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1067                autocomplete="new-password"1068                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1069              />1070              <Show when={changePwError()}>1071                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1072              </Show>1073              <Show when={changePwSuccess()}>1074                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1075              </Show>1076              <button1077                class="peach-btn peach-btn--accent peach-btn--sm"1078                style="align-self:flex-start"1079                onClick={() => void handleChangePassword()}1080                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1081              >1082                {changePwSaving() ? "saving…" : "save new password"}1083              </button>1084            </div>1085          </Show>10861087          {/* UI-5 / LV-4: view existing recovery words behind a password proof. */}1088          <div class="peach-settings-row peach-settings-row--meta">1089            <span>Recovery words</span>1090            <button1091              class="peach-btn peach-btn--subtle peach-btn--sm"1092              onClick={() => {1093                if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1094              }}1095            >1096              {showViewWords() ? "cancel" : "view"}1097            </button>1098          </div>1099          <Show when={showViewWords()}>1100            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1101              <Show1102                when={viewedWords()}1103                fallback={1104                  <>1105                    <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1106                      Enter your master password to reveal your recovery words.1107                    </p>1108                    <input1109                      class="peach-settings-input"1110                      type="password"1111                      placeholder="master password"1112                      value={viewWordsPassword()}1113                      onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1114                      autocomplete="current-password"1115                      onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1116                    />1117                    <Show when={viewWordsError()}>1118                      <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1119                    </Show>1120                    <button1121                      class="peach-btn peach-btn--accent peach-btn--sm"1122                      style="align-self:flex-start"1123                      onClick={() => void handleViewRecoveryWords()}1124                      disabled={viewWordsBusy() || !viewWordsPassword()}1125                    >1126                      {viewWordsBusy() ? "checking…" : "reveal recovery words"}1127                    </button>1128                  </>1129                }1130              >1131                <div class="peach-recovery-words-grid">1132                  <For each={viewedWords()!}>1133                    {(word, i) => (1134                      <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1135                    )}1136                  </For>1137                </div>1138                <button1139                  class="peach-btn peach-btn--subtle peach-btn--sm"1140                  style="align-self:flex-start"1141                  onClick={closeViewWords}1142                >done</button>1143              </Show>1144            </div>1145          </Show>11461147          <p class="peach-settings-sub-label">1148            DURESS PASSWORD1149            <Show when={!isPro()}>1150              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1151            </Show>1152          </p>1153          <div class="peach-settings-row peach-settings-row--top">1154            <div class="peach-settings-row-main">1155              <span>1156                Duress password1157                <Show when={!isPro()}>1158                  <span class="peach-pro-badge">Pro</span>1159                </Show>1160              </span>1161              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1162                If entered under coercion, shows a decoy empty vault instead of your real one.1163              </span>1164            </div>1165            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1166              {duressEnabled() ? "on" : "off"}1167            </div>1168          </div>11691170          <Show when={duressEnabled() && !showDuressSetup()}>1171            <div class="peach-settings-row peach-settings-row--meta">1172              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1173                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.1174              </span>1175            </div>1176          </Show>11771178          <Show when={duressSuccess()}>1179            <div class="peach-settings-row peach-settings-row--meta">1180              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1181            </div>1182          </Show>11831184          <Show when={!showDuressSetup()}>1185            <div class="peach-settings-row peach-settings-row--meta">1186              <button1187                class="peach-btn peach-btn--subtle peach-btn--sm"1188                onClick={() => {1189                  // Pro gate entry-point: free users are redirected to the1190                  // upgrade screen before the setup form opens. The server-1191                  // side SETUP_DURESS handler is the single enforcement1192                  // point; this is UI redirection so the form never appears1193                  // for a user who can't complete it.1194                  if (!isPro() && !duressEnabled()) {1195                    props.onProGate?.("duress_vault");1196                    setActiveCard("pro");1197                    return;1198                  }1199                  setShowDuressSetup(true);1200                  setDuressError(null);1201                  setDuressSuccess(null);1202                  setDuressPassword("");1203                  setDuressConfirm("");1204                  setDuressMainPassword("");1205                }}1206              >1207                {duressEnabled() ? "Change / disable" : "Set up"}1208              </button>1209            </div>1210          </Show>12111212          <Show when={showDuressSetup()}>1213            <div class="peach-duress-form">1214              <label class="peach-label">main password (to confirm)</label>1215              <input1216                class="peach-settings-input"1217                type="password"1218                placeholder="your current master password"1219                autocomplete="current-password"1220                value={duressMainPassword()}1221                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1222              />1223              <Show when={!duressEnabled()}>1224                <label class="peach-label">new duress password</label>1225                <input1226                  class="peach-settings-input"1227                  type="password"1228                  placeholder="duress password (min 8 chars)"1229                  autocomplete="new-password"1230                  value={duressPassword()}1231                  onInput={(e) => setDuressPassword(e.currentTarget.value)}1232                />1233                <label class="peach-label">confirm duress password</label>1234                <input1235                  class="peach-settings-input"1236                  type="password"1237                  placeholder="confirm duress password"1238                  autocomplete="new-password"1239                  value={duressConfirm()}1240                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}1241                />1242              </Show>12431244              <Show when={duressError()}>1245                <p class="peach-hint peach-hint-error">{duressError()}</p>1246              </Show>12471248              <div class="peach-duress-actions">1249                <button1250                  class="peach-btn peach-btn--subtle peach-btn--sm"1251                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1252                >1253                  cancel1254                </button>1255                <Show when={duressEnabled()}>1256                  <button1257                    class="peach-btn peach-btn--ghost peach-btn--sm"1258                    style="color:var(--peach-breach)"1259                    onClick={() => void handleDisableDuress()}1260                    disabled={duressSaving()}1261                  >1262                    {duressSaving() ? "disabling..." : "disable duress"}1263                  </button>1264                </Show>1265                <Show when={!duressEnabled()}>1266                  <button1267                    class="peach-btn peach-btn--accent peach-btn--sm"1268                    onClick={() => void handleSetupDuress()}1269                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1270                  >1271                    {duressSaving() ? "saving..." : "save"}1272                  </button>1273                </Show>1274              </div>1275            </div>1276          </Show>12771278          <p class="peach-settings-sub-label">1279            CANARY ENTRIES1280            <Show when={!isPro()}>1281              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1282            </Show>1283          </p>1284          <div class="peach-settings-row peach-settings-row--top">1285            <div class="peach-settings-row-main">1286              <span>1287                Canary entries1288                <Show when={!isPro()}>1289                  <span class="peach-pro-badge">Pro</span>1290                </Show>1291              </span>1292              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1293                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1294              </span>1295            </div>1296            <span class="peach-settings-value" style="flex-shrink:0">1297              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1298            </span>1299          </div>1300          <Show when={canaryEntries().length > 0}>1301            <For each={canaryEntries()}>1302              {(entry) => (1303                <div class="peach-settings-row peach-settings-row--meta">1304                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1305                  <button1306                    class="peach-btn peach-btn--subtle peach-btn--sm"1307                    onClick={() => void handleTestCanary(entry)}1308                    disabled={canaryTestingId() === entry.id}1309                    style="flex-shrink:0"1310                  >1311                    {canaryTestingId() === entry.id1312                      ? "sending…"1313                      : canaryTestResult()?.id === entry.id1314                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1315                        : "test"}1316                  </button>1317                </div>1318              )}1319            </For>1320          </Show>13211322          <p class="peach-settings-sub-label">PASSKEYS</p>1323          <div class="peach-settings-row peach-settings-row--top">1324            <div class="peach-settings-row-main">1325              <span>Intercept passkey requests</span>1326              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1327                Peach can handle passkey sign-in and creation on supported Chromium browsers.1328              </span>1329            </div>1330            <button1331              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1332              onClick={() => void handleTogglePasskeys()}1333              aria-label="Toggle passkey interception"1334              disabled={!passkeyState()?.supported || passkeySaving()}1335            >1336              <span class="peach-toggle-knob"></span>1337            </button>1338          </div>1339          <div class="peach-settings-row peach-settings-row--meta">1340            <span>Status</span>1341            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1342          </div>1343          <div class="peach-settings-row peach-settings-row--meta">1344            <span>Browser</span>1345            <span class="peach-settings-value">1346              {passkeyState()?.browserLabel ?? "Unknown"}1347              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1348            </span>1349          </div>1350          <div class="peach-info-notice" style="margin-top:8px">1351            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1352          </div>1353          <Show when={passkeyState()?.conflict}>1354            <div class="peach-settings-row peach-settings-row--meta">1355              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1356            </div>1357          </Show>1358        </div>1359      </div>1360    );1361  }13621363  function renderVault() {1364    return (1365      <div class="peach-settings-body">1366        <div class="peach-settings-group">1367          <p class="peach-settings-sub-label">ENTRIES</p>1368          <div class="peach-settings-row peach-settings-row--meta">1369            <span>Entries</span>1370            <span class="peach-settings-value">{props.entriesCount}</span>1371          </div>1372          <div class="peach-settings-row peach-settings-row--meta">1373            <span>Duplicates</span>1374            <Show when={dedupDone() !== null} fallback={1375              <button1376                class="peach-btn peach-btn--subtle peach-btn--sm"1377                onClick={() => void handleCountDuplicates()}1378                disabled={dedupRunning()}1379              >1380                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1381              </button>1382            }>1383              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1384                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1385              </span>1386            </Show>1387          </div>1388          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1389            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1390              <div class="peach-restore-warning">1391                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1392              </div>1393              <div style="display:flex;gap:6px">1394                <button1395                  class="peach-btn peach-btn--subtle peach-btn--sm"1396                  onClick={() => setDedupCount(null)}1397                >1398                  cancel1399                </button>1400                <button1401                  class="peach-btn peach-btn--accent peach-btn--sm"1402                  onClick={() => void handleDeduplicate()}1403                  disabled={dedupRunning()}1404                >1405                  {dedupRunning() ? "removing..." : "remove duplicates"}1406                </button>1407              </div>1408            </div>1409          </Show>14101411          <p class="peach-settings-sub-label">MAINTENANCE</p>1412          <div class="peach-settings-row peach-settings-row--meta">1413            <span>Trash auto-delete</span>1414            <select1415              class="peach-settings-select"1416              value={String(props.trashRetentionDays)}1417              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1418            >1419              <option value="7">7 days</option>1420              <option value="30">30 days</option>1421              <option value="90">90 days</option>1422              <option value="0">Never</option>1423            </select>1424          </div>1425          <div class="peach-settings-row peach-settings-row--meta">1426            <span>Advanced Vault Health</span>1427            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1428              open1429            </button>1430          </div>1431          <Show when={declinedCardCount() > 0}>1432            <div class="peach-settings-row peach-settings-row--meta">1433              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1434              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1435                Clear1436              </button>1437            </div>1438          </Show>14391440          <p class="peach-settings-sub-label">DATA</p>1441          <div class="peach-settings-row peach-settings-row--meta">1442            <span>Import passwords</span>1443            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1444              open1445            </button>1446          </div>1447          <div class="peach-settings-row peach-settings-row--meta">1448            <span>Export backup</span>1449            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1450              open1451            </button>1452          </div>1453          <div class="peach-settings-row peach-settings-row--meta">1454            <span>1455              Peach Codex1456              <Show when={!isPro()}>1457                <span class="peach-pro-badge">Pro</span>1458              </Show>1459            </span>1460            <button1461              class="peach-btn peach-btn--subtle peach-btn--sm"1462              onClick={() => props.onOpenCodexGenerate?.()}1463            >1464              open1465            </button>1466          </div>1467          {/* Restore from Codex: intentionally never gated — a printed1468              Codex must remain restorable regardless of license state. */}1469          <div class="peach-settings-row peach-settings-row--meta">1470            <span>Restore from Codex</span>1471            <button1472              class="peach-btn peach-btn--subtle peach-btn--sm"1473              onClick={() => props.onOpenCodexRestore?.()}1474            >1475              open1476            </button>1477          </div>14781479          <span class="peach-settings-sub-label">VAULT HISTORY</span>1480          <Show when={historyLoading()}>1481            <p class="peach-settings-hint">Loading…</p>1482          </Show>1483          <Show when={!historyLoading() && historySnapshots().length === 0}>1484            <p class="peach-settings-hint">1485              No snapshots yet. Peach saves automatically before every change —1486              saves, syncs, and imports.1487            </p>1488          </Show>1489          <Show when={!historyLoading() && historySnapshots().length > 0}>1490            <p class="peach-settings-hint">1491              Restoring locks your vault. Unlock with the password that1492              encrypted that snapshot to confirm.1493            </p>1494            <div class="peach-history-list">1495              <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1496                {(snap) => (1497                  <div class="peach-history-row">1498                    <div class="peach-history-meta">1499                      <span class="peach-history-time">1500                        {formatSnapshotTime(snap.savedAt)}1501                      </span>1502                      <span class="peach-history-sub">1503                        {[1504                          snap.entryCount !== null1505                            ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1506                            : null,1507                          reasonLabel(snap.reason),1508                        ]1509                          .filter(Boolean)1510                          .join(" · ")}1511                      </span>1512                    </div>1513                    <button1514                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1515                        historyRestoring() === snap.id ? " is-busy" : ""1516                      }`}1517                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1518                      disabled={historyRestoring() !== null}1519                    >1520                      <Show when={historyRestoring() === snap.id} fallback="Restore">1521                        …1522                      </Show>1523                    </button>1524                  </div>1525                )}1526              </For>1527            </div>1528            <Show when={historySnapshots().length > 3}>1529              <button1530                class="peach-settings-advanced-toggle"1531                onClick={() => setHistoryShowAll((v) => !v)}1532                aria-expanded={historyShowAll()}1533              >1534                <span class="peach-settings-hint" style="margin:0">1535                  {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1536                </span>1537                <span class="peach-settings-advanced-chevron">1538                  {historyShowAll() ? "▲" : "▼"}1539                </span>1540              </button>1541            </Show>1542            <p class="peach-history-footer">1543              {historySnapshots().length} of 10 snapshots1544            </p>1545          </Show>15461547          <button1548            class="peach-settings-advanced-toggle"1549            onClick={() => setShowVaultAdvanced((v) => !v)}1550            aria-expanded={showVaultAdvanced()}1551          >1552            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1553            <span class="peach-settings-advanced-chevron">1554              {showVaultAdvanced() ? "▲" : "▼"}1555            </span>1556          </button>15571558          <Show when={showVaultAdvanced()}>1559            <div class="peach-danger-card">1560              <div class="peach-danger-card-head">1561                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1562                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1563              </div>1564              <p class="peach-danger-card-copy">1565                Wipes the current vault and restores from a backup file.1566                Recoverable only if your backup is good.1567              </p>1568              <button1569                class="peach-danger-card-button"1570                onClick={() => props.onOpenFileImport?.()}1571              >1572                Choose backup file1573              </button>1574            </div>15751576            <Show when={props.onOpenPanic}>1577              <div class="peach-danger-card peach-danger-card--hazard">1578                <div class="peach-danger-card-head">1579                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1580                  <h3 class="peach-danger-card-title">Destroy vault</h3>1581                </div>1582                <p class="peach-danger-card-copy">1583                  Permanently wipes every entry, setting, and license on this1584                  device. There is no recovery.1585                </p>1586                <button1587                  class="peach-danger-card-button"1588                  onClick={() => props.onOpenPanic?.()}1589                >1590                  Begin destruction1591                </button>1592              </div>1593            </Show>1594          </Show>1595        </div>1596      </div>1597    );1598  }15991600  function renderSync() {1601    return (1602      <div class="peach-settings-body">1603        <div class="peach-settings-group">1604          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1605          <div1606            class="peach-settings-row peach-settings-row--meta"1607            style="align-items:center;gap:8px"1608            role="status"1609            aria-label={syncLightLabel()}1610            title={syncLightLabel()}1611          >1612            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1613            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1614              {syncFlash() ?? lastSyncLine()}1615            </span>1616          </div>1617          <Show when={pairedDevices().length > 0}>1618            <div class="peach-settings-row peach-settings-row--meta">1619              <span class="peach-settings-value" style="color:var(--peach-t2)">1620                {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01621                  ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1622                  : ""}1623              </span>1624              <button1625                class="peach-btn peach-btn--subtle peach-btn--sm"1626                aria-label="Sync this vault now"1627                disabled={syncStatus() === "syncing"}1628                onClick={() => void syncNow()}1629              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1630            </div>1631          </Show>16321633          <p class="peach-settings-sub-label">ADD A DEVICE</p>1634          <button1635            class="peach-sync-pair-option"1636            onClick={() => props.onOpenSyncPairing?.("code")}1637          >1638            <span class="peach-sync-pair-option-icon" aria-hidden="true">1639              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1640                <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1641                <path d="M9 15 L11 15" />1642              </svg>1643            </span>1644            <span class="peach-sync-pair-option-main">1645              <span class="peach-sync-pair-option-title">Show a code</span>1646              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1647            </span>1648            <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1649          </button>1650          <Show when={!isPro()}>1651            <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1652              At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1653            </p>1654          </Show>16551656          <Show when={pairedDevices().length > 0}>1657            <p class="peach-settings-sub-label">PAIRED DEVICES</p>1658            <For each={pairedDevices()}>1659              {(d) => (1660                <div class="peach-settings-row peach-settings-row--top">1661                  <div class="peach-settings-row-main">1662                    <Show1663                      when={renameDeviceId() === d.deviceId}1664                      fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1665                    >1666                      <input1667                        class="peach-settings-input"1668                        aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1669                        maxlength={64}1670                        value={renameDraft()}1671                        onInput={(event) => setRenameDraft(event.currentTarget.value)}1672                        onKeyDown={(event) => {1673                          if (event.key === "Enter") void renameDevice(d.deviceId);1674                          if (event.key === "Escape") setRenameDeviceId(null);1675                        }}1676                      />1677                    </Show>1678                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1679                      {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1680                    </span>1681                  </div>1682                  <Show1683                    when={confirmUnpair() === d.deviceId}1684                    fallback={1685                      <div style="display:flex;gap:4px;flex-shrink:0">1686                        <Show1687                          when={renameDeviceId() === d.deviceId}1688                          fallback={1689                            <button1690                              class="peach-btn peach-btn--subtle peach-btn--sm"1691                              onClick={() => {1692                                setRenameDraft(d.localAlias ?? d.deviceName ?? "");1693                                setRenameDeviceId(d.deviceId);1694                              }}1695                            >rename</button>1696                          }1697                        >1698                          <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1699                          <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1700                        </Show>1701                        <button1702                          class="peach-btn peach-btn--subtle peach-btn--sm"1703                          onClick={() => setConfirmUnpair(d.deviceId)}1704                        >remove</button>1705                      </div>1706                    }1707                  >1708                    {/* HOLE 4: revocation stops this device receiving future1709                        changes. It does NOT secure the vault against it — that1710                        device may already hold the recovery words, which are1711                        master-password-equivalent and restore the whole vault1712                        anywhere, forever, with no sync involved. Say so here,1713                        rather than leaving the user falsely reassured. */}1714                    <div class="peach-unpair-confirm">1715                      <p class="peach-unpair-confirm-note">1716                        Stops this device receiving future changes. It doesn't1717                        lock it out of what it already has — if it's lost or1718                        stolen, also change your master password and generate1719                        new recovery words.1720                      </p>1721                      <div style="display:flex;gap:6px;justify-content:flex-end">1722                        <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1723                        <button1724                          class="peach-btn peach-btn--sm"1725                          style="background:var(--peach-breach);border-color:var(--peach-breach)"1726                          onClick={() => void unpairDevice(d.deviceId)}1727                        >remove</button>1728                      </div>1729                    </div>1730                  </Show>1731                </div>1732              )}1733            </For>1734          </Show>17351736          <Show when={pendingVouched().length > 0}>1737            <p class="peach-settings-sub-label">1738              PENDING DEVICES1739              <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1740            </p>1741            <For each={pendingVouched()}>1742              {(dev) => (1743                <div class="peach-settings-row peach-settings-row--top">1744                  <div class="peach-settings-row-main">1745                    <span>{dev.deviceName}</span>1746                    <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1747                      Was added by another of your devices. Allow it to sync with this vault?1748                    </span>1749                  </div>1750                  <div style="display:flex;gap:6px;flex-shrink:0">1751                    <button1752                      class="peach-btn peach-btn--sm"1753                      disabled={vouchedBusy() === dev.deviceId}1754                      onClick={() => void confirmVouched(dev.deviceId)}1755                    >Allow</button>1756                    <button1757                      class="peach-btn peach-btn--subtle peach-btn--sm"1758                      disabled={vouchedBusy() === dev.deviceId}1759                      onClick={() => void rejectVouched(dev.deviceId)}1760                    >Reject</button>1761                  </div>1762                </div>1763              )}1764            </For>1765          </Show>17661767          <button1768            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1769            onClick={() => setShowSyncConnection((value) => !value)}1770            aria-expanded={showSyncConnection()}1771            aria-controls="peach-sync-connection"1772          >1773            <span>1774              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1775              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1776            </span>1777            <span class="peach-settings-advanced-chevron">1778              {showSyncConnection() ? "▲" : "▼"}1779            </span>1780          </button>1781          <Show when={showSyncConnection()}>1782            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1783              <SyncModeSelector />1784            </div>1785          </Show>17861787          <button1788            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1789            onClick={() => setShowSyncRecovery((value) => !value)}1790            aria-expanded={showSyncRecovery()}1791            aria-controls="peach-sync-recovery"1792          >1793            <span>1794              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1795              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1796            </span>1797            <span class="peach-settings-advanced-chevron">1798              {showSyncRecovery() ? "▲" : "▼"}1799            </span>1800          </button>1801          <Show when={showSyncRecovery()}>1802            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1803              <Show1804                when={rotateConfirmOpen()}1805                fallback={1806                  <div class="peach-settings-row peach-settings-row--top">1807                    <div class="peach-settings-row-main">1808                      <span>Rotate sync identity</span>1809                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1810                        Disconnects every device and replaces the keys used for sync.1811                      </span>1812                    </div>1813                    <button1814                      class="peach-btn peach-btn--subtle peach-btn--sm"1815                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1816                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1817                    >rotate</button>1818                  </div>1819                }1820              >1821                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1822                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1823                  <Show when={rotateError()}>1824                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1825                  </Show>1826                  <div style="display:flex;gap:6px;margin-top:10px">1827                    <button1828                      class="peach-btn peach-btn--subtle peach-btn--sm"1829                      disabled={rotating()}1830                      onClick={() => setRotateConfirmOpen(false)}1831                    >Cancel</button>1832                    <button1833                      class="peach-btn peach-btn--sm"1834                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1835                      disabled={rotating()}1836                      onClick={() => void doRotateSyncIdentity()}1837                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1838                  </div>1839                </div>1840              </Show>1841            </div>1842          </Show>1843        </div>1844      </div>1845    );1846  }18471848  function renderAppearance() {1849    const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1850      { id: "medium", label: "Default" },1851      { id: "large", label: "Large" },1852    ];1853    return (1854      <div class="peach-settings-body">1855        <div class="peach-settings-group">1856          <p class="peach-settings-sub-label">THEME</p>1857          <div class="peach-settings-row peach-settings-row--meta">1858            <span>Theme</span>1859            <button1860              class="peach-btn peach-btn--subtle peach-btn--sm"1861              onClick={() =>1862                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1863              }1864            >1865              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1866            </button>1867          </div>18681869          <p class="peach-settings-sub-label">ACCENT</p>1870          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1871            <span>Accent</span>1872            <div class="peach-accent-picker">1873              <For each={ACCENT_COLORS}>1874                {(color) => (1875                  <button1876                    class="peach-accent-swatch peach-swatch"1877                    classList={{ "is-active": props.accentColor === color.name }}1878                    style={{ background: color.hex }}1879                    onClick={() => void props.onAccentChange(color.name)}1880                    aria-label={color.name}1881                    title={color.name}1882                  />1883                )}1884              </For>1885            </div>1886          </div>18871888          <p class="peach-settings-sub-label">TEXT SIZE</p>1889          <div class="peach-settings-row peach-settings-row--meta">1890            <span>Size</span>1891            <div class="peach-pill-group">1892              <For each={sizeOptions}>1893                {(opt) => (1894                  <button1895                    class="peach-btn peach-btn--ghost peach-btn--sm"1896                    classList={{ "is-active": props.textSize === opt.id }}1897                    onClick={() => void props.onTextSizeChange(opt.id)}1898                  >1899                    {opt.label}1900                  </button>1901                )}1902              </For>1903            </div>1904          </div>19051906          <p class="peach-settings-sub-label">MOTION</p>1907          <div class="peach-settings-row peach-settings-row--meta">1908            <span>Reduce motion</span>1909            <button1910              class="peach-btn peach-btn--subtle peach-btn--sm"1911              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1912            >1913              {props.reduceMotion ? "On" : "Off"}1914            </button>1915          </div>1916        </div>1917      </div>1918    );1919  }19201921  function renderShortcuts() {1922    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1923    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19241925    function startCapture(action: ShortcutAction) {1926      setConflictMsg(null);1927      setCapturingAction(action);1928    }1929    function cancelCapture() {1930      setCapturingAction(null);1931      setConflictMsg(null);1932    }1933    function commitBinding(action: ShortcutAction, binding: Keybinding) {1934      const conflict = findConflict(props.shortcuts, binding, action);1935      if (conflict) {1936        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1937        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1938        return;1939      }1940      const next = { ...props.shortcuts, [action]: binding };1941      setCapturingAction(null);1942      setConflictMsg(null);1943      void props.onShortcutsChange(next);1944    }1945    function resetAction(action: ShortcutAction) {1946      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1947      void props.onShortcutsChange(next);1948    }1949    function resetAll() {1950      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1951    }19521953    // Capture-mode key handler — wired via ref onto the chip element so it1954    // catches the next non-modifier keypress and ignores other bindings.1955    function handleCaptureKey(e: KeyboardEvent) {1956      e.preventDefault();1957      e.stopPropagation();1958      if (e.key === "Escape") { cancelCapture(); return; }1959      const binding = keybindingFromEvent(e);1960      if (!binding) return; // bare modifier — keep waiting1961      const action = capturingAction();1962      if (action) commitBinding(action, binding);1963    }19641965    return (1966      <div class="peach-settings-body">1967        <div class="peach-settings-group peach-shortcuts-group">1968          <p class="peach-settings-sub-label">ENTRY VIEW</p>1969          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1970            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1971          </For>1972        </div>19731974        <div class="peach-settings-group peach-shortcuts-group">1975          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1976          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1977            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1978          </For>1979        </div>19801981        <Show when={conflictMsg()}>1982          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1983        </Show>19841985        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1986          <div class="peach-settings-row peach-settings-row--meta">1987            <span>Inline autofill on page</span>1988            <a1989              class="peach-settings-link"1990              href="chrome://extensions/shortcuts"1991              onClick={(e) => {1992                e.preventDefault();1993                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1994              }}1995            >1996              ⌘⇧P — customize ↗1997            </a>1998          </div>1999          <button2000            class="peach-btn peach-btn--subtle peach-btn--sm"2001            style="align-self:flex-start"2002            onClick={resetAll}2003          >2004            Reset all to defaults2005          </button>2006        </div>2007      </div>2008    );20092010    function renderRow(2011      desc: typeof SHORTCUT_DESCRIPTORS[number],2012      capturing: () => ShortcutAction | null,2013      onStart: (a: ShortcutAction) => void,2014      onCancel: () => void,2015      onReset: (a: ShortcutAction) => void,2016      onKey: (e: KeyboardEvent) => void,2017    ) {2018      const isActive = () => capturing() === desc.action;2019      const binding = () => props.shortcuts[desc.action];2020      const isCustom = () => {2021        const b = binding();2022        const d = DEFAULT_BINDINGS[desc.action];2023        return b.key !== d.key2024          || Boolean(b.ctrl)  !== Boolean(d.ctrl)2025          || Boolean(b.meta)  !== Boolean(d.meta)2026          || Boolean(b.shift) !== Boolean(d.shift)2027          || Boolean(b.alt)   !== Boolean(d.alt);2028      };2029      return (2030        <div class="peach-shortcut-row">2031          <span class="peach-shortcut-row-label">{desc.label}</span>2032          <button2033            class="peach-shortcut-chip"2034            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2035            onClick={() => isActive() ? onCancel() : onStart(desc.action)}2036            onKeyDown={(e) => { if (isActive()) onKey(e); }}2037            ref={(el) => {2038              // Auto-focus when entering capture mode so the keydown listener catches the next press.2039              if (el) {2040                queueMicrotask(() => { if (isActive()) el.focus(); });2041              }2042            }}2043          >2044            {isActive() ? "Press a key…" : formatKeybinding(binding())}2045          </button>2046          <Show when={isCustom() && !isActive()}>2047            <button2048              class="peach-shortcut-reset"2049              title="Reset to default"2050              onClick={() => onReset(desc.action)}2051            >2052              ↺2053            </button>2054          </Show>2055        </div>2056      );2057    }2058  }20592060  const PRO_FEATURES: Array<{2061    feature: ProFeature;2062    name: string;2063    desc: string;2064  }> = [2065    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2066    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2067    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2068    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2069  ];20702071  const PRO_SECURITY_FEATURES: Array<{2072    feature: ProFeature;2073    name: string;2074    desc: string;2075  }> = [2076    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2077    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2078  ];20792080  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2081  const [showKey, setShowKey] = createSignal(false);20822083  const [deactivating, setDeactivating] = createSignal(false);20842085  async function handleDeactivateLicense() {2086    setDeactivating(true);2087    await sendMessage({ type: "DEACTIVATE_LICENSE" });2088    setLicenseInfo(null);2089    await props.onLicenseChange?.();2090    setDeactivating(false);2091  }20922093  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2094    setCanaryTestingId(entry.id);2095    setCanaryTestResult(null);2096    const res = await sendMessage({2097      type: "FIRE_CANARY_PING",2098      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2099    }) as { ok: boolean };2100    setCanaryTestResult({ id: entry.id, ok: res.ok });2101    setCanaryTestingId(null);2102    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2103  }21042105  function renderLicense() {2106    async function onActivate() {2107      await handleActivateLicense();2108      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2109        await props.onLicenseChange?.();2110      }2111    }21122113    return (2114      <div class="peach-settings-body">2115        <Show2116          when={isPro()}2117          fallback={2118                <div class="peach-pro-upgrade">2119                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21202121                  <h2 class="peach-pro-headline">2122                    Every<em>thing,</em><br />every<em>where.</em>2123                  </h2>21242125                  <p class="peach-pro-subtitle">2126                    One key unlocks every Pro feature — for the lifetime of the app.2127                  </p>21282129                  <div class="peach-pro-grid">2130                    <For each={PRO_FEATURES}>2131                      {(item, i) => (2132                        <div2133                          class="peach-pro-grid-item"2134                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2135                        >2136                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2137                          <span class="peach-pro-grid-name">{item.name}</span>2138                          <span class="peach-pro-grid-desc">{item.desc}</span>2139                        </div>2140                      )}2141                    </For>2142                  </div>21432144                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>2145                  <div class="peach-pro-grid">2146                    <For each={PRO_SECURITY_FEATURES}>2147                      {(item, i) => (2148                        <div2149                          class="peach-pro-grid-item"2150                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2151                        >2152                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2153                          <span class="peach-pro-grid-name">{item.name}</span>2154                          <span class="peach-pro-grid-desc">{item.desc}</span>2155                        </div>2156                      )}2157                    </For>2158                  </div>21592160                  <div class="peach-pro-upgrade-actions">2161                    <p class="peach-pro-pricing">2162                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2163                    </p>2164                    <a2165                      href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2166                      target="_blank"2167                      rel="noopener noreferrer"2168                      class="peach-pro-upgrade-cta"2169                    >2170                      Get Peach Pro2171                    </a>2172                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2173                    <div class="peach-pro-key-inline">2174                      <input2175                        class="peach-pro-key-inline-input"2176                        type="text"2177                        placeholder="PEACH-XXXX-XXXX-XXXX"2178                        value={licenseKeyInput()}2179                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2180                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2181                        autocomplete="off"2182                        spellcheck={false}2183                      />2184                      <button2185                        class="peach-pro-key-apply"2186                        onClick={() => void onActivate()}2187                        disabled={activatingLicense() || !licenseKeyInput().trim()}2188                      >2189                        {activatingLicense() ? "…" : "APPLY"}2190                      </button>2191                    </div>2192                    <Show when={licenseError()}>2193                      <p class="peach-pro-key-error">{licenseError()}</p>2194                    </Show>2195                  </div>2196                </div>2197          }2198        >2199          <div class="peach-pro-active">2200            <div class="peach-pro-active-hero">2201              <button2202                class="peach-pro-active-mark-wrap"2203                type="button"2204                aria-label="Celebrate"2205                onClick={handleProCelebrationClick}2206              >2207                <PeachMark class="peach-pro-active-mark" />2208                <Show when={justActivated()}>2209                  <div class="peach-pro-sparks" aria-hidden="true">2210                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2211                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2212                    <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2213                    <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2214                    <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2215                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2216                    <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2217                    <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2218                  </div>2219                </Show>2220              </button>2221              <h2 class="peach-pro-active-title">Peach Pro</h2>2222              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2223              <Show when={justActivated()}>2224                <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2225              </Show>2226            </div>22272228            <Show when={licenseKey()}>2229              <div class="peach-settings-license">2230                <span class="peach-settings-label">LICENSE KEY</span>2231                <div class="peach-settings-license-row">2232                  <Show2233                    when={showKey()}2234                    fallback={2235                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2236                        ••••••••••••••••••••••••••••2237                      </span>2238                    }2239                  >2240                    <span class="peach-settings-license-key">{licenseKey()!}</span>2241                  </Show>2242                  <div class="peach-settings-license-actions">2243                    <button2244                      class="peach-settings-license-copy"2245                      title="Copy license key"2246                      onClick={() => copyLicenseKey(licenseKey()!)}2247                    >2248                      {copied() ? "✓" : "copy"}2249                    </button>2250                    <button2251                      class="peach-settings-license-toggle"2252                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}2253                      title={showKey() ? "Hide" : "Reveal"}2254                      onClick={() => setShowKey(v => !v)}2255                    >2256                      <Show2257                        when={showKey()}2258                        fallback={2259                          <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">2260                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2261                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2262                          </svg>2263                        }2264                      >2265                        <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">2266                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2267                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2268                          <line x1="2" y1="2" x2="12" y2="12" />2269                        </svg>2270                      </Show>2271                    </button>2272                  </div>2273                </div>2274              </div>2275            </Show>22762277            <div class="peach-pro-active-features">2278              <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2279                {(item) => (2280                  <div class="peach-pro-active-feature">2281                    <span class="peach-pro-active-feature-mark">✦</span>2282                    <span class="peach-pro-active-feature-name">{item.name}</span>2283                  </div>2284                )}2285              </For>2286            </div>22872288            <button2289              class="peach-pro-deactivate"2290              onClick={() => void handleDeactivateLicense()}2291              disabled={deactivating()}2292            >2293              {deactivating() ? "Deactivating…" : "Deactivate"}2294            </button>2295          </div>2296        </Show>2297      </div>2298    );2299  }23002301  function renderFeedback() {2302    return (2303      <div class="peach-settings-body">2304        <div class="peach-settings-group peach-feedback-group">2305          <Show2306            when={feedbackState() === 'sent'}2307            fallback={2308              <>2309                <p class="peach-feedback-intro">2310                  Help us improve Peach. Every report goes directly to our inbox.2311                </p>23122313                <p class="peach-settings-sub-label">TYPE</p>2314                <div class="peach-feedback-chips">2315                  <For each={FEEDBACK_CATEGORIES}>2316                    {(cat) => {2317                      const selected = () => fbCategory() === cat;2318                      return (2319                        <button2320                          type="button"2321                          class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2322                          aria-pressed={selected()}2323                          onClick={() => setFbCategory(cat)}2324                        >2325                          {cat}2326                        </button>2327                      );2328                    }}2329                  </For>2330                </div>23312332                <p class="peach-settings-sub-label">MESSAGE</p>2333                <textarea2334                  class="peach-feedback-textarea"2335                  placeholder="Describe the issue or idea..."2336                  maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2337                  value={fbMessage()}2338                  onInput={(e) => setFbMessage(e.currentTarget.value)}2339                />23402341                <p class="peach-feedback-meta">2342                  Version {version} · will be included automatically2343                </p>23442345                <button2346                  type="button"2347                  class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2348                  disabled={!fbMessage().trim() || feedbackState() === 'sending'}2349                  onClick={() => void handleSubmitFeedback()}2350                >2351                  <Switch>2352                    <Match when={feedbackState() === 'idle'}>Send</Match>2353                    <Match when={feedbackState() === 'sending'}>Sending...</Match>2354                    <Match when={feedbackState() === 'error'}>Failed - try again</Match>2355                  </Switch>2356                </button>23572358                <p class="peach-feedback-anon">2359                  Sent anonymously · no account required2360                </p>2361              </>2362            }2363          >2364            <div class="peach-feedback-thanks" role="status" aria-live="polite">2365              <p class="peach-feedback-thanks-title">Thank you</p>2366              <p>2367                Your note is in the queue. Someone will process it soon.2368              </p>2369            </div>2370          </Show>2371        </div>2372      </div>2373    );2374  }23752376  const bodies: StackBodies = {2377    protection: renderSecurity(),2378    vault: renderVault(),2379    sync: renderSync(),2380    appearance: renderAppearance(),2381    shortcuts: renderShortcuts(),2382    pro: renderLicense(),2383    feedback: renderFeedback(),2384  };23852386  return (2387    <Show2388      when={showNewRecoveryWords()}2389      fallback={2390        <div2391          class="peach-overlay-view peach-settings-overlay"2392          classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2393          style="display:flex;flex-direction:column"2394        >2395          <StackDeck2396            active={activeCard()}2397            onActiveChange={setActiveCard}2398            onBack={props.onBack}2399            cards={cards()}2400            bodies={bodies}2401            version={version}2402            isFanning={isFanning()}2403            celebratingCardId={justActivated() ? "pro" : null}2404            isPro={isPro()}2405          />2406        </div>2407      }2408    >2409      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2410    </Show>2411  );2412}